mirror of
http://88.130.71.182:3000/BlitTech/deals24togo_be.git
synced 2026-06-12 23:33:21 +00:00
Initial commit
This commit is contained in:
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
.venv
|
||||
venv
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
node_modules
|
||||
.DS_Store
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.example
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
coverage.xml
|
||||
|
||||
# Type checking / linting
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libjpeg-dev \
|
||||
zlib1g-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --upgrade pip && \
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Application code
|
||||
COPY . .
|
||||
|
||||
# Non-root user
|
||||
RUN adduser --disabled-password --gecos '' appuser && \
|
||||
chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD python -c "import httpx; r = httpx.get('http://localhost:8000/health'); r.raise_for_status()"
|
||||
|
||||
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
195
README.md
Normal file
195
README.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Deals24Togo — Backend API
|
||||
|
||||
Production-grade FastAPI backend for the Deals24Togo marketplace. Supports listings for real estate, vehicles, electronics, furniture, jobs, and services.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/v1/endpoints/ # Route handlers (auth, users, agencies, listings, etc.)
|
||||
│ ├── core/ # Config, security, Supabase client, logging, exceptions
|
||||
│ ├── middleware/ # Auth dependencies (JWT extraction, RBAC)
|
||||
│ ├── schemas/ # Pydantic request/response models
|
||||
│ ├── services/ # Business logic layer
|
||||
│ └── main.py # FastAPI app factory
|
||||
├── migrations/ # Supabase SQL migrations
|
||||
├── scripts/ # Seed & utility scripts
|
||||
├── tests/ # Pytest test suite
|
||||
├── Dockerfile # Production container
|
||||
├── docker-compose.yml # Local dev
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---------------|--------------------------------|
|
||||
| Framework | FastAPI 0.115+ |
|
||||
| Database | Supabase (PostgreSQL) |
|
||||
| Auth | JWT (python-jose) + bcrypt |
|
||||
| File Storage | Supabase Storage |
|
||||
| Validation | Pydantic v2 |
|
||||
| Rate Limiting | SlowAPI |
|
||||
| Logging | structlog (JSON in prod) |
|
||||
| Monitoring | Sentry (optional) |
|
||||
| Container | Docker |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication (`/api/v1/auth`)
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|-------------------------------|----------|--------------------------|
|
||||
| POST | `/register` | Public | Register user |
|
||||
| POST | `/login` | Public | Login, get tokens |
|
||||
| POST | `/refresh` | Public | Refresh access token |
|
||||
| GET | `/me` | Required | Get current user |
|
||||
| POST | `/change-password` | Required | Change password |
|
||||
| POST | `/password-reset/request` | Public | Request reset email |
|
||||
| POST | `/password-reset/confirm` | Public | Confirm reset with token |
|
||||
|
||||
### Users (`/api/v1/users`)
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|---------------------|---------|----------------------|
|
||||
| GET | `/me` | Required| Get my profile |
|
||||
| PATCH | `/me` | Required| Update my profile |
|
||||
| GET | `/` | Admin | List all users |
|
||||
| GET | `/{user_id}` | Admin | Get user by ID |
|
||||
| POST | `/{user_id}/verify` | Admin | Verify user |
|
||||
| DELETE | `/{user_id}` | Owner/Admin | Delete user |
|
||||
|
||||
### Agencies (`/api/v1/agencies`)
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|-------------------------|---------|--------------------------|
|
||||
| GET | `/` | Public | List agencies |
|
||||
| GET | `/me` | Required| Get my agency |
|
||||
| GET | `/{agency_id}` | Public | Get agency by ID |
|
||||
| POST | `/` | Required| Create agency |
|
||||
| PATCH | `/{agency_id}` | Owner/Admin | Update agency |
|
||||
| POST | `/{agency_id}/verify` | Admin | Verify agency |
|
||||
| POST | `/{agency_id}/revoke` | Admin | Revoke verification |
|
||||
| DELETE | `/{agency_id}` | Admin | Delete agency |
|
||||
|
||||
### Categories (`/api/v1/categories`)
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|---------------------|---------|------------------------|
|
||||
| GET | `/` | Public | List all categories |
|
||||
| GET | `/{category_id}` | Public | Get category |
|
||||
| GET | `/slug/{slug}` | Public | Get category by slug |
|
||||
| POST | `/` | Admin | Create category |
|
||||
| PATCH | `/{category_id}` | Admin | Update category |
|
||||
| DELETE | `/{category_id}` | Admin | Delete category |
|
||||
|
||||
### Listings (`/api/v1/listings`)
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|-------------------------|---------|--------------------------|
|
||||
| GET | `/` | Public | Search/list listings |
|
||||
| GET | `/featured` | Public | Top 8 popular listings |
|
||||
| GET | `/{listing_id}` | Public | Get listing (increments views) |
|
||||
| GET | `/agency/mine` | Agency | My agency's listings |
|
||||
| POST | `/` | Agency | Create listing |
|
||||
| PATCH | `/{listing_id}` | Owner/Admin | Update listing |
|
||||
| DELETE | `/{listing_id}` | Owner/Admin | Delete listing |
|
||||
| GET | `/admin/all` | Admin | List all (any status) |
|
||||
| PATCH | `/{listing_id}/status` | Admin | Approve/reject |
|
||||
| GET | `/stats/overview` | Required| Listing statistics |
|
||||
|
||||
### Messages (`/api/v1/messages`)
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|-----------------------------|---------|------------------------|
|
||||
| POST | `/` | Public | Send contact message |
|
||||
| GET | `/` | Agency | List my messages |
|
||||
| GET | `/unread-count` | Agency | Unread count |
|
||||
| PATCH | `/{message_id}/read` | Agency | Mark read/unread |
|
||||
| DELETE | `/{message_id}` | Agency | Delete message |
|
||||
|
||||
### Favorites (`/api/v1/favorites`)
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|-------------------------|---------|------------------------|
|
||||
| POST | `/` | Required| Add to favorites |
|
||||
| GET | `/` | Required| List my favorites |
|
||||
| GET | `/check/{listing_id}` | Required| Check if favorited |
|
||||
| DELETE | `/{listing_id}` | Required| Remove from favorites |
|
||||
|
||||
### Uploads (`/api/v1/uploads`)
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|-------------|---------|--------------------------|
|
||||
| POST | `/image` | Required| Upload single image |
|
||||
| POST | `/images` | Required| Upload multiple images |
|
||||
| DELETE | `/` | Required| Delete image by URL |
|
||||
|
||||
### Health (`/health`)
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|-----------|--------|--------------------|
|
||||
| GET | `/health` | Public | Health check |
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Supabase Project
|
||||
|
||||
1. Create a project at [supabase.com](https://supabase.com)
|
||||
2. Go to **SQL Editor** and run `migrations/001_initial_schema.sql`
|
||||
3. Go to **Storage** and create a bucket called `listings` with public access
|
||||
4. Copy your project URL, anon key, and service role key
|
||||
|
||||
### 2. Environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your Supabase credentials
|
||||
```
|
||||
|
||||
### 3. Run Locally
|
||||
|
||||
**With Docker:**
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
**Without Docker:**
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:create_app --factory --reload
|
||||
```
|
||||
|
||||
### 4. Seed Data
|
||||
|
||||
```bash
|
||||
python -m scripts.seed
|
||||
```
|
||||
|
||||
### 5. API Docs
|
||||
|
||||
Visit `http://localhost:8000/docs` (Swagger UI) in development mode.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Railway / Render / Fly.io
|
||||
|
||||
1. Push to a Git repository
|
||||
2. Connect the repo to your platform
|
||||
3. Set environment variables from `.env.example`
|
||||
4. Deploy — the `Dockerfile` handles everything
|
||||
|
||||
### Production Checklist
|
||||
|
||||
- [ ] Set `APP_ENV=production` and `DEBUG=false`
|
||||
- [ ] Set a strong random `SECRET_KEY` (64+ chars)
|
||||
- [ ] Configure `ALLOWED_ORIGINS` to your frontend domain
|
||||
- [ ] Set up Sentry DSN for error tracking
|
||||
- [ ] Configure SMTP for password reset emails
|
||||
- [ ] Enable Supabase RLS policies for direct client access
|
||||
- [ ] Set up Supabase Storage bucket with appropriate policies
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
The frontend should:
|
||||
|
||||
1. Store tokens from `/auth/login` response
|
||||
2. Send `Authorization: Bearer <access_token>` on every authenticated request
|
||||
3. Use `/auth/refresh` when the access token expires
|
||||
4. Replace all mock data imports with API calls to the endpoints above
|
||||
|
||||
**Base URL pattern:** `${API_BASE_URL}/api/v1/...`
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/endpoints/__init__.py
Normal file
0
app/api/v1/endpoints/__init__.py
Normal file
82
app/api/v1/endpoints/agencies.py
Normal file
82
app/api/v1/endpoints/agencies.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Agency CRUD endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.middleware.auth import get_current_user, require_admin, require_agency_or_admin
|
||||
from app.schemas.agency import (
|
||||
AgencyCreate,
|
||||
AgencyListResponse,
|
||||
AgencyResponse,
|
||||
AgencyUpdate,
|
||||
)
|
||||
from app.services.agency_service import AgencyService
|
||||
|
||||
router = APIRouter(prefix="/agencies", tags=["Agencies"])
|
||||
|
||||
|
||||
@router.get("/", response_model=AgencyListResponse)
|
||||
def list_agencies(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
verified_only: bool = Query(False),
|
||||
):
|
||||
svc = AgencyService()
|
||||
return svc.list_agencies(page=page, page_size=page_size, verified_only=verified_only)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AgencyResponse)
|
||||
def get_my_agency(user: dict = Depends(get_current_user)):
|
||||
svc = AgencyService()
|
||||
return svc.get_agency_by_user(user["id"])
|
||||
|
||||
|
||||
@router.get("/{agency_id}", response_model=AgencyResponse)
|
||||
def get_agency(agency_id: str):
|
||||
svc = AgencyService()
|
||||
return svc.get_agency(agency_id)
|
||||
|
||||
|
||||
@router.post("/", response_model=AgencyResponse, status_code=201)
|
||||
def create_agency(body: AgencyCreate, user: dict = Depends(get_current_user)):
|
||||
svc = AgencyService()
|
||||
# Prevent duplicate agencies for the same user
|
||||
try:
|
||||
existing = svc.get_agency_by_user(user["id"])
|
||||
return existing
|
||||
except Exception:
|
||||
pass
|
||||
return svc.create_agency(user["id"], body.model_dump())
|
||||
|
||||
|
||||
@router.patch("/{agency_id}", response_model=AgencyResponse)
|
||||
def update_agency(
|
||||
agency_id: str,
|
||||
body: AgencyUpdate,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
svc = AgencyService()
|
||||
return svc.update_agency(
|
||||
agency_id, user["id"], user["role"], body.model_dump(exclude_unset=True)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{agency_id}/verify", response_model=AgencyResponse)
|
||||
def verify_agency(agency_id: str, admin: dict = Depends(require_admin)):
|
||||
svc = AgencyService()
|
||||
return svc.verify_agency(agency_id, admin["role"])
|
||||
|
||||
|
||||
@router.post("/{agency_id}/revoke", response_model=AgencyResponse)
|
||||
def revoke_agency_verification(agency_id: str, admin: dict = Depends(require_admin)):
|
||||
svc = AgencyService()
|
||||
return svc.revoke_verification(agency_id, admin["role"])
|
||||
|
||||
|
||||
@router.delete("/{agency_id}")
|
||||
def delete_agency(agency_id: str, _admin: dict = Depends(require_admin)):
|
||||
svc = AgencyService()
|
||||
return svc.delete_agency(agency_id)
|
||||
81
app/api/v1/endpoints/auth.py
Normal file
81
app/api/v1/endpoints/auth.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Authentication endpoints — register, login, refresh, password reset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.schemas.auth import (
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
MessageResponse,
|
||||
PasswordResetConfirm,
|
||||
PasswordResetRequest,
|
||||
RefreshTokenRequest,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
)
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
|
||||
@router.post("/register", status_code=201, response_model=RegisterResponse)
|
||||
def register(body: RegisterRequest):
|
||||
svc = AuthService()
|
||||
return svc.register(
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
name=body.name,
|
||||
role=body.role,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(body: LoginRequest):
|
||||
svc = AuthService()
|
||||
return svc.login(email=body.email, password=body.password)
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
def refresh(body: RefreshTokenRequest):
|
||||
svc = AuthService()
|
||||
return svc.refresh(body.refresh_token)
|
||||
|
||||
|
||||
@router.post("/password-reset/request", response_model=MessageResponse)
|
||||
def request_password_reset(body: PasswordResetRequest):
|
||||
svc = AuthService()
|
||||
msg = svc.request_password_reset(body.email)
|
||||
return {"message": msg}
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm", response_model=MessageResponse)
|
||||
def confirm_password_reset(
|
||||
body: PasswordResetConfirm,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
svc = AuthService()
|
||||
return svc.reset_password(user["id"], body.new_password)
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=MessageResponse)
|
||||
def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
svc = AuthService()
|
||||
return svc.change_password(
|
||||
user["id"], user["email"], body.current_password, body.new_password
|
||||
)
|
||||
|
||||
|
||||
@router.post("/resend-verification", response_model=MessageResponse)
|
||||
def resend_verification(user: dict = Depends(get_current_user)):
|
||||
svc = AuthService()
|
||||
return svc.resend_verification(user["email"])
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_me(user: dict = Depends(get_current_user)):
|
||||
return user
|
||||
56
app/api/v1/endpoints/categories.py
Normal file
56
app/api/v1/endpoints/categories.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Category CRUD endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.middleware.auth import require_admin
|
||||
from app.schemas.category import (
|
||||
CategoryCreate,
|
||||
CategoryListResponse,
|
||||
CategoryResponse,
|
||||
CategoryUpdate,
|
||||
)
|
||||
from app.services.category_service import CategoryService
|
||||
|
||||
router = APIRouter(prefix="/categories", tags=["Categories"])
|
||||
|
||||
|
||||
@router.get("/", response_model=CategoryListResponse)
|
||||
def list_categories():
|
||||
svc = CategoryService()
|
||||
return svc.list_categories()
|
||||
|
||||
|
||||
@router.get("/{category_id}", response_model=CategoryResponse)
|
||||
def get_category(category_id: str):
|
||||
svc = CategoryService()
|
||||
return svc.get_category(category_id)
|
||||
|
||||
|
||||
@router.get("/slug/{slug}", response_model=CategoryResponse)
|
||||
def get_category_by_slug(slug: str):
|
||||
svc = CategoryService()
|
||||
return svc.get_category_by_slug(slug)
|
||||
|
||||
|
||||
@router.post("/", response_model=CategoryResponse, status_code=201)
|
||||
def create_category(body: CategoryCreate, _admin: dict = Depends(require_admin)):
|
||||
svc = CategoryService()
|
||||
return svc.create_category(body.model_dump())
|
||||
|
||||
|
||||
@router.patch("/{category_id}", response_model=CategoryResponse)
|
||||
def update_category(
|
||||
category_id: str,
|
||||
body: CategoryUpdate,
|
||||
_admin: dict = Depends(require_admin),
|
||||
):
|
||||
svc = CategoryService()
|
||||
return svc.update_category(category_id, body.model_dump(exclude_unset=True))
|
||||
|
||||
|
||||
@router.delete("/{category_id}")
|
||||
def delete_category(category_id: str, _admin: dict = Depends(require_admin)):
|
||||
svc = CategoryService()
|
||||
return svc.delete_category(category_id)
|
||||
35
app/api/v1/endpoints/favorites.py
Normal file
35
app/api/v1/endpoints/favorites.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Favorites / wishlist endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.schemas.favorite import FavoriteCreate, FavoriteListResponse, FavoriteResponse
|
||||
from app.services.favorite_service import FavoriteService
|
||||
|
||||
router = APIRouter(prefix="/favorites", tags=["Favorites"])
|
||||
|
||||
|
||||
@router.post("/", response_model=FavoriteResponse, status_code=201)
|
||||
def add_favorite(body: FavoriteCreate, user: dict = Depends(get_current_user)):
|
||||
svc = FavoriteService()
|
||||
return svc.add_favorite(user["id"], body.listing_id)
|
||||
|
||||
|
||||
@router.delete("/{listing_id}")
|
||||
def remove_favorite(listing_id: str, user: dict = Depends(get_current_user)):
|
||||
svc = FavoriteService()
|
||||
return svc.remove_favorite(user["id"], listing_id)
|
||||
|
||||
|
||||
@router.get("/", response_model=FavoriteListResponse)
|
||||
def list_favorites(user: dict = Depends(get_current_user)):
|
||||
svc = FavoriteService()
|
||||
return svc.list_favorites(user["id"])
|
||||
|
||||
|
||||
@router.get("/check/{listing_id}")
|
||||
def check_favorite(listing_id: str, user: dict = Depends(get_current_user)):
|
||||
svc = FavoriteService()
|
||||
return {"is_favorited": svc.is_favorited(user["id"], listing_id)}
|
||||
185
app/api/v1/endpoints/listings.py
Normal file
185
app/api/v1/endpoints/listings.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Listing CRUD + search endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||
|
||||
from app.middleware.auth import get_current_user, get_optional_user, require_admin
|
||||
from app.schemas.listing import (
|
||||
ListingCreate,
|
||||
ListingListResponse,
|
||||
ListingResponse,
|
||||
ListingStatusUpdate,
|
||||
ListingUpdate,
|
||||
)
|
||||
from app.services.agency_service import AgencyService
|
||||
from app.services.listing_service import ListingService
|
||||
|
||||
router = APIRouter(prefix="/listings", tags=["Listings"])
|
||||
|
||||
|
||||
# ── Public ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/", response_model=ListingListResponse)
|
||||
def list_listings(
|
||||
search: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
agency_id: Optional[str] = None,
|
||||
min_price: Optional[float] = None,
|
||||
max_price: Optional[float] = None,
|
||||
location: Optional[str] = None,
|
||||
listing_type: Optional[str] = None,
|
||||
condition: Optional[str] = None,
|
||||
sort_by: str = Query("newest"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
svc = ListingService()
|
||||
return svc.list_listings(
|
||||
search=search,
|
||||
category=category,
|
||||
agency_id=agency_id,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
location=location,
|
||||
listing_type=listing_type,
|
||||
condition=condition,
|
||||
sort_by=sort_by,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
status="approved",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/featured", response_model=ListingListResponse)
|
||||
def featured_listings():
|
||||
"""Return top 8 most viewed approved listings."""
|
||||
svc = ListingService()
|
||||
return svc.list_listings(
|
||||
sort_by="popular",
|
||||
page=1,
|
||||
page_size=8,
|
||||
status="approved",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{listing_id}", response_model=ListingResponse)
|
||||
def get_listing(
|
||||
listing_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: Optional[dict] = Depends(get_optional_user),
|
||||
):
|
||||
svc = ListingService()
|
||||
listing = svc.get_listing(listing_id)
|
||||
background_tasks.add_task(svc.increment_views, listing_id)
|
||||
return listing
|
||||
|
||||
|
||||
# ── Agency ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/agency/mine", response_model=ListingListResponse)
|
||||
def my_listings(
|
||||
status: Optional[str] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
agency_svc = AgencyService()
|
||||
agency = agency_svc.get_agency_by_user(user["id"])
|
||||
|
||||
svc = ListingService()
|
||||
return svc.list_listings(
|
||||
agency_id=agency["id"],
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=ListingResponse, status_code=201)
|
||||
def create_listing(body: ListingCreate, user: dict = Depends(get_current_user)):
|
||||
agency_svc = AgencyService()
|
||||
agency = agency_svc.get_agency_by_user(user["id"])
|
||||
|
||||
svc = ListingService()
|
||||
return svc.create_listing(agency["id"], body.model_dump())
|
||||
|
||||
|
||||
@router.patch("/{listing_id}", response_model=ListingResponse)
|
||||
def update_listing(
|
||||
listing_id: str,
|
||||
body: ListingUpdate,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
svc = ListingService()
|
||||
return svc.update_listing(
|
||||
listing_id, user["id"], user["role"], body.model_dump(exclude_unset=True)
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{listing_id}")
|
||||
def delete_listing(listing_id: str, user: dict = Depends(get_current_user)):
|
||||
svc = ListingService()
|
||||
return svc.delete_listing(listing_id, user["id"], user["role"])
|
||||
|
||||
|
||||
# ── Admin ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/admin/all", response_model=ListingListResponse)
|
||||
def admin_list_all(
|
||||
status: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
min_price: Optional[float] = None,
|
||||
max_price: Optional[float] = None,
|
||||
location: Optional[str] = None,
|
||||
listing_type: Optional[str] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
_admin: dict = Depends(require_admin),
|
||||
):
|
||||
svc = ListingService()
|
||||
return svc.list_listings(
|
||||
search=search,
|
||||
status=status,
|
||||
category=category,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
location=location,
|
||||
listing_type=listing_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{listing_id}/status", response_model=ListingResponse)
|
||||
def update_listing_status(
|
||||
listing_id: str,
|
||||
body: ListingStatusUpdate,
|
||||
_admin: dict = Depends(require_admin),
|
||||
):
|
||||
svc = ListingService()
|
||||
return svc.update_status(listing_id, body.status, body.rejection_reason)
|
||||
|
||||
|
||||
@router.get("/stats/overview")
|
||||
def listing_stats(
|
||||
agency_id: Optional[str] = None,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
svc = ListingService()
|
||||
if user["role"] != "admin":
|
||||
# Non-admins always see their own agency's stats only
|
||||
agency_svc = AgencyService()
|
||||
try:
|
||||
agency = agency_svc.get_agency_by_user(user["id"])
|
||||
agency_id = agency["id"]
|
||||
except Exception:
|
||||
return {"total": 0, "pending": 0, "approved": 0, "rejected": 0}
|
||||
|
||||
return svc.get_stats(agency_id)
|
||||
74
app/api/v1/endpoints/messages.py
Normal file
74
app/api/v1/endpoints/messages.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Contact / message endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.schemas.message import (
|
||||
MessageCreate,
|
||||
MessageListResponse,
|
||||
MessageMarkRead,
|
||||
MessageResponse,
|
||||
)
|
||||
from app.services.agency_service import AgencyService
|
||||
from app.services.message_service import MessageService
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["Messages"])
|
||||
|
||||
|
||||
@router.post("/", response_model=MessageResponse, status_code=201)
|
||||
def send_message(body: MessageCreate):
|
||||
"""Public endpoint — anyone can send a message about a listing."""
|
||||
svc = MessageService()
|
||||
return svc.send_message(body.model_dump())
|
||||
|
||||
|
||||
@router.get("/", response_model=MessageListResponse)
|
||||
def list_messages(
|
||||
read: Optional[bool] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List messages for the current user's agency."""
|
||||
agency_svc = AgencyService()
|
||||
agency = agency_svc.get_agency_by_user(user["id"])
|
||||
|
||||
svc = MessageService()
|
||||
return svc.list_messages(
|
||||
agency_id=agency["id"],
|
||||
user_id=user["id"],
|
||||
user_role=user["role"],
|
||||
read_filter=read,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
def unread_count(user: dict = Depends(get_current_user)):
|
||||
agency_svc = AgencyService()
|
||||
agency = agency_svc.get_agency_by_user(user["id"])
|
||||
|
||||
svc = MessageService()
|
||||
count = svc.get_unread_count(agency["id"])
|
||||
return {"unread_count": count}
|
||||
|
||||
|
||||
@router.patch("/{message_id}/read", response_model=MessageResponse)
|
||||
def mark_message_read(
|
||||
message_id: str,
|
||||
body: MessageMarkRead,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
svc = MessageService()
|
||||
return svc.mark_read(message_id, user["id"], user["role"], body.read)
|
||||
|
||||
|
||||
@router.delete("/{message_id}")
|
||||
def delete_message(message_id: str, user: dict = Depends(get_current_user)):
|
||||
svc = MessageService()
|
||||
return svc.delete_message(message_id, user["id"], user["role"])
|
||||
80
app/api/v1/endpoints/payments.py
Normal file
80
app/api/v1/endpoints/payments.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Payment endpoints — subscription and purchase flows via CinetPay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
|
||||
from app.middleware.auth import get_current_user, require_agency
|
||||
from app.schemas.payment import (
|
||||
PaymentInitiate,
|
||||
PaymentInitiateResponse,
|
||||
PaymentReceiptResponse,
|
||||
PaymentResponse,
|
||||
SubscriptionResponse,
|
||||
)
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
router = APIRouter(tags=["Payments"])
|
||||
|
||||
|
||||
# ── Payments ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/payments/initiate", response_model=PaymentInitiateResponse, status_code=201)
|
||||
def initiate_payment(
|
||||
body: PaymentInitiate,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
svc = PaymentService()
|
||||
return svc.initiate(
|
||||
user_id=user["id"],
|
||||
payment_type=body.type,
|
||||
plan=body.plan,
|
||||
listing_id=body.listing_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/payments/webhook")
|
||||
async def cinetpay_webhook(request: Request):
|
||||
"""Receive CinetPay webhook — no auth required. Returns 200 immediately."""
|
||||
try:
|
||||
form = await request.form()
|
||||
form_data = dict(form)
|
||||
except Exception:
|
||||
form_data = {}
|
||||
|
||||
# Also accept query params (CinetPay may use GET in some flows)
|
||||
if not form_data:
|
||||
form_data = dict(request.query_params)
|
||||
|
||||
svc = PaymentService()
|
||||
try:
|
||||
svc.handle_webhook(form_data)
|
||||
except Exception:
|
||||
pass # Always return 200 to CinetPay
|
||||
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
@router.get("/payments/", response_model=list[PaymentResponse])
|
||||
def list_my_payments(user: dict = Depends(get_current_user)):
|
||||
svc = PaymentService()
|
||||
return svc.get_my_payments(user["id"])
|
||||
|
||||
|
||||
@router.get("/payments/{transaction_id}", response_model=PaymentReceiptResponse)
|
||||
def get_payment_receipt(
|
||||
transaction_id: str,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
svc = PaymentService()
|
||||
return svc.get_receipt(transaction_id, user["id"])
|
||||
|
||||
|
||||
# ── Subscriptions ─────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/subscriptions/me")
|
||||
def my_subscription(user: dict = Depends(require_agency)):
|
||||
svc = PaymentService()
|
||||
return svc.get_subscription_status(user["id"])
|
||||
53
app/api/v1/endpoints/uploads.py
Normal file
53
app/api/v1/endpoints/uploads.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""File upload endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.services.upload_service import UploadService
|
||||
|
||||
router = APIRouter(prefix="/uploads", tags=["Uploads"])
|
||||
|
||||
|
||||
@router.post("/image")
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Upload an image and return its public URL."""
|
||||
contents = await file.read()
|
||||
svc = UploadService()
|
||||
url = svc.upload_image(
|
||||
file_bytes=contents,
|
||||
content_type=file.content_type or "image/jpeg",
|
||||
folder=f"users/{user['id']}",
|
||||
)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@router.post("/images")
|
||||
async def upload_multiple_images(
|
||||
files: list[UploadFile] = File(...),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Upload multiple images and return their public URLs."""
|
||||
svc = UploadService()
|
||||
urls = []
|
||||
for f in files:
|
||||
contents = await f.read()
|
||||
url = svc.upload_image(
|
||||
file_bytes=contents,
|
||||
content_type=f.content_type or "image/jpeg",
|
||||
folder=f"users/{user['id']}",
|
||||
)
|
||||
urls.append(url)
|
||||
return {"urls": urls}
|
||||
|
||||
|
||||
@router.delete("/")
|
||||
async def delete_image(url: str, user: dict = Depends(get_current_user)):
|
||||
"""Delete an image by URL."""
|
||||
svc = UploadService()
|
||||
success = svc.delete_image(url)
|
||||
return {"deleted": success}
|
||||
55
app/api/v1/endpoints/users.py
Normal file
55
app/api/v1/endpoints/users.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""User management endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.middleware.auth import get_current_user, require_admin
|
||||
from app.schemas.user import UserListResponse, UserResponse, UserUpdate
|
||||
from app.services.user_service import UserService
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Users"])
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_my_profile(user: dict = Depends(get_current_user)):
|
||||
svc = UserService()
|
||||
return svc.get_user(user["id"])
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
def update_my_profile(body: UserUpdate, user: dict = Depends(get_current_user)):
|
||||
svc = UserService()
|
||||
return svc.update_user(user["id"], body.model_dump(exclude_unset=True))
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
def get_user(user_id: str, _admin: dict = Depends(require_admin)):
|
||||
svc = UserService()
|
||||
return svc.get_user(user_id)
|
||||
|
||||
|
||||
@router.get("/", response_model=UserListResponse)
|
||||
def list_users(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
role: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
_admin: dict = Depends(require_admin),
|
||||
):
|
||||
svc = UserService()
|
||||
return svc.list_users(page=page, page_size=page_size, role=role, search=search)
|
||||
|
||||
|
||||
@router.post("/{user_id}/verify", response_model=UserResponse)
|
||||
def verify_user(user_id: str, _admin: dict = Depends(require_admin)):
|
||||
svc = UserService()
|
||||
return svc.verify_user(user_id)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_user(user_id: str, user: dict = Depends(get_current_user)):
|
||||
svc = UserService()
|
||||
return svc.delete_user(user_id, user["id"], user["role"])
|
||||
27
app/api/v1/router.py
Normal file
27
app/api/v1/router.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Aggregate all v1 routers."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import (
|
||||
agencies,
|
||||
auth,
|
||||
categories,
|
||||
favorites,
|
||||
listings,
|
||||
messages,
|
||||
payments,
|
||||
uploads,
|
||||
users,
|
||||
)
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(agencies.router)
|
||||
api_router.include_router(categories.router)
|
||||
api_router.include_router(listings.router)
|
||||
api_router.include_router(messages.router)
|
||||
api_router.include_router(favorites.router)
|
||||
api_router.include_router(uploads.router)
|
||||
api_router.include_router(payments.router)
|
||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
71
app/core/config.py
Normal file
71
app/core/config.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Application configuration loaded from environment variables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import List
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ── App ───────────────────────────────────────────────
|
||||
APP_NAME: str = "Deals24Togo"
|
||||
APP_ENV: str = "development"
|
||||
DEBUG: bool = False
|
||||
ALLOWED_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||
|
||||
@property
|
||||
def allowed_origins_list(self) -> List[str]:
|
||||
return [o.strip() for o in self.ALLOWED_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
# ── Supabase ──────────────────────────────────────────
|
||||
SUPABASE_URL: str
|
||||
SUPABASE_KEY: str
|
||||
SUPABASE_SERVICE_ROLE_KEY: str
|
||||
|
||||
# ── Auth / JWT ────────────────────────────────────────
|
||||
SUPABASE_JWT_SECRET: str = "change-me"
|
||||
|
||||
# ── Storage ───────────────────────────────────────────
|
||||
SUPABASE_STORAGE_BUCKET: str = "listings"
|
||||
MAX_UPLOAD_SIZE_MB: int = 10
|
||||
|
||||
@property
|
||||
def max_upload_size_bytes(self) -> int:
|
||||
return self.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||
|
||||
# ── Rate limiting ─────────────────────────────────────
|
||||
RATE_LIMIT_PER_MINUTE: int = 60
|
||||
|
||||
# ── Sentry ────────────────────────────────────────────
|
||||
SENTRY_DSN: str = ""
|
||||
|
||||
# ── Email ─────────────────────────────────────────────
|
||||
SMTP_HOST: str = ""
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
EMAIL_FROM: str = "noreply@deals24togo.com"
|
||||
|
||||
# ── Frontend ──────────────────────────────────────────
|
||||
FRONTEND_URL: str = "http://localhost:5173"
|
||||
|
||||
# ── CinetPay ──────────────────────────────────────────
|
||||
CINETPAY_API_KEY: str = ""
|
||||
CINETPAY_SITE_ID: str = ""
|
||||
BACKEND_PUBLIC_URL: str = "http://localhost:8000"
|
||||
SUBSCRIPTION_MONTHLY_AMOUNT: int = 1000
|
||||
SUBSCRIPTION_YEARLY_AMOUNT: int = 10000
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings() # type: ignore[call-arg]
|
||||
35
app/core/exceptions.py
Normal file
35
app/core/exceptions.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Application-level exceptions with HTTP status codes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
def __init__(self, status_code: int, detail: str):
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
super().__init__(detail)
|
||||
|
||||
|
||||
class NotFoundException(AppException):
|
||||
def __init__(self, detail: str = "Resource not found"):
|
||||
super().__init__(status_code=404, detail=detail)
|
||||
|
||||
|
||||
class UnauthorizedException(AppException):
|
||||
def __init__(self, detail: str = "Not authenticated"):
|
||||
super().__init__(status_code=401, detail=detail)
|
||||
|
||||
|
||||
class ForbiddenException(AppException):
|
||||
def __init__(self, detail: str = "Not enough permissions"):
|
||||
super().__init__(status_code=403, detail=detail)
|
||||
|
||||
|
||||
class BadRequestException(AppException):
|
||||
def __init__(self, detail: str = "Bad request"):
|
||||
super().__init__(status_code=400, detail=detail)
|
||||
|
||||
|
||||
class ConflictException(AppException):
|
||||
def __init__(self, detail: str = "Resource already exists"):
|
||||
super().__init__(status_code=409, detail=detail)
|
||||
58
app/core/logging.py
Normal file
58
app/core/logging.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Structured logging with structlog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import structlog
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
settings = get_settings()
|
||||
|
||||
log_level = logging.DEBUG if settings.DEBUG else logging.INFO
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
formatter = structlog.stdlib.ProcessorFormatter(
|
||||
processor=(
|
||||
structlog.dev.ConsoleRenderer()
|
||||
if settings.DEBUG
|
||||
else structlog.processors.JSONRenderer()
|
||||
),
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
root.setLevel(log_level)
|
||||
|
||||
# Silence noisy libs
|
||||
for name in ("httpx", "httpcore", "uvicorn.access"):
|
||||
logging.getLogger(name).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def get_logger(name: str = __name__) -> structlog.stdlib.BoundLogger:
|
||||
return structlog.get_logger(name)
|
||||
23
app/core/supabase.py
Normal file
23
app/core/supabase.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Supabase client helpers — one anon client, one service-role client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from supabase import Client, create_client
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_supabase_client() -> Client:
|
||||
"""Public / anon client — respects RLS policies."""
|
||||
s = get_settings()
|
||||
return create_client(s.SUPABASE_URL, s.SUPABASE_KEY)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_supabase_admin() -> Client:
|
||||
"""Service-role client — bypasses RLS. Use with care."""
|
||||
s = get_settings()
|
||||
return create_client(s.SUPABASE_URL, s.SUPABASE_SERVICE_ROLE_KEY)
|
||||
95
app/main.py
Normal file
95
app/main.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""FastAPI application factory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import sentry_sdk
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import AppException
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup / shutdown events."""
|
||||
setup_logging()
|
||||
settings = get_settings()
|
||||
|
||||
# Sentry
|
||||
if settings.SENTRY_DSN:
|
||||
sentry_sdk.init(
|
||||
dsn=settings.SENTRY_DSN,
|
||||
traces_sample_rate=0.1,
|
||||
environment=settings.APP_ENV,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"application_started",
|
||||
app_name=settings.APP_NAME,
|
||||
env=settings.APP_ENV,
|
||||
)
|
||||
yield
|
||||
logger.info("application_shutdown")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title=f"{settings.APP_NAME} API",
|
||||
description="Marketplace API for Deals24Togo — listings, agencies, categories, and more.",
|
||||
version="1.0.0",
|
||||
docs_url="/docs" if settings.DEBUG else None,
|
||||
redoc_url="/redoc" if settings.DEBUG else None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# ── CORS ──
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.allowed_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ── Rate limiter ──
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# ── Exception handlers ──
|
||||
@app.exception_handler(AppException)
|
||||
async def app_exception_handler(_request: Request, exc: AppException):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail},
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(_request: Request, exc: Exception):
|
||||
logger.error("unhandled_exception", error=str(exc), exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
|
||||
# ── Routes ──
|
||||
app.include_router(api_router)
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
def health_check():
|
||||
return {"status": "healthy", "app": settings.APP_NAME, "env": settings.APP_ENV}
|
||||
|
||||
return app
|
||||
0
app/middleware/__init__.py
Normal file
0
app/middleware/__init__.py
Normal file
75
app/middleware/auth.py
Normal file
75
app/middleware/auth.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""FastAPI dependencies for auth and RBAC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, Header
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import ForbiddenException, UnauthorizedException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
|
||||
async def get_current_user(authorization: Optional[str] = Header(None)) -> dict:
|
||||
"""Extract and validate the user from the Supabase-issued JWT."""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise UnauthorizedException("Missing or invalid authorization header")
|
||||
|
||||
token = authorization.split(" ", 1)[1]
|
||||
settings = get_settings()
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.SUPABASE_JWT_SECRET,
|
||||
algorithms=["HS256"],
|
||||
audience="authenticated",
|
||||
)
|
||||
except JWTError:
|
||||
raise UnauthorizedException("Invalid or expired token")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise UnauthorizedException("Invalid token payload")
|
||||
|
||||
db = get_supabase_admin()
|
||||
result = db.table("users").select("*").eq("id", user_id).execute()
|
||||
if not result.data:
|
||||
raise UnauthorizedException("User not found")
|
||||
|
||||
user = result.data[0]
|
||||
user.pop("password_hash", None)
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
authorization: Optional[str] = Header(None),
|
||||
) -> Optional[dict]:
|
||||
"""Return user if authenticated, None otherwise."""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
return None
|
||||
try:
|
||||
return await get_current_user(authorization)
|
||||
except UnauthorizedException:
|
||||
return None
|
||||
|
||||
|
||||
def require_role(*roles: str):
|
||||
"""Return a dependency that enforces one of the given roles."""
|
||||
|
||||
async def _check(user: dict = Depends(get_current_user)) -> dict:
|
||||
if user["role"] not in roles:
|
||||
raise ForbiddenException(
|
||||
f"Requires one of: {', '.join(roles)}"
|
||||
)
|
||||
return user
|
||||
|
||||
return _check
|
||||
|
||||
|
||||
# Convenience aliases
|
||||
require_admin = require_role("admin")
|
||||
require_agency = require_role("agency")
|
||||
require_agency_or_admin = require_role("agency", "admin")
|
||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
7
app/schemas/__init__.py
Normal file
7
app/schemas/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from app.schemas.auth import * # noqa: F401, F403
|
||||
from app.schemas.user import * # noqa: F401, F403
|
||||
from app.schemas.agency import * # noqa: F401, F403
|
||||
from app.schemas.category import * # noqa: F401, F403
|
||||
from app.schemas.listing import * # noqa: F401, F403
|
||||
from app.schemas.message import * # noqa: F401, F403
|
||||
from app.schemas.favorite import * # noqa: F401, F403
|
||||
55
app/schemas/agency.py
Normal file
55
app/schemas/agency.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Agency request / response schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, HttpUrl
|
||||
|
||||
|
||||
class AgencyBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str = Field(..., min_length=1, max_length=2000)
|
||||
address: str = Field(..., min_length=1, max_length=500)
|
||||
phone: str = Field(..., min_length=1, max_length=20)
|
||||
email: EmailStr
|
||||
website: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class AgencyCreate(AgencyBase):
|
||||
pass
|
||||
|
||||
|
||||
class AgencyUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(None, min_length=1, max_length=2000)
|
||||
address: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
phone: Optional[str] = Field(None, min_length=1, max_length=20)
|
||||
email: Optional[EmailStr] = None
|
||||
website: Optional[str] = Field(None, max_length=500)
|
||||
logo: Optional[str] = None
|
||||
|
||||
|
||||
class AgencyResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: str
|
||||
logo: Optional[str] = None
|
||||
address: str
|
||||
phone: str
|
||||
email: str
|
||||
website: Optional[str] = None
|
||||
verified: bool
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AgencyListResponse(BaseModel):
|
||||
agencies: list[AgencyResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
57
app/schemas/auth.py
Normal file
57
app/schemas/auth.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Auth-related request / response schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
# ── Requests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
role: str = Field(default="visitor", pattern="^(visitor|agency)$")
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
new_password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
|
||||
# ── Responses ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
message: str
|
||||
user: Dict[str, Any]
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
44
app/schemas/category.py
Normal file
44
app/schemas/category.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Category request / response schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: str = Field(..., min_length=1, max_length=500)
|
||||
icon: str = Field(default="tag", max_length=50)
|
||||
slug: str = Field(..., min_length=1, max_length=100, pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
pass
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
icon: Optional[str] = Field(None, max_length=50)
|
||||
slug: Optional[str] = Field(None, min_length=1, max_length=100, pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
icon: str
|
||||
slug: str
|
||||
listing_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CategoryListResponse(BaseModel):
|
||||
categories: list[CategoryResponse]
|
||||
total: int
|
||||
25
app/schemas/favorite.py
Normal file
25
app/schemas/favorite.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Favorite / wishlist schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class FavoriteCreate(BaseModel):
|
||||
listing_id: str
|
||||
|
||||
|
||||
class FavoriteResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
listing_id: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class FavoriteListResponse(BaseModel):
|
||||
favorites: list[FavoriteResponse]
|
||||
total: int
|
||||
102
app/schemas/listing.py
Normal file
102
app/schemas/listing.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Listing request / response schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
import math
|
||||
|
||||
|
||||
class ListingBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
description: str = Field(..., min_length=1, max_length=5000)
|
||||
price: float = Field(..., gt=0)
|
||||
category_id: str
|
||||
location: str = Field(..., min_length=1, max_length=255)
|
||||
images: list[str] = Field(default_factory=list, max_length=20)
|
||||
listing_type: str = Field(default="sale", pattern="^(sale|rent)$")
|
||||
condition: Optional[str] = Field(None, pattern="^(new|used|refurbished)$")
|
||||
negotiable: bool = False
|
||||
|
||||
@field_validator("price")
|
||||
@classmethod
|
||||
def price_must_be_finite(cls, v: float) -> float:
|
||||
if not math.isfinite(v):
|
||||
raise ValueError("price must be a finite number")
|
||||
return v
|
||||
|
||||
@field_validator("images")
|
||||
@classmethod
|
||||
def images_must_be_http(cls, v: list) -> list:
|
||||
for url in v:
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
raise ValueError(f"Image URL must start with http:// or https://: {url!r}")
|
||||
return v
|
||||
|
||||
|
||||
class ListingCreate(ListingBase):
|
||||
pass
|
||||
|
||||
|
||||
class ListingUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(None, min_length=1, max_length=5000)
|
||||
price: Optional[float] = Field(None, ge=0)
|
||||
category_id: Optional[str] = None
|
||||
location: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
images: Optional[list[str]] = Field(None, max_length=20)
|
||||
listing_type: Optional[str] = Field(None, pattern="^(sale|rent)$")
|
||||
condition: Optional[str] = Field(None, pattern="^(new|used|refurbished)$")
|
||||
negotiable: Optional[bool] = None
|
||||
|
||||
|
||||
class ListingStatusUpdate(BaseModel):
|
||||
status: str = Field(..., pattern="^(approved|rejected)$")
|
||||
rejection_reason: Optional[str] = Field(None, max_length=1000)
|
||||
|
||||
|
||||
class ListingResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
price: float
|
||||
images: list[str]
|
||||
status: str
|
||||
agency_id: str
|
||||
category_id: str
|
||||
location: str
|
||||
listing_type: str
|
||||
condition: Optional[str] = None
|
||||
negotiable: bool
|
||||
views_count: int = 0
|
||||
rejection_reason: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
# Joined fields (optional)
|
||||
agency_name: Optional[str] = None
|
||||
category_name: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ListingListResponse(BaseModel):
|
||||
listings: list[ListingResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class ListingSearchParams(BaseModel):
|
||||
search: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
min_price: Optional[float] = None
|
||||
max_price: Optional[float] = None
|
||||
location: Optional[str] = None
|
||||
listing_type: Optional[str] = None
|
||||
condition: Optional[str] = None
|
||||
sort_by: str = Field(default="newest", pattern="^(newest|oldest|price_asc|price_desc|popular)$")
|
||||
status: Optional[str] = None
|
||||
page: int = Field(default=1, ge=1)
|
||||
page_size: int = Field(default=20, ge=1, le=100)
|
||||
43
app/schemas/message.py
Normal file
43
app/schemas/message.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Message / contact-form schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
listing_id: str
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
email: EmailStr
|
||||
phone: Optional[str] = Field(None, max_length=20)
|
||||
message: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: str
|
||||
listing_id: str
|
||||
agency_id: str
|
||||
name: str
|
||||
email: str
|
||||
phone: Optional[str] = None
|
||||
message: str
|
||||
read: bool
|
||||
created_at: datetime
|
||||
# Joined
|
||||
listing_title: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class MessageListResponse(BaseModel):
|
||||
messages: list[MessageResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class MessageMarkRead(BaseModel):
|
||||
read: bool = True
|
||||
64
app/schemas/payment.py
Normal file
64
app/schemas/payment.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Payment-related Pydantic schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PaymentInitiate(BaseModel):
|
||||
type: str # 'subscription' | 'purchase'
|
||||
plan: Optional[str] = None # 'monthly' | 'yearly' (subscription only)
|
||||
listing_id: Optional[str] = None # purchase only
|
||||
|
||||
|
||||
class PaymentResponse(BaseModel):
|
||||
id: str
|
||||
transaction_id: str
|
||||
type: str
|
||||
payer_id: Optional[str] = None
|
||||
amount: float
|
||||
currency: str
|
||||
status: str
|
||||
payment_method: Optional[str] = None
|
||||
operator_id: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
paid_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class PaymentInitiateResponse(BaseModel):
|
||||
payment_url: str
|
||||
transaction_id: str
|
||||
|
||||
|
||||
class PaymentReceiptResponse(BaseModel):
|
||||
id: str
|
||||
transaction_id: str
|
||||
type: str
|
||||
amount: float
|
||||
currency: str
|
||||
status: str
|
||||
payment_method: Optional[str] = None
|
||||
operator_id: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
paid_at: Optional[datetime] = None
|
||||
# Enriched fields
|
||||
payer_name: Optional[str] = None
|
||||
payer_email: Optional[str] = None
|
||||
plan_label: Optional[str] = None
|
||||
listing_title: Optional[str] = None
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
id: str
|
||||
agency_id: str
|
||||
plan: str
|
||||
status: str
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
payment_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
46
app/schemas/user.py
Normal file
46
app/schemas/user.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""User request / response schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
role: str = Field(default="visitor")
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = Field(None, max_length=20)
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
verified: bool
|
||||
phone: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
users: list[UserResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
134
app/services/agency_service.py
Normal file
134
app/services/agency_service.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Agency CRUD service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.core.exceptions import ForbiddenException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
|
||||
class AgencyService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
|
||||
def get_agency(self, agency_id: str) -> dict:
|
||||
result = self.db.table("agencies").select("*").eq("id", agency_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("Agency not found")
|
||||
return result.data[0]
|
||||
|
||||
def get_agency_by_user(self, user_id: str) -> dict:
|
||||
result = self.db.table("agencies").select("*").eq("user_id", user_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("Agency not found for this user")
|
||||
return result.data[0]
|
||||
|
||||
def create_agency(self, user_id: str, data: dict) -> dict:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
agency_data = {
|
||||
"user_id": user_id,
|
||||
**data,
|
||||
"verified": False,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
result = self.db.table("agencies").insert(agency_data).execute()
|
||||
if not result.data:
|
||||
raise Exception("Failed to create agency")
|
||||
return result.data[0]
|
||||
|
||||
def update_agency(self, agency_id: str, user_id: str, user_role: str, data: dict) -> dict:
|
||||
# Check ownership or admin
|
||||
agency = self.get_agency(agency_id)
|
||||
if user_role != "admin" and agency["user_id"] != user_id:
|
||||
raise ForbiddenException("Not authorized to update this agency")
|
||||
|
||||
update_data = {k: v for k, v in data.items() if v is not None}
|
||||
if not update_data:
|
||||
return agency
|
||||
|
||||
update_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("agencies")
|
||||
.update(update_data)
|
||||
.eq("id", agency_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Agency not found")
|
||||
return result.data[0]
|
||||
|
||||
def list_agencies(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
verified_only: bool = False,
|
||||
) -> dict:
|
||||
query = self.db.table("agencies").select("*", count="exact")
|
||||
if verified_only:
|
||||
query = query.eq("verified", True)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = (
|
||||
query.order("created_at", desc=True)
|
||||
.range(offset, offset + page_size - 1)
|
||||
.execute()
|
||||
)
|
||||
|
||||
return {
|
||||
"agencies": result.data,
|
||||
"total": result.count or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
def verify_agency(self, agency_id: str, requester_role: str) -> dict:
|
||||
if requester_role != "admin":
|
||||
raise ForbiddenException("Only admins can verify agencies")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("agencies")
|
||||
.update({"verified": True, "updated_at": now})
|
||||
.eq("id", agency_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Agency not found")
|
||||
|
||||
# Also verify the associated user
|
||||
agency = result.data[0]
|
||||
self.db.table("users").update(
|
||||
{"verified": True, "updated_at": now}
|
||||
).eq("id", agency["user_id"]).execute()
|
||||
|
||||
return result.data[0]
|
||||
|
||||
def revoke_verification(self, agency_id: str, requester_role: str) -> dict:
|
||||
if requester_role != "admin":
|
||||
raise ForbiddenException("Only admins can revoke agency verification")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("agencies")
|
||||
.update({"verified": False, "updated_at": now})
|
||||
.eq("id", agency_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Agency not found")
|
||||
return result.data[0]
|
||||
|
||||
def delete_agency(self, agency_id: str) -> dict:
|
||||
# Fetch first to get user_id for cascade deletion
|
||||
agency_result = self.db.table("agencies").select("user_id").eq("id", agency_id).execute()
|
||||
if not agency_result.data:
|
||||
raise NotFoundException("Agency not found")
|
||||
user_id = agency_result.data[0]["user_id"]
|
||||
|
||||
# Delete messages belonging to this agency (avoids orphaned rows)
|
||||
self.db.table("messages").delete().eq("agency_id", agency_id).execute()
|
||||
self.db.table("agencies").delete().eq("id", agency_id).execute()
|
||||
# Delete the associated user account to avoid orphaned records
|
||||
self.db.table("users").delete().eq("id", user_id).execute()
|
||||
return {"message": "Agency deleted"}
|
||||
183
app/services/auth_service.py
Normal file
183
app/services/auth_service.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Authentication service — register, login, refresh, password reset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from gotrue.errors import AuthApiError
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import (
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
)
|
||||
from app.core.supabase import get_supabase_admin, get_supabase_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
# ── Register ─────────────────────────────────────────
|
||||
|
||||
def register(self, email: str, password: str, name: str, role: str = "visitor") -> dict:
|
||||
client = get_supabase_client()
|
||||
try:
|
||||
response = client.auth.sign_up({"email": email, "password": password})
|
||||
except AuthApiError as exc:
|
||||
raise BadRequestException(str(exc))
|
||||
|
||||
auth_user = response.user
|
||||
if not auth_user:
|
||||
raise BadRequestException("Registration failed — no user returned")
|
||||
|
||||
db = get_supabase_admin()
|
||||
user_data = {
|
||||
"id": auth_user.id,
|
||||
"email": email.lower(),
|
||||
"name": name,
|
||||
"role": role,
|
||||
"verified": False,
|
||||
}
|
||||
result = db.table("users").insert(user_data).execute()
|
||||
if not result.data:
|
||||
raise BadRequestException("Failed to create user profile")
|
||||
|
||||
user = result.data[0]
|
||||
|
||||
if role == "agency":
|
||||
agency_data = {
|
||||
"user_id": auth_user.id,
|
||||
"name": name,
|
||||
"description": "",
|
||||
"address": "",
|
||||
"phone": "",
|
||||
"email": email.lower(),
|
||||
"verified": False,
|
||||
}
|
||||
db.table("agencies").insert(agency_data).execute()
|
||||
|
||||
return {
|
||||
"message": "Registration successful. Please check your email to verify your account.",
|
||||
"user": self._sanitize_user(user),
|
||||
}
|
||||
|
||||
# ── Login ────────────────────────────────────────────
|
||||
|
||||
def login(self, email: str, password: str) -> dict:
|
||||
client = get_supabase_client()
|
||||
try:
|
||||
response = client.auth.sign_in_with_password({"email": email, "password": password})
|
||||
except AuthApiError:
|
||||
raise UnauthorizedException("Invalid email or password")
|
||||
|
||||
session = response.session
|
||||
auth_user = response.user
|
||||
|
||||
db = get_supabase_admin()
|
||||
result = db.table("users").select("*").eq("id", auth_user.id).execute()
|
||||
if not result.data:
|
||||
raise UnauthorizedException("User profile not found")
|
||||
|
||||
user = result.data[0]
|
||||
|
||||
# Sync verified flag if Supabase has confirmed the email
|
||||
if auth_user.email_confirmed_at and not user.get("verified"):
|
||||
db.table("users").update({"verified": True}).eq("id", auth_user.id).execute()
|
||||
user["verified"] = True
|
||||
|
||||
return {
|
||||
"access_token": session.access_token,
|
||||
"refresh_token": session.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user": self._sanitize_user(user),
|
||||
}
|
||||
|
||||
# ── Refresh ──────────────────────────────────────────
|
||||
|
||||
def refresh(self, refresh_token: str) -> dict:
|
||||
client = get_supabase_client()
|
||||
try:
|
||||
response = client.auth.refresh_session(refresh_token)
|
||||
except AuthApiError:
|
||||
raise UnauthorizedException("Invalid or expired refresh token")
|
||||
|
||||
session = response.session
|
||||
auth_user = response.user
|
||||
|
||||
db = get_supabase_admin()
|
||||
result = db.table("users").select("*").eq("id", auth_user.id).execute()
|
||||
if not result.data:
|
||||
raise UnauthorizedException("User profile not found")
|
||||
|
||||
user = result.data[0]
|
||||
return {
|
||||
"access_token": session.access_token,
|
||||
"refresh_token": session.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user": self._sanitize_user(user),
|
||||
}
|
||||
|
||||
# ── Password reset ───────────────────────────────────
|
||||
|
||||
def request_password_reset(self, email: str) -> str:
|
||||
settings = get_settings()
|
||||
redirect_to = f"{settings.FRONTEND_URL}/reset-password"
|
||||
try:
|
||||
get_supabase_client().auth.reset_password_for_email(
|
||||
email, {"redirect_to": redirect_to}
|
||||
)
|
||||
except AuthApiError as exc:
|
||||
logger.warning("Password reset request error for %s: %s", email, exc)
|
||||
return "If an account with that email exists, a reset link has been sent."
|
||||
|
||||
def reset_password(self, user_id: str, new_password: str) -> dict:
|
||||
try:
|
||||
get_supabase_admin().auth.admin.update_user_by_id(
|
||||
user_id, {"password": new_password}
|
||||
)
|
||||
except AuthApiError as exc:
|
||||
raise BadRequestException(str(exc))
|
||||
return {"message": "Password has been reset successfully"}
|
||||
|
||||
# ── Change password ──────────────────────────────────
|
||||
|
||||
def change_password(
|
||||
self, user_id: str, email: str, current_password: str, new_password: str
|
||||
) -> dict:
|
||||
# Re-authenticate to verify the current password
|
||||
client = get_supabase_client()
|
||||
try:
|
||||
client.auth.sign_in_with_password({"email": email, "password": current_password})
|
||||
except AuthApiError:
|
||||
raise BadRequestException("Current password is incorrect")
|
||||
|
||||
try:
|
||||
get_supabase_admin().auth.admin.update_user_by_id(
|
||||
user_id, {"password": new_password}
|
||||
)
|
||||
except AuthApiError as exc:
|
||||
raise BadRequestException(str(exc))
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
# ── Resend verification ──────────────────────────────
|
||||
|
||||
def resend_verification(self, email: str) -> dict:
|
||||
try:
|
||||
get_supabase_client().auth.resend({"type": "signup", "email": email})
|
||||
except AuthApiError as exc:
|
||||
logger.warning("Failed to resend verification to %s: %s", email, exc)
|
||||
return {"message": "Verification email sent"}
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_user(user: dict) -> dict:
|
||||
return {
|
||||
"id": user["id"],
|
||||
"email": user["email"],
|
||||
"name": user["name"],
|
||||
"role": user["role"],
|
||||
"verified": user["verified"],
|
||||
"created_at": user.get("created_at"),
|
||||
}
|
||||
108
app/services/category_service.py
Normal file
108
app/services/category_service.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Category CRUD service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core.exceptions import ConflictException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
|
||||
class CategoryService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
|
||||
def get_category(self, category_id: str) -> dict:
|
||||
result = self.db.table("categories").select("*").eq("id", category_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("Category not found")
|
||||
return result.data[0]
|
||||
|
||||
def get_category_by_slug(self, slug: str) -> dict:
|
||||
result = self.db.table("categories").select("*").eq("slug", slug).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("Category not found")
|
||||
return result.data[0]
|
||||
|
||||
def list_categories(self) -> dict:
|
||||
result = (
|
||||
self.db.table("categories")
|
||||
.select("*", count="exact")
|
||||
.order("name")
|
||||
.execute()
|
||||
)
|
||||
# Count listings per category in one extra query
|
||||
listings_result = self.db.table("listings").select("category_id").execute()
|
||||
counts: dict[str, int] = {}
|
||||
for row in listings_result.data or []:
|
||||
cid = row.get("category_id")
|
||||
if cid:
|
||||
counts[cid] = counts.get(cid, 0) + 1
|
||||
categories = result.data or []
|
||||
for cat in categories:
|
||||
cat["listing_count"] = counts.get(cat["id"], 0)
|
||||
return {"categories": categories, "total": result.count or 0}
|
||||
|
||||
def create_category(self, data: dict) -> dict:
|
||||
# Check slug uniqueness
|
||||
existing = (
|
||||
self.db.table("categories")
|
||||
.select("id")
|
||||
.eq("slug", data["slug"])
|
||||
.execute()
|
||||
)
|
||||
if existing.data:
|
||||
raise ConflictException("A category with this slug already exists")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
cat_data = {**data, "created_at": now, "updated_at": now}
|
||||
result = self.db.table("categories").insert(cat_data).execute()
|
||||
if not result.data:
|
||||
raise Exception("Failed to create category")
|
||||
return result.data[0]
|
||||
|
||||
def update_category(self, category_id: str, data: dict) -> dict:
|
||||
update_data = {k: v for k, v in data.items() if v is not None}
|
||||
if not update_data:
|
||||
return self.get_category(category_id)
|
||||
|
||||
# Check slug uniqueness if changing
|
||||
if "slug" in update_data:
|
||||
existing = (
|
||||
self.db.table("categories")
|
||||
.select("id")
|
||||
.eq("slug", update_data["slug"])
|
||||
.neq("id", category_id)
|
||||
.execute()
|
||||
)
|
||||
if existing.data:
|
||||
raise ConflictException("A category with this slug already exists")
|
||||
|
||||
update_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("categories")
|
||||
.update(update_data)
|
||||
.eq("id", category_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Category not found")
|
||||
return result.data[0]
|
||||
|
||||
def delete_category(self, category_id: str) -> dict:
|
||||
# Check if any listings reference this category
|
||||
listings = (
|
||||
self.db.table("listings")
|
||||
.select("id", count="exact")
|
||||
.eq("category_id", category_id)
|
||||
.execute()
|
||||
)
|
||||
if listings.data:
|
||||
raise ConflictException(
|
||||
f"Cannot delete category: {listings.count} listings reference it"
|
||||
)
|
||||
|
||||
result = self.db.table("categories").delete().eq("id", category_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("Category not found")
|
||||
return {"message": "Category deleted"}
|
||||
141
app/services/email_service.py
Normal file
141
app/services/email_service.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Email sending service via SMTP.
|
||||
|
||||
If SMTP credentials are not configured (SMTP_HOST / SMTP_USER empty),
|
||||
emails are logged to console instead — useful for local development.
|
||||
|
||||
Supabase SMTP settings can be found in:
|
||||
Project Settings → Auth → SMTP Settings (enable custom SMTP)
|
||||
|
||||
Or use any external provider (SendGrid, Mailgun, Brevo, etc.) and put
|
||||
the credentials in the .env file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailService:
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
return bool(self.settings.SMTP_HOST and self.settings.SMTP_USER)
|
||||
|
||||
def _send(self, to: str, subject: str, html_body: str, text_body: str) -> None:
|
||||
s = self.settings
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = s.EMAIL_FROM
|
||||
msg["To"] = to
|
||||
msg.attach(MIMEText(text_body, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
try:
|
||||
if s.SMTP_PORT == 465:
|
||||
with smtplib.SMTP_SSL(s.SMTP_HOST, s.SMTP_PORT, context=ctx) as srv:
|
||||
srv.login(s.SMTP_USER, s.SMTP_PASSWORD)
|
||||
srv.sendmail(s.EMAIL_FROM, to, msg.as_string())
|
||||
else:
|
||||
with smtplib.SMTP(s.SMTP_HOST, s.SMTP_PORT) as srv:
|
||||
srv.ehlo()
|
||||
srv.starttls(context=ctx)
|
||||
srv.login(s.SMTP_USER, s.SMTP_PASSWORD)
|
||||
srv.sendmail(s.EMAIL_FROM, to, msg.as_string())
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send email to %s: %s", to, exc)
|
||||
raise
|
||||
|
||||
# ── Public send methods ───────────────────────────────────
|
||||
|
||||
def send_password_reset_email(self, to_email: str, reset_url: str) -> None:
|
||||
subject = f"Reset your {self.settings.APP_NAME} password"
|
||||
html = f"""
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#1e293b">
|
||||
<h2 style="color:#0f4c75">Reset Your Password</h2>
|
||||
<p>You requested a password reset for your {self.settings.APP_NAME} account.</p>
|
||||
<p>Click the button below. This link expires in <strong>1 hour</strong>.</p>
|
||||
<p style="margin:24px 0">
|
||||
<a href="{reset_url}"
|
||||
style="background:#0ea5b5;color:#fff;padding:12px 24px;border-radius:6px;
|
||||
text-decoration:none;font-weight:bold">
|
||||
Reset Password
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#64748b;font-size:13px">
|
||||
If you did not request this, you can safely ignore this email.
|
||||
</p>
|
||||
<p style="color:#94a3b8;font-size:12px">Or paste this link: {reset_url}</p>
|
||||
</div>
|
||||
"""
|
||||
text = f"Reset your password at: {reset_url}\n\nThis link expires in 1 hour."
|
||||
|
||||
if self._is_configured():
|
||||
self._send(to_email, subject, html, text)
|
||||
else:
|
||||
logger.info("[DEV] Password reset email → %s URL: %s", to_email, reset_url)
|
||||
|
||||
def send_verification_email(self, to_email: str, verify_url: str, name: str) -> None:
|
||||
subject = f"Verify your {self.settings.APP_NAME} email address"
|
||||
html = f"""
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#1e293b">
|
||||
<h2 style="color:#0f4c75">Welcome, {name}!</h2>
|
||||
<p>Thanks for signing up. Please verify your email address to activate your account.</p>
|
||||
<p style="margin:24px 0">
|
||||
<a href="{verify_url}"
|
||||
style="background:#0ea5b5;color:#fff;padding:12px 24px;border-radius:6px;
|
||||
text-decoration:none;font-weight:bold">
|
||||
Verify Email
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#64748b;font-size:13px">
|
||||
This link expires in 24 hours. If you did not sign up, ignore this email.
|
||||
</p>
|
||||
<p style="color:#94a3b8;font-size:12px">Or paste this link: {verify_url}</p>
|
||||
</div>
|
||||
"""
|
||||
text = f"Hi {name},\n\nVerify your {self.settings.APP_NAME} account: {verify_url}\n\nExpires in 24 hours."
|
||||
|
||||
if self._is_configured():
|
||||
self._send(to_email, subject, html, text)
|
||||
else:
|
||||
logger.info("[DEV] Verification email → %s URL: %s", to_email, verify_url)
|
||||
|
||||
def send_new_message_notification(
|
||||
self, to_email: str, agency_name: str, sender_name: str, listing_title: str, dashboard_url: str
|
||||
) -> None:
|
||||
subject = f"New message from {sender_name} — {self.settings.APP_NAME}"
|
||||
html = f"""
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#1e293b">
|
||||
<h2 style="color:#0f4c75">New Message Received</h2>
|
||||
<p>Hi <strong>{agency_name}</strong>,</p>
|
||||
<p><strong>{sender_name}</strong> sent you a message about your listing
|
||||
<em>"{listing_title}"</em>.</p>
|
||||
<p style="margin:24px 0">
|
||||
<a href="{dashboard_url}"
|
||||
style="background:#0ea5b5;color:#fff;padding:12px 24px;border-radius:6px;
|
||||
text-decoration:none;font-weight:bold">
|
||||
View Message
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#94a3b8;font-size:12px">Or visit: {dashboard_url}</p>
|
||||
</div>
|
||||
"""
|
||||
text = (
|
||||
f"Hi {agency_name},\n\n"
|
||||
f"{sender_name} sent a message about \"{listing_title}\".\n\n"
|
||||
f"View it at: {dashboard_url}"
|
||||
)
|
||||
if self._is_configured():
|
||||
self._send(to_email, subject, html, text)
|
||||
else:
|
||||
logger.info("[DEV] New message notification → %s from: %s", to_email, sender_name)
|
||||
79
app/services/favorite_service.py
Normal file
79
app/services/favorite_service.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Favorites / wishlist service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core.exceptions import ConflictException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
|
||||
class FavoriteService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
|
||||
def add_favorite(self, user_id: str, listing_id: str) -> dict:
|
||||
# Check listing exists
|
||||
listing = self.db.table("listings").select("id").eq("id", listing_id).execute()
|
||||
if not listing.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
|
||||
# Check if already favorited
|
||||
existing = (
|
||||
self.db.table("favorites")
|
||||
.select("id")
|
||||
.eq("user_id", user_id)
|
||||
.eq("listing_id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
if existing.data:
|
||||
raise ConflictException("Listing already in favorites")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("favorites")
|
||||
.insert({"user_id": user_id, "listing_id": listing_id, "created_at": now})
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise Exception("Failed to add favorite")
|
||||
return result.data[0]
|
||||
|
||||
def remove_favorite(self, user_id: str, listing_id: str) -> dict:
|
||||
result = (
|
||||
self.db.table("favorites")
|
||||
.delete()
|
||||
.eq("user_id", user_id)
|
||||
.eq("listing_id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Favorite not found")
|
||||
return {"message": "Removed from favorites"}
|
||||
|
||||
def list_favorites(self, user_id: str) -> dict:
|
||||
result = (
|
||||
self.db.table("favorites")
|
||||
.select(
|
||||
"*, listings("
|
||||
"id, title, description, price, images, location, status, "
|
||||
"agency_id, category_id, listing_type, condition, negotiable, "
|
||||
"views_count, created_at, updated_at"
|
||||
")",
|
||||
count="exact",
|
||||
)
|
||||
.eq("user_id", user_id)
|
||||
.order("created_at", desc=True)
|
||||
.execute()
|
||||
)
|
||||
return {"favorites": result.data, "total": result.count or 0}
|
||||
|
||||
def is_favorited(self, user_id: str, listing_id: str) -> bool:
|
||||
result = (
|
||||
self.db.table("favorites")
|
||||
.select("id")
|
||||
.eq("user_id", user_id)
|
||||
.eq("listing_id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
return len(result.data) > 0
|
||||
260
app/services/listing_service.py
Normal file
260
app/services/listing_service.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""Listing CRUD + search service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.core.exceptions import ForbiddenException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
|
||||
class ListingService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
|
||||
# ── Single ───────────────────────────────────────────
|
||||
|
||||
def get_listing(self, listing_id: str) -> dict:
|
||||
result = (
|
||||
self.db.table("listings")
|
||||
.select("*, agencies(name), categories(name)")
|
||||
.eq("id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
return self._flatten(result.data[0])
|
||||
|
||||
def increment_views(self, listing_id: str) -> None:
|
||||
"""Atomically increment views_count via a read-free update expression.
|
||||
Runs as a background task so the API response is not delayed."""
|
||||
try:
|
||||
# Fetch current count and increment — best-effort, race is acceptable for a counter
|
||||
result = self.db.table("listings").select("views_count").eq("id", listing_id).execute()
|
||||
if result.data:
|
||||
new_count = (result.data[0].get("views_count") or 0) + 1
|
||||
self.db.table("listings").update({"views_count": new_count}).eq("id", listing_id).execute()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── List / Search ────────────────────────────────────
|
||||
|
||||
def list_listings(
|
||||
self,
|
||||
search: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
min_price: Optional[float] = None,
|
||||
max_price: Optional[float] = None,
|
||||
location: Optional[str] = None,
|
||||
listing_type: Optional[str] = None,
|
||||
condition: Optional[str] = None,
|
||||
status: Optional[str] = "approved",
|
||||
sort_by: str = "newest",
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
agency_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
query = self.db.table("listings").select(
|
||||
"*, agencies(name), categories(name, slug)", count="exact"
|
||||
)
|
||||
|
||||
# ── Filters ──
|
||||
if status:
|
||||
query = query.eq("status", status)
|
||||
if agency_id:
|
||||
query = query.eq("agency_id", agency_id)
|
||||
if category:
|
||||
# category might be a slug — resolve from categories table
|
||||
cat_result = (
|
||||
self.db.table("categories")
|
||||
.select("id")
|
||||
.eq("slug", category)
|
||||
.execute()
|
||||
)
|
||||
if cat_result.data:
|
||||
query = query.eq("category_id", cat_result.data[0]["id"])
|
||||
else:
|
||||
# Try direct ID match
|
||||
query = query.eq("category_id", category)
|
||||
if min_price is not None:
|
||||
query = query.gte("price", min_price)
|
||||
if max_price is not None:
|
||||
query = query.lte("price", max_price)
|
||||
if location:
|
||||
query = query.ilike("location", f"%{location}%")
|
||||
if listing_type:
|
||||
query = query.eq("listing_type", listing_type)
|
||||
if condition:
|
||||
query = query.eq("condition", condition)
|
||||
if search:
|
||||
query = query.or_(
|
||||
f"title.ilike.%{search}%,description.ilike.%{search}%"
|
||||
)
|
||||
|
||||
# ── Sort ──
|
||||
sort_map = {
|
||||
"newest": ("created_at", True),
|
||||
"oldest": ("created_at", False),
|
||||
"price_asc": ("price", False),
|
||||
"price_desc": ("price", True),
|
||||
"popular": ("views_count", True),
|
||||
}
|
||||
col, desc = sort_map.get(sort_by, ("created_at", True))
|
||||
query = query.order(col, desc=desc)
|
||||
|
||||
# ── Pagination ──
|
||||
offset = (page - 1) * page_size
|
||||
result = query.range(offset, offset + page_size - 1).execute()
|
||||
|
||||
listings = [self._flatten(l) for l in result.data]
|
||||
return {
|
||||
"listings": listings,
|
||||
"total": result.count or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
# ── Create ───────────────────────────────────────────
|
||||
|
||||
def create_listing(self, agency_id: str, data: dict) -> dict:
|
||||
# Subscription guard: agency must have an active subscription
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
sub_result = (
|
||||
self.db.table("subscriptions")
|
||||
.select("id")
|
||||
.eq("agency_id", agency_id)
|
||||
.eq("status", "active")
|
||||
.gt("ends_at", now_iso)
|
||||
.limit(1)
|
||||
.execute()
|
||||
)
|
||||
if not sub_result.data:
|
||||
raise ForbiddenException("Active subscription required to post listings")
|
||||
|
||||
now = now_iso
|
||||
listing_data = {
|
||||
"agency_id": agency_id,
|
||||
**data,
|
||||
"status": "pending",
|
||||
"views_count": 0,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
result = self.db.table("listings").insert(listing_data).execute()
|
||||
if not result.data:
|
||||
raise Exception("Failed to create listing")
|
||||
return result.data[0]
|
||||
|
||||
# ── Update ───────────────────────────────────────────
|
||||
|
||||
def update_listing(
|
||||
self, listing_id: str, user_id: str, user_role: str, data: dict
|
||||
) -> dict:
|
||||
listing = self._get_raw(listing_id)
|
||||
# Check ownership
|
||||
if user_role != "admin":
|
||||
agency = (
|
||||
self.db.table("agencies")
|
||||
.select("id, user_id")
|
||||
.eq("id", listing["agency_id"])
|
||||
.execute()
|
||||
)
|
||||
if not agency.data or agency.data[0]["user_id"] != user_id:
|
||||
raise ForbiddenException("Not authorized to update this listing")
|
||||
|
||||
update_data = {k: v for k, v in data.items() if v is not None}
|
||||
if not update_data:
|
||||
return listing
|
||||
|
||||
# Reset to pending if agency edits an approved/rejected listing
|
||||
if user_role != "admin" and listing["status"] in ("approved", "rejected"):
|
||||
update_data["status"] = "pending"
|
||||
|
||||
update_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("listings")
|
||||
.update(update_data)
|
||||
.eq("id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
return result.data[0]
|
||||
|
||||
# ── Status (admin) ───────────────────────────────────
|
||||
|
||||
def update_status(
|
||||
self, listing_id: str, status: str, rejection_reason: Optional[str] = None
|
||||
) -> dict:
|
||||
update_data: dict = {
|
||||
"status": status,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if status == "rejected" and rejection_reason:
|
||||
update_data["rejection_reason"] = rejection_reason
|
||||
elif status == "approved":
|
||||
update_data["rejection_reason"] = None
|
||||
|
||||
result = (
|
||||
self.db.table("listings")
|
||||
.update(update_data)
|
||||
.eq("id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
return result.data[0]
|
||||
|
||||
# ── Delete ───────────────────────────────────────────
|
||||
|
||||
def delete_listing(self, listing_id: str, user_id: str, user_role: str) -> dict:
|
||||
listing = self._get_raw(listing_id)
|
||||
if user_role != "admin":
|
||||
agency = (
|
||||
self.db.table("agencies")
|
||||
.select("id, user_id")
|
||||
.eq("id", listing["agency_id"])
|
||||
.execute()
|
||||
)
|
||||
if not agency.data or agency.data[0]["user_id"] != user_id:
|
||||
raise ForbiddenException("Not authorized to delete this listing")
|
||||
|
||||
self.db.table("listings").delete().eq("id", listing_id).execute()
|
||||
return {"message": "Listing deleted"}
|
||||
|
||||
# ── Stats ────────────────────────────────────────────
|
||||
|
||||
def get_stats(self, agency_id: Optional[str] = None) -> dict:
|
||||
query = self.db.table("listings").select("status")
|
||||
if agency_id:
|
||||
query = query.eq("agency_id", agency_id)
|
||||
result = query.execute()
|
||||
|
||||
statuses: dict[str, int] = {"pending": 0, "approved": 0, "rejected": 0, "sold": 0}
|
||||
for row in result.data:
|
||||
s = row.get("status")
|
||||
if s in statuses:
|
||||
statuses[s] += 1
|
||||
|
||||
total = sum(statuses.values())
|
||||
return {"total": total, **statuses}
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────
|
||||
|
||||
def _get_raw(self, listing_id: str) -> dict:
|
||||
result = self.db.table("listings").select("*").eq("id", listing_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
return result.data[0]
|
||||
|
||||
@staticmethod
|
||||
def _flatten(listing: dict) -> dict:
|
||||
"""Flatten joined agency/category names."""
|
||||
agencies = listing.pop("agencies", None)
|
||||
categories = listing.pop("categories", None)
|
||||
if agencies and isinstance(agencies, dict):
|
||||
listing["agency_name"] = agencies.get("name")
|
||||
if categories and isinstance(categories, dict):
|
||||
listing["category_name"] = categories.get("name")
|
||||
return listing
|
||||
159
app/services/message_service.py
Normal file
159
app/services/message_service.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Message / contact service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import ForbiddenException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
from app.services.email_service import EmailService
|
||||
|
||||
|
||||
class MessageService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
|
||||
def send_message(self, data: dict) -> dict:
|
||||
# Resolve agency_id and listing title
|
||||
listing_row = (
|
||||
self.db.table("listings")
|
||||
.select("agency_id, title")
|
||||
.eq("id", data["listing_id"])
|
||||
.execute()
|
||||
)
|
||||
if not listing_row.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
|
||||
agency_id = listing_row.data[0]["agency_id"]
|
||||
listing_title = listing_row.data[0].get("title", "")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
msg_data = {
|
||||
**data,
|
||||
"agency_id": agency_id,
|
||||
"read": False,
|
||||
"created_at": now,
|
||||
}
|
||||
result = self.db.table("messages").insert(msg_data).execute()
|
||||
if not result.data:
|
||||
raise Exception("Failed to send message")
|
||||
|
||||
# Notify agency via email (non-blocking)
|
||||
try:
|
||||
agency_row = (
|
||||
self.db.table("agencies")
|
||||
.select("name, email")
|
||||
.eq("id", agency_id)
|
||||
.execute()
|
||||
)
|
||||
if agency_row.data:
|
||||
agency = agency_row.data[0]
|
||||
settings = get_settings()
|
||||
dashboard_url = f"{settings.FRONTEND_URL}/agency/dashboard"
|
||||
EmailService().send_new_message_notification(
|
||||
to_email=agency["email"],
|
||||
agency_name=agency["name"],
|
||||
sender_name=data.get("name", "Someone"),
|
||||
listing_title=listing_title,
|
||||
dashboard_url=dashboard_url,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result.data[0]
|
||||
|
||||
def list_messages(
|
||||
self,
|
||||
agency_id: str,
|
||||
user_id: str,
|
||||
user_role: str,
|
||||
read_filter: Optional[bool] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> dict:
|
||||
# Verify ownership
|
||||
if user_role != "admin":
|
||||
agency = (
|
||||
self.db.table("agencies")
|
||||
.select("user_id")
|
||||
.eq("id", agency_id)
|
||||
.execute()
|
||||
)
|
||||
if not agency.data or agency.data[0]["user_id"] != user_id:
|
||||
raise ForbiddenException("Not authorized")
|
||||
|
||||
query = (
|
||||
self.db.table("messages")
|
||||
.select("*, listings(title)", count="exact")
|
||||
.eq("agency_id", agency_id)
|
||||
)
|
||||
if read_filter is not None:
|
||||
query = query.eq("read", read_filter)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = (
|
||||
query.order("created_at", desc=True)
|
||||
.range(offset, offset + page_size - 1)
|
||||
.execute()
|
||||
)
|
||||
|
||||
messages = []
|
||||
for m in result.data:
|
||||
listings_data = m.pop("listings", None)
|
||||
if listings_data and isinstance(listings_data, dict):
|
||||
m["listing_title"] = listings_data.get("title")
|
||||
messages.append(m)
|
||||
|
||||
return {
|
||||
"messages": messages,
|
||||
"total": result.count or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
def mark_read(self, message_id: str, user_id: str, user_role: str, read: bool = True) -> dict:
|
||||
msg = self.db.table("messages").select("*, agencies(user_id)").eq("id", message_id).execute()
|
||||
if not msg.data:
|
||||
raise NotFoundException("Message not found")
|
||||
|
||||
message = msg.data[0]
|
||||
agencies_data = message.get("agencies")
|
||||
if user_role != "admin":
|
||||
if not agencies_data or agencies_data.get("user_id") != user_id:
|
||||
raise ForbiddenException("Not authorized")
|
||||
|
||||
result = (
|
||||
self.db.table("messages")
|
||||
.update({"read": read})
|
||||
.eq("id", message_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Message not found")
|
||||
return result.data[0]
|
||||
|
||||
def get_unread_count(self, agency_id: str) -> int:
|
||||
result = (
|
||||
self.db.table("messages")
|
||||
.select("id", count="exact")
|
||||
.eq("agency_id", agency_id)
|
||||
.eq("read", False)
|
||||
.execute()
|
||||
)
|
||||
return result.count or 0
|
||||
|
||||
def delete_message(self, message_id: str, user_id: str, user_role: str) -> dict:
|
||||
msg = self.db.table("messages").select("*, agencies(user_id)").eq("id", message_id).execute()
|
||||
if not msg.data:
|
||||
raise NotFoundException("Message not found")
|
||||
|
||||
message = msg.data[0]
|
||||
agencies_data = message.get("agencies")
|
||||
if user_role != "admin":
|
||||
if not agencies_data or agencies_data.get("user_id") != user_id:
|
||||
raise ForbiddenException("Not authorized")
|
||||
|
||||
self.db.table("messages").delete().eq("id", message_id).execute()
|
||||
return {"message": "Message deleted"}
|
||||
350
app/services/payment_service.py
Normal file
350
app/services/payment_service.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""Payment service — CinetPay integration for subscriptions and purchases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import BadRequestException, ForbiddenException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
CINETPAY_INIT_URL = "https://api-checkout.cinetpay.com/v2/payment"
|
||||
CINETPAY_CHECK_URL = "https://api-checkout.cinetpay.com/v2/payment/check"
|
||||
|
||||
|
||||
class PaymentService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
self.settings = get_settings()
|
||||
|
||||
# ── Public methods ────────────────────────────────────
|
||||
|
||||
def initiate(
|
||||
self,
|
||||
user_id: str,
|
||||
payment_type: str,
|
||||
plan: Optional[str] = None,
|
||||
listing_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Create a pending payment record and return CinetPay redirect URL."""
|
||||
settings = self.settings
|
||||
|
||||
if payment_type == "subscription":
|
||||
if plan not in ("monthly", "yearly"):
|
||||
raise BadRequestException("plan must be 'monthly' or 'yearly'")
|
||||
amount = (
|
||||
settings.SUBSCRIPTION_MONTHLY_AMOUNT
|
||||
if plan == "monthly"
|
||||
else settings.SUBSCRIPTION_YEARLY_AMOUNT
|
||||
)
|
||||
# Resolve agency_id from user
|
||||
agency_result = (
|
||||
self.db.table("agencies").select("id").eq("user_id", user_id).execute()
|
||||
)
|
||||
if not agency_result.data:
|
||||
raise ForbiddenException("Agency profile not found")
|
||||
agency_id = agency_result.data[0]["id"]
|
||||
transaction_id = self._build_transaction_id("SUB", agency_id)
|
||||
description = f"Abonnement {plan} - Deals24Togo"
|
||||
metadata = {"plan": plan, "agency_id": agency_id}
|
||||
|
||||
elif payment_type == "purchase":
|
||||
if not listing_id:
|
||||
raise BadRequestException("listing_id is required for purchase")
|
||||
listing_result = (
|
||||
self.db.table("listings")
|
||||
.select("id, title, price, status")
|
||||
.eq("id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
if not listing_result.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
listing = listing_result.data[0]
|
||||
if listing["status"] != "approved":
|
||||
raise BadRequestException("Listing is not available for purchase")
|
||||
amount = float(listing["price"])
|
||||
transaction_id = self._build_transaction_id("PUR", listing_id)
|
||||
description = f"Achat - {listing['title'][:40]}"
|
||||
metadata = {"listing_id": listing_id, "listing_title": listing["title"]}
|
||||
|
||||
else:
|
||||
raise BadRequestException("type must be 'subscription' or 'purchase'")
|
||||
|
||||
# Insert pending payment row
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
payment_row = {
|
||||
"transaction_id": transaction_id,
|
||||
"type": payment_type,
|
||||
"payer_id": user_id,
|
||||
"amount": amount,
|
||||
"currency": "XOF",
|
||||
"status": "pending",
|
||||
"metadata": metadata,
|
||||
"created_at": now,
|
||||
}
|
||||
insert_result = self.db.table("payments").insert(payment_row).execute()
|
||||
if not insert_result.data:
|
||||
raise Exception("Failed to create payment record")
|
||||
|
||||
# Call CinetPay
|
||||
notify_url = f"{settings.BACKEND_PUBLIC_URL}/api/v1/payments/webhook"
|
||||
return_url = f"{settings.FRONTEND_URL}/payment-return?transaction_id={transaction_id}"
|
||||
|
||||
payload = {
|
||||
"apikey": settings.CINETPAY_API_KEY,
|
||||
"site_id": settings.CINETPAY_SITE_ID,
|
||||
"transaction_id": transaction_id,
|
||||
"amount": int(amount),
|
||||
"currency": "XOF",
|
||||
"description": description,
|
||||
"notify_url": notify_url,
|
||||
"return_url": return_url,
|
||||
"channels": "ALL",
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.post(CINETPAY_INIT_URL, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if data.get("code") != "201" and data.get("code") != "00":
|
||||
raise Exception(f"CinetPay error: {data.get('message', 'Unknown error')}")
|
||||
|
||||
payment_url = data["data"]["payment_url"]
|
||||
return {"payment_url": payment_url, "transaction_id": transaction_id}
|
||||
|
||||
def handle_webhook(self, form_data: dict) -> None:
|
||||
"""Process CinetPay webhook notification."""
|
||||
settings = self.settings
|
||||
transaction_id = form_data.get("cpm_trans_id")
|
||||
site_id = form_data.get("cpm_site_id")
|
||||
|
||||
if not transaction_id:
|
||||
return
|
||||
|
||||
# Validate site_id
|
||||
if site_id and site_id != str(settings.CINETPAY_SITE_ID):
|
||||
return
|
||||
|
||||
# Idempotency: check if already completed
|
||||
result = (
|
||||
self.db.table("payments")
|
||||
.select("*")
|
||||
.eq("transaction_id", transaction_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
return
|
||||
|
||||
payment = result.data[0]
|
||||
if payment["status"] == "completed":
|
||||
return # Already processed
|
||||
|
||||
# Verify with CinetPay
|
||||
verify_payload = {
|
||||
"apikey": settings.CINETPAY_API_KEY,
|
||||
"site_id": settings.CINETPAY_SITE_ID,
|
||||
"transaction_id": transaction_id,
|
||||
}
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.post(CINETPAY_CHECK_URL, json=verify_payload)
|
||||
resp.raise_for_status()
|
||||
verify_data = resp.json()
|
||||
|
||||
if verify_data.get("code") != "00":
|
||||
return
|
||||
|
||||
cp_status = verify_data["data"].get("status")
|
||||
payment_method = verify_data["data"].get("payment_method")
|
||||
operator_id = verify_data["data"].get("operator_id")
|
||||
|
||||
if cp_status == "ACCEPTED":
|
||||
# Update payment to completed
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self.db.table("payments").update(
|
||||
{
|
||||
"status": "completed",
|
||||
"payment_method": payment_method,
|
||||
"operator_id": operator_id,
|
||||
"paid_at": now,
|
||||
}
|
||||
).eq("transaction_id", transaction_id).execute()
|
||||
|
||||
# Re-fetch updated payment
|
||||
updated = (
|
||||
self.db.table("payments")
|
||||
.select("*")
|
||||
.eq("transaction_id", transaction_id)
|
||||
.execute()
|
||||
)
|
||||
if updated.data:
|
||||
payment = updated.data[0]
|
||||
if payment["type"] == "subscription":
|
||||
self._activate_subscription(payment)
|
||||
elif payment["type"] == "purchase":
|
||||
self._complete_purchase(payment)
|
||||
|
||||
elif cp_status in ("REFUSED",):
|
||||
self.db.table("payments").update({"status": "failed"}).eq(
|
||||
"transaction_id", transaction_id
|
||||
).execute()
|
||||
|
||||
def get_receipt(self, transaction_id: str, user_id: str) -> dict:
|
||||
"""Return enriched receipt data for a transaction."""
|
||||
result = (
|
||||
self.db.table("payments")
|
||||
.select("*")
|
||||
.eq("transaction_id", transaction_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Payment not found")
|
||||
|
||||
payment = result.data[0]
|
||||
if payment["payer_id"] != user_id:
|
||||
raise ForbiddenException("Not authorized to view this payment")
|
||||
|
||||
# Enrich with payer info
|
||||
payer_name = None
|
||||
payer_email = None
|
||||
user_result = (
|
||||
self.db.table("users")
|
||||
.select("name, email")
|
||||
.eq("id", user_id)
|
||||
.execute()
|
||||
)
|
||||
if user_result.data:
|
||||
payer_name = user_result.data[0].get("name")
|
||||
payer_email = user_result.data[0].get("email")
|
||||
|
||||
# Enrich with plan label or listing title
|
||||
metadata = payment.get("metadata") or {}
|
||||
plan_label = None
|
||||
listing_title = None
|
||||
|
||||
if payment["type"] == "subscription":
|
||||
plan = metadata.get("plan")
|
||||
plan_label = "Mensuel (1 mois)" if plan == "monthly" else "Annuel (12 mois)"
|
||||
elif payment["type"] == "purchase":
|
||||
listing_title = metadata.get("listing_title")
|
||||
if not listing_title:
|
||||
lid = metadata.get("listing_id")
|
||||
if lid:
|
||||
lr = (
|
||||
self.db.table("listings")
|
||||
.select("title")
|
||||
.eq("id", lid)
|
||||
.execute()
|
||||
)
|
||||
if lr.data:
|
||||
listing_title = lr.data[0]["title"]
|
||||
|
||||
return {
|
||||
**payment,
|
||||
"payer_name": payer_name,
|
||||
"payer_email": payer_email,
|
||||
"plan_label": plan_label,
|
||||
"listing_title": listing_title,
|
||||
}
|
||||
|
||||
def get_my_payments(self, user_id: str) -> list:
|
||||
"""List all payments for a user."""
|
||||
result = (
|
||||
self.db.table("payments")
|
||||
.select("*")
|
||||
.eq("payer_id", user_id)
|
||||
.order("created_at", desc=True)
|
||||
.execute()
|
||||
)
|
||||
return result.data or []
|
||||
|
||||
def get_subscription_status(self, user_id: str) -> dict:
|
||||
"""Return the current subscription status for the agency user."""
|
||||
agency_result = (
|
||||
self.db.table("agencies").select("id").eq("user_id", user_id).execute()
|
||||
)
|
||||
if not agency_result.data:
|
||||
raise NotFoundException("Agency not found")
|
||||
|
||||
agency_id = agency_result.data[0]["id"]
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
result = (
|
||||
self.db.table("subscriptions")
|
||||
.select("*")
|
||||
.eq("agency_id", agency_id)
|
||||
.eq("status", "active")
|
||||
.gt("ends_at", now)
|
||||
.order("ends_at", desc=True)
|
||||
.limit(1)
|
||||
.execute()
|
||||
)
|
||||
|
||||
if result.data:
|
||||
return {"has_active_subscription": True, "subscription": result.data[0]}
|
||||
return {"has_active_subscription": False, "subscription": None}
|
||||
|
||||
# ── Private helpers ───────────────────────────────────
|
||||
|
||||
def _activate_subscription(self, payment: dict) -> None:
|
||||
"""Create or extend subscription after successful payment."""
|
||||
metadata = payment.get("metadata") or {}
|
||||
agency_id = metadata.get("agency_id")
|
||||
plan = metadata.get("plan")
|
||||
|
||||
if not agency_id or not plan:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
days = 30 if plan == "monthly" else 365
|
||||
ends_at = (now + timedelta(days=days)).isoformat()
|
||||
starts_at = now.isoformat()
|
||||
|
||||
self.db.table("subscriptions").insert(
|
||||
{
|
||||
"agency_id": agency_id,
|
||||
"plan": plan,
|
||||
"status": "active",
|
||||
"starts_at": starts_at,
|
||||
"ends_at": ends_at,
|
||||
"payment_id": payment["id"],
|
||||
"created_at": starts_at,
|
||||
}
|
||||
).execute()
|
||||
|
||||
def _complete_purchase(self, payment: dict) -> None:
|
||||
"""Record purchase and mark listing as sold."""
|
||||
metadata = payment.get("metadata") or {}
|
||||
listing_id = metadata.get("listing_id")
|
||||
|
||||
if not listing_id:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Insert purchase record
|
||||
self.db.table("purchases").insert(
|
||||
{
|
||||
"listing_id": listing_id,
|
||||
"buyer_id": payment.get("payer_id"),
|
||||
"amount": payment["amount"],
|
||||
"payment_id": payment["id"],
|
||||
"created_at": now,
|
||||
}
|
||||
).execute()
|
||||
|
||||
# Mark listing as sold
|
||||
self.db.table("listings").update(
|
||||
{"status": "sold", "updated_at": now}
|
||||
).eq("id", listing_id).execute()
|
||||
|
||||
@staticmethod
|
||||
def _build_transaction_id(prefix: str, resource_id: str) -> str:
|
||||
"""Build a ≤50-char alphanumeric transaction ID."""
|
||||
short_id = resource_id[:8].replace("-", "")
|
||||
ts = int(time.time())
|
||||
return f"{prefix}{short_id}{ts}"
|
||||
94
app/services/upload_service.py
Normal file
94
app/services/upload_service.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""File upload service using Supabase Storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import BadRequestException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
ALLOWED_CONTENT_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
}
|
||||
|
||||
MAX_DIMENSION = 2048
|
||||
|
||||
|
||||
class UploadService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
self.settings = get_settings()
|
||||
self.bucket = self.settings.SUPABASE_STORAGE_BUCKET
|
||||
|
||||
def upload_image(
|
||||
self,
|
||||
file_bytes: bytes,
|
||||
content_type: str,
|
||||
folder: str = "images",
|
||||
max_size_mb: Optional[int] = None,
|
||||
) -> str:
|
||||
max_bytes = (max_size_mb or self.settings.MAX_UPLOAD_SIZE_MB) * 1024 * 1024
|
||||
|
||||
if content_type not in ALLOWED_CONTENT_TYPES:
|
||||
raise BadRequestException(
|
||||
f"Invalid file type. Allowed: {', '.join(ALLOWED_CONTENT_TYPES)}"
|
||||
)
|
||||
|
||||
if len(file_bytes) > max_bytes:
|
||||
raise BadRequestException(
|
||||
f"File too large. Max: {max_size_mb or self.settings.MAX_UPLOAD_SIZE_MB}MB"
|
||||
)
|
||||
|
||||
# Validate and optionally resize
|
||||
try:
|
||||
img = Image.open(BytesIO(file_bytes))
|
||||
img.verify()
|
||||
img = Image.open(BytesIO(file_bytes)) # Re-open after verify
|
||||
|
||||
# Resize if too large
|
||||
if max(img.size) > MAX_DIMENSION:
|
||||
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
|
||||
buffer = BytesIO()
|
||||
fmt = "JPEG" if content_type == "image/jpeg" else "PNG"
|
||||
img.save(buffer, format=fmt, quality=85)
|
||||
file_bytes = buffer.getvalue()
|
||||
except Exception:
|
||||
raise BadRequestException("Invalid image file")
|
||||
|
||||
ext = content_type.split("/")[-1]
|
||||
if ext == "jpeg":
|
||||
ext = "jpg"
|
||||
filename = f"{folder}/{uuid.uuid4().hex}.{ext}"
|
||||
|
||||
self.db.storage.from_(self.bucket).upload(
|
||||
path=filename,
|
||||
file=file_bytes,
|
||||
file_options={"content-type": content_type},
|
||||
)
|
||||
|
||||
# Return public URL
|
||||
public_url = self.db.storage.from_(self.bucket).get_public_url(filename)
|
||||
return public_url
|
||||
|
||||
def delete_image(self, file_url: str) -> bool:
|
||||
"""Extract path from URL and delete from storage."""
|
||||
try:
|
||||
bucket_prefix = f"/storage/v1/object/public/{self.bucket}/"
|
||||
if bucket_prefix in file_url:
|
||||
path = file_url.split(bucket_prefix)[-1]
|
||||
else:
|
||||
# Try extracting from full URL
|
||||
path = file_url.split(f"{self.bucket}/")[-1]
|
||||
|
||||
self.db.storage.from_(self.bucket).remove([path])
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
110
app/services/user_service.py
Normal file
110
app/services/user_service.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""User CRUD service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.core.exceptions import ConflictException, ForbiddenException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
|
||||
def get_user(self, user_id: str) -> dict:
|
||||
result = self.db.table("users").select("*").eq("id", user_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("User not found")
|
||||
user = result.data[0]
|
||||
user.pop("password_hash", None)
|
||||
return user
|
||||
|
||||
def update_user(self, user_id: str, data: dict) -> dict:
|
||||
# Remove None values
|
||||
update_data = {k: v for k, v in data.items() if v is not None}
|
||||
if not update_data:
|
||||
return self.get_user(user_id)
|
||||
|
||||
# Check email uniqueness if changing email
|
||||
if "email" in update_data:
|
||||
existing = (
|
||||
self.db.table("users")
|
||||
.select("id")
|
||||
.eq("email", update_data["email"].lower())
|
||||
.neq("id", user_id)
|
||||
.execute()
|
||||
)
|
||||
if existing.data:
|
||||
raise ConflictException("A user with this email already exists")
|
||||
update_data["email"] = update_data["email"].lower()
|
||||
|
||||
update_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("users")
|
||||
.update(update_data)
|
||||
.eq("id", user_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("User not found")
|
||||
user = result.data[0]
|
||||
user.pop("password_hash", None)
|
||||
return user
|
||||
|
||||
def list_users(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
role: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
) -> dict:
|
||||
query = self.db.table("users").select("*", count="exact")
|
||||
if role:
|
||||
query = query.eq("role", role)
|
||||
if search:
|
||||
query = query.or_(f"name.ilike.%{search}%,email.ilike.%{search}%")
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = (
|
||||
query.order("created_at", desc=True)
|
||||
.range(offset, offset + page_size - 1)
|
||||
.execute()
|
||||
)
|
||||
|
||||
users = []
|
||||
for u in result.data:
|
||||
u.pop("password_hash", None)
|
||||
users.append(u)
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"total": result.count or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
def verify_user(self, user_id: str) -> dict:
|
||||
result = (
|
||||
self.db.table("users")
|
||||
.update({"verified": True, "updated_at": datetime.now(timezone.utc).isoformat()})
|
||||
.eq("id", user_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("User not found")
|
||||
user = result.data[0]
|
||||
user.pop("password_hash", None)
|
||||
return user
|
||||
|
||||
def delete_user(self, user_id: str, requester_id: str, requester_role: str) -> dict:
|
||||
if requester_role != "admin" and requester_id != user_id:
|
||||
raise ForbiddenException("Cannot delete other users")
|
||||
if requester_role == "admin" and requester_id == user_id:
|
||||
raise ForbiddenException("Admins cannot delete their own account")
|
||||
|
||||
result = self.db.table("users").delete().eq("id", user_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("User not found")
|
||||
return {"message": "User deleted"}
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- APP_ENV=development
|
||||
- DEBUG=true
|
||||
volumes:
|
||||
- .:/app
|
||||
command: >
|
||||
uvicorn app.main:create_app --factory
|
||||
--host 0.0.0.0 --port 8000 --reload
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://localhost:8000/health'); r.raise_for_status()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
180
migrations/001_initial_schema.sql
Normal file
180
migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,180 @@
|
||||
-- ============================================================
|
||||
-- Deals24Togo — Supabase Database Schema
|
||||
-- Run this in Supabase SQL Editor to create all tables
|
||||
-- ============================================================
|
||||
|
||||
-- Enable UUID generation
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ── USERS ────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'visitor'
|
||||
CHECK (role IN ('admin', 'agency', 'visitor')),
|
||||
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
phone TEXT,
|
||||
avatar_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_email ON users (email);
|
||||
CREATE INDEX idx_users_role ON users (role);
|
||||
|
||||
-- ── AGENCIES ─────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agencies (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
logo TEXT,
|
||||
address TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
website TEXT,
|
||||
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_agencies_user_id ON agencies (user_id);
|
||||
CREATE INDEX idx_agencies_verified ON agencies (verified);
|
||||
|
||||
-- ── CATEGORIES ───────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT NOT NULL DEFAULT 'tag',
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_categories_slug ON categories (slug);
|
||||
|
||||
-- ── LISTINGS ─────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS listings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
price NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||
images JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
agency_id UUID NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
|
||||
category_id UUID NOT NULL REFERENCES categories(id) ON DELETE RESTRICT,
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
listing_type TEXT NOT NULL DEFAULT 'sale'
|
||||
CHECK (listing_type IN ('sale', 'rent')),
|
||||
condition TEXT CHECK (condition IN ('new', 'used', 'refurbished')),
|
||||
negotiable BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
views_count INTEGER NOT NULL DEFAULT 0,
|
||||
rejection_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_listings_status ON listings (status);
|
||||
CREATE INDEX idx_listings_agency_id ON listings (agency_id);
|
||||
CREATE INDEX idx_listings_category_id ON listings (category_id);
|
||||
CREATE INDEX idx_listings_price ON listings (price);
|
||||
CREATE INDEX idx_listings_created_at ON listings (created_at DESC);
|
||||
CREATE INDEX idx_listings_views_count ON listings (views_count DESC);
|
||||
CREATE INDEX idx_listings_location ON listings USING gin (to_tsvector('english', location));
|
||||
|
||||
-- Full-text search index on title + description
|
||||
CREATE INDEX idx_listings_fts ON listings USING gin (
|
||||
to_tsvector('english', title || ' ' || description)
|
||||
);
|
||||
|
||||
-- ── MESSAGES ─────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
listing_id UUID NOT NULL REFERENCES listings(id) ON DELETE CASCADE,
|
||||
agency_id UUID NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
message TEXT NOT NULL,
|
||||
read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_messages_agency_id ON messages (agency_id);
|
||||
CREATE INDEX idx_messages_listing_id ON messages (listing_id);
|
||||
CREATE INDEX idx_messages_read ON messages (read);
|
||||
|
||||
-- ── FAVORITES ────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
listing_id UUID NOT NULL REFERENCES listings(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, listing_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_favorites_user_id ON favorites (user_id);
|
||||
CREATE INDEX idx_favorites_listing_id ON favorites (listing_id);
|
||||
|
||||
-- ── ROW LEVEL SECURITY (RLS) ────────────────────────────
|
||||
-- NOTE: The backend uses the service_role key which bypasses RLS.
|
||||
-- These policies are for any direct Supabase client access.
|
||||
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE agencies ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE categories ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE listings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE favorites ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Public read for approved listings
|
||||
CREATE POLICY "Public can view approved listings"
|
||||
ON listings FOR SELECT
|
||||
USING (status = 'approved');
|
||||
|
||||
-- Public read for categories
|
||||
CREATE POLICY "Public can view categories"
|
||||
ON categories FOR SELECT
|
||||
TO anon, authenticated
|
||||
USING (true);
|
||||
|
||||
-- Public read for verified agencies
|
||||
CREATE POLICY "Public can view agencies"
|
||||
ON agencies FOR SELECT
|
||||
USING (true);
|
||||
|
||||
-- Users can read their own data
|
||||
CREATE POLICY "Users can view own data"
|
||||
ON users FOR SELECT
|
||||
USING (auth.uid()::text = id::text);
|
||||
|
||||
-- Users can manage their own favorites
|
||||
CREATE POLICY "Users manage own favorites"
|
||||
ON favorites FOR ALL
|
||||
USING (auth.uid()::text = user_id::text);
|
||||
|
||||
-- ── SEED DATA ────────────────────────────────────────────
|
||||
|
||||
-- Default categories
|
||||
INSERT INTO categories (name, description, icon, slug) VALUES
|
||||
('Real Estate', 'Homes, apartments, land, and commercial properties', 'home', 'real-estate'),
|
||||
('Vehicles', 'Cars, motorcycles, boats, and other vehicles', 'car', 'vehicles'),
|
||||
('Electronics', 'Computers, phones, TVs, and other electronic devices', 'smartphone', 'electronics'),
|
||||
('Furniture', 'Home and office furniture, decor, and appliances', 'sofa', 'furniture'),
|
||||
('Jobs', 'Job listings and career opportunities', 'briefcase', 'jobs'),
|
||||
('Services', 'Professional services and skilled trades', 'wrench', 'services')
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
-- ── STORAGE BUCKET ───────────────────────────────────────
|
||||
-- Run this separately in Supabase Dashboard > Storage or via API:
|
||||
-- CREATE BUCKET 'listings' with public access enabled
|
||||
5
migrations/002_supabase_auth.sql
Normal file
5
migrations/002_supabase_auth.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Migration 002: Supabase Auth integration
|
||||
-- Supabase Auth manages passwords; password_hash is no longer required.
|
||||
|
||||
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
|
||||
ALTER TABLE users ALTER COLUMN password_hash SET DEFAULT NULL;
|
||||
55
migrations/003_payments.sql
Normal file
55
migrations/003_payments.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- Migration 003: Payments, Subscriptions, Purchases
|
||||
-- Run in Supabase SQL editor
|
||||
|
||||
-- Extend listings status to include 'sold'
|
||||
ALTER TABLE listings
|
||||
DROP CONSTRAINT IF EXISTS listings_status_check;
|
||||
ALTER TABLE listings
|
||||
ADD CONSTRAINT listings_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'sold'));
|
||||
|
||||
-- payments: one row per CinetPay transaction
|
||||
CREATE TABLE payments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
transaction_id TEXT UNIQUE NOT NULL,
|
||||
type TEXT NOT NULL
|
||||
CHECK (type IN ('subscription', 'purchase')),
|
||||
payer_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
amount NUMERIC(12,2) NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'XOF',
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'completed', 'failed', 'cancelled')),
|
||||
payment_method TEXT,
|
||||
operator_id TEXT,
|
||||
metadata JSONB DEFAULT '{}'::JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
paid_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- subscriptions: active plan per agency
|
||||
CREATE TABLE subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
agency_id UUID NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
|
||||
plan TEXT NOT NULL CHECK (plan IN ('monthly', 'yearly')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired')),
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
payment_id UUID REFERENCES payments(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- purchases: visitor buys a listing
|
||||
CREATE TABLE purchases (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
listing_id UUID NOT NULL REFERENCES listings(id) ON DELETE CASCADE,
|
||||
buyer_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
amount NUMERIC(12,2) NOT NULL,
|
||||
payment_id UUID REFERENCES payments(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_payments_transaction_id ON payments(transaction_id);
|
||||
CREATE INDEX idx_payments_payer_id ON payments(payer_id);
|
||||
CREATE INDEX idx_subscriptions_agency_id ON subscriptions(agency_id);
|
||||
CREATE INDEX idx_subscriptions_ends_at ON subscriptions(ends_at);
|
||||
CREATE INDEX idx_purchases_listing_id ON purchases(listing_id);
|
||||
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[project]
|
||||
name = "deals24togo-be"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.129.0",
|
||||
"gotrue>=2.12.4",
|
||||
"passlib>=1.7.4",
|
||||
"pillow>=12.1.1",
|
||||
"pydantic-settings>=2.13.1",
|
||||
"python-jose>=3.5.0",
|
||||
"sentry-sdk>=2.53.0",
|
||||
"slowapi>=0.1.9",
|
||||
"structlog>=25.5.0",
|
||||
"supabase>=2.28.0",
|
||||
"uvicorn>=0.41.0",
|
||||
]
|
||||
10
ruff.toml
Normal file
10
ruff.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["app"]
|
||||
0
scripts/__init__.py
Normal file
0
scripts/__init__.py
Normal file
70
scripts/seed.py
Normal file
70
scripts/seed.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Seed script — creates an admin user and sample data via Supabase Auth.
|
||||
|
||||
Usage:
|
||||
python -m scripts.seed
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
|
||||
def _create_user(db, email: str, password: str, name: str, role: str, with_agency: bool = False):
|
||||
"""Create a Supabase Auth user + profile row. Skip if already exists."""
|
||||
existing = db.table("users").select("id").eq("email", email).execute()
|
||||
if existing.data:
|
||||
print(f"ℹ️ User already exists: {email}")
|
||||
return
|
||||
|
||||
auth_response = db.auth.admin.create_user({
|
||||
"email": email,
|
||||
"password": password,
|
||||
"email_confirm": True,
|
||||
})
|
||||
user_id = auth_response.user.id
|
||||
|
||||
db.table("users").insert({
|
||||
"id": user_id,
|
||||
"email": email,
|
||||
"name": name,
|
||||
"role": role,
|
||||
"verified": True,
|
||||
}).execute()
|
||||
|
||||
if with_agency:
|
||||
db.table("agencies").insert({
|
||||
"user_id": user_id,
|
||||
"name": "Demo Real Estate Agency",
|
||||
"description": "A demo agency for testing the platform.",
|
||||
"address": "123 Demo Street, Lomé, Togo",
|
||||
"phone": "+228 90 00 00 00",
|
||||
"email": email,
|
||||
"website": "https://demo-agency.deals24togo.com",
|
||||
"verified": True,
|
||||
}).execute()
|
||||
|
||||
print(f"✅ Created {role}: {email} / {password}")
|
||||
|
||||
|
||||
def seed():
|
||||
db = get_supabase_admin()
|
||||
|
||||
_create_user(db, "admin@deals24togo.com", "admin123456", "Admin", "admin")
|
||||
_create_user(db, "agency@deals24togo.com", "agency123456", "Demo Agency", "agency", with_agency=True)
|
||||
_create_user(db, "visitor@deals24togo.com", "visitor123456", "Demo Visitor", "visitor")
|
||||
|
||||
print("\n🎉 Seeding complete!")
|
||||
print(" Admin: admin@deals24togo.com / admin123456")
|
||||
print(" Agency: agency@deals24togo.com / agency123456")
|
||||
print(" Visitor: visitor@deals24togo.com / visitor123456")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed()
|
||||
11
test_main.http
Normal file
11
test_main.http
Normal file
@@ -0,0 +1,11 @@
|
||||
# Test your FastAPI endpoints
|
||||
|
||||
GET http://127.0.0.1:8000/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
GET http://127.0.0.1:8000/hello/User
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
40
tests/conftest.py
Normal file
40
tests/conftest.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Test fixtures and configuration."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Set test env vars before importing app
|
||||
os.environ.setdefault("SUPABASE_URL", "https://test.supabase.co")
|
||||
os.environ.setdefault("SUPABASE_KEY", "test-key")
|
||||
os.environ.setdefault("SUPABASE_SERVICE_ROLE_KEY", "test-service-key")
|
||||
os.environ.setdefault("SUPABASE_JWT_SECRET", "test-jwt-secret")
|
||||
os.environ.setdefault("APP_ENV", "test")
|
||||
os.environ.setdefault("DEBUG", "true")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
from app.main import create_app
|
||||
return create_app()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers():
|
||||
"""Generate auth headers with a Supabase-format test JWT."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import jwt
|
||||
|
||||
payload = {
|
||||
"sub": "test-user-id",
|
||||
"aud": "authenticated",
|
||||
"role": "visitor",
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
}
|
||||
token = jwt.encode(payload, "test-jwt-secret", algorithm="HS256")
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
13
tests/test_health.py
Normal file
13
tests/test_health.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Basic smoke tests."""
|
||||
|
||||
|
||||
def test_health_check(client):
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
|
||||
|
||||
def test_docs_available_in_debug(client):
|
||||
response = client.get("/docs")
|
||||
assert response.status_code == 200
|
||||
Reference in New Issue
Block a user