feat: Freelancer Match — AI-матчинг, escrow, milestones, portfolio, skill-tests, verification

This commit is contained in:
2026-07-03 15:03:30 +00:00
commit 0b785db1b3
61 changed files with 2725 additions and 0 deletions
+60
View File
@@ -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
+160
View File
@@ -0,0 +1,160 @@
# Freelancer Match — Умная площадка для фрилансеров
Площадка для фрилансеров и заказчиков с AI-подбором, escrow-гарантом сделок и рейтинговой системой.
## 🚀 Быстрый старт
### Требования
- Docker + Docker Compose
- Python 3.12+ (для локальной разработки)
- Node.js 20+ (для фронтенда)
- PostgreSQL 16+
- Redis 7+
### Запуск через Docker Compose
```bash
# 1. Скопируйте .env.example в .env и заполните переменные
cp backend/.env.example backend/.env
# 2. Запустите стек
docker compose up -d --build
# 3. Примените миграции БД
docker compose exec backend alembic upgrade head
# 4. Проверьте что всё работает
curl http://localhost:8000/api/health
```
### Локальная разработка
#### Backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
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) |
## 📁 Структура проекта
```
freelancer-match/
├── backend/ # FastAPI бэкенд
│ ├── app/
│ │ ├── api/ # API endpoints
│ │ ├── models/ # SQLAlchemy модели
│ │ ├── schemas/ # Pydantic схемы
│ │ ├── services/ # Бизнес-логика
│ │ └── core/ # Базовые модули (БД, безопасность)
│ ├── alembic/ # Миграции БД
│ ├── tests/ # Тесты pytest
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/ # Next.js фронтенд
│ ├── app/ # App Router (pages)
│ ├── components/ui/ # UI компоненты
│ ├── lib/ # Утилиты и провайдеры
│ ├── Dockerfile
│ └── package.json
├── docker-compose.yml # Стек сервисов
└── .drone.yml # CI/CD для Drone CI
```
## 🧪 Тесты
```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/login` | Вход (JWT) |
| GET | `/api/projects` | Список проектов |
| POST | `/api/projects` | Создать проект |
| POST | `/api/ai/match-project` | AI-подбор фрилансеров |
| POST | `/api/escrow/create` | Создать escrow-транзакцию |
## 🚀 Деплой на сервер
```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
+34
View File
@@ -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"]
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.env
.venv/
*.egg-info/
dist/
build/
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+34
View File
@@ -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)}] позволяет качественно выполнить задачу."
}
+84
View File
@@ -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"]}
+82
View File
@@ -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)}
+71
View File
@@ -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)}
+67
View File
@@ -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"}
+122
View File
@@ -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),
)
+73
View File
@@ -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)}
+80
View File
@@ -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}
+106
View File
@@ -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
]
+72
View File
@@ -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"}
+48
View File
@@ -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()
+19
View File
@@ -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
+49
View File
@@ -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")
+63
View File
@@ -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"}
+24
View File
@@ -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",
]
+21
View File
@@ -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())
+23
View File
@@ -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())
+21
View File
@@ -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())
+26
View File
@@ -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())
+22
View File
@@ -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())
+23
View File
@@ -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())
+47
View File
@@ -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")
+22
View File
@@ -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())
+33
View File
@@ -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))
+61
View File
@@ -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")
+27
View File
@@ -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())
+24
View File
@@ -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())
+17
View File
@@ -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",
]
+17
View File
@@ -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]
+8
View File
@@ -0,0 +1,8 @@
"""Схемы авторизации."""
from pydantic import BaseModel
class TokenPair(BaseModel):
access_token: str
refresh_token: str
+14
View File
@@ -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 транзакции")
+36
View File
@@ -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
+20
View File
@@ -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
+20
View File
@@ -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
+28
View File
@@ -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] = []
+150
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.37
alembic==1.14.1
asyncpg==0.30.0
pydantic-settings==2.7.1
pydantic[email]==2.9.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
openai==1.58.1
redis[hiredis]==5.2.1
celery==5.4.0
stripe==11.3.0
python-multipart==0.0.18
+1
View File
@@ -0,0 +1 @@
# Tests for freelancer-match backend
+83
View File
@@ -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
+13
View File
@@ -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"
+12
View File
@@ -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"]
+86
View File
@@ -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>
);
}
+73
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+20
View File
@@ -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);
}
+18
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
+66
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+51
View File
@@ -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 };
+14
View File
@@ -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>
);
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "freelancer-match-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@tanstack/react-query": "^5.60.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.2",
"tailwind-merge": "^2.6.0",
"zod": "^3.23.8",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/node": "^20.17.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+11
View File
@@ -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: [],
};
+23
View File
@@ -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"]
}