feat: Freelancer Match — полная продакшн версия с AI-матчингом и escrow
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
# 🤝 Freelancer Match — Умная площадка для фрилансеров
|
||||||
|
|
||||||
|
Площадка с AI-матчингом и escrow-гарантом сделок.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||||
|
│ Next.js │ │ FastAPI │ │ Redis │
|
||||||
|
│ (Frontend) │◄──►│ (Backend) │◄──►│ (Cache/Queue)│
|
||||||
|
└───────────────┘ └───────┬───────┘ └───────────────┘
|
||||||
|
│
|
||||||
|
┌───────▼───────┐
|
||||||
|
│ PostgreSQL + │
|
||||||
|
│ pgvector │
|
||||||
|
└───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
| Компонент | Технология |
|
||||||
|
|-----------|------------|
|
||||||
|
| Backend | Python 3.12, FastAPI, SQLAlchemy 2.0 |
|
||||||
|
| Frontend | Next.js 14+, Tailwind CSS, shadcn/ui |
|
||||||
|
| БД | PostgreSQL 16 + pgvector (AI-эмбеддинги) |
|
||||||
|
| Кэш/Очереди | Redis 7+ |
|
||||||
|
| AI | OpenAI embeddings + LLM для матчинга |
|
||||||
|
| Платежи | Stripe Connect (escrow-гарант) |
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск инфраструктуры
|
||||||
|
docker compose up -d postgres redis
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
cp .env.example .env # настройте переменные
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Метод | Endpoint | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| POST | `/api/auth/register` | Регистрация |
|
||||||
|
| POST | `/api/auth/login` | Логин (JWT) |
|
||||||
|
| GET | `/api/projects?status=open` | Список проектов |
|
||||||
|
| POST | `/api/projects` | Создать проект |
|
||||||
|
| POST | `/api/ai/match-project` | AI-подбор фрилансеров |
|
||||||
|
| POST | `/api/escrow/create` | Создать escrow-транзакцию |
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend → Railway / AWS ECS
|
||||||
|
# Frontend → Vercel
|
||||||
|
# БД → Supabase / AWS RDS
|
||||||
|
# Redis → Upstash
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT © 2026 Freelancer Match
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
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_...
|
||||||
|
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=...
|
||||||
|
SMTP_PASSWORD=...
|
||||||
|
EMAIL_FROM=noreply@freelancermatch.com
|
||||||
|
|
||||||
|
ALLOWED_ORIGINS=["http://localhost:3000","https://freelancermatch.com"]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Alembic конфигурация для async migrations."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models import * # noqa: F401,F403 — все модели должны быть импортированы
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_online() -> None:
|
||||||
|
connectable = create_async_engine(config.get_main_option("sqlalchemy.url"))
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection):
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_migrations_online())
|
||||||
@@ -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,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,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,53 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
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.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "service": "freelancer-match"}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Модели 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.work_session import WorkSession
|
||||||
|
from app.models.review import Review
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.notification import Notification
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User", "FreelancerProfile", "ClientProfile",
|
||||||
|
"Project", "Proposal", "AIMatch",
|
||||||
|
"EscrowTransaction", "WorkSession",
|
||||||
|
"Review", "Message", "Notification",
|
||||||
|
]
|
||||||
@@ -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,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,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,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,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,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,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
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: freelancer_match
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -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,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,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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user