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

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