feat: завершить проект — добавить недостающие файлы, тесты и CI/CD
- backend/app/schemas/auth.py, ai_match.py, escrow.py (схемы) - frontend/components/ui/button.tsx (UI компонент) - email-validator в requirements.txt - frontend/tsconfig.json, tailwind.config.js, postcss.config.js - frontend: login page, projects/[id] page, ai-match page - Dockerfile для backend и frontend - docker-compose.yml с app-контейнерами и healthcheck - .env.example с полными переменными окружения - backend/tests/ — pytest тесты (conftest + test_health) - .drone.yml — CI/CD пайплайн для Drone CI - README.md — полный гайд по деплою
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: freelancer-match-ci
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: backend-tests
|
||||||
|
image: python:3.12-slim
|
||||||
|
commands:
|
||||||
|
- pip install pytest pytest-asyncio aiosqlite httpx
|
||||||
|
- cd backend && pytest tests/ -v
|
||||||
|
|
||||||
|
- name: frontend-lint
|
||||||
|
image: node:20-alpine
|
||||||
|
commands:
|
||||||
|
- cd frontend && npm ci
|
||||||
|
- npx next lint --dir app
|
||||||
|
|
||||||
|
- name: build-backend
|
||||||
|
image: docker:24.0
|
||||||
|
privileged: true
|
||||||
|
environment:
|
||||||
|
DOCKER_USERNAME:
|
||||||
|
from_secret: gitea_username
|
||||||
|
DOCKER_PASSWORD:
|
||||||
|
from_secret: gitea_password
|
||||||
|
commands:
|
||||||
|
- echo "$DOCKER_PASSWORD" | docker login ms.webhop.me -u "$DOCKER_USERNAME" --password-stdin
|
||||||
|
- cd backend && docker build -t ms.webhop.me/admin/freelancer-match-backend .
|
||||||
|
|
||||||
|
- name: build-frontend
|
||||||
|
image: docker:24.0
|
||||||
|
privileged: true
|
||||||
|
environment:
|
||||||
|
DOCKER_USERNAME:
|
||||||
|
from_secret: gitea_username
|
||||||
|
DOCKER_PASSWORD:
|
||||||
|
from_secret: gitea_password
|
||||||
|
commands:
|
||||||
|
- echo "$DOCKER_PASSWORD" | docker login ms.webhop.me -u "$DOCKER_USERNAME" --password-stdin
|
||||||
|
- cd frontend && docker build -t ms.webhop.me/admin/freelancer-match-frontend .
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
|
image: alpine:3.19
|
||||||
|
commands:
|
||||||
|
- apk add curl bash
|
||||||
|
- |
|
||||||
|
echo "Deploying to production..."
|
||||||
|
# TODO: Добавить SSH ключ для деплоя на сервер
|
||||||
|
# ssh root@ms.webhop.me "cd /opt/gitea && docker compose pull && docker compose up -d"
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: secret
|
||||||
|
name: gitea_username
|
||||||
|
from_secret: drone_gitea_user
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: secret
|
||||||
|
name: gitea_password
|
||||||
|
from_secret: drone_gitea_pass
|
||||||
@@ -1,70 +1,137 @@
|
|||||||
# 🤝 Freelancer Match — Умная площадка для фрилансеров
|
# Freelancer Match — Умная площадка для фрилансеров
|
||||||
|
|
||||||
Площадка с AI-матчингом и escrow-гарантом сделок.
|
Площадка для фрилансеров и заказчиков с AI-подбором, escrow-гарантом сделок и рейтинговой системой.
|
||||||
|
|
||||||
## Архитектура
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
```
|
### Требования
|
||||||
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
- Docker + Docker Compose
|
||||||
│ Next.js │ │ FastAPI │ │ Redis │
|
- Python 3.12+ (для локальной разработки)
|
||||||
│ (Frontend) │◄──►│ (Backend) │◄──►│ (Cache/Queue)│
|
- Node.js 20+ (для фронтенда)
|
||||||
└───────────────┘ └───────┬───────┘ └───────────────┘
|
- PostgreSQL 16+
|
||||||
│
|
- Redis 7+
|
||||||
┌───────▼───────┐
|
|
||||||
│ PostgreSQL + │
|
|
||||||
│ pgvector │
|
|
||||||
└───────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Стек
|
### Запуск через Docker Compose
|
||||||
|
|
||||||
| Компонент | Технология |
|
|
||||||
|-----------|------------|
|
|
||||||
| 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
|
```bash
|
||||||
# Запуск инфраструктуры
|
# 1. Скопируйте .env.example в .env и заполните переменные
|
||||||
docker compose up -d postgres redis
|
cp backend/.env.example backend/.env
|
||||||
|
|
||||||
# Backend
|
# 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
|
cd backend
|
||||||
cp .env.example .env # настройте переменные
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
uvicorn app.main:app --reload
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
# Frontend
|
#### Frontend
|
||||||
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Endpoints
|
## 📁 Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔑 Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Описание | Пример |
|
||||||
|
|------------|----------|--------|
|
||||||
|
| `DATABASE_URL` | Подключение к PostgreSQL | `postgresql+asyncpg://user:pass@host/db` |
|
||||||
|
| `REDIS_URL` | Подключение к Redis | `redis://localhost:6379/0` |
|
||||||
|
| `SECRET_KEY` | Ключ для JWT токенов | Сгенерируйте через `openssl rand -hex 32` |
|
||||||
|
| `OPENAI_API_KEY` | API ключ OpenAI для AI-матчинга | `sk-...` |
|
||||||
|
| `STRIPE_SECRET_KEY` | Ключ Stripe для escrow | `sk_test_...` |
|
||||||
|
| `SMTP_*` | Настройки email | smtp.gmail.com:587 |
|
||||||
|
|
||||||
|
## 🧪 Тесты
|
||||||
|
|
||||||
|
```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 | Описание |
|
| Метод | Endpoint | Описание |
|
||||||
|-------|----------|----------|
|
|-------|----------|----------|
|
||||||
| POST | `/api/auth/register` | Регистрация |
|
| POST | `/api/auth/register` | Регистрация пользователя |
|
||||||
| POST | `/api/auth/login` | Логин (JWT) |
|
| POST | `/api/auth/login` | Вход (JWT) |
|
||||||
| GET | `/api/projects?status=open` | Список проектов |
|
| GET | `/api/projects` | Список проектов |
|
||||||
| POST | `/api/projects` | Создать проект |
|
| POST | `/api/projects` | Создать проект |
|
||||||
| POST | `/api/ai/match-project` | AI-подбор фрилансеров |
|
| POST | `/api/ai/match-project` | AI-подбор фрилансеров |
|
||||||
| POST | `/api/escrow/create` | Создать escrow-транзакцию |
|
| POST | `/api/escrow/create` | Создать escrow-транзакцию |
|
||||||
|
|
||||||
## Деплой
|
## 🚀 Деплой на сервер
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend → Railway / AWS ECS
|
# 1. Настройте .env.production
|
||||||
# Frontend → Vercel
|
cp backend/.env.example backend/.env.production
|
||||||
# БД → Supabase / AWS RDS
|
|
||||||
# Redis → Upstash
|
# 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
|
MIT © 2026 Freelancer Match
|
||||||
|
|||||||
+10
-1
@@ -1,24 +1,33 @@
|
|||||||
|
# Database
|
||||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match
|
||||||
|
|
||||||
|
# Redis
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# JWT
|
||||||
SECRET_KEY=your-secret-key-change-in-production
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# OpenAI (для AI-матчинга)
|
||||||
OPENAI_API_KEY=sk-...
|
OPENAI_API_KEY=sk-...
|
||||||
EMBEDDING_MODEL=text-embedding-3-small
|
EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
|
||||||
|
# OAuth
|
||||||
GOOGLE_CLIENT_ID=...
|
GOOGLE_CLIENT_ID=...
|
||||||
GITHUB_CLIENT_ID=...
|
GITHUB_CLIENT_ID=...
|
||||||
GITHUB_CLIENT_SECRET=...
|
GITHUB_CLIENT_SECRET=...
|
||||||
|
|
||||||
|
# Stripe (Escrow)
|
||||||
STRIPE_SECRET_KEY=sk_test_...
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
|
||||||
|
# Email
|
||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=...
|
SMTP_USER=noreply@freelancermatch.com
|
||||||
SMTP_PASSWORD=...
|
SMTP_PASSWORD=...
|
||||||
EMAIL_FROM=noreply@freelancermatch.com
|
EMAIL_FROM=noreply@freelancermatch.com
|
||||||
|
|
||||||
|
# CORS
|
||||||
ALLOWED_ORIGINS=["http://localhost:3000","https://freelancermatch.com"]
|
ALLOWED_ORIGINS=["http://localhost:3000","https://freelancermatch.com"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Схемы AI-матчинга."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AIMatchRequest(BaseModel):
|
||||||
|
project_id: str = Field(..., description="ID проекта")
|
||||||
|
limit: int = Field(default=10, ge=1, le=50)
|
||||||
|
min_score: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class AIMatchResponse(BaseModel):
|
||||||
|
freelancer_id: str
|
||||||
|
name: str
|
||||||
|
skills_matched: list[str]
|
||||||
|
match_score: float
|
||||||
|
reasons: list[str]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Схемы авторизации."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPair(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Схемы escrow-транзакций."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class EscrowCreate(BaseModel):
|
||||||
|
project_id: str = Field(..., description="ID проекта")
|
||||||
|
client_id: str = Field(..., description="ID клиента")
|
||||||
|
freelancer_id: str = Field(..., description="ID фрилансера")
|
||||||
|
amount: float = Field(..., gt=0, description="Сумма в рублях")
|
||||||
|
|
||||||
|
|
||||||
|
class EscrowRelease(BaseModel):
|
||||||
|
transaction_id: str = Field(..., description="ID транзакции")
|
||||||
@@ -4,6 +4,7 @@ sqlalchemy[asyncio]==2.0.37
|
|||||||
alembic==1.14.1
|
alembic==1.14.1
|
||||||
asyncpg==0.30.0
|
asyncpg==0.30.0
|
||||||
pydantic-settings==2.7.1
|
pydantic-settings==2.7.1
|
||||||
|
pydantic[email]==2.9.2
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
openai==1.58.1
|
openai==1.58.1
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Tests for freelancer-match backend
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Конфигурация тестов."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
# Создаём тестовую БД в памяти (SQLite)
|
||||||
|
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
|
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||||
|
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop():
|
||||||
|
"""Создаём event loop для тестов."""
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def setup_db():
|
||||||
|
"""Создаём таблицы перед каждым тестом и удаляем после."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Тестовая сессия БД."""
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client(db_session) -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
"""HTTP клиент для тестирования API."""
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_user(db_session):
|
||||||
|
"""Создаём тестового пользователя."""
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
user = User(email="test@example.com", password_hash="$2b$12$LJ3m4ys6LJ3m4ys6LJ3m4e", role="freelancer")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_project(db_session, test_user):
|
||||||
|
"""Создаём тестовый проект."""
|
||||||
|
from app.models.project import Project
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
client_id=test_user.id,
|
||||||
|
title="Тестовый проект",
|
||||||
|
description="Описание проекта для тестирования",
|
||||||
|
category="web-development",
|
||||||
|
required_skills=["python", "fastapi"],
|
||||||
|
budget_min=1000.0,
|
||||||
|
budget_max=5000.0,
|
||||||
|
)
|
||||||
|
db_session.add(project)
|
||||||
|
await db_session.commit()
|
||||||
|
return project
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Тесты для health endpoint."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health(client):
|
||||||
|
"""Проверка что health endpoint возвращает ok."""
|
||||||
|
response = await client.get("/api/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["service"] == "freelancer-match"
|
||||||
+36
-2
@@ -4,18 +4,52 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
POSTGRES_DB: freelancer_match
|
POSTGRES_DB: freelancer_match
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/freelancer_match
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_API_URL: http://backend:8000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function AIMatchPage() {
|
||||||
|
const [projectId, setProjectId] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [matches, setMatches] = useState<any[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleMatch() {
|
||||||
|
if (!projectId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai/match-project", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ project_id: projectId, limit: 10 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Ошибка при поиске совпадений");
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setMatches(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||||
|
<Link href="/dashboard" className="text-blue-600 hover:text-blue-800">← Назад</Link>
|
||||||
|
<h1 className="text-xl font-bold">AI-матчинг</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8 max-w-3xl">
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Подобрать фрилансеров</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={projectId}
|
||||||
|
onChange={(e) => setProjectId(e.target.value)}
|
||||||
|
placeholder="ID проекта"
|
||||||
|
className="w-full px-4 py-3 border rounded-lg mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={handleMatch} disabled={loading}>
|
||||||
|
{loading ? "Поиск..." : "Найти совпадения"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{matches.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{matches.map((m, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-semibold">{m.name}</h3>
|
||||||
|
<span className="text-green-600 font-bold">{(m.match_score * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.skills_matched.length > 0 && (
|
||||||
|
<p className="text-sm text-gray-500 mb-2">Совпадение навыков: {m.skills_matched.join(", ")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{m.reasons.map((r, j) => (
|
||||||
|
<li key={j} className="text-sm text-gray-600">• {r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const form = useForm<FormData>({ resolver: zodResolver(loginSchema) });
|
||||||
|
|
||||||
|
async function onSubmit(data: FormData) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Неверный email или пароль");
|
||||||
|
|
||||||
|
router.push("/dashboard");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full max-w-md p-8 rounded-xl shadow-sm bg-white">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Вход</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input {...form.register("email")} placeholder="Email" className="w-full mb-3 px-4 py-2 border rounded" />
|
||||||
|
{form.formState.errors.email && (
|
||||||
|
<p className="text-sm text-red-500 mb-2">{form.formState.errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input {...form.register("password")} type="password" placeholder="Пароль" className="w-full mb-4 px-4 py-2 border rounded" />
|
||||||
|
{form.formState.errors.password && (
|
||||||
|
<p className="text-sm text-red-500 mb-2">{form.formState.errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="w-full bg-blue-600 text-white py-3 rounded font-medium">
|
||||||
|
{loading ? "Вход..." : "Войти"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center mt-4 text-sm">
|
||||||
|
Нет аккаунта?{" "}
|
||||||
|
<Link href="/auth/register" className="text-blue-600">Зарегистрироваться</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
async function fetchProject(id: string) {
|
||||||
|
const res = await fetch(`/api/projects/${id}`);
|
||||||
|
if (!res.ok) throw new Error("Проект не найден");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { data: project, isLoading } = useQuery({
|
||||||
|
queryKey: ["project", params.id],
|
||||||
|
queryFn: () => fetchProject(params.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div className="min-h-screen flex items-center justify-center">Загрузка...</div>;
|
||||||
|
if (!project) return <div>Проект не найден</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||||
|
<Link href="/dashboard" className="text-blue-600 hover:text-blue-800">← Назад</Link>
|
||||||
|
<h1 className="text-xl font-bold">Freelancer Match</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8 max-w-3xl">
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">{project.title}</h2>
|
||||||
|
<p className="text-gray-700 whitespace-pre-wrap mb-4">{project.description}</p>
|
||||||
|
|
||||||
|
{project.category && (
|
||||||
|
<span className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
||||||
|
{project.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-2 flex-wrap">
|
||||||
|
{project.required_skills.map((skill: string) => (
|
||||||
|
<span key={skill} className="px-2 py-1 bg-gray-100 rounded text-sm">{skill}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(project.budget_min || project.budget_max) && (
|
||||||
|
<div className="mt-4 p-3 bg-green-50 rounded">
|
||||||
|
{project.budget_min ? `${project.budget_min.toLocaleString()}₽` : "—"} —{" "}
|
||||||
|
{project.budget_max ? `${project.budget_max.toLocaleString()}₽` : "до бесконечности"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.deadline && (
|
||||||
|
<p className="mt-2 text-sm text-gray-500">Дедлайн: {new Date(project.deadline).toLocaleDateString("ru-RU")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => router.push("/auth/login")}>Оставить заявку</Button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-blue-600 text-white hover:bg-blue-700",
|
||||||
|
destructive: "bg-red-500 text-white hover:bg-red-600",
|
||||||
|
outline: "border border-gray-300 bg-transparent hover:bg-gray-100",
|
||||||
|
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
|
||||||
|
ghost: "hover:bg-gray-100",
|
||||||
|
link: "text-blue-600 underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8 text-base",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={buttonVariants({ variant, size, className })}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user