Initial commit

This commit is contained in:
belviskhoremk
2026-03-06 22:57:58 +00:00
commit c4d836a0f9
60 changed files with 5423 additions and 0 deletions

16
.dockerignore Normal file
View 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
View 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
View 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
View 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
View File

0
app/api/__init__.py Normal file
View File

0
app/api/v1/__init__.py Normal file
View File

View File

View 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)

View 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

View 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)

View 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)}

View 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)

View 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"])

View 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"])

View 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}

View 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
View 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
View File

71
app/core/config.py Normal file
View 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
View 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
View 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
View 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
View 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

View File

75
app/middleware/auth.py Normal file
View 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
View File

7
app/schemas/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

View 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"}

View 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"),
}

View 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"}

View 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)

View 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

View 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

View 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"}

View 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}"

View 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

View 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
View 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

View 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

View 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;

View 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
View 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
View 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
View File

70
scripts/seed.py Normal file
View 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
View 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
View File

40
tests/conftest.py Normal file
View 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
View 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

1568
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff