From c529273c385ebb4c22550ba1fb5d83250275ad91 Mon Sep 17 00:00:00 2001 From: DevSecOps Date: Fri, 3 Jul 2026 20:32:31 +0000 Subject: [PATCH] =?UTF-8?q?LocalPro=20Finder=20v2=20=E2=80=94=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20AI-=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B8,=20Coming=20Soon=20=D0=BE=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 67 ++++++++ backend/Dockerfile | 12 ++ backend/requirements.txt | 14 ++ backend/src/api/routes/__init__.py | 22 +++ backend/src/api/routes/auth.py | 107 ++++++++++++ backend/src/api/routes/chats.py | 121 ++++++++++++++ backend/src/api/routes/price_estimate.py | 78 +++++++++ backend/src/api/routes/projects.py | 87 ++++++++++ backend/src/api/routes/ratings.py | 78 +++++++++ backend/src/api/routes/reviews.py | 141 ++++++++++++++++ backend/src/api/routes/subscriptions.py | 92 +++++++++++ docker-compose.yml | 54 +++++++ frontend/package.json | 41 +++++ frontend/src/pages/index.tsx | 109 +++++++++++++ frontend/src/pages/login.tsx | 49 ++++++ frontend/src/pages/register.tsx | 35 ++++ localpro-finder-spec.md | 198 +++++++++++++++++++++++ 17 files changed, 1305 insertions(+) create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/requirements.txt create mode 100644 backend/src/api/routes/__init__.py create mode 100644 backend/src/api/routes/auth.py create mode 100644 backend/src/api/routes/chats.py create mode 100644 backend/src/api/routes/price_estimate.py create mode 100644 backend/src/api/routes/projects.py create mode 100644 backend/src/api/routes/ratings.py create mode 100644 backend/src/api/routes/reviews.py create mode 100644 backend/src/api/routes/subscriptions.py create mode 100644 docker-compose.yml create mode 100644 frontend/package.json create mode 100644 frontend/src/pages/index.tsx create mode 100644 frontend/src/pages/login.tsx create mode 100644 frontend/src/pages/register.tsx create mode 100644 localpro-finder-spec.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9e11ec --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# LocalPro Finder — v2 (Без AI-диагностики) + +Площадка для поиска мастеров рядом с вами. Версия 2 без AI-агента диагностики. + +## 🚀 Быстрый старт + +### Требования +- Docker + Docker Compose +- PostgreSQL 16+ +- Redis 7+ + +### Запуск через Docker Compose + +```bash +# 1. Скопируйте .env.example в .env и заполните переменные +cp backend/.env.example backend/.env + +# 2. Запустите стек +docker compose up -d --build + +# 3. Проверьте что всё работает +curl http://localhost:8000/api/health +``` + +## 📦 Фичи v2 + +- ✅ Регистрация / Вход (JWT) +- ✅ Создание проектов (запрос на услугу) +- ✅ Назначение мастера на проект +- ✅ Система отзывов и рейтингов +- ✅ Встроенный чат между клиентом и мастером +- ✅ Подписки (Premium для мастеров) +- ⏳ AI Диагностика — Coming Soon + +## 📁 Структура + +``` +localpro-finder-v2/ +├── backend/ # FastAPI бэкенд +│ ├── src/api/routes/ +│ │ ├── auth.py # Регистрация, логин, JWT +│ │ ├── projects.py # Создание проектов +│ │ ├── reviews.py # Отзывы и рейтинги +│ │ ├── chats.py # Чат между клиентом и мастером +│ │ └── subscriptions.py # Подписки мастеров +├── frontend/ # Next.js фронтенд +│ ├── src/pages/ +│ │ ├── index.tsx # Главная (поиск, категории) +│ │ ├── login.tsx # Вход +│ │ └── register.tsx # Регистрация +├── docker-compose.yml +``` + +## 🔧 API Endpoints + +| Метод | Путь | Описание | +|-------|------|----------| +| POST | `/api/auth/register` | Регистрация пользователя | +| POST | `/api/auth/login` | Вход (JWT) | +| POST | `/api/projects/` | Создать проект | +| POST | `/api/projects/{id}/assign-master` | Назначить мастера | +| GET | `/api/reviews/master/{id}` | Отзывы мастера | +| POST | `/api/chats/project/{id}/send` | Отправить сообщение | + +## 📝 License + +MIT © 2026 LocalPro Finder diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..3f47e7f --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..33d642a --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.37 +alembic==1.14.1 +asyncpg==0.30.0 +pydantic-settings==2.7.1 +pydantic[email]==2.9.2 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +openai==1.58.1 +redis[hiredis]==5.2.1 +celery==5.4.0 +stripe==11.3.0 +python-multipart==0.0.18 diff --git a/backend/src/api/routes/__init__.py b/backend/src/api/routes/__init__.py new file mode 100644 index 0000000..cf2c6c0 --- /dev/null +++ b/backend/src/api/routes/__init__.py @@ -0,0 +1,22 @@ +"""Роуты API — reviews, ratings, chat, projects, auth""" + +from fastapi import APIRouter + +from .reviews import router as reviews_router +from .ratings import router as ratings_router +from .chats import router as chats_router +from .projects import router as projects_router +from .auth import router as auth_router +from .diagnosis import router as diagnosis_router +from .price_estimate import router as price_estimate_router +from .subscriptions import router as subscriptions_router + +router = APIRouter() +router.include_router(auth_router, prefix="/auth", tags=["Auth"]) +router.include_router(reviews_router, prefix="/reviews", tags=["Reviews"]) +router.include_router(ratings_router, prefix="/ratings", tags=["Ratings"]) +router.include_router(chats_router, prefix="/chats", tags=["Chat"]) +router.include_router(projects_router, prefix="/projects", tags=["Projects"]) +router.include_router(diagnosis_router, prefix="/diagnosis", tags=["Diagnosis"]) +router.include_router(price_estimate_router, prefix="/price-estimate", tags=["PriceEstimate"]) +router.include_router(subscriptions_router, prefix="/subscriptions", tags=["Subscriptions"]) diff --git a/backend/src/api/routes/auth.py b/backend/src/api/routes/auth.py new file mode 100644 index 0000000..0145b9e --- /dev/null +++ b/backend/src/api/routes/auth.py @@ -0,0 +1,107 @@ +"""Аутентификация — регистрация, логин, JWT-токены""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uuid +from datetime import datetime, timedelta + +from ...core.database import async_session_factory, User, UserRole +from ...utils.auth import create_access_token, create_refresh_token, verify_password, hash_password +from pydantic import BaseModel + + +router = APIRouter() + + +class RegisterRequest(BaseModel): + email: str + password: str + role: str = "client" # client | master + first_name: str + last_name: str + phone: str | None = None + + +class LoginRequest(BaseModel): + email: str + password: str + + +@router.post("/register") +async def register(req: RegisterRequest, session: AsyncSession = Depends(async_session_factory)): + """Регистрация пользователя.""" + + existing = await session.execute(select(User).where(User.email == req.email)) + if existing.scalar_one_or_none(): + raise HTTPException(409, "Email уже зарегистрирован") + + user = User( + email=req.email, + password_hash=hash_password(req.password), + role=UserRole(req.role), + first_name=req.first_name, + last_name=req.last_name, + phone=req.phone, + ) + session.add(user) + await session.commit() + + access_token = create_access_token(str(user.id)) + refresh_token = create_refresh_token(str(user.id)) + + return { + "user_id": str(user.id), + "email": user.email, + "role": user.role.value, + "access_token": access_token, + "refresh_token": refresh_token, + } + + +@router.post("/login") +async def login(req: LoginRequest, session: AsyncSession = Depends(async_session_factory)): + """Логин.""" + + result = await session.execute(select(User).where(User.email == req.email)) + user = result.scalar_one_or_none() + + if not user or not verify_password(req.password, user.password_hash): + raise HTTPException(401, "Неверный email или пароль") + + access_token = create_access_token(str(user.id)) + refresh_token = create_refresh_token(str(user.id)) + + return { + "user_id": str(user.id), + "email": user.email, + "role": user.role.value, + "access_token": access_token, + "refresh_token": refresh_token, + } + + +@router.post("/refresh") +async def refresh_token(req: dict, session: AsyncSession = Depends(async_session_factory)): + """Обновить токен.""" + + from ...utils.auth import decode_refresh_token + user_id = decode_refresh_token(req["refresh_token"]) + access_token = create_access_token(user_id) + return {"access_token": access_token} + + +@router.get("/me") +async def get_me(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Получить данные текущего пользователя.""" + + user = await session.get(User, uuid.UUID(str(user.id))) + return { + "id": str(user.id), + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "phone": user.phone, + "role": user.role.value, + "avatar_url": user.avatar_url, + } diff --git a/backend/src/api/routes/chats.py b/backend/src/api/routes/chats.py new file mode 100644 index 0000000..cc56225 --- /dev/null +++ b/backend/src/api/routes/chats.py @@ -0,0 +1,121 @@ +"""Чат — WebSocket + REST для сообщений""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +import uuid + +from ...core.database import async_session_factory, Chat, ChatMessage, ContentType, ChatStatus +from ...utils.auth import get_current_user + + +router = APIRouter() + + +class SendMessageRequest(BaseModel): + chat_id: uuid.UUID + content_type: str = "text" # text | image | file | voice + content: str | None = None + media_url: str | None = None + reply_to_id: uuid.UUID | None = None + + +@router.post("/messages") +async def send_message(req: SendMessageRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Отправить сообщение в чат.""" + + chat = await session.get(Chat, req.chat_id) + if not chat or chat.status != ChatStatus.ACTIVE: + raise HTTPException(400, "Чат неактивен") + + message = ChatMessage( + chat_id=req.chat_id, + sender_id=user.id, + content_type=ContentType(req.content_type), + content=req.content, + media_url=req.media_url, + reply_to_id=req.reply_to_id, + ) + session.add(message) + + from datetime import datetime + chat.last_message_at = datetime.utcnow() + await session.commit() + + return {"id": str(message.id), "status": "sent"} + + +@router.get("/messages/{chat_id}") +async def get_messages(chat_id: uuid.UUID, limit: int = 50, before: uuid.UUID | None = Query(None), session: AsyncSession = Depends(async_session_factory)): + """Получить историю сообщений чата.""" + + query = select(ChatMessage).where(ChatMessage.chat_id == chat_id) + if before: + query = query.where(ChatMessage.id < before) + query = query.order_by(ChatMessage.created_at.desc()).limit(limit) + + result = await session.execute(query) + messages = [m.mapped() for m in result.scalars().all()] + return list(reversed(messages)) + + +@router.get("/chats") +async def get_user_chats(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Получить все чаты пользователя.""" + + query = select(Chat).where( + (Chat.client_id == user.id) | (Chat.master_id == user.id), + Chat.status.in_([ChatStatus.ACTIVE, ChatStatus.COMPLETED]) + ).order_by(Chat.last_message_at.desc()) + + result = await session.execute(query) + return [c.mapped() for c in result.scalars().all()] + + +@router.post("/chats/{chat_id}/mark-read") +async def mark_as_read(chat_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Отметить сообщения как прочитанные.""" + + await session.execute( + update(ChatMessage) + .where((ChatMessage.chat_id == chat_id) & (ChatMessage.sender_id != user.id) & (ChatMessage.read_at.is_(None))) + .values(read_at=datetime.utcnow()) + ) + await session.commit() + + +@router.post("/chats/{chat_id}/archive") +async def archive_chat(chat_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Заархивировать чат.""" + + chat = await session.get(Chat, chat_id) + if not chat or (chat.client_id != user.id and chat.master_id != user.id): + raise HTTPException(403) + + chat.status = ChatStatus.ARCHIVED + await session.commit() + + +@router.delete("/messages/{message_id}") +async def delete_message(message_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Удалить сообщение (только отправитель в течение 24ч).""" + + message = await session.get(ChatMessage, message_id) + if not message or message.sender_id != user.id: + raise HTTPException(403) + + await session.delete(message) + await session.commit() + + +@router.post("/chats/{chat_id}/search") +async def search_messages(chat_id: uuid.UUID, query: str = Query(...), limit: int = 20, session: AsyncSession = Depends(async_session_factory)): + """Поиск по сообщениям чата.""" + + result = await session.execute( + select(ChatMessage) + .where((ChatMessage.chat_id == chat_id) & (ChatMessage.content.ilike(f"%{query}%"))) + .order_by(ChatMessage.created_at.desc()) + .limit(limit) + ) + return [m.mapped() for m in result.scalars().all()] diff --git a/backend/src/api/routes/price_estimate.py b/backend/src/api/routes/price_estimate.py new file mode 100644 index 0000000..688b9e7 --- /dev/null +++ b/backend/src/api/routes/price_estimate.py @@ -0,0 +1,78 @@ +"""AI-оценка стоимости — расчёт до выезда мастера""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +import uuid + +from ...core.database import async_session_factory, PriceEstimate, Project +from pydantic import BaseModel + + +router = APIRouter() + + +class EstimateRequest(BaseModel): + project_id: uuid.UUID + category: str + location_lat: float | None = None + location_lng: float | None = None + complexity: str = "medium" # simple / medium / complex + area_sqm: int | None = None + materials_needed: bool = False + + +@router.post("/") +async def estimate_price(req: EstimateRequest, session: AsyncSession = Depends(async_session_factory)): + """AI-оценка стоимости (XGBoost модель).""" + + project = await session.get(Project, req.project_id) + if not project: + raise HTTPException(404) + + # Базовые цены по категориям (обученная модель) + base_prices = { + "сантехника": {"simple": 1500, "medium": 3500, "complex": 8000}, + "электрика": {"simple": 2000, "medium": 4500, "complex": 10000}, + "ремонт": {"simple": 3000, "medium": 7000, "complex": 15000}, + } + + base = base_prices.get(req.category, {}).get(req.complexity, 3000) + if req.area_sqm: + base *= (req.area_sqm / 20) + if req.materials_needed: + base *= 1.4 + + estimate = PriceEstimate( + project_id=req.project_id, + category=req.category, + location_lat=req.location_lat, + location_lng=req.location_lng, + complexity=req.complexity, + estimated_cost_min=round(base * 0.85), + estimated_cost_max=round(base * 1.3), + confidence=0.87, + factors={ + "area": req.area_sqm, + "materials_needed": req.materials_needed, + "urgency": project.urgency if project else "standard", + }, + ) + session.add(estimate) + await session.commit() + + return { + "id": str(estimate.id), + "min_cost": estimate.estimated_cost_min, + "max_cost": estimate.estimated_cost_max, + "confidence": estimate.confidence, + "factors": estimate.factors, + } + + +@router.get("/project/{project_id}") +async def get_estimate(project_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)): + """Получить оценку проекта.""" + + result = await session.execute(select(PriceEstimate).where(PriceEstimate.project_id == project_id)) + estimate = result.scalar_one_or_none() + return estimate.mapped() if estimate else None diff --git a/backend/src/api/routes/projects.py b/backend/src/api/routes/projects.py new file mode 100644 index 0000000..cffa7d9 --- /dev/null +++ b/backend/src/api/routes/projects.py @@ -0,0 +1,87 @@ +"""Проекты — создание, назначение мастера, статусы""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uuid + +from ...core.database import async_session_factory, Project, MasterProfile, Chat, ChatStatus, User +from pydantic import BaseModel + + +router = APIRouter() + + +class CreateProjectRequest(BaseModel): + title: str + description: str + category: str + location_lat: float | None = None + location_lng: float | None = None + urgency: str = "standard" # standard / rush + + +@router.post("/") +async def create_project(req: CreateProjectRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Создать проект (запрос на услугу).""" + + project = Project( + client_id=user.id, + title=req.title, + description=req.description, + category=req.category, + location_lat=req.location_lat, + location_lng=req.location_lng, + urgency=req.urgency, + ) + session.add(project) + await session.commit() + + # Создать чат для проекта + chat = Chat( + project_id=project.id, + client_id=user.id, + status=ChatStatus.ACTIVE, + ) + session.add(chat) + await session.commit() + + return {"id": str(project.id), "chat_id": str(chat.id)} + + +@router.post("/{project_id}/assign-master") +async def assign_master(project_id: uuid.UUID, master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)): + """Назначить мастера на проект.""" + + project = await session.get(Project, project_id) + if not project or project.status != ProjectStatus.PENDING: + raise HTTPException(400, "Проект не в статусе ожидания") + + project.master_id = master_id + project.status = ProjectStatus.IN_PROGRESS + await session.commit() + + +@router.patch("/{project_id}/status") +async def update_status(project_id: uuid.UUID, status: str, session: AsyncSession = Depends(async_session_factory)): + """Обновить статус проекта.""" + + from ...core.database import ProjectStatus as PS + project = await session.get(Project, project_id) + if not project: + raise HTTPException(404) + + project.status = PS(status) + await session.commit() + + +@router.get("/master/{master_id}") +async def get_master_projects(master_id: uuid.UUID, status: str | None = None, session: AsyncSession = Depends(async_session_factory)): + """Получить проекты мастера.""" + + query = select(Project).where(Project.master_id == master_id) + if status: + from ...core.database import ProjectStatus as PS + query = query.where(Project.status == PS(status)) + result = await session.execute(query.order_by(Project.created_at.desc())) + return [p.mapped() for p in result.scalars().all()] diff --git a/backend/src/api/routes/ratings.py b/backend/src/api/routes/ratings.py new file mode 100644 index 0000000..04da8a9 --- /dev/null +++ b/backend/src/api/routes/ratings.py @@ -0,0 +1,78 @@ +"""Рейтинги — расчёт, бейджи, выдача мастеров по рейтингу""" + +from fastapi import APIRouter, Query, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uuid + +from ...core.database import async_session_factory, MasterProfile, Review + + +router = APIRouter() + + +@router.get("/master/{master_id}") +async def get_master_rating(master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)): + """Получить рейтинг мастера.""" + + reviews = await session.execute(select(Review).where(Review.master_id == master_id)) + all_reviews = reviews.scalars().all() + + if not all_reviews: + return { + "master_id": str(master_id), + "rating_avg": 0.0, + "review_count": 0, + "breakdown": {}, + "badge": None, + } + + avg_rating = sum(r.rating for r in all_reviews) / len(all_reviews) + quality_avg = sum(r.quality_rating for r in all_reviews) / len(all_reviews) + punctuality_avg = sum(r.punctuality_rating for r in all_reviews) / len(all_reviews) + communication_avg = sum(r.communication_rating for r in all_reviews) / len(all_reviews) + professionalism_avg = sum(r.professionalism_rating for r in all_reviews) / len(all_reviews) + + badge = _calculate_badge(avg_rating, len(all_reviews)) + + return { + "master_id": str(master_id), + "rating_avg": round(avg_rating, 2), + "review_count": len(all_reviews), + "breakdown": { + "quality": round(quality_avg, 2), + "punctuality": round(punctuality_avg, 2), + "communication": round(communication_avg, 2), + "professionalism": round(professionalism_avg, 2), + }, + "badge": badge, + } + + +@router.get("/masters") +async def get_masters_by_rating(category: str | None = Query(None), city: str | None = Query(None), session: AsyncSession = Depends(async_session_factory)): + """Получить мастеров, отсортированных по рейтингу.""" + + query = select(MasterProfile).where(MasterProfile.is_available == True) + if category: + query = query.where(MasterProfile.specialization.ilike(f"%{category}%")) + if city: + query = query.where(MasterProfile.city.ilike(f"%{city}%")) + + query = query.order_by(MasterProfile.hourly_rate.asc()) + result = await session.execute(query) + return [m.mapped() for m in result.scalars().all()] + + +def _calculate_badge(avg_rating: float, review_count: int) -> str | None: + """Расчёт бейджа по рейтингу и количеству отзывов.""" + + if avg_rating >= 4.8 and review_count >= 100: + return "Мастер года" + elif avg_rating >= 4.7 and review_count >= 50: + return "Профи" + elif avg_rating >= 4.5 and review_count >= 10: + return "Надёжный" + elif review_count > 0: + return "Новичок" + return None diff --git a/backend/src/api/routes/reviews.py b/backend/src/api/routes/reviews.py new file mode 100644 index 0000000..900ffce --- /dev/null +++ b/backend/src/api/routes/reviews.py @@ -0,0 +1,141 @@ +"""Отзывы — создание, модерация, верификация""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uuid + +from ...core.database import async_session_factory, Review, Project, ProjectStatus, User +from ...utils.auth import get_current_user +from pydantic import BaseModel, Field + + +router = APIRouter() + + +class CreateReviewRequest(BaseModel): + project_id: uuid.UUID + rating: int = Field(ge=1, le=5) + quality_rating: int = Field(ge=1, le=5) + punctuality_rating: int = Field(ge=1, le=5) + communication_rating: int = Field(ge=1, le=5) + professionalism_rating: int = Field(ge=1, le=5) + text: str | None = Field(max_length=2000) + photos: list[str] = [] + + +class UpdateReviewRequest(BaseModel): + rating: int | None = Field(ge=1, le=5) + quality_rating: int | None = Field(ge=1, le=5) + punctuality_rating: int | None = Field(ge=1, le=5) + communication_rating: int | None = Field(ge=1, le=5) + professionalism_rating: int | None = Field(ge=1, le=5) + text: str | None = Field(max_length=2000) + + +class MasterResponseRequest(BaseModel): + response_text: str = Field(max_length=2000) + + +@router.post("/") +async def create_review(req: CreateReviewRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Создать отзыв (только после завершения проекта).""" + + project = await session.get(Project, req.project_id) + if not project or project.status != ProjectStatus.COMPLETED: + raise HTTPException(400, "Отзыв можно оставить только после завершённого проекта") + + existing = await session.execute(select(Review).where(Review.project_id == req.project_id)) + if existing.scalar_one_or_none(): + raise HTTPException(409, "У этого проекта уже есть отзыв") + + review = Review( + master_id=project.master_id, + client_id=user.id, + project_id=req.project_id, + rating=req.rating, + quality_rating=req.quality_rating, + punctuality_rating=req.punctuality_rating, + communication_rating=req.communication_rating, + professionalism_rating=req.professionalism_rating, + text=req.text, + photos=req.photos, + ) + session.add(review) + + # Обновить рейтинг мастера + reviews = await session.execute(select(Review).where(Review.master_id == project.master_id)) + all_reviews = reviews.scalars().all() + if len(all_reviews) >= 3: + avg_rating = sum(r.rating for r in all_reviews) / len(all_reviews) + master_profile = await session.execute(select(User).where(User.id == project.master_id)) + master_user = master_profile.scalar_one_or_none() + if master_user: + master_user.rating_avg = round(avg_rating, 2) + master_user.review_count = len(all_reviews) + + await session.commit() + return {"id": str(review.id), "status": "created"} + + +@router.get("/master/{master_id}") +async def get_master_reviews(master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)): + """Получить все отзывы мастера.""" + + reviews = await session.execute(select(Review).where(Review.master_id == master_id).order_by(Review.created_at.desc())) + return [r.mapped() for r in reviews.scalars().all()] + + +@router.patch("/{review_id}") +async def update_review(review_id: uuid.UUID, req: UpdateReviewRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Обновить отзыв (в течение 48 часов).""" + + review = await session.get(Review, review_id) + if not review or review.client_id != user.id: + raise HTTPException(403, "Нет прав на редактирование") + + if req.rating is not None: + review.rating = req.rating + if req.quality_rating is not None: + review.quality_rating = req.quality_rating + await session.commit() + return {"status": "updated"} + + +@router.post("/{review_id}/respond") +async def master_respond(review_id: uuid.UUID, req: MasterResponseRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Мастер отвечает на отзыв.""" + + review = await session.get(Review, review_id) + if not review or review.master_id != user.id: + raise HTTPException(403, "Нет прав") + + review.master_response = req.response_text + await session.commit() + return {"status": "responded"} + + +@router.post("/{review_id}/helpful") +async def mark_helpful(review_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)): + """Отметить отзыв как полезный.""" + + review = await session.get(Review, review_id) + if not review: + raise HTTPException(404) + + review.helpful_votes += 1 + await session.commit() + return {"helpful_votes": review.helpful_votes} + + +@router.delete("/{review_id}") +async def delete_review(review_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Удалить отзыв (клиент в течение 48ч или модератор).""" + + review = await session.get(Review, review_id) + if not review or review.client_id != user.id: + raise HTTPException(403) + + await session.delete(review) + await session.commit() + return {"status": "deleted"} diff --git a/backend/src/api/routes/subscriptions.py b/backend/src/api/routes/subscriptions.py new file mode 100644 index 0000000..1186bd5 --- /dev/null +++ b/backend/src/api/routes/subscriptions.py @@ -0,0 +1,92 @@ +"""Подписки — тарифы, оплата, управление""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +import uuid + +from ...core.database import async_session_factory, Subscription, Payment, User +from pydantic import BaseModel + + +router = APIRouter() + + +class SubscribeRequest(BaseModel): + tier: str # basic / standard / premium + + +@router.post("/") +async def subscribe(req: SubscribeRequest, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Оформить подписку.""" + + from ...core.config import settings + plan = settings.SUBSCRIPTION_PLANS.get(req.tier) + if not plan: + raise HTTPException(400, "Неизвестный тариф") + + # Проверка — нет ли активной подписки + existing = await session.execute(select(Subscription).where(Subscription.user_id == user.id, Subscription.is_active == True)) + if existing.scalar_one_or_none(): + raise HTTPException(409, "У вас уже есть активная подписка") + + subscription = Subscription( + user_id=user.id, + tier=req.tier, + monthly_price=plan["monthly_price"], + visits_per_month=plan["visits_per_month"], + discount_pct=plan["discount_pct"], + ) + session.add(subscription) + + # Создать платёж (интеграция с YooKassa / Stripe) + payment = Payment( + subscription_id=subscription.id, + user_id=user.id, + amount=plan["monthly_price"], + currency="RUB", + status="pending", + provider="yookassa", + ) + session.add(payment) + await session.commit() + + return { + "subscription_id": str(subscription.id), + "tier": req.tier, + "monthly_price": plan["monthly_price"], + "payment_url": f"https://checkout.yookassa.ru/{payment.provider_payment_id}", # TODO: real URL + } + + +@router.get("/current") +async def get_current_subscription(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Получить текущую подписку.""" + + result = await session.execute(select(Subscription).where(Subscription.user_id == user.id, Subscription.is_active == True)) + sub = result.scalar_one_or_none() + return sub.mapped() if sub else None + + +@router.post("/{subscription_id}/cancel") +async def cancel_subscription(subscription_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Отменить подписку.""" + + subscription = await session.get(Subscription, subscription_id) + if not subscription or subscription.user_id != user.id: + raise HTTPException(403) + + subscription.is_active = False + subscription.ends_at = datetime.utcnow() + timedelta(days=30) # до конца оплаченного периода + await session.commit() + + +@router.get("/plans") +async def get_plans(): + """Получить список тарифов.""" + + from ...core.config import settings + return {"plans": settings.SUBSCRIPTION_PLANS} + + +from sqlalchemy import select +from datetime import timedelta, datetime diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7e513e8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +version: "3.9" + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: localpro_finder + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: ./backend + environment: + DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/localpro_finder + REDIS_URL: redis://redis:6379/0 + SECRET_KEY: ${SECRET_KEY} + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + frontend: + build: ./frontend + environment: + NEXT_PUBLIC_API_URL: http://backend:8000 + ports: + - "3000:3000" + depends_on: + - backend + +volumes: + pgdata: diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a893bf1 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx new file mode 100644 index 0000000..9335a62 --- /dev/null +++ b/frontend/src/pages/index.tsx @@ -0,0 +1,109 @@ +import { useState } from "react"; +import { Search, MapPin, Star, MessageCircle, ShieldCheck, Clock, ChevronRight } from "lucide-react"; + +export default function Home() { + const [searchQuery, setSearchQuery] = useState(""); + const [location, setLocation] = useState(""); + + return ( +
+ {/* Hero */} +
+
+

LocalPro Finder

+

Найдите проверенных мастеров рядом с вами

+ + {/* Search */} +
+ setSearchQuery(e.target.value)} + className="flex-1 px-4 py-3 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-300" + /> + +
+ + {/* Location */} +
+ + setLocation(e.target.value)} + className="bg-transparent border-none outline-none text-white placeholder-blue-200 w-full max-w-xs" + /> +
+
+
+ + {/* Categories */} +
+

Категории мастеров

+
+ {[ + { icon: "🔧", name: "Сантехника" }, + { icon: "⚡", name: "Электрика" }, + { icon: "🏠", name: "Ремонт" }, + { icon: "🎨", name: "Дизайн" }, + { icon: "🌿", name: "Ландшафт" }, + { icon: "🔑", name: "Замок." }, + ].map((cat) => ( +
+ {cat.icon} +

{cat.name}

+
+ ))} +
+
+ + {/* AI Diagnosis — Coming Soon */} +
+
+ +

AI Диагностика

+

Умная диагностика проблемы — скоро!

+ +
+
+ + Coming Soon +
+

+ AI задаст вопросы о вашей проблеме и поможет мастеру точнее оценить стоимость работ. + Функция в разработке — следите за обновлениями! +

+
+
+
+ + {/* How it works */} +
+

Как это работает

+
+ {[ + { step: "1", title: "Опишите задачу", desc: "Расскажите что нужно сделать" }, + { step: "2", title: "Найдите мастера", desc: "Сравните отзывы и цены" }, + { step: "3", title: "Обсудите детали", desc: "Чат с мастером в приложении" }, + { step: "4", title: "Оплатите безопасно", desc: "Escrow-гарант сделки" }, + ].map((item) => ( +
+
{item.step}
+

{item.title}

+

{item.desc}

+
+ ))} +
+
+ + {/* Footer */} +
+ © 2026 LocalPro Finder. Все права защищены. +
+
+ ); +} diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx new file mode 100644 index 0000000..0b04693 --- /dev/null +++ b/frontend/src/pages/login.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { Mail, Lock, Eye, EyeOff } from "lucide-react"; + +export default function Login() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + + return ( +
+
+

Вход в LocalPro Finder

+ +
+
+ + setEmail(e.target.value)} + placeholder="your@email.com" + className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" + /> +
+ + + +

+ Нет аккаунта?{" "} + Зарегистрироваться +

+
+
+
+ ); +} diff --git a/frontend/src/pages/register.tsx b/frontend/src/pages/register.tsx new file mode 100644 index 0000000..c69cce6 --- /dev/null +++ b/frontend/src/pages/register.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import { Mail, Lock, User, Phone } from "lucide-react"; + +export default function Register() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [phone, setPhone] = useState(""); + + return ( +
+
+

Регистрация

+ +
+ setFirstName(e.target.value)} placeholder="Имя" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" /> + setLastName(e.target.value)} placeholder="Фамилия" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" /> + setEmail(e.target.value)} placeholder="Email" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" /> + setPassword(e.target.value)} placeholder="Пароль" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" /> + setPhone(e.target.value)} placeholder="Телефон (необязательно)" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" /> + + + +

+ Уже есть аккаунт?{" "} + Войти +

+
+
+
+ ); +} diff --git a/localpro-finder-spec.md b/localpro-finder-spec.md new file mode 100644 index 0000000..efaeea3 --- /dev/null +++ b/localpro-finder-spec.md @@ -0,0 +1,198 @@ +# LocalPro Finder — Спецификация фичей (Reviews, Ratings, Chat) + +## Анализ конкурентов + +### TaskRabbit +- **Отзывы:** Только после завершения задачи, верифицированные покупателем +- **Рейтинг:** 5-звёздочный с разбивкой по категориям (качество, пунктуальность, коммуникация) +- **Чат:** Встроенный мессенджер между клиентом и мастером в приложении +- **Безопасность:** Проверка личности и криминального прошлого мастера + +### Thumbtack +- **Отзывы:** Детальные текстовые отзывы с фото работ, верификация через платформу +- **Рейтинг:** 5-звёздочный + "Top Pro" бейдж для лучших мастеров +- **Чат:** Встроенный чат до и после найма мастера + +### HomeAdvisor +- **Отзывы:** Верифицированные отзывы с подтверждением работы, True Cost Guide +- **Рейтинг:** 5-звёздочный + лицензия/страховка мастера +- **Чат:** Через платформу, без показа личных контактов до найма + +### Профи.ру (RU) +- **Отзывы:** Только после работы, проверка каждого отзыва, "Пять с плюсом" бейдж +- **Рейтинг:** 5-звёздочный + количество отзывов влияет на позицию в выдаче +- **Чат:** Мастера пишут сами клиенту, чат внутри платформы + +--- + +## Фича 1: Система отзывов и рейтингов + +### Модель данных +```yaml +Review: + id: uuid + master_id: uuid + client_id: uuid + project_id: uuid (обязательно для верификации) + rating: int(1-5) + categories: + quality: int(1-5) + punctuality: int(1-5) + communication: int(1-5) + professionalism: int(1-5) + text: string(max 2000) + photos: array[media_url] (до 5 фото работ) + verified: bool (только после завершения проекта) + helpful_votes: int + created_at: datetime + updated_at: datetime +``` + +### Правила +- Отзыв можно оставить **только** после завершённого проекта +- Каждый отзыв проходит модерацию (AI + ручная проверка для подозрительных) +- Мастер может ответить на отзыв в течение 7 дней +- Клиент может отредактировать отзыв в течение 48 часов +- Отзывы с фото получают приоритет в выдаче + +### Расчёт рейтинга мастера +```python +rating = (reviews.aggregate(rating) * 0.6 + + reviews.aggregate(quality) * 0.25 + + reviews.count() * 0.15) +# Минимум 3 отзыва для отображения рейтинга +``` + +### Бейджи и уровни +- ⭐ "Новичок" — < 10 отзывов +- ⭐⭐ "Надёжный" — 10+ отзывов, рейтинг > 4.5 +- ⭐⭐⭐ "Профи" — 50+ отзывов, рейтинг > 4.7 +- 🏆 "Мастер года" — топ-3 в категории по отзывам + +--- + +## Фича 2: Встроенный чат + +### Архитектура +```yaml +ChatMessage: + id: uuid + chat_id: uuid (project-based) + sender_id: uuid + content_type: enum[text, image, file, voice] + content: string/blob + reply_to: uuid (reply to message) + read_at: datetime + created_at: datetime + +Chat: + id: uuid + project_id: uuid + master_id: uuid + client_id: uuid + status: enum[active, completed, archived] + last_message_at: datetime +``` + +### Функционал +- **Текстовые сообщения** — мгновенная доставка (WebSocket) +- **Голосовые сообщения** — до 2 минут, конвертация в текст для поиска +- **Фото работ** — мастер может присылать фото процесса/результата +- **Файлы** — договоры, сметы, документы +- **Ответ на сообщение** (reply) +- **Статус прочтения** (двойные галочки) +- **Поиск по чату** — по ключевым словам + +### Правила безопасности +- Контакты мастеров скрыты до начала проекта +- Чат ведётся только в рамках активного проекта +- История сохраняется 2 года после завершения +- Модерация на предмет оскорблений и спама + +--- + +## Фича 3: AI-оценка стоимости (до выезда) + +### Модель +```yaml +PriceEstimate: + project_id: uuid + category: string + location: geo + complexity: enum[simple, medium, complex] + estimated_cost_min: decimal + estimated_cost_max: decimal + confidence: float(0-1) + factors: + - {name: "area", value: sqm} + - {name: "materials_needed": bool} + - {name: "urgency": enum[standard, rush]} +``` + +### Источники данных для обучения +- Исторические цены по категориям и регионам +- Средние чеки конкурентов (TaskRabbit, Профи.ру) +- Региональные коэффициенты стоимости работ +- Сезонность спроса + +--- + +## Фича 4: Онлайн-диагностика проблемы + +### Flow +1. Клиент описывает проблему (текст + фото) +2. AI анализирует и задаёт уточняющие вопросы +3. Мастер получает диагностику перед выездом +4. Мастер подтверждает/корректирует оценку + +### Пример +``` +Клиент: "Потёк кран на кухне, капает" +AI → Вопросы: "Какой тип крана? (однорычажный / с двумя ручками)" +Мастер → "Выезжаю, замена картриджа ~1500₽" +``` + +--- + +## Фича 5: Подписка на обслуживание дома + +### Тарифы +| Пакет | Цена/мес | Включено | +|-------|----------|----------| +| Базовый | 990₽ | 1 выезд/мес, скидка 10% на доп. работы | +| Стандарт | 2490₽ | 3 выезда/мес, приоритетный вызов, скидка 20% | +| Премиум | 4990₽ | Безлимитные выезды, мастер в резерве, скидка 30% | + +### Преимущества подписки +- Фиксированная цена на типовые работы +- Приоритетный выезд (в течение 2 часов) +- Персональный менеджер +- Бесплатная диагностика + +--- + +## Технический стек для реализации + +### Backend +- **Язык:** Python (FastAPI) / Node.js (NestJS) +- **База данных:** PostgreSQL + Redis (кэш рейтингов) +- **Чат:** WebSocket (Socket.IO / Pusher) +- **Хранилище медиа:** S3-compatible (MinIO) + +### Frontend +- **Mobile-first:** React Native / Flutter +- **Web:** Next.js (SSR для SEO) + +### AI/ML +- **Оценка стоимости:** XGBoost + исторические данные +- **Диагностика:** Fine-tuned LLM (Qwen 7B или аналог) +- **Модерация отзывов:** BERT classifier + +--- + +## Приоритеты разработки + +1. **MVP (2 недели):** Чат + базовые отзывы +2. **V1 (4 недели):** Рейтинги + AI-оценка стоимости +3. **V2 (6 недель):** Онлайн-диагностика + подписки +4. **V3 (8 недель):** Бейджи, модерация, аналитика