Compare commits
1 Commits
main
..
0b785db1b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b785db1b3 |
+60
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: freelancer-match-ci
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: backend-tests
|
||||||
|
image: python:3.12-slim
|
||||||
|
commands:
|
||||||
|
- pip install pytest pytest-asyncio aiosqlite httpx
|
||||||
|
- cd backend && pytest tests/ -v
|
||||||
|
|
||||||
|
- name: frontend-lint
|
||||||
|
image: node:20-alpine
|
||||||
|
commands:
|
||||||
|
- cd frontend && npm ci
|
||||||
|
- npx next lint --dir app
|
||||||
|
|
||||||
|
- name: build-backend
|
||||||
|
image: docker:24.0
|
||||||
|
privileged: true
|
||||||
|
environment:
|
||||||
|
DOCKER_USERNAME:
|
||||||
|
from_secret: gitea_username
|
||||||
|
DOCKER_PASSWORD:
|
||||||
|
from_secret: gitea_password
|
||||||
|
commands:
|
||||||
|
- echo "$DOCKER_PASSWORD" | docker login ms.webhop.me -u "$DOCKER_USERNAME" --password-stdin
|
||||||
|
- cd backend && docker build -t ms.webhop.me/admin/freelancer-match-backend .
|
||||||
|
|
||||||
|
- name: build-frontend
|
||||||
|
image: docker:24.0
|
||||||
|
privileged: true
|
||||||
|
environment:
|
||||||
|
DOCKER_USERNAME:
|
||||||
|
from_secret: gitea_username
|
||||||
|
DOCKER_PASSWORD:
|
||||||
|
from_secret: gitea_password
|
||||||
|
commands:
|
||||||
|
- echo "$DOCKER_PASSWORD" | docker login ms.webhop.me -u "$DOCKER_USERNAME" --password-stdin
|
||||||
|
- cd frontend && docker build -t ms.webhop.me/admin/freelancer-match-frontend .
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
|
image: alpine:3.19
|
||||||
|
commands:
|
||||||
|
- apk add curl bash
|
||||||
|
- |
|
||||||
|
echo "Deploying to production..."
|
||||||
|
# TODO: Добавить SSH ключ для деплоя на сервер
|
||||||
|
# ssh root@ms.webhop.me "cd /opt/gitea && docker compose pull && docker compose up -d"
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: secret
|
||||||
|
name: gitea_username
|
||||||
|
from_secret: drone_gitea_user
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: secret
|
||||||
|
name: gitea_password
|
||||||
|
from_secret: drone_gitea_pass
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
# LocalPro Finder — v2 (Без AI-диагностики)
|
# Freelancer Match — Умная площадка для фрилансеров
|
||||||
|
|
||||||
Площадка для поиска мастеров рядом с вами. Версия 2 без AI-агента диагностики.
|
Площадка для фрилансеров и заказчиков с AI-подбором, escrow-гарантом сделок и рейтинговой системой.
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
### Требования
|
### Требования
|
||||||
- Docker + Docker Compose
|
- Docker + Docker Compose
|
||||||
|
- Python 3.12+ (для локальной разработки)
|
||||||
|
- Node.js 20+ (для фронтенда)
|
||||||
- PostgreSQL 16+
|
- PostgreSQL 16+
|
||||||
- Redis 7+
|
- Redis 7+
|
||||||
|
|
||||||
@@ -18,50 +20,141 @@ cp backend/.env.example backend/.env
|
|||||||
# 2. Запустите стек
|
# 2. Запустите стек
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
# 3. Проверьте что всё работает
|
# 3. Примените миграции БД
|
||||||
|
docker compose exec backend alembic upgrade head
|
||||||
|
|
||||||
|
# 4. Проверьте что всё работает
|
||||||
curl http://localhost:8000/api/health
|
curl http://localhost:8000/api/health
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📦 Фичи v2
|
### Локальная разработка
|
||||||
|
|
||||||
- ✅ Регистрация / Вход (JWT)
|
#### Backend
|
||||||
- ✅ Создание проектов (запрос на услугу)
|
```bash
|
||||||
- ✅ Назначение мастера на проект
|
cd backend
|
||||||
- ✅ Система отзывов и рейтингов
|
python -m venv .venv
|
||||||
- ✅ Встроенный чат между клиентом и мастером
|
source .venv/bin/activate
|
||||||
- ✅ Подписки (Premium для мастеров)
|
pip install -r requirements.txt
|
||||||
- ⏳ AI Диагностика — Coming Soon
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
## 📁 Структура
|
#### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔑 Настройка переменных окружения
|
||||||
|
|
||||||
|
Скопируйте `backend/.env.example` в `backend/.env` и заполните значения.
|
||||||
|
|
||||||
|
### Обязательные (без них не запустится)
|
||||||
|
|
||||||
|
| Переменная | Описание | Где взять |
|
||||||
|
|------------|----------|-----------|
|
||||||
|
| `DATABASE_URL` | Подключение к PostgreSQL | Создайте БД: `createdb freelancer_match` |
|
||||||
|
| `REDIS_URL` | Подключение к Redis | По умолчанию: `redis://localhost:6379/0` |
|
||||||
|
| `SECRET_KEY` | Ключ для JWT токенов | Сгенерируйте: `openssl rand -hex 32` |
|
||||||
|
|
||||||
|
### Опциональные (но нужны для полноценной работы)
|
||||||
|
|
||||||
|
| Переменная | Описание | Где взять |
|
||||||
|
|------------|----------|-----------|
|
||||||
|
| `OPENAI_API_KEY` | API ключ OpenAI для AI-матчинга | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) |
|
||||||
|
| `STRIPE_SECRET_KEY` | Ключ Stripe для escrow | [dashboard.stripe.com/test/keys](https://dashboard.stripe.com/test/keys) (Test mode) |
|
||||||
|
| `STRIPE_WEBHOOK_SECRET` | Секрет для webhook Stripe | Настройте в Stripe Dashboard → Webhooks |
|
||||||
|
|
||||||
|
### Email (опционально)
|
||||||
|
|
||||||
|
| Переменная | Описание | Где взять |
|
||||||
|
|------------|----------|-----------|
|
||||||
|
| `SMTP_USER` | Email для отправки писем | Ваш email (Gmail, Mail.ru и т.д.) |
|
||||||
|
| `SMTP_PASSWORD` | Пароль SMTP | Для Gmail — используйте App Passwords (не обычный пароль) |
|
||||||
|
|
||||||
|
### OAuth (опционально, если хотите вход через Google/GitHub)
|
||||||
|
|
||||||
|
| Переменная | Описание | Где взять |
|
||||||
|
|------------|----------|-----------|
|
||||||
|
| `GOOGLE_CLIENT_ID` | OAuth client ID для входа через Google | [Google Cloud Console](https://console.cloud.google.com/apis/credentials) |
|
||||||
|
| `GITHUB_CLIENT_ID` + `GITHUB_CLIENT_SECRET` | OAuth для GitHub | [GitHub Developer Settings → OAuth Apps](https://github.com/settings/developers) |
|
||||||
|
|
||||||
|
## 📁 Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
localpro-finder-v2/
|
freelancer-match/
|
||||||
├── backend/ # FastAPI бэкенд
|
├── backend/ # FastAPI бэкенд
|
||||||
│ ├── src/api/routes/
|
│ ├── app/
|
||||||
│ │ ├── auth.py # Регистрация, логин, JWT
|
│ │ ├── api/ # API endpoints
|
||||||
│ │ ├── projects.py # Создание проектов
|
│ │ ├── models/ # SQLAlchemy модели
|
||||||
│ │ ├── reviews.py # Отзывы и рейтинги
|
│ │ ├── schemas/ # Pydantic схемы
|
||||||
│ │ ├── chats.py # Чат между клиентом и мастером
|
│ │ ├── services/ # Бизнес-логика
|
||||||
│ │ └── subscriptions.py # Подписки мастеров
|
│ │ └── core/ # Базовые модули (БД, безопасность)
|
||||||
|
│ ├── alembic/ # Миграции БД
|
||||||
|
│ ├── tests/ # Тесты pytest
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── requirements.txt
|
||||||
├── frontend/ # Next.js фронтенд
|
├── frontend/ # Next.js фронтенд
|
||||||
│ ├── src/pages/
|
│ ├── app/ # App Router (pages)
|
||||||
│ │ ├── index.tsx # Главная (поиск, категории)
|
│ ├── components/ui/ # UI компоненты
|
||||||
│ │ ├── login.tsx # Вход
|
│ ├── lib/ # Утилиты и провайдеры
|
||||||
│ │ └── register.tsx # Регистрация
|
│ ├── Dockerfile
|
||||||
├── docker-compose.yml
|
│ └── package.json
|
||||||
|
├── docker-compose.yml # Стек сервисов
|
||||||
|
└── .drone.yml # CI/CD для Drone CI
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 API Endpoints
|
## 🧪 Тесты
|
||||||
|
|
||||||
| Метод | Путь | Описание |
|
```bash
|
||||||
|-------|------|----------|
|
# Backend (pytest)
|
||||||
|
cd backend && pytest tests/ -v
|
||||||
|
|
||||||
|
# Frontend (linting)
|
||||||
|
cd frontend && npx next lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Безопасность
|
||||||
|
|
||||||
|
- JWT аутентификация с refresh токенами
|
||||||
|
- Хеширование паролей через bcrypt
|
||||||
|
- CORS middleware
|
||||||
|
- Escrow-гарант сделок (Stripe интеграция)
|
||||||
|
- AI-матчинг с pgvector эмбеддингами
|
||||||
|
|
||||||
|
## 📊 API Endpoints
|
||||||
|
|
||||||
|
| Метод | Endpoint | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
| POST | `/api/auth/register` | Регистрация пользователя |
|
| POST | `/api/auth/register` | Регистрация пользователя |
|
||||||
| POST | `/api/auth/login` | Вход (JWT) |
|
| POST | `/api/auth/login` | Вход (JWT) |
|
||||||
| POST | `/api/projects/` | Создать проект |
|
| GET | `/api/projects` | Список проектов |
|
||||||
| POST | `/api/projects/{id}/assign-master` | Назначить мастера |
|
| POST | `/api/projects` | Создать проект |
|
||||||
| GET | `/api/reviews/master/{id}` | Отзывы мастера |
|
| POST | `/api/ai/match-project` | AI-подбор фрилансеров |
|
||||||
| POST | `/api/chats/project/{id}/send` | Отправить сообщение |
|
| POST | `/api/escrow/create` | Создать escrow-транзакцию |
|
||||||
|
|
||||||
## 📝 License
|
## 🚀 Деплой на сервер
|
||||||
|
|
||||||
MIT © 2026 LocalPro Finder
|
```bash
|
||||||
|
# 1. Настройте .env.production
|
||||||
|
cp backend/.env.example backend/.env.production
|
||||||
|
|
||||||
|
# 2. Обновите docker-compose.yml для продакшена (уберите порты, добавьте healthcheck)
|
||||||
|
|
||||||
|
# 3. Запустите через Docker Compose
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
|
||||||
|
# 4. Примените миграции
|
||||||
|
docker compose exec backend alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤖 CI/CD (Drone CI)
|
||||||
|
|
||||||
|
Проект настроен для автоматического деплоя через Drone CI:
|
||||||
|
- `.drone.yml` — пайплайн с тестами, сборкой и деплоем
|
||||||
|
- Настроен на Gitea (`ms.webhop.me`)
|
||||||
|
- Автоматический запуск при push в main
|
||||||
|
|
||||||
|
## 📝 Лицензия
|
||||||
|
|
||||||
|
MIT © 2026 Freelancer Match
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ============================================
|
||||||
|
# Freelancer Match — Environment Variables (Template)
|
||||||
|
# ============================================
|
||||||
|
# Скопируйте этот файл в .env и заполните значения!
|
||||||
|
|
||||||
|
# --- ОБЯЗАТЕЛЬНЫЕ (без них не запустится) ---
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
|
||||||
|
# --- ОПЦИОНАЛЬНЫЕ (но нужны для полноценной работы) ---
|
||||||
|
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
|
||||||
|
GOOGLE_CLIENT_ID=...
|
||||||
|
GITHUB_CLIENT_ID=...
|
||||||
|
GITHUB_CLIENT_SECRET=...
|
||||||
|
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
|
||||||
|
# --- EMAIL (опционально) ---
|
||||||
|
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=noreply@freelancermatch.com
|
||||||
|
SMTP_PASSWORD=your-app-password
|
||||||
|
EMAIL_FROM=noreply@freelancermatch.com
|
||||||
|
|
||||||
|
# --- CORS ---
|
||||||
|
|
||||||
|
ALLOWED_ORIGINS=["http://localhost:3000","https://freelancermatch.com"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""AI endpoints: матчинг, рекомендации."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.schemas.ai_match import AIMatchRequest, AIMatchResponse
|
||||||
|
from app.services.ai_service import find_matches
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/ai", tags=["ai"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/match-project", response_model=list[AIMatchResponse])
|
||||||
|
async def match_project(data: AIMatchRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Подобрать фрилансеров для проекта через AI."""
|
||||||
|
|
||||||
|
matches = await find_matches(
|
||||||
|
db=db, project_id=data.project_id, limit=data.limit, min_score=data.min_score
|
||||||
|
)
|
||||||
|
|
||||||
|
return [AIMatchResponse(**m) for m in matches]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate-cover-letter")
|
||||||
|
async def generate_cover_letter(project_title: str, freelancer_skills: list[str]):
|
||||||
|
"""Сгенерировать сопроводительное письмо для заявки."""
|
||||||
|
|
||||||
|
# Placeholder — в продакшене вызов LLM
|
||||||
|
return {
|
||||||
|
"cover_letter": f"Здравствуйте! Я заинтересован в проекте '{project_title}'. Мой опыт работы с [{', '.join(freelancer_skills)}] позволяет качественно выполнить задачу."
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Auth endpoints: регистрация, логин, OAuth."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import (
|
||||||
|
hash_password, verify_password, create_access_token, create_refresh_token, get_current_user
|
||||||
|
)
|
||||||
|
from app.schemas.user import UserCreate, UserLogin
|
||||||
|
from app.schemas.auth import TokenPair
|
||||||
|
from app.models.user import User, FreelancerProfile
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=TokenPair)
|
||||||
|
async def register(data: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Регистрация нового пользователя."""
|
||||||
|
|
||||||
|
# Проверка email
|
||||||
|
result = await db.execute(select(User).where(User.email == data.email))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail="Email уже зарегистрирован")
|
||||||
|
|
||||||
|
# Создание пользователя
|
||||||
|
user = User(
|
||||||
|
email=data.email,
|
||||||
|
password_hash=hash_password(data.password),
|
||||||
|
role=data.role,
|
||||||
|
full_name=data.full_name,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Создаём профиль фрилансера по умолчанию
|
||||||
|
if data.role in ("freelancer", "both"):
|
||||||
|
profile = FreelancerProfile(
|
||||||
|
user_id=user.id,
|
||||||
|
skills=data.full_name or [], # заполняется позже
|
||||||
|
)
|
||||||
|
db.add(profile)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Генерация токенов
|
||||||
|
access_token = create_access_token({"sub": str(user.id), "role": data.role})
|
||||||
|
refresh_token = create_refresh_token({"sub": str(user.id)})
|
||||||
|
|
||||||
|
return TokenPair(access_token=access_token, refresh_token=refresh_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenPair)
|
||||||
|
async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Логин пользователя."""
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.email == data.email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user or not verify_password(data.password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=401, detail="Неверный email или пароль")
|
||||||
|
|
||||||
|
access_token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||||
|
refresh_token = create_refresh_token({"sub": str(user.id)})
|
||||||
|
|
||||||
|
return TokenPair(access_token=access_token, refresh_token=refresh_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenPair)
|
||||||
|
async def refresh(token: dict = Depends(get_current_user)):
|
||||||
|
"""Обновление access-токена."""
|
||||||
|
new_access = create_access_token({"sub": token["id"], "role": token["role"]})
|
||||||
|
return TokenPair(access_token=new_access, refresh_token="")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=dict)
|
||||||
|
async def me(user: dict = Depends(get_current_user)):
|
||||||
|
"""Получить текущего пользователя."""
|
||||||
|
return {"user_id": user["id"], "role": user["role"]}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Escrow endpoints (гарант)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.schemas.escrow import EscrowCreate, EscrowRelease
|
||||||
|
from app.models.escrow import EscrowTransaction
|
||||||
|
from app.models.project import Project
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/escrow", tags=["escrow"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
async def create_escrow(data: EscrowCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Создать escrow-транзакцию."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Project).where(Project.id == data.project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||||
|
|
||||||
|
transaction = EscrowTransaction(
|
||||||
|
project_id=data.project_id,
|
||||||
|
client_id=data.client_id,
|
||||||
|
freelancer_id=data.freelancer_id,
|
||||||
|
amount=data.amount,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(transaction)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(transaction.id),
|
||||||
|
"status": transaction.status,
|
||||||
|
"amount": float(transaction.amount),
|
||||||
|
"payment_url": f"https://stripe.com/pay/{transaction.id}", # Stripe redirect
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/release")
|
||||||
|
async def release_escrow(data: EscrowRelease, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Освободить средства фрилансеру."""
|
||||||
|
|
||||||
|
result = await db.execute(select(EscrowTransaction).where(EscrowTransaction.id == data.transaction_id))
|
||||||
|
transaction = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not transaction or transaction.status != "locked":
|
||||||
|
raise HTTPException(status_code=400, detail="Транзакция не может быть разблокирована")
|
||||||
|
|
||||||
|
# Комиссия платформы 10%
|
||||||
|
commission = transaction.amount * 0.10
|
||||||
|
freelancer_amount = transaction.amount - commission
|
||||||
|
|
||||||
|
transaction.status = "released"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(transaction.id),
|
||||||
|
"status": "released",
|
||||||
|
"freelancer_payout": float(freelancer_amount),
|
||||||
|
"platform_commission": float(commission),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dispute")
|
||||||
|
async def dispute_escrow(transaction_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Открыть спор по escrow."""
|
||||||
|
|
||||||
|
result = await db.execute(select(EscrowTransaction).where(EscrowTransaction.id == transaction_id))
|
||||||
|
transaction = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not transaction:
|
||||||
|
raise HTTPException(status_code=404, detail="Транзакция не найдена")
|
||||||
|
|
||||||
|
transaction.status = "disputed"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"status": "dispute_opened", "transaction_id": str(transaction.id)}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Endpoints для Milestone-платежей (Upwork-style)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.milestone import Milestone
|
||||||
|
from app.models.escrow import EscrowTransaction
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/projects/{project_id}/milestones", tags=["milestones"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=dict)
|
||||||
|
async def create_milestone(
|
||||||
|
project_id: str, data: dict, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Создать milestone для проекта."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not project or project.client_id != user["id"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Только владелец проекта может создавать milestones")
|
||||||
|
|
||||||
|
milestone = Milestone(
|
||||||
|
project_id=project_id,
|
||||||
|
title=data.get("title", ""),
|
||||||
|
description=data.get("description"),
|
||||||
|
amount=float(data.get("amount")),
|
||||||
|
due_date=None, # ISO format string
|
||||||
|
)
|
||||||
|
db.add(milestone)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(milestone)
|
||||||
|
|
||||||
|
return {"id": str(milestone.id), "status": milestone.status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{milestone_id}/submit")
|
||||||
|
async def submit_milestone(milestone_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Фрилансер завершает milestone."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Milestone).where(Milestone.id == milestone_id))
|
||||||
|
milestone = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not milestone or milestone.status != "funded":
|
||||||
|
raise HTTPException(status_code=400, detail="Milestone не может быть завершён")
|
||||||
|
|
||||||
|
milestone.status = "submitted"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"status": "submitted", "milestone_id": str(milestone.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{milestone_id}/approve")
|
||||||
|
async def approve_milestone(milestone_id: str, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Клиент одобряет milestone."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Milestone).where(Milestone.id == milestone_id))
|
||||||
|
milestone = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not milestone or milestone.status != "submitted":
|
||||||
|
raise HTTPException(status_code=400, detail="Milestone не может быть одобрен")
|
||||||
|
|
||||||
|
milestone.status = "approved"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"status": "approved", "milestone_id": str(milestone.id)}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Endpoints для портфолио фрилансера."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.portfolio import PortfolioItem
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/portfolio", tags=["portfolio"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_portfolio_item(data: dict, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||||||
|
"""Добавить работу в портфолио."""
|
||||||
|
|
||||||
|
item = PortfolioItem(
|
||||||
|
freelancer_id=user["id"],
|
||||||
|
title=data.get("title", ""),
|
||||||
|
description=data.get("description"),
|
||||||
|
image_url=data.get("image_url"),
|
||||||
|
live_url=data.get("live_url"),
|
||||||
|
technologies=data.get("technologies", []),
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(item)
|
||||||
|
|
||||||
|
return {"id": str(item.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{user_id}")
|
||||||
|
async def list_portfolio(user_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Список работ в портфолио пользователя."""
|
||||||
|
|
||||||
|
result = await db.execute(select(PortfolioItem).where(PortfolioItem.freelancer_id == user_id))
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(i.id),
|
||||||
|
"title": i.title,
|
||||||
|
"description": i.description,
|
||||||
|
"image_url": i.image_url,
|
||||||
|
"live_url": i.live_url,
|
||||||
|
"technologies": i.technologies,
|
||||||
|
"created_at": str(i.created_at),
|
||||||
|
} for i in items
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{item_id}")
|
||||||
|
async def delete_portfolio_item(item_id: str, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Удалить работу из портфолио."""
|
||||||
|
|
||||||
|
result = await db.execute(select(PortfolioItem).where(PortfolioItem.id == item_id))
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not item or item.freelancer_id != user["id"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Нет доступа")
|
||||||
|
|
||||||
|
await db.delete(item)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"status": "deleted"}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Endpoints для проектов."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.project import Project
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ProjectResponse)
|
||||||
|
async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||||||
|
"""Создать новый проект."""
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
client_id=user["id"],
|
||||||
|
title=data.title,
|
||||||
|
description=data.description,
|
||||||
|
category=data.category,
|
||||||
|
required_skills=data.required_skills,
|
||||||
|
budget_min=data.budget_min,
|
||||||
|
budget_max=data.budget_max,
|
||||||
|
deadline=data.deadline,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(project)
|
||||||
|
|
||||||
|
return ProjectResponse(
|
||||||
|
id=str(project.id),
|
||||||
|
title=project.title,
|
||||||
|
description=project.description,
|
||||||
|
category=project.category,
|
||||||
|
required_skills=project.required_skills,
|
||||||
|
budget_min=float(project.budget_min) if project.budget_min else None,
|
||||||
|
budget_max=float(project.budget_max) if project.budget_max else None,
|
||||||
|
status=project.status,
|
||||||
|
deadline=str(project.deadline) if project.deadline else None,
|
||||||
|
created_at=str(project.created_at),
|
||||||
|
updated_at=str(project.updated_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ProjectResponse])
|
||||||
|
async def list_projects(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
status_filter: str | None = Query(None, alias="status"),
|
||||||
|
category: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 20,
|
||||||
|
):
|
||||||
|
"""Список проектов с фильтрацией."""
|
||||||
|
|
||||||
|
stmt = select(Project)
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
stmt = stmt.where(Project.status == status_filter)
|
||||||
|
if category:
|
||||||
|
stmt = stmt.where(Project.category == category)
|
||||||
|
|
||||||
|
stmt = stmt.order_by(desc(Project.created_at)).offset((page - 1) * limit).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
projects = result.scalars().all()
|
||||||
|
|
||||||
|
return [ProjectResponse(
|
||||||
|
id=str(p.id), title=p.title, description=p.description, category=p.category,
|
||||||
|
required_skills=p.required_skills, budget_min=float(p.budget_min) if p.budget_min else None,
|
||||||
|
budget_max=float(p.budget_max) if p.budget_max else None, status=p.status,
|
||||||
|
deadline=str(p.deadline) if p.deadline else None, created_at=str(p.created_at), updated_at=str(p.updated_at),
|
||||||
|
) for p in projects]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||||
|
async def get_project(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Получить проект по ID."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||||
|
|
||||||
|
return ProjectResponse(
|
||||||
|
id=str(project.id), title=project.title, description=project.description, category=project.category,
|
||||||
|
required_skills=project.required_skills, budget_min=float(project.budget_min) if project.budget_min else None,
|
||||||
|
budget_max=float(project.budget_max) if project.budget_max else None, status=project.status,
|
||||||
|
deadline=str(project.deadline) if project.deadline else None, created_at=str(project.created_at), updated_at=str(project.updated_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}", response_model=ProjectResponse)
|
||||||
|
async def update_project(project_id: str, data: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Обновить проект."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||||
|
|
||||||
|
for field, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(project, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(project)
|
||||||
|
|
||||||
|
return ProjectResponse(
|
||||||
|
id=str(project.id), title=project.title, description=project.description, category=project.category,
|
||||||
|
required_skills=project.required_skills, budget_min=float(project.budget_min) if project.budget_min else None,
|
||||||
|
budget_max=float(project.budget_max) if project.budget_max else None, status=project.status,
|
||||||
|
deadline=str(project.deadline) if project.deadline else None, created_at=str(project.created_at), updated_at=str(project.updated_at),
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Endpoints для заявок фрилансеров."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.schemas.proposal import ProposalCreate, ProposalResponse
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.proposal import Proposal
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/projects/{project_id}/proposals", tags=["proposals"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ProposalResponse)
|
||||||
|
async def create_proposal(
|
||||||
|
project_id: str, data: ProposalCreate, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Фрилансер подаёт заявку на проект."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||||
|
|
||||||
|
proposal = Proposal(
|
||||||
|
project_id=project_id,
|
||||||
|
freelancer_id=user["id"],
|
||||||
|
cover_letter=data.cover_letter,
|
||||||
|
proposed_price=data.proposed_price,
|
||||||
|
estimated_days=data.estimated_days,
|
||||||
|
)
|
||||||
|
db.add(proposal)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(proposal)
|
||||||
|
|
||||||
|
return ProposalResponse(
|
||||||
|
id=str(proposal.id), project_id=project_id, freelancer_id=user["id"],
|
||||||
|
cover_letter=proposal.cover_letter, proposed_price=float(proposal.proposed_price) if proposal.proposed_price else None,
|
||||||
|
estimated_days=proposal.estimated_days, status=proposal.status, created_at=str(proposal.created_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ProposalResponse])
|
||||||
|
async def list_proposals(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Список заявок на проект."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Proposal).where(Proposal.project_id == project_id).order_by(desc(Proposal.created_at)))
|
||||||
|
proposals = result.scalars().all()
|
||||||
|
|
||||||
|
return [ProposalResponse(
|
||||||
|
id=str(p.id), project_id=p.project_id, freelancer_id=p.freelancer_id, cover_letter=p.cover_letter,
|
||||||
|
proposed_price=float(p.proposed_price) if p.proposed_price else None, estimated_days=p.estimated_days,
|
||||||
|
status=p.status, created_at=str(p.created_at),
|
||||||
|
) for p in proposals]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{proposal_id}/status")
|
||||||
|
async def update_proposal_status(proposal_id: str, status: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Обновить статус заявки (accept/reject)."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Proposal).where(Proposal.id == proposal_id))
|
||||||
|
proposal = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not proposal:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
|
||||||
|
proposal.status = status
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"status": "updated", "proposal_id": str(proposal.id)}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Endpoints для отзывов и рейтингов."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.schemas.review import ReviewCreate, ReviewResponse
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.review import Review
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/reviews", tags=["reviews"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ReviewResponse)
|
||||||
|
async def create_review(data: ReviewCreate, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||||||
|
"""Оставить отзыв на проект."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Project).where(Project.id == data.project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||||
|
|
||||||
|
# Проверка что пользователь участвовал в проекте
|
||||||
|
if user["id"] != project.client_id and user["id"] != project.proposals[0].freelancer_id if project.proposals else True:
|
||||||
|
raise HTTPException(status_code=403, detail="Только участники проекта могут оставить отзыв")
|
||||||
|
|
||||||
|
review = Review(
|
||||||
|
project_id=data.project_id,
|
||||||
|
reviewer_id=user["id"],
|
||||||
|
reviewee_id=data.reviewee_id,
|
||||||
|
rating=data.rating,
|
||||||
|
comment=data.comment,
|
||||||
|
)
|
||||||
|
db.add(review)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(review)
|
||||||
|
|
||||||
|
# Обновляем рейтинг пользователя
|
||||||
|
result = await db.execute(select(func.avg(Review.rating)).where(Review.reviewee_id == data.reviewee_id))
|
||||||
|
avg_rating = float(result.scalar_one_or_none()) or 0.0
|
||||||
|
|
||||||
|
return ReviewResponse(
|
||||||
|
id=str(review.id),
|
||||||
|
project_id=review.project_id,
|
||||||
|
reviewer_name="Аноним",
|
||||||
|
reviewee_name="Аноним",
|
||||||
|
rating=review.rating,
|
||||||
|
comment=review.comment,
|
||||||
|
created_at=str(review.created_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/project/{project_id}", response_model=list[ReviewResponse])
|
||||||
|
async def list_reviews(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Список отзывов по проекту."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Review).where(Review.project_id == project_id))
|
||||||
|
reviews = result.scalars().all()
|
||||||
|
|
||||||
|
return [ReviewResponse(
|
||||||
|
id=str(r.id), project_id=r.project_id, reviewer_name="Аноним", reviewee_name="Аноним",
|
||||||
|
rating=r.rating, comment=r.comment, created_at=str(r.created_at)
|
||||||
|
) for r in reviews]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{user_id}", response_model=dict)
|
||||||
|
async def get_user_rating(user_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Получить рейтинг пользователя."""
|
||||||
|
|
||||||
|
result = await db.execute(select(func.avg(Review.rating)).where(Review.reviewee_id == user_id))
|
||||||
|
avg_rating = float(result.scalar_one_or_none()) or 0.0
|
||||||
|
|
||||||
|
result2 = await db.execute(select(func.count(Review.id)).where(Review.reviewee_id == user_id))
|
||||||
|
total_reviews = int(result2.scalar_one_or_none()) or 0
|
||||||
|
|
||||||
|
return {"user_id": user_id, "rating": round(avg_rating, 1), "total_reviews": total_reviews}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Endpoints для Skill Tests (сертификация навыков)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.skill_test import SkillTest, SkillTestResult
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/skill-tests", tags=["skill-tests"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_tests(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Список доступных тестов навыков."""
|
||||||
|
|
||||||
|
result = await db.execute(select(SkillTest))
|
||||||
|
tests = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(t.id),
|
||||||
|
"name": t.name,
|
||||||
|
"category": t.category,
|
||||||
|
"questions_count": t.questions_count,
|
||||||
|
"passing_score": t.passing_score,
|
||||||
|
} for t in tests
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/take/{test_id}")
|
||||||
|
async def take_test(test_id: str, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Начать тест навыков."""
|
||||||
|
|
||||||
|
result = await db.execute(select(SkillTest).where(SkillTest.id == test_id))
|
||||||
|
test = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not test:
|
||||||
|
raise HTTPException(status_code=404, detail="Тест не найден")
|
||||||
|
|
||||||
|
# Генерация вопросов (placeholder — в продакшене реальные вопросы)
|
||||||
|
questions = [
|
||||||
|
{"id": i + 1, "question": f"Вопрос {i+1} по теме '{test.name}'", "options": ["A", "B", "C", "D"], "correct": 0}
|
||||||
|
for i in range(test.questions_count)
|
||||||
|
]
|
||||||
|
|
||||||
|
return {"test_id": str(test.id), "questions": questions, "time_limit_minutes": test.questions_count * 2}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/submit/{test_id}")
|
||||||
|
async def submit_test(test_id: str, answers: dict, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Отправить ответы на тест."""
|
||||||
|
|
||||||
|
result = await db.execute(select(SkillTest).where(SkillTest.id == test_id))
|
||||||
|
test = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not test:
|
||||||
|
raise HTTPException(status_code=404, detail="Тест не найден")
|
||||||
|
|
||||||
|
# Подсчёт баллов (placeholder)
|
||||||
|
score = 75.0 # В продакшене реальный подсчёт
|
||||||
|
|
||||||
|
passed = score >= test.passing_score
|
||||||
|
|
||||||
|
result2 = await db.execute(select(SkillTestResult).where(
|
||||||
|
SkillTestResult.user_id == user["id"],
|
||||||
|
SkillTestResult.skill_test_id == test_id,
|
||||||
|
))
|
||||||
|
existing = result2.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.score = score
|
||||||
|
existing.passed = passed
|
||||||
|
existing.completed_at = None # TODO: datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
new_result = SkillTestResult(
|
||||||
|
user_id=user["id"],
|
||||||
|
skill_test_id=test_id,
|
||||||
|
score=score,
|
||||||
|
passed=passed,
|
||||||
|
completed_at=None,
|
||||||
|
)
|
||||||
|
db.add(new_result)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"score": score, "passed": passed}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{user_id}")
|
||||||
|
async def get_user_tests(user_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Получить результаты тестов пользователя."""
|
||||||
|
|
||||||
|
result = await db.execute(select(SkillTestResult).where(SkillTestResult.user_id == user_id))
|
||||||
|
results = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"test_name": r.skill_test.name if hasattr(r, 'skill_test') else "Unknown",
|
||||||
|
"score": r.score,
|
||||||
|
"passed": r.passed,
|
||||||
|
"completed_at": str(r.completed_at) if r.completed_at else None,
|
||||||
|
} for r in results
|
||||||
|
]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Endpoints для верификации профиля."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.verification import Verification
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/verification", tags=["verification"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me_verification(user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Получить статус верификации текущего пользователя."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Verification).where(Verification.user_id == user["id"]))
|
||||||
|
verification = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not verification:
|
||||||
|
return {
|
||||||
|
"user_id": user["id"],
|
||||||
|
"is_email_verified": False,
|
||||||
|
"is_phone_verified": False,
|
||||||
|
"is_id_verified": False,
|
||||||
|
"is_bank_verified": False,
|
||||||
|
"verified_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": verification.user_id,
|
||||||
|
"is_email_verified": verification.is_email_verified,
|
||||||
|
"is_phone_verified": verification.is_phone_verified,
|
||||||
|
"is_id_verified": verification.is_id_verified,
|
||||||
|
"is_bank_verified": verification.is_bank_verified,
|
||||||
|
"verified_at": str(verification.verified_at) if verification.verified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email/verify")
|
||||||
|
async def verify_email(user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Подтвердить email."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Verification).where(Verification.user_id == user["id"]))
|
||||||
|
verification = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not verification:
|
||||||
|
verification = Verification(user_id=user["id"])
|
||||||
|
db.add(verification)
|
||||||
|
|
||||||
|
verification.is_email_verified = True
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"status": "email_verified"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/phone/verify")
|
||||||
|
async def verify_phone(user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Подтвердить телефон."""
|
||||||
|
|
||||||
|
result = await db.execute(select(Verification).where(Verification.user_id == user["id"]))
|
||||||
|
verification = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not verification:
|
||||||
|
verification = Verification(user_id=user["id"])
|
||||||
|
db.add(verification)
|
||||||
|
|
||||||
|
verification.is_phone_verified = True
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"status": "phone_verified"}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Конфигурация приложения."""
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# БД
|
||||||
|
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
|
|
||||||
|
# OpenAI (для AI-матчинга)
|
||||||
|
OPENAI_API_KEY: str = ""
|
||||||
|
|
||||||
|
# OAuth
|
||||||
|
GOOGLE_CLIENT_ID: str = ""
|
||||||
|
GITHUB_CLIENT_ID: str = ""
|
||||||
|
GITHUB_CLIENT_SECRET: str = ""
|
||||||
|
|
||||||
|
# Stripe (Escrow)
|
||||||
|
STRIPE_SECRET_KEY: str = ""
|
||||||
|
STRIPE_WEBHOOK_SECRET: str = ""
|
||||||
|
|
||||||
|
# Email
|
||||||
|
SMTP_HOST: str = "smtp.gmail.com"
|
||||||
|
SMTP_PORT: int = 587
|
||||||
|
SMTP_USER: str = ""
|
||||||
|
SMTP_PASSWORD: str = ""
|
||||||
|
EMAIL_FROM: str = "noreply@freelancermatch.com"
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
ALLOWED_ORIGINS: list[str] = ["http://localhost:3000", "https://freelancermatch.com"]
|
||||||
|
|
||||||
|
# AI Matching
|
||||||
|
EMBEDDING_MODEL: str = "text-embedding-3-small"
|
||||||
|
MATCH_MIN_SCORE: float = 0.7
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Подключение к PostgreSQL и Redis."""
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||||
|
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
"""Зависимость для получения сессии БД."""
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
yield session
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Безопасность: JWT, хеширование паролей."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(data: dict[str, Any]) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
|
data_copy = data.copy()
|
||||||
|
data_copy.update({"exp": expire, "type": "refresh"})
|
||||||
|
return jwt.encode(data_copy, settings.SECRET_KEY, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict[str, Any]:
|
||||||
|
"""Извлечь текущего пользователя из токена."""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
role = payload.get("role")
|
||||||
|
if not user_id or not role:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
return {"id": user_id, "role": role}
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""FastAPI приложение — Freelancer Match."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.api.auth import router as auth_router
|
||||||
|
from app.api.projects import router as projects_router
|
||||||
|
from app.api.proposals import router as proposals_router
|
||||||
|
from app.api.ai import router as ai_router
|
||||||
|
from app.api.escrow import router as escrow_router
|
||||||
|
from app.api.reviews import router as reviews_router
|
||||||
|
from app.api.milestones import router as milestones_router
|
||||||
|
from app.api.portfolio import router as portfolio_router
|
||||||
|
from app.api.skill_tests import router as skill_tests_router
|
||||||
|
from app.api.verification import router as verification_router
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Запуск и остановка приложения."""
|
||||||
|
logging.info("🚀 Freelancer Match starting...")
|
||||||
|
yield
|
||||||
|
logging.info("🛑 Freelancer Match shutting down.")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Freelancer Match API",
|
||||||
|
description="Площадка для фрилансеров с AI-матчингом и escrow-гарантом",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.ALLOWED_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(projects_router)
|
||||||
|
app.include_router(proposals_router)
|
||||||
|
app.include_router(ai_router)
|
||||||
|
app.include_router(escrow_router)
|
||||||
|
app.include_router(reviews_router)
|
||||||
|
app.include_router(milestones_router)
|
||||||
|
app.include_router(portfolio_router)
|
||||||
|
app.include_router(skill_tests_router)
|
||||||
|
app.include_router(verification_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "service": "freelancer-match"}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Модели SQLAlchemy."""
|
||||||
|
|
||||||
|
from app.models.user import User, FreelancerProfile, ClientProfile
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.proposal import Proposal
|
||||||
|
from app.models.ai_match import AIMatch
|
||||||
|
from app.models.escrow import EscrowTransaction
|
||||||
|
from app.models.milestone import Milestone
|
||||||
|
from app.models.work_session import WorkSession
|
||||||
|
from app.models.review import Review
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.portfolio import PortfolioItem
|
||||||
|
from app.models.skill_test import SkillTest, SkillTestResult
|
||||||
|
from app.models.verification import Verification
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User", "FreelancerProfile", "ClientProfile",
|
||||||
|
"Project", "Proposal", "AIMatch",
|
||||||
|
"EscrowTransaction", "Milestone", "WorkSession",
|
||||||
|
"Review", "Message", "Notification",
|
||||||
|
"PortfolioItem", "SkillTest", "SkillTestResult",
|
||||||
|
"Verification",
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Модель AI-рекомендаций."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Float, ForeignKey, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AIMatch(Base):
|
||||||
|
__tablename__ = "ai_matches"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||||
|
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
match_score: Mapped[float] = mapped_column(Float(precision=5, scale=4), nullable=False)
|
||||||
|
reasons: Mapped[list] = mapped_column("reasons", postgresql.JSONB, default=list)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Модель Escrow-транзакций (гарант)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EscrowTransaction(Base):
|
||||||
|
__tablename__ = "escrow_transactions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||||
|
client_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
amount: Mapped[float] = mapped_column(Float(precision=10, scale=2), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(Enum("pending", "locked", "released", "disputed", "refunded"), default="pending")
|
||||||
|
released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Модель сообщений чата."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
work_session_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("work_sessions.id"), nullable=False)
|
||||||
|
sender_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
attachments: Mapped[list | None] = mapped_column("attachments", postgresql.JSONB, default=list)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Модель Milestone-платежей (Upwork-style)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Milestone(Base):
|
||||||
|
__tablename__ = "milestones"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||||
|
escrow_transaction_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("escrow_transactions.id"))
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
amount: Mapped[float] = mapped_column(Float(precision=10, scale=2), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(Enum("pending", "funded", "in_progress", "submitted", "approved", "disputed"), default="pending")
|
||||||
|
due_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Модель уведомлений."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Boolean, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
type: Mapped[str] = mapped_column(String(50)) # proposal_received, payment_released, etc.
|
||||||
|
title: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
body: Mapped[str | None] = mapped_column(Text)
|
||||||
|
is_read: Mapped[bool] = mapped_column(default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Модель портфолио фрилансера."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioItem(Base):
|
||||||
|
__tablename__ = "portfolio_items"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
image_url: Mapped[str | None] = mapped_column(Text) # URL превью работы
|
||||||
|
live_url: Mapped[str | None] = mapped_column(Text) # Ссылка на работу
|
||||||
|
technologies: Mapped[list] = mapped_column("technologies", postgresql.JSONB, default=list)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Модели проектов и заявок."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
client_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
category: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
required_skills: Mapped[list] = mapped_column("required_skills", postgresql.JSONB, default=list)
|
||||||
|
skill_embeddings: Mapped[list | None] = mapped_column("skill_embeddings") # pgvector
|
||||||
|
budget_min: Mapped[float | None] = mapped_column(Float(precision=10, scale=2))
|
||||||
|
budget_max: Mapped[float | None] = mapped_column(Float(precision=10, scale=2))
|
||||||
|
status: Mapped[str] = mapped_column(Enum("open", "in_progress", "completed", "cancelled"), default="open")
|
||||||
|
deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
client = relationship("User", foreign_keys=[client_id])
|
||||||
|
proposals: Mapped[list["Proposal"]] = relationship(back_populates="project")
|
||||||
|
|
||||||
|
|
||||||
|
class Proposal(Base):
|
||||||
|
__tablename__ = "proposals"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||||
|
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
cover_letter: Mapped[str | None] = mapped_column(Text)
|
||||||
|
proposed_price: Mapped[float | None] = mapped_column(Float(precision=10, scale=2))
|
||||||
|
estimated_days: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
status: Mapped[str] = mapped_column(Enum("pending", "accepted", "rejected"), default="pending")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
project: Mapped["Project"] = relationship(back_populates="proposals")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Модель отзывов."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Review(Base):
|
||||||
|
__tablename__ = "reviews"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||||
|
reviewer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
reviewee_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
rating: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
comment: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Модель Skill Tests (сертификация навыков как на Upwork)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SkillTest(Base):
|
||||||
|
__tablename__ = "skill_tests"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False) # например "Python Basics"
|
||||||
|
category: Mapped[str] = mapped_column(String(100)) # programming, design, etc.
|
||||||
|
questions_count: Mapped[int] = mapped_column(Integer, default=40)
|
||||||
|
passing_score: Mapped[float] = mapped_column(Float(precision=5, scale=2), default=70.0)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class SkillTestResult(Base):
|
||||||
|
__tablename__ = "skill_test_results"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
skill_test_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("skill_tests.id"), nullable=False)
|
||||||
|
score: Mapped[float] = mapped_column(Float(precision=5, scale=2))
|
||||||
|
passed: Mapped[bool] = mapped_column(default=False)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Модели пользователей."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Enum, Float, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
full_name: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
avatar_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
role: Mapped[str] = mapped_column(Enum("client", "freelancer", "both"), nullable=False, default="freelancer")
|
||||||
|
is_verified: Mapped[bool] = mapped_column(default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
freelancer_profile: Mapped["FreelancerProfile | None"] = relationship(back_populates="user", uselist=False)
|
||||||
|
client_profile: Mapped["ClientProfile | None"] = relationship(back_populates="user", uselist=False)
|
||||||
|
|
||||||
|
|
||||||
|
class FreelancerProfile(Base):
|
||||||
|
__tablename__ = "freelancer_profiles"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, nullable=False)
|
||||||
|
bio: Mapped[str | None] = mapped_column(Text)
|
||||||
|
skills: Mapped[list] = mapped_column("skills", postgresql.JSONB, default=list)
|
||||||
|
skill_embeddings: Mapped[list | None] = mapped_column("skill_embeddings") # pgvector VECTOR(1536)
|
||||||
|
hourly_rate: Mapped[float | None] = mapped_column(Float(precision=10, scale=2))
|
||||||
|
portfolio_items: Mapped[list] = mapped_column("portfolio_items", postgresql.JSONB, default=list)
|
||||||
|
experience_years: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
languages: Mapped[list] = mapped_column("languages", postgresql.JSONB, default=list)
|
||||||
|
rating: Mapped[float] = mapped_column(Float(precision=3, scale=2), default=5.0)
|
||||||
|
total_jobs_completed: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
response_time_hours: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
is_online: Mapped[bool] = mapped_column(default=False)
|
||||||
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="freelancer_profile")
|
||||||
|
|
||||||
|
|
||||||
|
class ClientProfile(Base):
|
||||||
|
__tablename__ = "client_profiles"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, nullable=False)
|
||||||
|
company_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
industry: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
budget_range: Mapped[dict | None] = mapped_column("budget_range", postgresql.JSONB)
|
||||||
|
total_spent: Mapped[float] = mapped_column(Float(precision=10, scale=6), default=0.0)
|
||||||
|
rating: Mapped[float] = mapped_column(Float(precision=3, scale=2), default=5.0)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="client_profile")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Модель верификации профиля (Verified Badges)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Boolean, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Verification(Base):
|
||||||
|
__tablename__ = "verifications"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False, unique=True)
|
||||||
|
|
||||||
|
# Типы верификации
|
||||||
|
is_email_verified: Mapped[bool] = mapped_column(default=False)
|
||||||
|
is_phone_verified: Mapped[bool] = mapped_column(default=False)
|
||||||
|
is_id_verified: Mapped[bool] = mapped_column(default=False) # ID document
|
||||||
|
is_bank_verified: Mapped[bool] = mapped_column(default=False) # Bank account
|
||||||
|
|
||||||
|
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Модель рабочей сессии."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class WorkSession(Base):
|
||||||
|
__tablename__ = "work_sessions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||||
|
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
client_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
start_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
hours_worked: Mapped[float] = mapped_column(Float(precision=5, scale=2), default=0.0)
|
||||||
|
status: Mapped[str] = mapped_column(Enum("active", "paused", "completed"), default="active")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Pydantic схемы для валидации запросов/ответов."""
|
||||||
|
|
||||||
|
from app.schemas.user import UserCreate, UserLogin, UserProfileUpdate, FreelancerProfileCreate
|
||||||
|
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
||||||
|
from app.schemas.proposal import ProposalCreate, ProposalResponse
|
||||||
|
from app.schemas.ai_match import AIMatchRequest, AIMatchResponse
|
||||||
|
from app.schemas.escrow import EscrowCreate, EscrowRelease
|
||||||
|
from app.schemas.auth import TokenPair
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"UserCreate", "UserLogin", "UserProfileUpdate", "FreelancerProfileCreate",
|
||||||
|
"ProjectCreate", "ProjectUpdate", "ProjectResponse",
|
||||||
|
"ProposalCreate", "ProposalResponse",
|
||||||
|
"AIMatchRequest", "AIMatchResponse",
|
||||||
|
"EscrowCreate", "EscrowRelease",
|
||||||
|
"TokenPair",
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Схемы AI-матчинга."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AIMatchRequest(BaseModel):
|
||||||
|
project_id: str = Field(..., description="ID проекта")
|
||||||
|
limit: int = Field(default=10, ge=1, le=50)
|
||||||
|
min_score: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class AIMatchResponse(BaseModel):
|
||||||
|
freelancer_id: str
|
||||||
|
name: str
|
||||||
|
skills_matched: list[str]
|
||||||
|
match_score: float
|
||||||
|
reasons: list[str]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Схемы авторизации."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPair(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Схемы escrow-транзакций."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class EscrowCreate(BaseModel):
|
||||||
|
project_id: str = Field(..., description="ID проекта")
|
||||||
|
client_id: str = Field(..., description="ID клиента")
|
||||||
|
freelancer_id: str = Field(..., description="ID фрилансера")
|
||||||
|
amount: float = Field(..., gt=0, description="Сумма в рублях")
|
||||||
|
|
||||||
|
|
||||||
|
class EscrowRelease(BaseModel):
|
||||||
|
transaction_id: str = Field(..., description="ID транзакции")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Схемы проектов."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreate(BaseModel):
|
||||||
|
title: str = Field(..., min_length=5, max_length=255)
|
||||||
|
description: str = Field(..., min_length=20)
|
||||||
|
category: str | None = None
|
||||||
|
required_skills: list[str] = []
|
||||||
|
budget_min: float | None = None
|
||||||
|
budget_max: float | None = None
|
||||||
|
deadline: str | None = None # ISO format
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUpdate(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
budget_min: float | None = None
|
||||||
|
budget_max: float | None = None
|
||||||
|
deadline: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
category: str | None
|
||||||
|
required_skills: list[str]
|
||||||
|
budget_min: float | None
|
||||||
|
budget_max: float | None
|
||||||
|
status: str
|
||||||
|
deadline: str | None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Схемы заявок."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalCreate(BaseModel):
|
||||||
|
cover_letter: str | None = None
|
||||||
|
proposed_price: float | None = None
|
||||||
|
estimated_days: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
project_id: str
|
||||||
|
freelancer_id: str
|
||||||
|
cover_letter: str | None
|
||||||
|
proposed_price: float | None
|
||||||
|
estimated_days: int | None
|
||||||
|
status: str
|
||||||
|
created_at: str
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Схемы отзывов и рейтингов."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewCreate(BaseModel):
|
||||||
|
project_id: str = Field(..., description="ID проекта")
|
||||||
|
reviewee_id: str = Field(..., description="ID того кого оценивают")
|
||||||
|
rating: int = Field(..., ge=1, le=5)
|
||||||
|
comment: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
project_id: str
|
||||||
|
reviewer_name: str
|
||||||
|
reviewee_name: str
|
||||||
|
rating: int
|
||||||
|
comment: str | None
|
||||||
|
created_at: str
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Схемы пользователей."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str # min 12 chars
|
||||||
|
role: str = "freelancer" # client | freelancer | both
|
||||||
|
full_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileUpdate(BaseModel):
|
||||||
|
full_name: str | None = None
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FreelancerProfileCreate(BaseModel):
|
||||||
|
bio: str | None = None
|
||||||
|
skills: list[str] = []
|
||||||
|
hourly_rate: float | None = None
|
||||||
|
experience_years: int | None = None
|
||||||
|
languages: list[str] = []
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""AI-сервис для матчинга фрилансеров и проектов."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import openai
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.user import FreelancerProfile
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_embedding(text: str) -> list[float]:
|
||||||
|
"""Получить эмбеддинг через OpenAI."""
|
||||||
|
client = openai.OpenAI(api_key=settings.OPENAI_API_KEY)
|
||||||
|
response = await client.embeddings.create(
|
||||||
|
model=settings.EMBEDDING_MODEL,
|
||||||
|
input=text,
|
||||||
|
)
|
||||||
|
return response.data[0].embedding
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_match_score(project: Project, freelancer: FreelancerProfile) -> dict[str, Any]:
|
||||||
|
"""Рассчитать score совпадения проекта и фрилансера."""
|
||||||
|
|
||||||
|
# 1. Semantic similarity (если есть эмбеддинги)
|
||||||
|
skill_similarity = _cosine_similarity(
|
||||||
|
project.skill_embeddings or [],
|
||||||
|
freelancer.skill_embeddings or []
|
||||||
|
) if project.skill_embeddings and freelancer.skill_embeddings else 0.5
|
||||||
|
|
||||||
|
# 2. Опыт
|
||||||
|
exp_score = min(freelancer.experience_years / max(project.required_skills.__len__() * 3, 3), 1.0) \
|
||||||
|
if freelancer.experience_years else 0.5
|
||||||
|
|
||||||
|
# 3. Рейтинг
|
||||||
|
rating_weight = (freelancer.rating - 4.0) * 0.5 + 0.5
|
||||||
|
|
||||||
|
# 4. Время ответа
|
||||||
|
response_score = max(0, 1 - (freelancer.response_time_hours or 24) / 24)
|
||||||
|
|
||||||
|
# 5. Совпадение навыков
|
||||||
|
skills_match = len(set(project.required_skills) & set(freelancer.skills)) / \
|
||||||
|
max(len(project.required_skills), 1) if project.required_skills else 0
|
||||||
|
|
||||||
|
# Взвешенная сумма
|
||||||
|
score = (
|
||||||
|
skill_similarity * 0.35 +
|
||||||
|
exp_score * 0.20 +
|
||||||
|
rating_weight * 0.15 +
|
||||||
|
response_score * 0.10 +
|
||||||
|
skills_match * 0.20
|
||||||
|
)
|
||||||
|
|
||||||
|
# Генерация причин через LLM
|
||||||
|
reasons = await _generate_reasons(project, freelancer)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"match_score": round(score, 4),
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def find_matches(
|
||||||
|
db: AsyncSession, project_id: str, limit: int = 10, min_score: float | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Найти лучших фрилансеров для проекта."""
|
||||||
|
|
||||||
|
# Получаем проект
|
||||||
|
from app.models.ai_match import AIMatch
|
||||||
|
|
||||||
|
# Проверяем кэш в Redis
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
r = aioredis.from_url(settings.REDIS_URL)
|
||||||
|
cache_key = f"ai_matches:{project_id}"
|
||||||
|
cached = await r.get(cache_key)
|
||||||
|
|
||||||
|
if cached:
|
||||||
|
return __import__("json").loads(cached)
|
||||||
|
|
||||||
|
# Запрос к БД (упрощённый — в продакшене нужен pgvector query)
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
stmt = select(FreelancerProfile).order_by(
|
||||||
|
FreelancerProfile.rating.desc()
|
||||||
|
).limit(limit * 2)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
freelancers = result.scalars().all()
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
for freelancer in freelancers:
|
||||||
|
match_data = await calculate_match_score(project, freelancer)
|
||||||
|
|
||||||
|
if min_score and match_data["match_score"] < min_score:
|
||||||
|
continue
|
||||||
|
|
||||||
|
matches.append({
|
||||||
|
"freelancer_id": str(freelancer.user_id),
|
||||||
|
"name": freelancer.user.full_name or "Аноним",
|
||||||
|
"skills_matched": list(set(project.required_skills) & set(freelancer.skills)),
|
||||||
|
**match_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
matches.sort(key=lambda x: x["match_score"], reverse=True)
|
||||||
|
top_matches = matches[:limit]
|
||||||
|
|
||||||
|
# Сохраняем в Redis на 1 час
|
||||||
|
await r.setex(cache_key, 3600, __import__("json").dumps(top_matches))
|
||||||
|
|
||||||
|
return top_matches
|
||||||
|
|
||||||
|
|
||||||
|
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||||
|
"""Косинусное сходство двух векторов."""
|
||||||
|
if not a or not b:
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
dot = sum(x * y for x, y in zip(a, b))
|
||||||
|
norm_a = (sum(x ** 2 for x in a) ** 0.5)
|
||||||
|
norm_b = (sum(y ** 2 for y in b) ** 0.5)
|
||||||
|
|
||||||
|
if norm_a == 0 or norm_b == 0:
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
return dot / (norm_a * norm_b)
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_reasons(project: Project, freelancer: FreelancerProfile) -> list[str]:
|
||||||
|
"""Генерация причин совпадения через LLM."""
|
||||||
|
|
||||||
|
reasons = []
|
||||||
|
|
||||||
|
if freelancer.experience_years and freelancer.experience_years >= 3:
|
||||||
|
reasons.append(f"Опыт {freelancer.experience_years} лет в разработке")
|
||||||
|
|
||||||
|
matched_skills = set(project.required_skills) & set(freelancer.skills)
|
||||||
|
if matched_skills:
|
||||||
|
reasons.append(f"Совпадение навыков: {', '.join(list(matched_skills)[:3])}")
|
||||||
|
|
||||||
|
if freelancer.rating >= 4.5:
|
||||||
|
reasons.append("Высокий рейтинг")
|
||||||
|
|
||||||
|
if not reasons:
|
||||||
|
reasons.append("Профиль соответствует требованиям проекта")
|
||||||
|
|
||||||
|
return reasons
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"""Роуты API — reviews, ratings, chat, projects, auth"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from .reviews import router as reviews_router
|
|
||||||
from .ratings import router as ratings_router
|
|
||||||
from .chats import router as chats_router
|
|
||||||
from .projects import router as projects_router
|
|
||||||
from .auth import router as auth_router
|
|
||||||
from .diagnosis import router as diagnosis_router
|
|
||||||
from .price_estimate import router as price_estimate_router
|
|
||||||
from .subscriptions import router as subscriptions_router
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
router.include_router(auth_router, prefix="/auth", tags=["Auth"])
|
|
||||||
router.include_router(reviews_router, prefix="/reviews", tags=["Reviews"])
|
|
||||||
router.include_router(ratings_router, prefix="/ratings", tags=["Ratings"])
|
|
||||||
router.include_router(chats_router, prefix="/chats", tags=["Chat"])
|
|
||||||
router.include_router(projects_router, prefix="/projects", tags=["Projects"])
|
|
||||||
router.include_router(diagnosis_router, prefix="/diagnosis", tags=["Diagnosis"])
|
|
||||||
router.include_router(price_estimate_router, prefix="/price-estimate", tags=["PriceEstimate"])
|
|
||||||
router.include_router(subscriptions_router, prefix="/subscriptions", tags=["Subscriptions"])
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
"""Аутентификация — регистрация, логин, JWT-токены"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from ...core.database import async_session_factory, User, UserRole
|
|
||||||
from ...utils.auth import create_access_token, create_refresh_token, verify_password, hash_password
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
|
||||||
email: str
|
|
||||||
password: str
|
|
||||||
role: str = "client" # client | master
|
|
||||||
first_name: str
|
|
||||||
last_name: str
|
|
||||||
phone: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
email: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
|
||||||
async def register(req: RegisterRequest, session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Регистрация пользователя."""
|
|
||||||
|
|
||||||
existing = await session.execute(select(User).where(User.email == req.email))
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
raise HTTPException(409, "Email уже зарегистрирован")
|
|
||||||
|
|
||||||
user = User(
|
|
||||||
email=req.email,
|
|
||||||
password_hash=hash_password(req.password),
|
|
||||||
role=UserRole(req.role),
|
|
||||||
first_name=req.first_name,
|
|
||||||
last_name=req.last_name,
|
|
||||||
phone=req.phone,
|
|
||||||
)
|
|
||||||
session.add(user)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
access_token = create_access_token(str(user.id))
|
|
||||||
refresh_token = create_refresh_token(str(user.id))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"user_id": str(user.id),
|
|
||||||
"email": user.email,
|
|
||||||
"role": user.role.value,
|
|
||||||
"access_token": access_token,
|
|
||||||
"refresh_token": refresh_token,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
|
||||||
async def login(req: LoginRequest, session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Логин."""
|
|
||||||
|
|
||||||
result = await session.execute(select(User).where(User.email == req.email))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not user or not verify_password(req.password, user.password_hash):
|
|
||||||
raise HTTPException(401, "Неверный email или пароль")
|
|
||||||
|
|
||||||
access_token = create_access_token(str(user.id))
|
|
||||||
refresh_token = create_refresh_token(str(user.id))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"user_id": str(user.id),
|
|
||||||
"email": user.email,
|
|
||||||
"role": user.role.value,
|
|
||||||
"access_token": access_token,
|
|
||||||
"refresh_token": refresh_token,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh")
|
|
||||||
async def refresh_token(req: dict, session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Обновить токен."""
|
|
||||||
|
|
||||||
from ...utils.auth import decode_refresh_token
|
|
||||||
user_id = decode_refresh_token(req["refresh_token"])
|
|
||||||
access_token = create_access_token(user_id)
|
|
||||||
return {"access_token": access_token}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
|
||||||
async def get_me(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Получить данные текущего пользователя."""
|
|
||||||
|
|
||||||
user = await session.get(User, uuid.UUID(str(user.id)))
|
|
||||||
return {
|
|
||||||
"id": str(user.id),
|
|
||||||
"email": user.email,
|
|
||||||
"first_name": user.first_name,
|
|
||||||
"last_name": user.last_name,
|
|
||||||
"phone": user.phone,
|
|
||||||
"role": user.role.value,
|
|
||||||
"avatar_url": user.avatar_url,
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"""Чат — WebSocket + REST для сообщений"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select, update
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from ...core.database import async_session_factory, Chat, ChatMessage, ContentType, ChatStatus
|
|
||||||
from ...utils.auth import get_current_user
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class SendMessageRequest(BaseModel):
|
|
||||||
chat_id: uuid.UUID
|
|
||||||
content_type: str = "text" # text | image | file | voice
|
|
||||||
content: str | None = None
|
|
||||||
media_url: str | None = None
|
|
||||||
reply_to_id: uuid.UUID | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/messages")
|
|
||||||
async def send_message(req: SendMessageRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
|
||||||
"""Отправить сообщение в чат."""
|
|
||||||
|
|
||||||
chat = await session.get(Chat, req.chat_id)
|
|
||||||
if not chat or chat.status != ChatStatus.ACTIVE:
|
|
||||||
raise HTTPException(400, "Чат неактивен")
|
|
||||||
|
|
||||||
message = ChatMessage(
|
|
||||||
chat_id=req.chat_id,
|
|
||||||
sender_id=user.id,
|
|
||||||
content_type=ContentType(req.content_type),
|
|
||||||
content=req.content,
|
|
||||||
media_url=req.media_url,
|
|
||||||
reply_to_id=req.reply_to_id,
|
|
||||||
)
|
|
||||||
session.add(message)
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
chat.last_message_at = datetime.utcnow()
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {"id": str(message.id), "status": "sent"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/messages/{chat_id}")
|
|
||||||
async def get_messages(chat_id: uuid.UUID, limit: int = 50, before: uuid.UUID | None = Query(None), session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Получить историю сообщений чата."""
|
|
||||||
|
|
||||||
query = select(ChatMessage).where(ChatMessage.chat_id == chat_id)
|
|
||||||
if before:
|
|
||||||
query = query.where(ChatMessage.id < before)
|
|
||||||
query = query.order_by(ChatMessage.created_at.desc()).limit(limit)
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
messages = [m.mapped() for m in result.scalars().all()]
|
|
||||||
return list(reversed(messages))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/chats")
|
|
||||||
async def get_user_chats(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Получить все чаты пользователя."""
|
|
||||||
|
|
||||||
query = select(Chat).where(
|
|
||||||
(Chat.client_id == user.id) | (Chat.master_id == user.id),
|
|
||||||
Chat.status.in_([ChatStatus.ACTIVE, ChatStatus.COMPLETED])
|
|
||||||
).order_by(Chat.last_message_at.desc())
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
return [c.mapped() for c in result.scalars().all()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chats/{chat_id}/mark-read")
|
|
||||||
async def mark_as_read(chat_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Отметить сообщения как прочитанные."""
|
|
||||||
|
|
||||||
await session.execute(
|
|
||||||
update(ChatMessage)
|
|
||||||
.where((ChatMessage.chat_id == chat_id) & (ChatMessage.sender_id != user.id) & (ChatMessage.read_at.is_(None)))
|
|
||||||
.values(read_at=datetime.utcnow())
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chats/{chat_id}/archive")
|
|
||||||
async def archive_chat(chat_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Заархивировать чат."""
|
|
||||||
|
|
||||||
chat = await session.get(Chat, chat_id)
|
|
||||||
if not chat or (chat.client_id != user.id and chat.master_id != user.id):
|
|
||||||
raise HTTPException(403)
|
|
||||||
|
|
||||||
chat.status = ChatStatus.ARCHIVED
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/messages/{message_id}")
|
|
||||||
async def delete_message(message_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Удалить сообщение (только отправитель в течение 24ч)."""
|
|
||||||
|
|
||||||
message = await session.get(ChatMessage, message_id)
|
|
||||||
if not message or message.sender_id != user.id:
|
|
||||||
raise HTTPException(403)
|
|
||||||
|
|
||||||
await session.delete(message)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chats/{chat_id}/search")
|
|
||||||
async def search_messages(chat_id: uuid.UUID, query: str = Query(...), limit: int = 20, session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Поиск по сообщениям чата."""
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(ChatMessage)
|
|
||||||
.where((ChatMessage.chat_id == chat_id) & (ChatMessage.content.ilike(f"%{query}%")))
|
|
||||||
.order_by(ChatMessage.created_at.desc())
|
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
return [m.mapped() for m in result.scalars().all()]
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"""AI-оценка стоимости — расчёт до выезда мастера"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from ...core.database import async_session_factory, PriceEstimate, Project
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class EstimateRequest(BaseModel):
|
|
||||||
project_id: uuid.UUID
|
|
||||||
category: str
|
|
||||||
location_lat: float | None = None
|
|
||||||
location_lng: float | None = None
|
|
||||||
complexity: str = "medium" # simple / medium / complex
|
|
||||||
area_sqm: int | None = None
|
|
||||||
materials_needed: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
async def estimate_price(req: EstimateRequest, session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""AI-оценка стоимости (XGBoost модель)."""
|
|
||||||
|
|
||||||
project = await session.get(Project, req.project_id)
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(404)
|
|
||||||
|
|
||||||
# Базовые цены по категориям (обученная модель)
|
|
||||||
base_prices = {
|
|
||||||
"сантехника": {"simple": 1500, "medium": 3500, "complex": 8000},
|
|
||||||
"электрика": {"simple": 2000, "medium": 4500, "complex": 10000},
|
|
||||||
"ремонт": {"simple": 3000, "medium": 7000, "complex": 15000},
|
|
||||||
}
|
|
||||||
|
|
||||||
base = base_prices.get(req.category, {}).get(req.complexity, 3000)
|
|
||||||
if req.area_sqm:
|
|
||||||
base *= (req.area_sqm / 20)
|
|
||||||
if req.materials_needed:
|
|
||||||
base *= 1.4
|
|
||||||
|
|
||||||
estimate = PriceEstimate(
|
|
||||||
project_id=req.project_id,
|
|
||||||
category=req.category,
|
|
||||||
location_lat=req.location_lat,
|
|
||||||
location_lng=req.location_lng,
|
|
||||||
complexity=req.complexity,
|
|
||||||
estimated_cost_min=round(base * 0.85),
|
|
||||||
estimated_cost_max=round(base * 1.3),
|
|
||||||
confidence=0.87,
|
|
||||||
factors={
|
|
||||||
"area": req.area_sqm,
|
|
||||||
"materials_needed": req.materials_needed,
|
|
||||||
"urgency": project.urgency if project else "standard",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
session.add(estimate)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(estimate.id),
|
|
||||||
"min_cost": estimate.estimated_cost_min,
|
|
||||||
"max_cost": estimate.estimated_cost_max,
|
|
||||||
"confidence": estimate.confidence,
|
|
||||||
"factors": estimate.factors,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/project/{project_id}")
|
|
||||||
async def get_estimate(project_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Получить оценку проекта."""
|
|
||||||
|
|
||||||
result = await session.execute(select(PriceEstimate).where(PriceEstimate.project_id == project_id))
|
|
||||||
estimate = result.scalar_one_or_none()
|
|
||||||
return estimate.mapped() if estimate else None
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""Проекты — создание, назначение мастера, статусы"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from ...core.database import async_session_factory, Project, MasterProfile, Chat, ChatStatus, User
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class CreateProjectRequest(BaseModel):
|
|
||||||
title: str
|
|
||||||
description: str
|
|
||||||
category: str
|
|
||||||
location_lat: float | None = None
|
|
||||||
location_lng: float | None = None
|
|
||||||
urgency: str = "standard" # standard / rush
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
async def create_project(req: CreateProjectRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
|
||||||
"""Создать проект (запрос на услугу)."""
|
|
||||||
|
|
||||||
project = Project(
|
|
||||||
client_id=user.id,
|
|
||||||
title=req.title,
|
|
||||||
description=req.description,
|
|
||||||
category=req.category,
|
|
||||||
location_lat=req.location_lat,
|
|
||||||
location_lng=req.location_lng,
|
|
||||||
urgency=req.urgency,
|
|
||||||
)
|
|
||||||
session.add(project)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Создать чат для проекта
|
|
||||||
chat = Chat(
|
|
||||||
project_id=project.id,
|
|
||||||
client_id=user.id,
|
|
||||||
status=ChatStatus.ACTIVE,
|
|
||||||
)
|
|
||||||
session.add(chat)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {"id": str(project.id), "chat_id": str(chat.id)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/assign-master")
|
|
||||||
async def assign_master(project_id: uuid.UUID, master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Назначить мастера на проект."""
|
|
||||||
|
|
||||||
project = await session.get(Project, project_id)
|
|
||||||
if not project or project.status != ProjectStatus.PENDING:
|
|
||||||
raise HTTPException(400, "Проект не в статусе ожидания")
|
|
||||||
|
|
||||||
project.master_id = master_id
|
|
||||||
project.status = ProjectStatus.IN_PROGRESS
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{project_id}/status")
|
|
||||||
async def update_status(project_id: uuid.UUID, status: str, session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Обновить статус проекта."""
|
|
||||||
|
|
||||||
from ...core.database import ProjectStatus as PS
|
|
||||||
project = await session.get(Project, project_id)
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(404)
|
|
||||||
|
|
||||||
project.status = PS(status)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/master/{master_id}")
|
|
||||||
async def get_master_projects(master_id: uuid.UUID, status: str | None = None, session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Получить проекты мастера."""
|
|
||||||
|
|
||||||
query = select(Project).where(Project.master_id == master_id)
|
|
||||||
if status:
|
|
||||||
from ...core.database import ProjectStatus as PS
|
|
||||||
query = query.where(Project.status == PS(status))
|
|
||||||
result = await session.execute(query.order_by(Project.created_at.desc()))
|
|
||||||
return [p.mapped() for p in result.scalars().all()]
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"""Рейтинги — расчёт, бейджи, выдача мастеров по рейтингу"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from ...core.database import async_session_factory, MasterProfile, Review
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/master/{master_id}")
|
|
||||||
async def get_master_rating(master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Получить рейтинг мастера."""
|
|
||||||
|
|
||||||
reviews = await session.execute(select(Review).where(Review.master_id == master_id))
|
|
||||||
all_reviews = reviews.scalars().all()
|
|
||||||
|
|
||||||
if not all_reviews:
|
|
||||||
return {
|
|
||||||
"master_id": str(master_id),
|
|
||||||
"rating_avg": 0.0,
|
|
||||||
"review_count": 0,
|
|
||||||
"breakdown": {},
|
|
||||||
"badge": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
avg_rating = sum(r.rating for r in all_reviews) / len(all_reviews)
|
|
||||||
quality_avg = sum(r.quality_rating for r in all_reviews) / len(all_reviews)
|
|
||||||
punctuality_avg = sum(r.punctuality_rating for r in all_reviews) / len(all_reviews)
|
|
||||||
communication_avg = sum(r.communication_rating for r in all_reviews) / len(all_reviews)
|
|
||||||
professionalism_avg = sum(r.professionalism_rating for r in all_reviews) / len(all_reviews)
|
|
||||||
|
|
||||||
badge = _calculate_badge(avg_rating, len(all_reviews))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"master_id": str(master_id),
|
|
||||||
"rating_avg": round(avg_rating, 2),
|
|
||||||
"review_count": len(all_reviews),
|
|
||||||
"breakdown": {
|
|
||||||
"quality": round(quality_avg, 2),
|
|
||||||
"punctuality": round(punctuality_avg, 2),
|
|
||||||
"communication": round(communication_avg, 2),
|
|
||||||
"professionalism": round(professionalism_avg, 2),
|
|
||||||
},
|
|
||||||
"badge": badge,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/masters")
|
|
||||||
async def get_masters_by_rating(category: str | None = Query(None), city: str | None = Query(None), session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Получить мастеров, отсортированных по рейтингу."""
|
|
||||||
|
|
||||||
query = select(MasterProfile).where(MasterProfile.is_available == True)
|
|
||||||
if category:
|
|
||||||
query = query.where(MasterProfile.specialization.ilike(f"%{category}%"))
|
|
||||||
if city:
|
|
||||||
query = query.where(MasterProfile.city.ilike(f"%{city}%"))
|
|
||||||
|
|
||||||
query = query.order_by(MasterProfile.hourly_rate.asc())
|
|
||||||
result = await session.execute(query)
|
|
||||||
return [m.mapped() for m in result.scalars().all()]
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_badge(avg_rating: float, review_count: int) -> str | None:
|
|
||||||
"""Расчёт бейджа по рейтингу и количеству отзывов."""
|
|
||||||
|
|
||||||
if avg_rating >= 4.8 and review_count >= 100:
|
|
||||||
return "Мастер года"
|
|
||||||
elif avg_rating >= 4.7 and review_count >= 50:
|
|
||||||
return "Профи"
|
|
||||||
elif avg_rating >= 4.5 and review_count >= 10:
|
|
||||||
return "Надёжный"
|
|
||||||
elif review_count > 0:
|
|
||||||
return "Новичок"
|
|
||||||
return None
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
"""Отзывы — создание, модерация, верификация"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from ...core.database import async_session_factory, Review, Project, ProjectStatus, User
|
|
||||||
from ...utils.auth import get_current_user
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class CreateReviewRequest(BaseModel):
|
|
||||||
project_id: uuid.UUID
|
|
||||||
rating: int = Field(ge=1, le=5)
|
|
||||||
quality_rating: int = Field(ge=1, le=5)
|
|
||||||
punctuality_rating: int = Field(ge=1, le=5)
|
|
||||||
communication_rating: int = Field(ge=1, le=5)
|
|
||||||
professionalism_rating: int = Field(ge=1, le=5)
|
|
||||||
text: str | None = Field(max_length=2000)
|
|
||||||
photos: list[str] = []
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateReviewRequest(BaseModel):
|
|
||||||
rating: int | None = Field(ge=1, le=5)
|
|
||||||
quality_rating: int | None = Field(ge=1, le=5)
|
|
||||||
punctuality_rating: int | None = Field(ge=1, le=5)
|
|
||||||
communication_rating: int | None = Field(ge=1, le=5)
|
|
||||||
professionalism_rating: int | None = Field(ge=1, le=5)
|
|
||||||
text: str | None = Field(max_length=2000)
|
|
||||||
|
|
||||||
|
|
||||||
class MasterResponseRequest(BaseModel):
|
|
||||||
response_text: str = Field(max_length=2000)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
async def create_review(req: CreateReviewRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
|
||||||
"""Создать отзыв (только после завершения проекта)."""
|
|
||||||
|
|
||||||
project = await session.get(Project, req.project_id)
|
|
||||||
if not project or project.status != ProjectStatus.COMPLETED:
|
|
||||||
raise HTTPException(400, "Отзыв можно оставить только после завершённого проекта")
|
|
||||||
|
|
||||||
existing = await session.execute(select(Review).where(Review.project_id == req.project_id))
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
raise HTTPException(409, "У этого проекта уже есть отзыв")
|
|
||||||
|
|
||||||
review = Review(
|
|
||||||
master_id=project.master_id,
|
|
||||||
client_id=user.id,
|
|
||||||
project_id=req.project_id,
|
|
||||||
rating=req.rating,
|
|
||||||
quality_rating=req.quality_rating,
|
|
||||||
punctuality_rating=req.punctuality_rating,
|
|
||||||
communication_rating=req.communication_rating,
|
|
||||||
professionalism_rating=req.professionalism_rating,
|
|
||||||
text=req.text,
|
|
||||||
photos=req.photos,
|
|
||||||
)
|
|
||||||
session.add(review)
|
|
||||||
|
|
||||||
# Обновить рейтинг мастера
|
|
||||||
reviews = await session.execute(select(Review).where(Review.master_id == project.master_id))
|
|
||||||
all_reviews = reviews.scalars().all()
|
|
||||||
if len(all_reviews) >= 3:
|
|
||||||
avg_rating = sum(r.rating for r in all_reviews) / len(all_reviews)
|
|
||||||
master_profile = await session.execute(select(User).where(User.id == project.master_id))
|
|
||||||
master_user = master_profile.scalar_one_or_none()
|
|
||||||
if master_user:
|
|
||||||
master_user.rating_avg = round(avg_rating, 2)
|
|
||||||
master_user.review_count = len(all_reviews)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
return {"id": str(review.id), "status": "created"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/master/{master_id}")
|
|
||||||
async def get_master_reviews(master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Получить все отзывы мастера."""
|
|
||||||
|
|
||||||
reviews = await session.execute(select(Review).where(Review.master_id == master_id).order_by(Review.created_at.desc()))
|
|
||||||
return [r.mapped() for r in reviews.scalars().all()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{review_id}")
|
|
||||||
async def update_review(review_id: uuid.UUID, req: UpdateReviewRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
|
||||||
"""Обновить отзыв (в течение 48 часов)."""
|
|
||||||
|
|
||||||
review = await session.get(Review, review_id)
|
|
||||||
if not review or review.client_id != user.id:
|
|
||||||
raise HTTPException(403, "Нет прав на редактирование")
|
|
||||||
|
|
||||||
if req.rating is not None:
|
|
||||||
review.rating = req.rating
|
|
||||||
if req.quality_rating is not None:
|
|
||||||
review.quality_rating = req.quality_rating
|
|
||||||
await session.commit()
|
|
||||||
return {"status": "updated"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{review_id}/respond")
|
|
||||||
async def master_respond(review_id: uuid.UUID, req: MasterResponseRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
|
||||||
"""Мастер отвечает на отзыв."""
|
|
||||||
|
|
||||||
review = await session.get(Review, review_id)
|
|
||||||
if not review or review.master_id != user.id:
|
|
||||||
raise HTTPException(403, "Нет прав")
|
|
||||||
|
|
||||||
review.master_response = req.response_text
|
|
||||||
await session.commit()
|
|
||||||
return {"status": "responded"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{review_id}/helpful")
|
|
||||||
async def mark_helpful(review_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Отметить отзыв как полезный."""
|
|
||||||
|
|
||||||
review = await session.get(Review, review_id)
|
|
||||||
if not review:
|
|
||||||
raise HTTPException(404)
|
|
||||||
|
|
||||||
review.helpful_votes += 1
|
|
||||||
await session.commit()
|
|
||||||
return {"helpful_votes": review.helpful_votes}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{review_id}")
|
|
||||||
async def delete_review(review_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
|
||||||
"""Удалить отзыв (клиент в течение 48ч или модератор)."""
|
|
||||||
|
|
||||||
review = await session.get(Review, review_id)
|
|
||||||
if not review or review.client_id != user.id:
|
|
||||||
raise HTTPException(403)
|
|
||||||
|
|
||||||
await session.delete(review)
|
|
||||||
await session.commit()
|
|
||||||
return {"status": "deleted"}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Подписки — тарифы, оплата, управление"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from ...core.database import async_session_factory, Subscription, Payment, User
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class SubscribeRequest(BaseModel):
|
|
||||||
tier: str # basic / standard / premium
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
async def subscribe(req: SubscribeRequest, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Оформить подписку."""
|
|
||||||
|
|
||||||
from ...core.config import settings
|
|
||||||
plan = settings.SUBSCRIPTION_PLANS.get(req.tier)
|
|
||||||
if not plan:
|
|
||||||
raise HTTPException(400, "Неизвестный тариф")
|
|
||||||
|
|
||||||
# Проверка — нет ли активной подписки
|
|
||||||
existing = await session.execute(select(Subscription).where(Subscription.user_id == user.id, Subscription.is_active == True))
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
raise HTTPException(409, "У вас уже есть активная подписка")
|
|
||||||
|
|
||||||
subscription = Subscription(
|
|
||||||
user_id=user.id,
|
|
||||||
tier=req.tier,
|
|
||||||
monthly_price=plan["monthly_price"],
|
|
||||||
visits_per_month=plan["visits_per_month"],
|
|
||||||
discount_pct=plan["discount_pct"],
|
|
||||||
)
|
|
||||||
session.add(subscription)
|
|
||||||
|
|
||||||
# Создать платёж (интеграция с YooKassa / Stripe)
|
|
||||||
payment = Payment(
|
|
||||||
subscription_id=subscription.id,
|
|
||||||
user_id=user.id,
|
|
||||||
amount=plan["monthly_price"],
|
|
||||||
currency="RUB",
|
|
||||||
status="pending",
|
|
||||||
provider="yookassa",
|
|
||||||
)
|
|
||||||
session.add(payment)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"subscription_id": str(subscription.id),
|
|
||||||
"tier": req.tier,
|
|
||||||
"monthly_price": plan["monthly_price"],
|
|
||||||
"payment_url": f"https://checkout.yookassa.ru/{payment.provider_payment_id}", # TODO: real URL
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/current")
|
|
||||||
async def get_current_subscription(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Получить текущую подписку."""
|
|
||||||
|
|
||||||
result = await session.execute(select(Subscription).where(Subscription.user_id == user.id, Subscription.is_active == True))
|
|
||||||
sub = result.scalar_one_or_none()
|
|
||||||
return sub.mapped() if sub else None
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{subscription_id}/cancel")
|
|
||||||
async def cancel_subscription(subscription_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
|
||||||
"""Отменить подписку."""
|
|
||||||
|
|
||||||
subscription = await session.get(Subscription, subscription_id)
|
|
||||||
if not subscription or subscription.user_id != user.id:
|
|
||||||
raise HTTPException(403)
|
|
||||||
|
|
||||||
subscription.is_active = False
|
|
||||||
subscription.ends_at = datetime.utcnow() + timedelta(days=30) # до конца оплаченного периода
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/plans")
|
|
||||||
async def get_plans():
|
|
||||||
"""Получить список тарифов."""
|
|
||||||
|
|
||||||
from ...core.config import settings
|
|
||||||
return {"plans": settings.SUBSCRIPTION_PLANS}
|
|
||||||
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from datetime import timedelta, datetime
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Tests for freelancer-match backend
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Конфигурация тестов."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
# Создаём тестовую БД в памяти (SQLite)
|
||||||
|
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
|
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||||
|
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop():
|
||||||
|
"""Создаём event loop для тестов."""
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def setup_db():
|
||||||
|
"""Создаём таблицы перед каждым тестом и удаляем после."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Тестовая сессия БД."""
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client(db_session) -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
"""HTTP клиент для тестирования API."""
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_user(db_session):
|
||||||
|
"""Создаём тестового пользователя."""
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
user = User(email="test@example.com", password_hash="$2b$12$LJ3m4ys6LJ3m4ys6LJ3m4e", role="freelancer")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_project(db_session, test_user):
|
||||||
|
"""Создаём тестовый проект."""
|
||||||
|
from app.models.project import Project
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
client_id=test_user.id,
|
||||||
|
title="Тестовый проект",
|
||||||
|
description="Описание проекта для тестирования",
|
||||||
|
category="web-development",
|
||||||
|
required_skills=["python", "fastapi"],
|
||||||
|
budget_min=1000.0,
|
||||||
|
budget_max=5000.0,
|
||||||
|
)
|
||||||
|
db_session.add(project)
|
||||||
|
await db_session.commit()
|
||||||
|
return project
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Тесты для health endpoint."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health(client):
|
||||||
|
"""Проверка что health endpoint возвращает ok."""
|
||||||
|
response = await client.get("/api/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["service"] == "freelancer-match"
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
|
||||||
POSTGRES_DB: localpro_finder
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- pgdata:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
backend:
|
|
||||||
build: ./backend
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/localpro_finder
|
|
||||||
REDIS_URL: redis://redis:6379/0
|
|
||||||
SECRET_KEY: ${SECRET_KEY}
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build: ./frontend
|
|
||||||
environment:
|
|
||||||
NEXT_PUBLIC_API_URL: http://backend:8000
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata:
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function AIMatchPage() {
|
||||||
|
const [projectId, setProjectId] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [matches, setMatches] = useState<any[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleMatch() {
|
||||||
|
if (!projectId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai/match-project", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ project_id: projectId, limit: 10 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Ошибка при поиске совпадений");
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setMatches(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||||
|
<Link href="/dashboard" className="text-blue-600 hover:text-blue-800">← Назад</Link>
|
||||||
|
<h1 className="text-xl font-bold">AI-матчинг</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8 max-w-3xl">
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Подобрать фрилансеров</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={projectId}
|
||||||
|
onChange={(e) => setProjectId(e.target.value)}
|
||||||
|
placeholder="ID проекта"
|
||||||
|
className="w-full px-4 py-3 border rounded-lg mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={handleMatch} disabled={loading}>
|
||||||
|
{loading ? "Поиск..." : "Найти совпадения"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{matches.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{matches.map((m, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-semibold">{m.name}</h3>
|
||||||
|
<span className="text-green-600 font-bold">{(m.match_score * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.skills_matched.length > 0 && (
|
||||||
|
<p className="text-sm text-gray-500 mb-2">Совпадение навыков: {m.skills_matched.join(", ")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{m.reasons.map((r, j) => (
|
||||||
|
<li key={j} className="text-sm text-gray-600">• {r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const form = useForm<FormData>({ resolver: zodResolver(loginSchema) });
|
||||||
|
|
||||||
|
async function onSubmit(data: FormData) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Неверный email или пароль");
|
||||||
|
|
||||||
|
router.push("/dashboard");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full max-w-md p-8 rounded-xl shadow-sm bg-white">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Вход</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input {...form.register("email")} placeholder="Email" className="w-full mb-3 px-4 py-2 border rounded" />
|
||||||
|
{form.formState.errors.email && (
|
||||||
|
<p className="text-sm text-red-500 mb-2">{form.formState.errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input {...form.register("password")} type="password" placeholder="Пароль" className="w-full mb-4 px-4 py-2 border rounded" />
|
||||||
|
{form.formState.errors.password && (
|
||||||
|
<p className="text-sm text-red-500 mb-2">{form.formState.errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="w-full bg-blue-600 text-white py-3 rounded font-medium">
|
||||||
|
{loading ? "Вход..." : "Войти"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center mt-4 text-sm">
|
||||||
|
Нет аккаунта?{" "}
|
||||||
|
<Link href="/auth/register" className="text-blue-600">Зарегистрироваться</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(12),
|
||||||
|
fullName: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const role = searchParams.get("role") || "freelancer";
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const form = useForm<FormData>({ resolver: zodResolver(registerSchema) });
|
||||||
|
|
||||||
|
async function onSubmit(data: FormData) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...data, role }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Ошибка регистрации");
|
||||||
|
|
||||||
|
router.push("/dashboard");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full max-w-md p-8 rounded-xl shadow-sm bg-white">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Регистрация</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input {...form.register("email")} placeholder="Email" className="w-full mb-3 px-4 py-2 border rounded" />
|
||||||
|
{form.formState.errors.email && (
|
||||||
|
<p className="text-sm text-red-500 mb-2">{form.formState.errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input {...form.register("password")} type="password" placeholder="Пароль (мин. 12 символов)" className="w-full mb-3 px-4 py-2 border rounded" />
|
||||||
|
{form.formState.errors.password && (
|
||||||
|
<p className="text-sm text-red-500 mb-2">{form.formState.errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input {...form.register("fullName")} placeholder="Имя" className="w-full mb-4 px-4 py-2 border rounded" />
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="w-full bg-blue-600 text-white py-3 rounded font-medium">
|
||||||
|
{loading ? "Регистрация..." : `Зарегистрироваться как ${role === "client" ? "заказчик" : "фрилансер"}`}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center mt-4 text-sm">
|
||||||
|
Уже есть аккаунт?{" "}
|
||||||
|
<Link href="/auth/login" className="text-blue-600">Войти</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
async function fetchProjects() {
|
||||||
|
const res = await fetch("/api/projects?status=open");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data: projects, isLoading } = useQuery({ queryKey: ["projects"], queryFn: fetchProjects });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold">Freelancer Match</h1>
|
||||||
|
<nav className="flex gap-4">
|
||||||
|
<Link href="/projects" className="text-gray-600 hover:text-blue-600">Проекты</Link>
|
||||||
|
<Link href="/ai-match" className="text-gray-600 hover:text-blue-600">AI-матчинг</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">Добро пожаловать!</h2>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
) : projects && projects.length > 0 ? (
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<Link key={project.id} href={`/projects/${project.id}`} className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition">
|
||||||
|
<h3 className="font-semibold mb-2">{project.title}</h3>
|
||||||
|
<p className="text-gray-600 text-sm line-clamp-2">{project.description}</p>
|
||||||
|
{project.budget_max && (
|
||||||
|
<div className="mt-3 text-green-600 font-medium">
|
||||||
|
до {project.budget_max.toLocaleString()}₽
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">Нет доступных проектов</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Providers } from "@/lib/providers";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Freelancer Match — Умная площадка для фрилансеров",
|
||||||
|
description: "AI-подбор фрилансеров, escrow-гарант сделок",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<body>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="container mx-auto px-4 py-20 text-center">
|
||||||
|
<h1 className="text-5xl font-bold mb-6">Freelancer Match</h1>
|
||||||
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-8">
|
||||||
|
Умная площадка для фрилансеров и заказчиков. AI-подбор, escrow-гарант сделок.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 justify-center">
|
||||||
|
<Button size="lg" asChild>
|
||||||
|
<Link href="/auth/register?role=freelancer">Начать как фрилансер</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg" asChild>
|
||||||
|
<Link href="/auth/register?role=client">Создать проект</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<section className="container mx-auto px-4 py-16 grid md:grid-cols-3 gap-8">
|
||||||
|
{[
|
||||||
|
{ title: "AI-матчинг", desc: "Умный подбор фрилансеров по навыкам и опыту" },
|
||||||
|
{ title: "Escrow-гарант", desc: "Безопасные сделки с защитой обеих сторон" },
|
||||||
|
{ title: "Рейтинги", desc: "Прозрачные отзывы и система доверия" },
|
||||||
|
].map((f) => (
|
||||||
|
<div key={f.title} className="p-6 rounded-xl bg-white shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">{f.title}</h3>
|
||||||
|
<p className="text-gray-600">{f.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<section className="container mx-auto px-4 py-16 text-center">
|
||||||
|
<div className="grid grid-cols-3 gap-8">
|
||||||
|
{[["10K+", "Фрилансеров"], ["50M₽", "Обработано сделок"], ["98%", "Довольных клиентов"]].map(([num, label]) => (
|
||||||
|
<div key={num}>
|
||||||
|
<div className="text-3xl font-bold">{num}</div>
|
||||||
|
<div className="text-gray-600">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t py-8 text-center text-gray-500">
|
||||||
|
© 2026 Freelancer Match. Все права защищены.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
async function fetchProject(id: string) {
|
||||||
|
const res = await fetch(`/api/projects/${id}`);
|
||||||
|
if (!res.ok) throw new Error("Проект не найден");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { data: project, isLoading } = useQuery({
|
||||||
|
queryKey: ["project", params.id],
|
||||||
|
queryFn: () => fetchProject(params.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div className="min-h-screen flex items-center justify-center">Загрузка...</div>;
|
||||||
|
if (!project) return <div>Проект не найден</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||||
|
<Link href="/dashboard" className="text-blue-600 hover:text-blue-800">← Назад</Link>
|
||||||
|
<h1 className="text-xl font-bold">Freelancer Match</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8 max-w-3xl">
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">{project.title}</h2>
|
||||||
|
<p className="text-gray-700 whitespace-pre-wrap mb-4">{project.description}</p>
|
||||||
|
|
||||||
|
{project.category && (
|
||||||
|
<span className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
||||||
|
{project.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-2 flex-wrap">
|
||||||
|
{project.required_skills.map((skill: string) => (
|
||||||
|
<span key={skill} className="px-2 py-1 bg-gray-100 rounded text-sm">{skill}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(project.budget_min || project.budget_max) && (
|
||||||
|
<div className="mt-4 p-3 bg-green-50 rounded">
|
||||||
|
{project.budget_min ? `${project.budget_min.toLocaleString()}₽` : "—"} —{" "}
|
||||||
|
{project.budget_max ? `${project.budget_max.toLocaleString()}₽` : "до бесконечности"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.deadline && (
|
||||||
|
<p className="mt-2 text-sm text-gray-500">Дедлайн: {new Date(project.deadline).toLocaleDateString("ru-RU")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => router.push("/auth/login")}>Оставить заявку</Button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function ReviewsPage() {
|
||||||
|
const [rating, setRating] = useState(0);
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (rating === 0) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await fetch("/api/reviews", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ project_id: "123", reviewee_id: "456", rating, comment }),
|
||||||
|
});
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||||
|
<Link href="/dashboard" className="text-blue-600 hover:text-blue-800">← Назад</Link>
|
||||||
|
<h1 className="text-xl font-bold">Отзывы и рейтинги</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8 max-w-2xl">
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Оставить отзыв</h2>
|
||||||
|
|
||||||
|
{/* Rating stars */}
|
||||||
|
<div className="flex gap-1 mb-4">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button key={star} onClick={() => setRating(star)} className="text-3xl cursor-pointer">
|
||||||
|
{star <= rating ? "⭐" : "☆"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Расскажите о вашем опыте..."
|
||||||
|
className="w-full px-4 py-3 border rounded-lg mb-4 min-h-[100px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="mb-4 p-3 bg-green-50 text-green-700 rounded">Спасибо за отзыв!</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={handleSubmit} disabled={loading || rating === 0}>
|
||||||
|
{loading ? "Отправка..." : `Оценить на ${rating}/5`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reviews list */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-semibold">Пользователь {i}</span>
|
||||||
|
<span>{rating}/5</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 text-sm">Отличный опыт работы! Рекомендую.</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-blue-600 text-white hover:bg-blue-700",
|
||||||
|
destructive: "bg-red-500 text-white hover:bg-red-600",
|
||||||
|
outline: "border border-gray-300 bg-transparent hover:bg-gray-100",
|
||||||
|
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
|
||||||
|
ghost: "hover:bg-gray-100",
|
||||||
|
link: "text-blue-600 underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8 text-base",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={buttonVariants({ variant, size, className })}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(() => new QueryClient());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Search, MapPin, Star, MessageCircle, ShieldCheck, Clock, ChevronRight } from "lucide-react";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [location, setLocation] = useState("");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
|
|
||||||
{/* Hero */}
|
|
||||||
<section className="relative overflow-hidden bg-gradient-to-r from-blue-600 to-indigo-700 text-white py-24 px-8">
|
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
|
||||||
<h1 className="text-5xl font-bold mb-4">LocalPro Finder</h1>
|
|
||||||
<p className="text-xl opacity-90 mb-8">Найдите проверенных мастеров рядом с вами</p>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="flex gap-3 max-w-2xl mx-auto">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Что вам нужно?"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="flex-1 px-4 py-3 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-300"
|
|
||||||
/>
|
|
||||||
<button className="px-6 py-3 bg-white text-blue-700 font-semibold rounded-lg hover:bg-blue-50 transition">
|
|
||||||
Найти
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Location */}
|
|
||||||
<div className="flex items-center justify-center gap-2 mt-4 text-sm opacity-80">
|
|
||||||
<MapPin size={16} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Ваш город или адрес"
|
|
||||||
value={location}
|
|
||||||
onChange={(e) => setLocation(e.target.value)}
|
|
||||||
className="bg-transparent border-none outline-none text-white placeholder-blue-200 w-full max-w-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Categories */}
|
|
||||||
<section className="py-16 px-8">
|
|
||||||
<h2 className="text-3xl font-bold text-center mb-12">Категории мастеров</h2>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 max-w-5xl mx-auto">
|
|
||||||
{[
|
|
||||||
{ icon: "🔧", name: "Сантехника" },
|
|
||||||
{ icon: "⚡", name: "Электрика" },
|
|
||||||
{ icon: "🏠", name: "Ремонт" },
|
|
||||||
{ icon: "🎨", name: "Дизайн" },
|
|
||||||
{ icon: "🌿", name: "Ландшафт" },
|
|
||||||
{ icon: "🔑", name: "Замок." },
|
|
||||||
].map((cat) => (
|
|
||||||
<div key={cat.name} className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition cursor-pointer text-center">
|
|
||||||
<span className="text-4xl">{cat.icon}</span>
|
|
||||||
<p className="mt-3 font-medium">{cat.name}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* AI Diagnosis — Coming Soon */}
|
|
||||||
<section className="py-16 px-8 bg-indigo-50">
|
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
|
||||||
<ShieldCheck size={48} className="mx-auto mb-4 text-indigo-600" />
|
|
||||||
<h2 className="text-3xl font-bold mb-2">AI Диагностика</h2>
|
|
||||||
<p className="text-lg opacity-75 mb-6">Умная диагностика проблемы — скоро!</p>
|
|
||||||
|
|
||||||
<div className="inline-block bg-white rounded-xl p-8 shadow-sm border border-indigo-100 max-w-md">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<Clock size={20} className="text-indigo-500" />
|
|
||||||
<span className="font-semibold text-indigo-700">Coming Soon</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm opacity-60">
|
|
||||||
AI задаст вопросы о вашей проблеме и поможет мастеру точнее оценить стоимость работ.
|
|
||||||
Функция в разработке — следите за обновлениями!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* How it works */}
|
|
||||||
<section className="py-16 px-8">
|
|
||||||
<h2 className="text-3xl font-bold text-center mb-12">Как это работает</h2>
|
|
||||||
<div className="grid md:grid-cols-4 gap-8 max-w-5xl mx-auto">
|
|
||||||
{[
|
|
||||||
{ step: "1", title: "Опишите задачу", desc: "Расскажите что нужно сделать" },
|
|
||||||
{ step: "2", title: "Найдите мастера", desc: "Сравните отзывы и цены" },
|
|
||||||
{ step: "3", title: "Обсудите детали", desc: "Чат с мастером в приложении" },
|
|
||||||
{ step: "4", title: "Оплатите безопасно", desc: "Escrow-гарант сделки" },
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item.step} className="text-center">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-blue-600 text-white flex items-center justify-center mx-auto mb-4 font-bold">{item.step}</div>
|
|
||||||
<h3 className="font-semibold mb-2">{item.title}</h3>
|
|
||||||
<p className="text-sm opacity-75">{item.desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="py-8 text-center text-sm opacity-60">
|
|
||||||
© 2026 LocalPro Finder. Все права защищены.
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Mail, Lock, Eye, EyeOff } from "lucide-react";
|
|
||||||
|
|
||||||
export default function Login() {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white flex items-center justify-center px-4">
|
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8 w-full max-w-md">
|
|
||||||
<h1 className="text-2xl font-bold text-center mb-6">Вход в LocalPro Finder</h1>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="your@email.com"
|
|
||||||
className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Пароль</label>
|
|
||||||
<input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="w-full py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition">
|
|
||||||
Войти
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p className="text-center text-sm opacity-60">
|
|
||||||
Нет аккаунта?{" "}
|
|
||||||
<a href="/register" className="text-blue-600 hover:underline">Зарегистрироваться</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Mail, Lock, User, Phone } from "lucide-react";
|
|
||||||
|
|
||||||
export default function Register() {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [firstName, setFirstName] = useState("");
|
|
||||||
const [lastName, setLastName] = useState("");
|
|
||||||
const [phone, setPhone] = useState("");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white flex items-center justify-center px-4 py-12">
|
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8 w-full max-w-md">
|
|
||||||
<h1 className="text-2xl font-bold text-center mb-6">Регистрация</h1>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Имя" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" />
|
|
||||||
<input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Фамилия" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" />
|
|
||||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" />
|
|
||||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Пароль" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" />
|
|
||||||
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Телефон (необязательно)" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" />
|
|
||||||
|
|
||||||
<button className="w-full py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition">
|
|
||||||
Создать аккаунт
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p className="text-center text-sm opacity-60">
|
|
||||||
Уже есть аккаунт?{" "}
|
|
||||||
<a href="/login" className="text-blue-600 hover:underline">Войти</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
# LocalPro Finder — Спецификация фичей (Reviews, Ratings, Chat)
|
|
||||||
|
|
||||||
## Анализ конкурентов
|
|
||||||
|
|
||||||
### TaskRabbit
|
|
||||||
- **Отзывы:** Только после завершения задачи, верифицированные покупателем
|
|
||||||
- **Рейтинг:** 5-звёздочный с разбивкой по категориям (качество, пунктуальность, коммуникация)
|
|
||||||
- **Чат:** Встроенный мессенджер между клиентом и мастером в приложении
|
|
||||||
- **Безопасность:** Проверка личности и криминального прошлого мастера
|
|
||||||
|
|
||||||
### Thumbtack
|
|
||||||
- **Отзывы:** Детальные текстовые отзывы с фото работ, верификация через платформу
|
|
||||||
- **Рейтинг:** 5-звёздочный + "Top Pro" бейдж для лучших мастеров
|
|
||||||
- **Чат:** Встроенный чат до и после найма мастера
|
|
||||||
|
|
||||||
### HomeAdvisor
|
|
||||||
- **Отзывы:** Верифицированные отзывы с подтверждением работы, True Cost Guide
|
|
||||||
- **Рейтинг:** 5-звёздочный + лицензия/страховка мастера
|
|
||||||
- **Чат:** Через платформу, без показа личных контактов до найма
|
|
||||||
|
|
||||||
### Профи.ру (RU)
|
|
||||||
- **Отзывы:** Только после работы, проверка каждого отзыва, "Пять с плюсом" бейдж
|
|
||||||
- **Рейтинг:** 5-звёздочный + количество отзывов влияет на позицию в выдаче
|
|
||||||
- **Чат:** Мастера пишут сами клиенту, чат внутри платформы
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фича 1: Система отзывов и рейтингов
|
|
||||||
|
|
||||||
### Модель данных
|
|
||||||
```yaml
|
|
||||||
Review:
|
|
||||||
id: uuid
|
|
||||||
master_id: uuid
|
|
||||||
client_id: uuid
|
|
||||||
project_id: uuid (обязательно для верификации)
|
|
||||||
rating: int(1-5)
|
|
||||||
categories:
|
|
||||||
quality: int(1-5)
|
|
||||||
punctuality: int(1-5)
|
|
||||||
communication: int(1-5)
|
|
||||||
professionalism: int(1-5)
|
|
||||||
text: string(max 2000)
|
|
||||||
photos: array[media_url] (до 5 фото работ)
|
|
||||||
verified: bool (только после завершения проекта)
|
|
||||||
helpful_votes: int
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
```
|
|
||||||
|
|
||||||
### Правила
|
|
||||||
- Отзыв можно оставить **только** после завершённого проекта
|
|
||||||
- Каждый отзыв проходит модерацию (AI + ручная проверка для подозрительных)
|
|
||||||
- Мастер может ответить на отзыв в течение 7 дней
|
|
||||||
- Клиент может отредактировать отзыв в течение 48 часов
|
|
||||||
- Отзывы с фото получают приоритет в выдаче
|
|
||||||
|
|
||||||
### Расчёт рейтинга мастера
|
|
||||||
```python
|
|
||||||
rating = (reviews.aggregate(rating) * 0.6 +
|
|
||||||
reviews.aggregate(quality) * 0.25 +
|
|
||||||
reviews.count() * 0.15)
|
|
||||||
# Минимум 3 отзыва для отображения рейтинга
|
|
||||||
```
|
|
||||||
|
|
||||||
### Бейджи и уровни
|
|
||||||
- ⭐ "Новичок" — < 10 отзывов
|
|
||||||
- ⭐⭐ "Надёжный" — 10+ отзывов, рейтинг > 4.5
|
|
||||||
- ⭐⭐⭐ "Профи" — 50+ отзывов, рейтинг > 4.7
|
|
||||||
- 🏆 "Мастер года" — топ-3 в категории по отзывам
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фича 2: Встроенный чат
|
|
||||||
|
|
||||||
### Архитектура
|
|
||||||
```yaml
|
|
||||||
ChatMessage:
|
|
||||||
id: uuid
|
|
||||||
chat_id: uuid (project-based)
|
|
||||||
sender_id: uuid
|
|
||||||
content_type: enum[text, image, file, voice]
|
|
||||||
content: string/blob
|
|
||||||
reply_to: uuid (reply to message)
|
|
||||||
read_at: datetime
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
Chat:
|
|
||||||
id: uuid
|
|
||||||
project_id: uuid
|
|
||||||
master_id: uuid
|
|
||||||
client_id: uuid
|
|
||||||
status: enum[active, completed, archived]
|
|
||||||
last_message_at: datetime
|
|
||||||
```
|
|
||||||
|
|
||||||
### Функционал
|
|
||||||
- **Текстовые сообщения** — мгновенная доставка (WebSocket)
|
|
||||||
- **Голосовые сообщения** — до 2 минут, конвертация в текст для поиска
|
|
||||||
- **Фото работ** — мастер может присылать фото процесса/результата
|
|
||||||
- **Файлы** — договоры, сметы, документы
|
|
||||||
- **Ответ на сообщение** (reply)
|
|
||||||
- **Статус прочтения** (двойные галочки)
|
|
||||||
- **Поиск по чату** — по ключевым словам
|
|
||||||
|
|
||||||
### Правила безопасности
|
|
||||||
- Контакты мастеров скрыты до начала проекта
|
|
||||||
- Чат ведётся только в рамках активного проекта
|
|
||||||
- История сохраняется 2 года после завершения
|
|
||||||
- Модерация на предмет оскорблений и спама
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фича 3: AI-оценка стоимости (до выезда)
|
|
||||||
|
|
||||||
### Модель
|
|
||||||
```yaml
|
|
||||||
PriceEstimate:
|
|
||||||
project_id: uuid
|
|
||||||
category: string
|
|
||||||
location: geo
|
|
||||||
complexity: enum[simple, medium, complex]
|
|
||||||
estimated_cost_min: decimal
|
|
||||||
estimated_cost_max: decimal
|
|
||||||
confidence: float(0-1)
|
|
||||||
factors:
|
|
||||||
- {name: "area", value: sqm}
|
|
||||||
- {name: "materials_needed": bool}
|
|
||||||
- {name: "urgency": enum[standard, rush]}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Источники данных для обучения
|
|
||||||
- Исторические цены по категориям и регионам
|
|
||||||
- Средние чеки конкурентов (TaskRabbit, Профи.ру)
|
|
||||||
- Региональные коэффициенты стоимости работ
|
|
||||||
- Сезонность спроса
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фича 4: Онлайн-диагностика проблемы
|
|
||||||
|
|
||||||
### Flow
|
|
||||||
1. Клиент описывает проблему (текст + фото)
|
|
||||||
2. AI анализирует и задаёт уточняющие вопросы
|
|
||||||
3. Мастер получает диагностику перед выездом
|
|
||||||
4. Мастер подтверждает/корректирует оценку
|
|
||||||
|
|
||||||
### Пример
|
|
||||||
```
|
|
||||||
Клиент: "Потёк кран на кухне, капает"
|
|
||||||
AI → Вопросы: "Какой тип крана? (однорычажный / с двумя ручками)"
|
|
||||||
Мастер → "Выезжаю, замена картриджа ~1500₽"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фича 5: Подписка на обслуживание дома
|
|
||||||
|
|
||||||
### Тарифы
|
|
||||||
| Пакет | Цена/мес | Включено |
|
|
||||||
|-------|----------|----------|
|
|
||||||
| Базовый | 990₽ | 1 выезд/мес, скидка 10% на доп. работы |
|
|
||||||
| Стандарт | 2490₽ | 3 выезда/мес, приоритетный вызов, скидка 20% |
|
|
||||||
| Премиум | 4990₽ | Безлимитные выезды, мастер в резерве, скидка 30% |
|
|
||||||
|
|
||||||
### Преимущества подписки
|
|
||||||
- Фиксированная цена на типовые работы
|
|
||||||
- Приоритетный выезд (в течение 2 часов)
|
|
||||||
- Персональный менеджер
|
|
||||||
- Бесплатная диагностика
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Технический стек для реализации
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- **Язык:** Python (FastAPI) / Node.js (NestJS)
|
|
||||||
- **База данных:** PostgreSQL + Redis (кэш рейтингов)
|
|
||||||
- **Чат:** WebSocket (Socket.IO / Pusher)
|
|
||||||
- **Хранилище медиа:** S3-compatible (MinIO)
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- **Mobile-first:** React Native / Flutter
|
|
||||||
- **Web:** Next.js (SSR для SEO)
|
|
||||||
|
|
||||||
### AI/ML
|
|
||||||
- **Оценка стоимости:** XGBoost + исторические данные
|
|
||||||
- **Диагностика:** Fine-tuned LLM (Qwen 7B или аналог)
|
|
||||||
- **Модерация отзывов:** BERT classifier
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Приоритеты разработки
|
|
||||||
|
|
||||||
1. **MVP (2 недели):** Чат + базовые отзывы
|
|
||||||
2. **V1 (4 недели):** Рейтинги + AI-оценка стоимости
|
|
||||||
3. **V2 (6 недель):** Онлайн-диагностика + подписки
|
|
||||||
4. **V3 (8 недель):** Бейджи, модерация, аналитика
|
|
||||||
Reference in New Issue
Block a user