Initial Commit

This commit is contained in:
belviskhoremk
2026-05-12 00:34:21 +00:00
commit d2dc43b16f
57 changed files with 6056 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
.venv/
.claude/
.junie/
.agents/
__pycache__/
*.pyc
*.pyo
.env
.env.example
.idea/
*.egg-info/
dist/
.DS_Store

0
app/__init__.py Normal file
View File

40
app/config.py Normal file
View File

@@ -0,0 +1,40 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# Supabase
SUPABASE_URL: str = ""
SUPABASE_ANON_KEY: str = ""
SUPABASE_SERVICE_ROLE_KEY: str = ""
SUPABASE_JWT_SECRET: str = ""
DATABASE_URL: str = ""
# Stripe
STRIPE_SECRET_KEY: str = ""
STRIPE_WEBHOOK_SECRET: str = ""
STRIPE_CURRENCY: str = "eur"
# App
APP_ENV: str = "development"
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"]
BUSINESS_TIMEZONE: str = "Europe/Berlin"
BUSINESS_NAME: str = "Bado Hair"
# Email
SMTP_HOST: str = ""
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
EMAIL_FROM: str = ""
@property
def is_production(self) -> bool:
return self.APP_ENV == "production"
@lru_cache
def get_settings() -> Settings:
return Settings()

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

10
app/core/pagination.py Normal file
View File

@@ -0,0 +1,10 @@
from typing import Annotated
from fastapi import Query
def pagination_params(
page: Annotated[int, Query(ge=1)] = 1,
per_page: Annotated[int, Query(ge=1, le=100)] = 20,
) -> tuple[int, int, int]:
offset = (page - 1) * per_page
return page, per_page, offset

42
app/core/responses.py Normal file
View File

@@ -0,0 +1,42 @@
from typing import Any, TypeVar, Generic
from pydantic import BaseModel
T = TypeVar("T")
class ErrorDetail(BaseModel):
code: str
message: str
details: Any = None
class APIResponse(BaseModel, Generic[T]):
success: bool
data: T | None = None
error: ErrorDetail | None = None
class PaginationMeta(BaseModel):
total: int
page: int
per_page: int
pages: int
class PaginatedAPIResponse(BaseModel, Generic[T]):
success: bool = True
data: list[T] = []
meta: PaginationMeta
def ok(data: Any = None) -> dict:
return {"success": True, "data": data}
def paginated(data: list, total: int, page: int, per_page: int) -> dict:
pages = max(1, (total + per_page - 1) // per_page)
return {
"success": True,
"data": data,
"meta": {"total": total, "page": page, "per_page": per_page, "pages": pages},
}

58
app/core/security.py Normal file
View File

@@ -0,0 +1,58 @@
import json
import urllib.request
import jwt
from app.config import get_settings
from app.exceptions import UnauthorizedError
_jwks_cache: dict[str, object] = {}
def _get_public_key(kid: str) -> object:
if kid in _jwks_cache:
return _jwks_cache[kid]
settings = get_settings()
url = f"{settings.SUPABASE_URL}/auth/v1/.well-known/jwks.json"
with urllib.request.urlopen(url, timeout=10) as resp:
jwks = json.loads(resp.read())
for key_data in jwks.get("keys", []):
if key_data.get("kid") == kid:
public_key = jwt.algorithms.ECAlgorithm.from_jwk(key_data)
_jwks_cache[kid] = public_key
return public_key
raise UnauthorizedError("Public key not found")
def decode_token(token: str) -> dict:
try:
header = jwt.get_unverified_header(token)
alg = header.get("alg", "HS256")
kid = header.get("kid")
if alg == "HS256":
settings = get_settings()
key = settings.SUPABASE_JWT_SECRET
else:
key = _get_public_key(kid)
payload = jwt.decode(
token,
key,
algorithms=[alg],
audience="authenticated",
options={"verify_exp": True},
)
return payload
except jwt.ExpiredSignatureError:
raise UnauthorizedError("Token has expired")
except jwt.InvalidTokenError as e:
raise UnauthorizedError(f"Invalid token: {e}")
except UnauthorizedError:
raise
except Exception as e:
raise UnauthorizedError(f"Token validation failed: {e}")

42
app/database.py Normal file
View File

@@ -0,0 +1,42 @@
import asyncpg
from app.config import get_settings
_pool: asyncpg.Pool | None = None
async def get_pool() -> asyncpg.Pool:
global _pool
if _pool is None:
settings = get_settings()
_pool = await asyncpg.create_pool(
settings.DATABASE_URL,
min_size=2,
max_size=10,
command_timeout=60,
init=_init_connection,
)
return _pool
async def _init_connection(conn: asyncpg.Connection):
await conn.set_type_codec(
"jsonb",
encoder=lambda v: __import__("json").dumps(v),
decoder=lambda v: __import__("json").loads(v),
schema="pg_catalog",
format="text",
)
await conn.set_type_codec(
"json",
encoder=lambda v: __import__("json").dumps(v),
decoder=lambda v: __import__("json").loads(v),
schema="pg_catalog",
format="text",
)
async def close_pool():
global _pool
if _pool:
await _pool.close()
_pool = None

56
app/dependencies.py Normal file
View File

@@ -0,0 +1,56 @@
from typing import AsyncGenerator
import asyncpg
from fastapi import Depends, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.core.security import decode_token
from app.database import get_pool
from app.exceptions import UnauthorizedError, ForbiddenError, UserBlockedError
_bearer = HTTPBearer()
_bearer_optional = HTTPBearer(auto_error=False)
async def get_db() -> AsyncGenerator[asyncpg.Connection, None]:
pool = await get_pool()
async with pool.acquire() as conn:
yield conn
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Security(_bearer),
db: asyncpg.Connection = Depends(get_db),
) -> dict:
payload = decode_token(credentials.credentials)
user_id = payload.get("sub")
if not user_id:
raise UnauthorizedError()
profile = await db.fetchrow(
"SELECT id, email, full_name, phone, role, is_blocked FROM profiles WHERE id = $1",
user_id,
)
if not profile:
raise UnauthorizedError("User profile not found")
if profile["is_blocked"]:
raise UserBlockedError()
return dict(profile)
async def require_admin(user: dict = Depends(get_current_user)) -> dict:
if user["role"] != "admin":
raise ForbiddenError()
return user
async def get_current_user_optional(
credentials: HTTPAuthorizationCredentials | None = Security(_bearer_optional),
db: asyncpg.Connection = Depends(get_db),
) -> dict | None:
if not credentials:
return None
try:
return await get_current_user(credentials, db)
except Exception:
return None

58
app/exceptions.py Normal file
View File

@@ -0,0 +1,58 @@
from typing import Any
class AppError(Exception):
def __init__(self, code: str, message: str, status_code: int = 400, details: Any = None):
self.code = code
self.message = message
self.status_code = status_code
self.details = details
super().__init__(message)
class NotFoundError(AppError):
def __init__(self, resource: str, details: Any = None):
code = f"{resource.upper().replace(' ', '_')}_NOT_FOUND"
message = f"{resource.replace('_', ' ').capitalize()} not found"
super().__init__(code=code, message=message, status_code=404, details=details)
class UnauthorizedError(AppError):
def __init__(self, message: str = "Authentication required"):
super().__init__(code="UNAUTHORIZED", message=message, status_code=401)
class ForbiddenError(AppError):
def __init__(self, message: str = "Insufficient permissions"):
super().__init__(code="FORBIDDEN", message=message, status_code=403)
class ConflictError(AppError):
def __init__(self, code: str, message: str, details: Any = None):
super().__init__(code=code, message=message, status_code=409, details=details)
class PaymentError(AppError):
def __init__(self, message: str, details: Any = None):
super().__init__(code="PAYMENT_FAILED", message=message, status_code=402, details=details)
class ValidationError(AppError):
def __init__(self, message: str, details: Any = None):
super().__init__(code="VALIDATION_ERROR", message=message, status_code=422, details=details)
class OutOfStockError(AppError):
def __init__(self, product_name: str = ""):
msg = f"'{product_name}' is out of stock" if product_name else "One or more items are out of stock"
super().__init__(code="OUT_OF_STOCK", message=msg, status_code=409)
class SlotUnavailableError(AppError):
def __init__(self):
super().__init__(code="SLOT_UNAVAILABLE", message="This time slot is no longer available", status_code=409)
class UserBlockedError(AppError):
def __init__(self):
super().__init__(code="USER_BLOCKED", message="Your account has been suspended", status_code=403)

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

77
app/models/admin.py Normal file
View File

@@ -0,0 +1,77 @@
from pydantic import BaseModel, EmailStr, Field
from uuid import UUID
from datetime import datetime
from typing import Any
class DashboardStats(BaseModel):
revenue_today: float
revenue_week: float
revenue_month: float
orders_pending: int
bookings_upcoming: int
low_stock_count: int
new_customers_month: int
class RevenueStats(BaseModel):
period: str
revenue: float
orders_count: int
bookings_count: int
class ActivityItem(BaseModel):
id: UUID
actor_name: str | None = None
action: str
entity_type: str
entity_id: UUID | None = None
metadata: dict | None = None
created_at: datetime
class CustomerOut(BaseModel):
id: UUID
email: str
full_name: str | None = None
phone: str | None = None
is_blocked: bool
orders_count: int = 0
bookings_count: int = 0
total_spent: float = 0.0
created_at: datetime
class UpdateCustomer(BaseModel):
is_blocked: bool | None = None
full_name: str | None = Field(None, max_length=100)
class StoreSettingOut(BaseModel):
key: str
value: Any
updated_at: datetime
class StoreSettingUpdate(BaseModel):
value: Any
class AdminUserCreate(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
full_name: str
class EmailTemplateOut(BaseModel):
name: str
subject: str
body_html: str
body_text: str | None = None
class EmailTemplateUpdate(BaseModel):
subject: str
body_html: str
body_text: str | None = None

50
app/models/auth.py Normal file
View File

@@ -0,0 +1,50 @@
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
# Accept either `name` (frontend) or `full_name`
name: str | None = Field(None, min_length=2, max_length=100)
full_name: str | None = Field(None, min_length=2, max_length=100)
phone: str | None = None
def resolved_name(self) -> str:
return self.name or self.full_name or ""
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
class RefreshRequest(BaseModel):
refresh_token: str
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
new_password: str = Field(min_length=8)
class ProfileOut(BaseModel):
id: str
email: str
full_name: str | None = None
phone: str | None = None
role: str
class UpdateProfileRequest(BaseModel):
full_name: str | None = Field(None, min_length=2, max_length=100)
phone: str | None = None

99
app/models/bookings.py Normal file
View File

@@ -0,0 +1,99 @@
from typing import Literal
from pydantic import BaseModel, Field
from uuid import UUID
from datetime import datetime, date, time
BookingStatus = Literal["pending", "confirmed", "cancelled", "completed", "no_show"]
class SlotCreate(BaseModel):
date: date
start_time: time
end_time: time
class WeeklyScheduleCreate(BaseModel):
day_of_week: int = Field(ge=0, le=6, description="0=Monday, 6=Sunday")
start_time: time
end_time: time
slot_duration_minutes: int = Field(ge=15, le=480, default=60)
class WeeklyScheduleOut(BaseModel):
id: UUID
day_of_week: int
start_time: time
end_time: time
slot_duration_minutes: int
is_active: bool
class GenerateSlotsRequest(BaseModel):
from_date: date
to_date: date = Field(description="Max 90 days from from_date")
class SlotOut(BaseModel):
id: UUID
date: date
start_time: time
end_time: time
is_blocked: bool
block_reason: str | None = None
is_booked: bool = False
class UpdateSlotRequest(BaseModel):
is_blocked: bool
block_reason: str | None = None
class BlockedDateCreate(BaseModel):
date: date
reason: str | None = None
class BlockedDateOut(BaseModel):
id: UUID
date: date
reason: str | None = None
class BookingCreate(BaseModel):
slot_id: UUID
service_note: str | None = Field(None, max_length=500)
# Guest fields — required when not authenticated
guest_name: str | None = Field(None, max_length=100)
guest_email: str | None = None
guest_phone: str | None = None
class BookingOut(BaseModel):
id: UUID
user_id: UUID | None = None
slot_id: UUID
slot_date: date
slot_start: str # "HH:MM"
slot_end: str # "HH:MM"
service_note: str | None = None
# Resolved client info (from profile or guest fields)
client_name: str | None = None
client_email: str | None = None
client_phone: str | None = None
status: str
amount_paid: float | None = None
stripe_payment_intent_id: str | None = None
admin_notes: str | None = None
created_at: datetime
updated_at: datetime
class BookingCheckoutResponse(BaseModel):
booking_id: UUID
client_secret: str
amount: float
class UpdateBookingStatus(BaseModel):
status: BookingStatus
admin_notes: str | None = None

57
app/models/orders.py Normal file
View File

@@ -0,0 +1,57 @@
from typing import Literal
from pydantic import BaseModel, Field
from uuid import UUID
from datetime import datetime
OrderStatus = Literal["pending", "paid", "processing", "shipped", "delivered", "cancelled", "refunded"]
class OrderItemCreate(BaseModel):
product_id: UUID
quantity: int = Field(ge=1)
class OrderCreate(BaseModel):
items: list[OrderItemCreate] = Field(min_length=1)
shipping_address: dict | None = None
notes: str | None = None
class OrderItemOut(BaseModel):
id: UUID
product_id: UUID
product_name: str
quantity: int
unit_price: float
@property
def subtotal(self) -> float:
return round(self.quantity * self.unit_price, 2)
class OrderOut(BaseModel):
id: UUID
user_id: UUID
status: str
total_amount: float
items: list[OrderItemOut] = []
shipping_address: dict | None = None
notes: str | None = None
stripe_payment_intent_id: str | None = None
created_at: datetime
updated_at: datetime
class CheckoutResponse(BaseModel):
order_id: UUID
client_secret: str
amount: float
class UpdateOrderStatus(BaseModel):
status: OrderStatus
class RefundRequest(BaseModel):
reason: str | None = None
amount: float | None = Field(None, gt=0)

70
app/models/products.py Normal file
View File

@@ -0,0 +1,70 @@
from typing import Literal
from pydantic import BaseModel, Field
from uuid import UUID
from datetime import datetime
ProductCategory = Literal["clip-in", "tape-in", "ponytail", "keratin"]
class ProductCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str | None = None
price: float = Field(gt=0)
original_price: float | None = Field(None, gt=0)
category: ProductCategory | None = None
colors: list[str] = []
lengths: list[str] = []
features: list[str] = []
stock_quantity: int = Field(ge=0, default=0)
is_featured: bool = False
is_hidden: bool = False
is_new: bool = False
is_bestseller: bool = False
class ProductUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200)
description: str | None = None
price: float | None = Field(None, gt=0)
original_price: float | None = Field(None, gt=0)
category: ProductCategory | None = None
colors: list[str] | None = None
lengths: list[str] | None = None
features: list[str] | None = None
stock_quantity: int | None = Field(None, ge=0)
is_featured: bool | None = None
is_hidden: bool | None = None
is_new: bool | None = None
is_bestseller: bool | None = None
class StockUpdate(BaseModel):
id: UUID
stock_quantity: int = Field(ge=0)
class BulkStockUpdateRequest(BaseModel):
updates: list[StockUpdate]
class ProductOut(BaseModel):
id: UUID
name: str
description: str | None = None
price: float
original_price: float | None = None
category: str | None = None
image: str = ""
images: list[str] = []
colors: list[str] = []
lengths: list[str] = []
features: list[str] = []
stock_quantity: int
is_featured: bool
is_hidden: bool
is_new: bool
is_bestseller: bool
rating: float = 0
review_count: int = 0
created_at: datetime
updated_at: datetime

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

View File

View File

@@ -0,0 +1,179 @@
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Depends, Query
import asyncpg
from app.core.pagination import pagination_params
from app.core.responses import ok, paginated
from app.dependencies import get_db, require_admin
from app.models.bookings import (
UpdateBookingStatus, SlotCreate, WeeklyScheduleCreate,
GenerateSlotsRequest, BlockedDateCreate, UpdateSlotRequest,
)
from app.services import booking_service, slot_service
router = APIRouter(tags=["Admin — Bookings"])
# ── Bookings ──────────────────────────────────────────────────────────────────
@router.get("/bookings")
async def list_bookings(
pagination: Annotated[tuple, Depends(pagination_params)],
status: str | None = Query(None),
from_date: date | None = Query(None),
to_date: date | None = Query(None),
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
page, per_page, offset = pagination
bookings, total = await booking_service.list_bookings(
db, page, per_page, offset,
status=status, from_date=from_date, to_date=to_date,
)
return paginated(bookings, total, page, per_page)
@router.get("/bookings/{booking_id}")
async def get_booking(
booking_id: str,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
booking = await booking_service.get_booking(db, booking_id)
return ok(booking)
@router.patch("/bookings/{booking_id}")
async def update_booking(
booking_id: str,
body: UpdateBookingStatus,
db: asyncpg.Connection = Depends(get_db),
admin: dict = Depends(require_admin),
):
booking = await booking_service.admin_update_booking(
db, booking_id, body.status, body.admin_notes, str(admin["id"])
)
return ok(booking)
@router.delete("/bookings/{booking_id}", status_code=204)
async def delete_booking(
booking_id: str,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
await booking_service.admin_delete_booking(db, booking_id)
# ── Slots ─────────────────────────────────────────────────────────────────────
@router.get("/slots")
async def list_slots(
from_date: date = Query(...),
to_date: date = Query(...),
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
slots = await slot_service.list_slots(db, from_date, to_date, available_only=False)
return ok(slots)
@router.post("/slots", status_code=201)
async def create_slot(
body: SlotCreate,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
slot = await slot_service.create_slot(db, body)
return ok(slot)
@router.patch("/slots/{slot_id}")
async def update_slot(
slot_id: str,
body: UpdateSlotRequest,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
slot = await slot_service.update_slot(db, slot_id, body.is_blocked, body.block_reason)
return ok(slot)
@router.delete("/slots/{slot_id}", status_code=204)
async def delete_slot(
slot_id: str,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
await slot_service.delete_slot(db, slot_id)
@router.post("/slots/generate")
async def generate_slots(
body: GenerateSlotsRequest,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
created = await slot_service.generate_slots(db, body.from_date, body.to_date)
return ok({"created": created})
# ── Weekly schedule ───────────────────────────────────────────────────────────
@router.get("/schedule")
async def get_schedule(
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
schedule = await slot_service.get_schedule(db)
return ok(schedule)
@router.post("/schedule", status_code=201)
async def create_schedule(
body: WeeklyScheduleCreate,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
entry = await slot_service.create_schedule_entry(db, body)
return ok(entry)
@router.delete("/schedule/{schedule_id}", status_code=204)
async def delete_schedule(
schedule_id: str,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
await slot_service.delete_schedule_entry(db, schedule_id)
# ── Blocked dates ─────────────────────────────────────────────────────────────
@router.get("/blocked-dates")
async def get_blocked_dates(
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
dates = await slot_service.get_blocked_dates(db)
return ok(dates)
@router.post("/blocked-dates", status_code=201)
async def add_blocked_date(
body: BlockedDateCreate,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
entry = await slot_service.add_blocked_date(db, body.date, body.reason)
return ok(entry)
@router.delete("/blocked-dates/{blocked_date_id}", status_code=204)
async def remove_blocked_date(
blocked_date_id: str,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
await slot_service.remove_blocked_date(db, blocked_date_id)

View File

@@ -0,0 +1,147 @@
import csv
import io
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
import asyncpg
from app.core.pagination import pagination_params
from app.core.responses import ok, paginated
from app.dependencies import get_db, require_admin
from app.exceptions import NotFoundError
from app.models.admin import UpdateCustomer
router = APIRouter(prefix="/customers", tags=["Admin — Customers"])
@router.get("")
async def list_customers(
pagination: Annotated[tuple, Depends(pagination_params)],
search: str | None = Query(None),
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
page, per_page, offset = pagination
conditions = ["role = 'client'"]
params: list = []
if search:
params.append(f"%{search}%")
conditions.append(f"(email ILIKE ${len(params)} OR full_name ILIKE ${len(params)})")
where = f"WHERE {' AND '.join(conditions)}"
total = await db.fetchval(f"SELECT COUNT(*) FROM profiles {where}", *params)
params.extend([per_page, offset])
rows = await db.fetch(
f"""
SELECT
p.id, p.email, p.full_name, p.phone, p.is_blocked, p.created_at,
COUNT(DISTINCT o.id) AS orders_count,
COUNT(DISTINCT b.id) AS bookings_count,
COALESCE(SUM(o.total_amount) FILTER (WHERE o.status IN ('paid','shipped','delivered')), 0) AS total_spent
FROM profiles p
LEFT JOIN orders o ON o.user_id = p.id
LEFT JOIN bookings b ON b.user_id = p.id
{where}
GROUP BY p.id
ORDER BY p.created_at DESC
LIMIT ${len(params) - 1} OFFSET ${len(params)}
""",
*params,
)
return paginated([dict(r) for r in rows], total, page, per_page)
@router.get("/export")
async def export_customers(
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
rows = await db.fetch(
"SELECT id, email, full_name, phone, is_blocked, created_at FROM profiles WHERE role = 'client' ORDER BY created_at DESC"
)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["ID", "Email", "Name", "Phone", "Blocked", "Joined"])
for r in rows:
writer.writerow([r["id"], r["email"], r["full_name"], r["phone"], r["is_blocked"], r["created_at"]])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=customers.csv"},
)
@router.get("/{customer_id}")
async def get_customer(
customer_id: str,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
row = await db.fetchrow(
"""
SELECT
p.id, p.email, p.full_name, p.phone, p.is_blocked, p.created_at,
COUNT(DISTINCT o.id) AS orders_count,
COUNT(DISTINCT b.id) AS bookings_count,
COALESCE(SUM(o.total_amount) FILTER (WHERE o.status IN ('paid','shipped','delivered')), 0) AS total_spent
FROM profiles p
LEFT JOIN orders o ON o.user_id = p.id
LEFT JOIN bookings b ON b.user_id = p.id
WHERE p.id = $1
GROUP BY p.id
""",
customer_id,
)
if not row:
raise NotFoundError("customer")
orders = await db.fetch(
"SELECT id, status, total_amount, created_at FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 10",
customer_id,
)
bookings = await db.fetch(
"""
SELECT b.id, b.status, b.amount_paid, ts.date, ts.start_time
FROM bookings b JOIN time_slots ts ON ts.id = b.slot_id
WHERE b.user_id = $1
ORDER BY ts.date DESC
LIMIT 10
""",
customer_id,
)
result = dict(row)
result["recent_orders"] = [dict(r) for r in orders]
result["recent_bookings"] = [dict(r) for r in bookings]
return ok(result)
@router.patch("/{customer_id}")
async def update_customer(
customer_id: str,
body: UpdateCustomer,
db: asyncpg.Connection = Depends(get_db),
admin: dict = Depends(require_admin),
):
updates = {k: v for k, v in body.model_dump().items() if v is not None}
if not updates:
raise NotFoundError("customer")
set_clauses = [f"{k} = ${i + 2}" for i, k in enumerate(updates)]
row = await db.fetchrow(
f"UPDATE profiles SET {', '.join(set_clauses)}, updated_at = now() WHERE id = $1 AND role = 'client' RETURNING *",
customer_id, *updates.values(),
)
if not row:
raise NotFoundError("customer")
action = "customer.blocked" if updates.get("is_blocked") else "customer.updated"
await db.execute(
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id) VALUES ($1, $2, 'customer', $3)",
str(admin["id"]), action, customer_id,
)
return ok(dict(row))

View File

@@ -0,0 +1,114 @@
from fastapi import APIRouter, Depends, Query
import asyncpg
from app.core.responses import ok
from app.dependencies import get_db, require_admin
router = APIRouter(prefix="/stats", tags=["Admin — Dashboard"])
@router.get("/overview")
async def overview(
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
# Revenue
revenue_today = float(await db.fetchval(
"SELECT COALESCE(SUM(total_amount),0) FROM orders WHERE status IN ('paid','processing','shipped','delivered') AND DATE(created_at)=CURRENT_DATE"
) or 0)
revenue_week = float(await db.fetchval(
"SELECT COALESCE(SUM(total_amount),0) FROM orders WHERE status IN ('paid','processing','shipped','delivered') AND created_at>=date_trunc('week',now())"
) or 0)
revenue_month = float(await db.fetchval(
"SELECT COALESCE(SUM(total_amount),0) FROM orders WHERE status IN ('paid','processing','shipped','delivered') AND created_at>=date_trunc('month',now())"
) or 0)
# Booking counts — what the frontend dashboard shows
orders_pending = int(await db.fetchval("SELECT COUNT(*) FROM orders WHERE status='pending'") or 0)
bookings_pending = int(await db.fetchval("SELECT COUNT(*) FROM bookings WHERE status='pending'") or 0)
bookings_confirmed = int(await db.fetchval(
"SELECT COUNT(*) FROM bookings b JOIN time_slots ts ON ts.id=b.slot_id WHERE b.status='confirmed' AND ts.date>=CURRENT_DATE"
) or 0)
# Products
products_count = int(await db.fetchval("SELECT COUNT(*) FROM products WHERE is_hidden=false") or 0)
catalog_value = float(await db.fetchval(
"SELECT COALESCE(SUM(price),0) FROM products WHERE is_hidden=false"
) or 0)
low_stock_count = int(await db.fetchval(
"SELECT COUNT(*) FROM products WHERE stock_quantity<=5 AND is_hidden=false"
) or 0)
new_customers_month = int(await db.fetchval(
"SELECT COUNT(*) FROM profiles WHERE role='client' AND created_at>=date_trunc('month',now())"
) or 0)
return ok({
# Revenue stats
"revenue_today": revenue_today,
"revenue_week": revenue_week,
"revenue_month": revenue_month,
# Booking stats (matches frontend dashboard cards)
"orders_pending": orders_pending,
"bookings_pending": bookings_pending,
"bookings_confirmed": bookings_confirmed,
"bookings_upcoming": bookings_confirmed,
# Product stats (matches frontend dashboard cards)
"products_count": products_count,
"catalog_value": catalog_value,
"low_stock_count": low_stock_count,
# Customer stats
"new_customers_month": new_customers_month,
})
@router.get("/revenue")
async def revenue(
period: str = Query("month", pattern="^(today|week|month|year)$"),
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
trunc = {"today": "day", "week": "week", "month": "month", "year": "year"}[period]
rows = await db.fetch(
f"""
SELECT date_trunc('{trunc}', created_at) AS period,
COALESCE(SUM(total_amount), 0) AS revenue,
COUNT(*) AS orders_count
FROM orders
WHERE status IN ('paid','processing','shipped','delivered')
AND created_at >= date_trunc('{trunc}', now()) - interval '12 {trunc}s'
GROUP BY 1 ORDER BY 1 DESC
""",
)
booking_rows = await db.fetch(
f"""
SELECT date_trunc('{trunc}', b.created_at) AS period,
COALESCE(SUM(b.amount_paid), 0) AS revenue,
COUNT(*) AS bookings_count
FROM bookings b
WHERE b.status IN ('confirmed','completed')
AND b.created_at >= date_trunc('{trunc}', now()) - interval '12 {trunc}s'
GROUP BY 1 ORDER BY 1 DESC
""",
)
return ok({"orders": [dict(r) for r in rows], "bookings": [dict(r) for r in booking_rows]})
@router.get("/activity", tags=["Admin — Dashboard"])
async def activity(
limit: int = Query(20, ge=1, le=100),
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
rows = await db.fetch(
"""
SELECT al.id, p.full_name AS actor_name, al.action,
al.entity_type, al.entity_id, al.metadata, al.created_at
FROM activity_log al
LEFT JOIN profiles p ON p.id = al.actor_id
ORDER BY al.created_at DESC
LIMIT $1
""",
limit,
)
return ok([dict(r) for r in rows])

View File

@@ -0,0 +1,89 @@
import csv
import io
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
import asyncpg
from app.core.pagination import pagination_params
from app.core.responses import ok, paginated
from app.dependencies import get_db, require_admin
from app.models.orders import UpdateOrderStatus, RefundRequest
from app.services import order_service
router = APIRouter(prefix="/orders", tags=["Admin — Orders"])
@router.get("")
async def list_orders(
pagination: Annotated[tuple, Depends(pagination_params)],
status: str | None = Query(None),
from_date: date | None = Query(None),
to_date: date | None = Query(None),
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
page, per_page, offset = pagination
orders, total = await order_service.list_orders(db, page, per_page, offset, status=status)
return paginated(orders, total, page, per_page)
@router.get("/export")
async def export_orders(
status: str | None = Query(None),
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
rows = await db.fetch(
"""
SELECT o.id, p.email, p.full_name, o.status, o.total_amount, o.created_at
FROM orders o
JOIN profiles p ON p.id = o.user_id
ORDER BY o.created_at DESC
"""
)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["ID", "Email", "Name", "Status", "Total", "Date"])
for r in rows:
writer.writerow([r["id"], r["email"], r["full_name"], r["status"], r["total_amount"], r["created_at"]])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=orders.csv"},
)
@router.get("/{order_id}")
async def get_order(
order_id: str,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
order = await order_service.get_order(db, order_id)
return ok(order)
@router.patch("/{order_id}/status")
async def update_status(
order_id: str,
body: UpdateOrderStatus,
db: asyncpg.Connection = Depends(get_db),
admin: dict = Depends(require_admin),
):
order = await order_service.update_order_status(db, order_id, body.status, str(admin["id"]))
return ok(order)
@router.post("/{order_id}/refund")
async def refund_order(
order_id: str,
body: RefundRequest,
db: asyncpg.Connection = Depends(get_db),
admin: dict = Depends(require_admin),
):
order = await order_service.refund_order(db, order_id, str(admin["id"]), body.amount, body.reason)
return ok(order)

View File

@@ -0,0 +1,116 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Query, UploadFile, File
import asyncpg
from app.core.pagination import pagination_params
from app.core.responses import ok, paginated
from app.dependencies import get_db, require_admin
from app.exceptions import ValidationError
from app.models.products import ProductCreate, ProductUpdate, BulkStockUpdateRequest
from app.services import product_service, storage_service
router = APIRouter(prefix="/products", tags=["Admin — Products"])
@router.get("")
async def list_products(
pagination: Annotated[tuple, Depends(pagination_params)],
search: str | None = Query(None),
category: str | None = Query(None),
bestseller: bool = Query(False),
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
page, per_page, offset = pagination
products, total = await product_service.list_products(
db, page, per_page, offset,
include_hidden=True,
category=category,
bestseller=bestseller,
search=search,
)
return paginated(products, total, page, per_page)
@router.post("", status_code=201)
async def create_product(
body: ProductCreate,
db: asyncpg.Connection = Depends(get_db),
admin: dict = Depends(require_admin),
):
product = await product_service.create_product(db, body)
await _log(db, admin, "product.created", str(product["id"]))
return ok(product)
@router.put("/{product_id}")
async def update_product(
product_id: str,
body: ProductUpdate,
db: asyncpg.Connection = Depends(get_db),
admin: dict = Depends(require_admin),
):
product = await product_service.update_product(db, product_id, body)
await _log(db, admin, "product.updated", product_id)
return ok(product)
@router.delete("/{product_id}", status_code=204)
async def delete_product(
product_id: str,
db: asyncpg.Connection = Depends(get_db),
admin: dict = Depends(require_admin),
):
await product_service.delete_product(db, product_id)
await _log(db, admin, "product.deleted", product_id)
@router.post("/{product_id}/images", status_code=201)
async def upload_image(
product_id: str,
file: UploadFile = File(...),
db: asyncpg.Connection = Depends(get_db),
admin: dict = Depends(require_admin),
):
if not file.content_type or not file.content_type.startswith("image/"):
raise ValidationError("Only image files are allowed")
content = await file.read()
if len(content) > 10 * 1024 * 1024:
raise ValidationError("Image must be under 10MB")
image_data = await storage_service.upload_product_image(product_id, content, file.content_type)
product = await product_service.add_image(db, product_id, image_data["url"])
return ok(product)
@router.delete("/{product_id}/images")
async def delete_image(
product_id: str,
url: str = Query(..., description="Full image URL to remove"),
storage_path: str = Query(..., description="Storage path for deletion from bucket"),
db: asyncpg.Connection = Depends(get_db),
admin: dict = Depends(require_admin),
):
await storage_service.delete_product_image(storage_path)
product = await product_service.remove_image(db, product_id, url)
return ok(product)
@router.post("/bulk-stock")
async def bulk_stock(
body: BulkStockUpdateRequest,
db: asyncpg.Connection = Depends(get_db),
admin: dict = Depends(require_admin),
):
updates = [u.model_dump() for u in body.updates]
await product_service.bulk_stock_update(db, updates)
await _log(db, admin, "product.bulk_stock_updated", None)
return ok({"updated": len(updates)})
async def _log(db, admin, action, entity_id, metadata=None):
await db.execute(
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, 'product', $3, $4)",
str(admin["id"]), action, entity_id, metadata,
)

View File

@@ -0,0 +1,88 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
import asyncpg
from app.core.responses import ok
from app.dependencies import get_db, require_admin
from app.exceptions import NotFoundError
router = APIRouter(prefix="/services", tags=["Admin — Services"])
class ServiceCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
description: str | None = None
duration_minutes: int = Field(default=60, ge=5, le=480)
price: float = Field(default=0.0, ge=0)
is_active: bool = True
class ServiceUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=120)
description: str | None = None
duration_minutes: int | None = Field(None, ge=5, le=480)
price: float | None = Field(None, ge=0)
is_active: bool | None = None
@router.get("")
async def list_services(
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
rows = await db.fetch(
"SELECT * FROM services ORDER BY price ASC, name ASC"
)
return ok([dict(r) for r in rows])
@router.post("", status_code=201)
async def create_service(
body: ServiceCreate,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
row = await db.fetchrow(
"""
INSERT INTO services (name, description, duration_minutes, price, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
""",
body.name, body.description, body.duration_minutes, body.price, body.is_active,
)
return ok(dict(row))
@router.put("/{service_id}")
async def update_service(
service_id: str,
body: ServiceUpdate,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
updates = {k: v for k, v in body.model_dump().items() if v is not None}
if not updates:
row = await db.fetchrow("SELECT * FROM services WHERE id = $1", service_id)
if not row:
raise NotFoundError("service")
return ok(dict(row))
set_clauses = [f"{k} = ${i + 2}" for i, k in enumerate(updates)]
row = await db.fetchrow(
f"UPDATE services SET {', '.join(set_clauses)}, updated_at = now() WHERE id = $1 RETURNING *",
service_id, *updates.values(),
)
if not row:
raise NotFoundError("service")
return ok(dict(row))
@router.delete("/{service_id}", status_code=204)
async def delete_service(
service_id: str,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
result = await db.execute("DELETE FROM services WHERE id = $1", service_id)
if result == "DELETE 0":
raise NotFoundError("service")

View File

@@ -0,0 +1,99 @@
from fastapi import APIRouter, Depends
import asyncpg
from app.core.responses import ok
from app.dependencies import get_db, require_admin
from app.exceptions import NotFoundError
from app.models.admin import StoreSettingUpdate, AdminUserCreate, EmailTemplateUpdate
from app.services import auth_service
router = APIRouter(tags=["Admin — Settings"])
# ── Store settings ────────────────────────────────────────────────────────────
@router.get("/settings")
async def get_settings(
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
rows = await db.fetch("SELECT key, value, updated_at FROM store_settings ORDER BY key")
return ok([dict(r) for r in rows])
@router.put("/settings/{key}")
async def update_setting(
key: str,
body: StoreSettingUpdate,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
import json
row = await db.fetchrow(
"""
INSERT INTO store_settings (key, value)
VALUES ($1, $2::jsonb)
ON CONFLICT (key) DO UPDATE SET value = $2::jsonb, updated_at = now()
RETURNING *
""",
key, json.dumps(body.value),
)
return ok(dict(row))
# ── Email templates ───────────────────────────────────────────────────────────
@router.get("/email-templates")
async def get_templates(
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
rows = await db.fetch("SELECT * FROM email_templates ORDER BY name")
return ok([dict(r) for r in rows])
@router.put("/email-templates/{name}")
async def update_template(
name: str,
body: EmailTemplateUpdate,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
row = await db.fetchrow(
"""
INSERT INTO email_templates (name, subject, body_html, body_text)
VALUES ($1, $2, $3, $4)
ON CONFLICT (name) DO UPDATE SET subject = $2, body_html = $3, body_text = $4, updated_at = now()
RETURNING *
""",
name, body.subject, body.body_html, body.body_text,
)
return ok(dict(row))
# ── Admin users ───────────────────────────────────────────────────────────────
@router.get("/users")
async def list_admin_users(
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
rows = await db.fetch(
"SELECT id, email, full_name, created_at FROM profiles WHERE role = 'admin' ORDER BY created_at"
)
return ok([dict(r) for r in rows])
@router.post("/users", status_code=201)
async def create_admin_user(
body: AdminUserCreate,
db: asyncpg.Connection = Depends(get_db),
_: dict = Depends(require_admin),
):
user = await auth_service.create_admin_user(body.email, body.password, body.full_name, db)
return ok({
"id": str(user["id"]),
"email": user["email"],
"full_name": user["full_name"],
"role": user["role"],
})

60
app/routers/auth.py Normal file
View File

@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends
import asyncpg
from app.core.responses import ok
from app.dependencies import get_db, get_current_user
from app.models.auth import RegisterRequest, LoginRequest, RefreshRequest, ForgotPasswordRequest, UpdateProfileRequest
from app.services import auth_service
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/register", status_code=201)
async def register(body: RegisterRequest, db: asyncpg.Connection = Depends(get_db)):
data = await auth_service.register(body, db)
return ok(data)
@router.post("/login")
async def login(body: LoginRequest):
data = await auth_service.login(body)
return ok(data)
@router.post("/refresh")
async def refresh(body: RefreshRequest):
data = await auth_service.refresh(body)
return ok(data)
@router.post("/forgot-password")
async def forgot_password(body: ForgotPasswordRequest):
await auth_service.forgot_password(body.email)
return ok({"message": "If that email exists, a reset link has been sent."})
@router.get("/me")
async def me(user: dict = Depends(get_current_user)):
return ok({
"id": str(user["id"]),
"email": user["email"],
"full_name": user["full_name"],
"phone": user["phone"],
"role": user["role"],
})
@router.patch("/me")
async def update_me(
body: UpdateProfileRequest,
user: dict = Depends(get_current_user),
db: asyncpg.Connection = Depends(get_db),
):
updated = await auth_service.update_profile(str(user["id"]), db, body.full_name, body.phone)
return ok({
"id": str(updated["id"]),
"email": updated["email"],
"full_name": updated["full_name"],
"phone": updated["phone"],
"role": updated["role"],
})

65
app/routers/bookings.py Normal file
View File

@@ -0,0 +1,65 @@
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Depends, Query
import asyncpg
from app.core.pagination import pagination_params
from app.core.responses import ok, paginated
from app.dependencies import get_db, get_current_user, get_current_user_optional
from app.models.bookings import BookingCreate
from app.services import booking_service, slot_service
router = APIRouter(prefix="/bookings", tags=["Bookings"])
@router.get("/slots")
async def get_available_slots(
from_date: date = Query(...),
to_date: date = Query(...),
db: asyncpg.Connection = Depends(get_db),
):
slots = await slot_service.list_slots(db, from_date, to_date, available_only=True)
return ok(slots)
@router.post("", status_code=201)
async def create_booking(
body: BookingCreate,
user: dict | None = Depends(get_current_user_optional),
db: asyncpg.Connection = Depends(get_db),
):
result = await booking_service.create_booking(db, body, user=user)
return ok(result)
@router.get("")
async def list_my_bookings(
pagination: Annotated[tuple, Depends(pagination_params)],
status: str | None = Query(None),
user: dict = Depends(get_current_user),
db: asyncpg.Connection = Depends(get_db),
):
page, per_page, offset = pagination
bookings, total = await booking_service.list_bookings(
db, page, per_page, offset, user_id=str(user["id"]), status=status
)
return paginated(bookings, total, page, per_page)
@router.get("/{booking_id}")
async def get_booking(
booking_id: str,
user: dict = Depends(get_current_user),
db: asyncpg.Connection = Depends(get_db),
):
booking = await booking_service.get_booking(db, booking_id, user_id=str(user["id"]))
return ok(booking)
@router.delete("/{booking_id}", status_code=204)
async def cancel_booking(
booking_id: str,
user: dict = Depends(get_current_user),
db: asyncpg.Connection = Depends(get_db),
):
await booking_service.cancel_booking(db, booking_id, str(user["id"]))

23
app/routers/contact.py Normal file
View File

@@ -0,0 +1,23 @@
from pydantic import BaseModel, EmailStr
from fastapi import APIRouter, Depends
import asyncpg
from app.core.responses import ok
from app.dependencies import get_db
router = APIRouter(prefix="/contact", tags=["Contact"])
class ContactRequest(BaseModel):
name: str
email: EmailStr
message: str
@router.post("")
async def submit_contact(body: ContactRequest, db: asyncpg.Connection = Depends(get_db)):
await db.execute(
"INSERT INTO contact_messages (name, email, message) VALUES ($1, $2, $3)",
body.name, body.email, body.message,
)
return ok({"message": "Your message has been received. We will get back to you shortly."})

43
app/routers/orders.py Normal file
View File

@@ -0,0 +1,43 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Query
import asyncpg
from app.core.pagination import pagination_params
from app.core.responses import ok, paginated
from app.dependencies import get_db, get_current_user
from app.models.orders import OrderCreate
from app.services import order_service
router = APIRouter(prefix="/orders", tags=["Orders"])
@router.post("", status_code=201)
async def create_order(
body: OrderCreate,
user: dict = Depends(get_current_user),
db: asyncpg.Connection = Depends(get_db),
):
result = await order_service.create_order(db, str(user["id"]), body)
return ok(result)
@router.get("")
async def list_my_orders(
pagination: Annotated[tuple, Depends(pagination_params)],
status: str | None = Query(None),
user: dict = Depends(get_current_user),
db: asyncpg.Connection = Depends(get_db),
):
page, per_page, offset = pagination
orders, total = await order_service.list_orders(db, page, per_page, offset, user_id=str(user["id"]), status=status)
return paginated(orders, total, page, per_page)
@router.get("/{order_id}")
async def get_order(
order_id: str,
user: dict = Depends(get_current_user),
db: asyncpg.Connection = Depends(get_db),
):
order = await order_service.get_order(db, order_id, user_id=str(user["id"]))
return ok(order)

47
app/routers/payments.py Normal file
View File

@@ -0,0 +1,47 @@
from fastapi import APIRouter, Request, Header
import asyncpg
from fastapi import Depends
from app.core.responses import ok
from app.dependencies import get_db
from app.exceptions import PaymentError
from app.services import stripe_service, order_service, booking_service
router = APIRouter(prefix="/payments", tags=["Payments"])
@router.post("/webhook")
async def stripe_webhook(
request: Request,
stripe_signature: str = Header(None, alias="stripe-signature"),
db: asyncpg.Connection = Depends(get_db),
):
payload = await request.body()
if not stripe_signature:
raise PaymentError("Missing signature")
event = stripe_service.verify_webhook(payload, stripe_signature)
intent = event.get("data", {}).get("object", {})
metadata = intent.get("metadata", {})
entity_type = metadata.get("entity_type")
entity_id = metadata.get("entity_id")
payment_intent_id = intent.get("id")
if not entity_type or not entity_id or entity_id == "pending":
return ok({"received": True})
if event["type"] == "payment_intent.succeeded":
if entity_type == "order":
await order_service.handle_payment_succeeded(db, payment_intent_id, entity_id)
elif entity_type == "booking":
await booking_service.handle_payment_succeeded(db, payment_intent_id, entity_id)
elif event["type"] == "payment_intent.payment_failed":
if entity_type == "order":
await order_service.handle_payment_failed(db, payment_intent_id, entity_id)
elif entity_type == "booking":
await booking_service.handle_payment_failed(db, payment_intent_id, entity_id)
return ok({"received": True})

39
app/routers/products.py Normal file
View File

@@ -0,0 +1,39 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Query
import asyncpg
from app.core.pagination import pagination_params
from app.core.responses import ok, paginated
from app.dependencies import get_db
from app.services import product_service
router = APIRouter(prefix="/products", tags=["Products"])
@router.get("")
async def list_products(
pagination: Annotated[tuple, Depends(pagination_params)],
search: str | None = Query(None),
category: str | None = Query(None),
bestseller: bool = Query(False),
is_new: bool = Query(False),
exclude: str | None = Query(None, description="Exclude a product ID (e.g. for similar products)"),
db: asyncpg.Connection = Depends(get_db),
):
page, per_page, offset = pagination
products, total = await product_service.list_products(
db, page, per_page, offset,
include_hidden=False,
category=category,
bestseller=bestseller,
is_new=is_new,
search=search,
exclude_id=exclude,
)
return paginated(products, total, page, per_page)
@router.get("/{product_id}")
async def get_product(product_id: str, db: asyncpg.Connection = Depends(get_db)):
product = await product_service.get_product(db, product_id)
return ok(product)

15
app/routers/services.py Normal file
View File

@@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends
import asyncpg
from app.core.responses import ok
from app.dependencies import get_db
router = APIRouter(prefix="/services", tags=["Services"])
@router.get("")
async def list_services(db: asyncpg.Connection = Depends(get_db)):
rows = await db.fetch(
"SELECT id, name, description, duration_minutes, price FROM services WHERE is_active = true ORDER BY price ASC"
)
return ok([dict(r) for r in rows])

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

View File

@@ -0,0 +1,127 @@
import asyncpg
from supabase import create_client, Client
from app.config import get_settings
from app.exceptions import AppError, UnauthorizedError
from app.models.auth import RegisterRequest, LoginRequest, RefreshRequest
def _client() -> Client:
s = get_settings()
return create_client(s.SUPABASE_URL, s.SUPABASE_ANON_KEY)
def _admin_client() -> Client:
s = get_settings()
return create_client(s.SUPABASE_URL, s.SUPABASE_SERVICE_ROLE_KEY)
async def register(req: RegisterRequest, db: asyncpg.Connection) -> dict:
try:
result = _client().auth.sign_up({"email": req.email, "password": req.password})
except Exception as e:
raise AppError("REGISTRATION_FAILED", str(e), 400)
if not result.user:
raise AppError("REGISTRATION_FAILED", "Could not create account", 400)
user_id = str(result.user.id)
await db.execute(
"""
INSERT INTO profiles (id, email, full_name, phone, role)
VALUES ($1, $2, $3, $4, 'client')
ON CONFLICT (id) DO NOTHING
""",
user_id, req.email, req.resolved_name(), req.phone,
)
if result.session:
return {
"access_token": result.session.access_token,
"refresh_token": result.session.refresh_token,
"token_type": "bearer",
"expires_in": result.session.expires_in,
}
return {"message": "Account created successfully."}
async def login(req: LoginRequest) -> dict:
try:
result = _client().auth.sign_in_with_password({"email": req.email, "password": req.password})
except Exception:
raise UnauthorizedError("Invalid email or password")
if not result.session:
raise UnauthorizedError("Invalid email or password")
return {
"access_token": result.session.access_token,
"refresh_token": result.session.refresh_token,
"token_type": "bearer",
"expires_in": result.session.expires_in,
}
async def refresh(req: RefreshRequest) -> dict:
try:
result = _client().auth.refresh_session(req.refresh_token)
except Exception:
raise UnauthorizedError("Invalid or expired refresh token")
return {
"access_token": result.session.access_token,
"refresh_token": result.session.refresh_token,
"token_type": "bearer",
"expires_in": result.session.expires_in,
}
async def forgot_password(email: str):
try:
_client().auth.reset_password_email(email)
except Exception:
pass # Always return success to avoid email enumeration
async def update_profile(user_id: str, db: asyncpg.Connection, full_name: str | None, phone: str | None) -> dict:
updates = {}
if full_name is not None:
updates["full_name"] = full_name
if phone is not None:
updates["phone"] = phone
if not updates:
row = await db.fetchrow("SELECT * FROM profiles WHERE id = $1", user_id)
return dict(row)
set_clauses = [f"{k} = ${i + 2}" for i, k in enumerate(updates)]
row = await db.fetchrow(
f"UPDATE profiles SET {', '.join(set_clauses)}, updated_at = now() WHERE id = $1 RETURNING *",
user_id, *updates.values(),
)
return dict(row)
async def create_admin_user(email: str, password: str, full_name: str, db: asyncpg.Connection) -> dict:
try:
result = _admin_client().auth.admin.create_user({
"email": email,
"password": password,
"email_confirm": True,
})
except Exception as e:
raise AppError("USER_CREATE_FAILED", str(e), 400)
user_id = str(result.user.id)
await db.execute(
"""
INSERT INTO profiles (id, email, full_name, role)
VALUES ($1, $2, $3, 'admin')
ON CONFLICT (id) DO UPDATE SET role = 'admin'
""",
user_id, email, full_name,
)
row = await db.fetchrow("SELECT * FROM profiles WHERE id = $1", user_id)
return dict(row)

View File

@@ -0,0 +1,272 @@
from uuid import UUID
import asyncpg
from app.exceptions import NotFoundError, SlotUnavailableError, AppError, ValidationError
from app.models.bookings import BookingCreate
async def create_booking(
db: asyncpg.Connection,
data: BookingCreate,
user: dict | None = None,
) -> dict:
# Resolve identity
if user:
user_id = str(user["id"])
guest_name = guest_email = guest_phone = None
else:
if not data.guest_email or not data.guest_name:
raise ValidationError("guest_name and guest_email are required for unauthenticated bookings")
user_id = None
guest_name = data.guest_name
guest_email = data.guest_email
guest_phone = data.guest_phone
async with db.transaction():
slot = await db.fetchrow(
"""
SELECT ts.* FROM time_slots ts
WHERE ts.id = $1
AND ts.is_blocked = false
AND ts.date >= CURRENT_DATE
AND NOT EXISTS (SELECT 1 FROM blocked_dates bd WHERE bd.date = ts.date)
AND NOT EXISTS (
SELECT 1 FROM bookings b
WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed')
)
""",
str(data.slot_id),
)
if not slot:
raise SlotUnavailableError()
booking = await db.fetchrow(
"""
INSERT INTO bookings
(user_id, slot_id, service_note, status,
guest_name, guest_email, guest_phone)
VALUES ($1, $2, $3, 'pending', $4, $5, $6)
RETURNING *
""",
user_id, str(data.slot_id), data.service_note,
guest_name, guest_email, guest_phone,
)
return {"booking_id": str(booking["id"])}
async def get_booking(db: asyncpg.Connection, booking_id: str, user_id: str | None = None) -> dict:
query = """
SELECT b.*,
ts.date AS slot_date,
ts.start_time AS slot_start,
ts.end_time AS slot_end,
p.full_name AS profile_name,
p.email AS profile_email,
p.phone AS profile_phone,
s.name AS service_name
FROM bookings b
JOIN time_slots ts ON ts.id = b.slot_id
LEFT JOIN profiles p ON p.id = b.user_id
LEFT JOIN services s ON s.id = b.service_id
WHERE b.id = $1
"""
params: list = [booking_id]
if user_id:
query += " AND b.user_id = $2"
params.append(user_id)
row = await db.fetchrow(query, *params)
if not row:
raise NotFoundError("booking")
return _shape(row)
async def list_bookings(
db: asyncpg.Connection,
page: int,
per_page: int,
offset: int,
user_id: str | None = None,
status: str | None = None,
from_date=None,
to_date=None,
) -> tuple[list[dict], int]:
conditions: list[str] = []
params: list = []
if user_id:
params.append(user_id)
conditions.append(f"b.user_id = ${len(params)}")
if status:
params.append(status)
conditions.append(f"b.status = ${len(params)}")
if from_date:
params.append(from_date)
conditions.append(f"ts.date >= ${len(params)}")
if to_date:
params.append(to_date)
conditions.append(f"ts.date <= ${len(params)}")
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
total = await db.fetchval(
f"SELECT COUNT(*) FROM bookings b JOIN time_slots ts ON ts.id = b.slot_id {where}",
*params,
)
params.extend([per_page, offset])
rows = await db.fetch(
f"""
SELECT b.*,
ts.date AS slot_date,
ts.start_time AS slot_start,
ts.end_time AS slot_end,
p.full_name AS profile_name,
p.email AS profile_email,
p.phone AS profile_phone,
s.name AS service_name
FROM bookings b
JOIN time_slots ts ON ts.id = b.slot_id
LEFT JOIN profiles p ON p.id = b.user_id
LEFT JOIN services s ON s.id = b.service_id
{where}
ORDER BY ts.date DESC, ts.start_time DESC
LIMIT ${len(params) - 1} OFFSET ${len(params)}
""",
*params,
)
return [_shape(r) for r in rows], total
async def cancel_booking(db: asyncpg.Connection, booking_id: str, user_id: str):
booking = await db.fetchrow(
"SELECT * FROM bookings WHERE id = $1 AND user_id = $2",
booking_id, user_id,
)
if not booking:
raise NotFoundError("booking")
if booking["status"] not in ("pending", "confirmed"):
raise AppError("CANNOT_CANCEL", "Only pending or confirmed bookings can be cancelled", 400)
await db.execute(
"UPDATE bookings SET status = 'cancelled', updated_at = now() WHERE id = $1",
booking_id,
)
if booking["stripe_payment_intent_id"] and booking["status"] == "confirmed":
await stripe_service.create_refund(booking["stripe_payment_intent_id"])
async def admin_update_booking(
db: asyncpg.Connection,
booking_id: str,
status: str,
admin_notes: str | None,
actor_id: str,
) -> dict:
row = await db.fetchrow(
"""
UPDATE bookings SET status = $2, admin_notes = COALESCE($3, admin_notes), updated_at = now()
WHERE id = $1 RETURNING *
""",
booking_id, status, admin_notes,
)
if not row:
raise NotFoundError("booking")
booking = dict(row)
email = booking.get("guest_email")
if not email and booking.get("user_id"):
profile = await db.fetchrow("SELECT email FROM profiles WHERE id = $1", str(booking["user_id"]))
if profile:
email = profile["email"]
if email:
slot = await db.fetchrow("SELECT date, start_time FROM time_slots WHERE id = $1", str(booking["slot_id"]))
if slot:
from app.services import email_service
if status == "confirmed":
await email_service.send_booking_confirmed(email, str(slot["date"]), _fmt_time(slot["start_time"]))
elif status == "cancelled":
await email_service.send_booking_cancelled(email, str(slot["date"]), _fmt_time(slot["start_time"]))
await _log(db, actor_id, f"booking.{status}", "booking", booking_id)
return await get_booking(db, booking_id)
async def admin_delete_booking(db: asyncpg.Connection, booking_id: str):
result = await db.execute("DELETE FROM bookings WHERE id = $1", booking_id)
if result == "DELETE 0":
raise NotFoundError("booking")
async def handle_payment_succeeded(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
booking = await db.fetchrow(
"SELECT * FROM bookings WHERE id = $1 AND stripe_payment_intent_id = $2",
entity_id, payment_intent_id,
)
if not booking:
return
await db.execute(
"UPDATE bookings SET status = 'confirmed', updated_at = now() WHERE id = $1",
entity_id,
)
email = booking["guest_email"]
if not email and booking["user_id"]:
profile = await db.fetchrow("SELECT email FROM profiles WHERE id = $1", str(booking["user_id"]))
if profile:
email = profile["email"]
slot = await db.fetchrow("SELECT date, start_time FROM time_slots WHERE id = $1", str(booking["slot_id"]))
if email and slot:
from app.services.email_service import send_booking_confirmed
await send_booking_confirmed(email, str(slot["date"]), _fmt_time(slot["start_time"]))
async def handle_payment_failed(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
await db.execute(
"UPDATE bookings SET status = 'cancelled', updated_at = now() WHERE id = $1 AND status = 'pending'",
entity_id,
)
async def _get_booking_price(db: asyncpg.Connection) -> float:
val = await db.fetchval("SELECT value FROM store_settings WHERE key = 'default_booking_price'")
if val is not None:
try:
return float(val)
except (TypeError, ValueError):
pass
return 0.0
def _shape(r) -> dict:
d = dict(r)
d["slot_start"] = _fmt_time(d.get("slot_start"))
d["slot_end"] = _fmt_time(d.get("slot_end"))
# Resolve client identity: profile takes priority over guest fields
d["client_name"] = d.pop("profile_name", None) or d.get("guest_name")
d["client_email"] = d.pop("profile_email", None) or d.get("guest_email")
d["client_phone"] = d.pop("profile_phone", None) or d.get("guest_phone")
# Clean up internal fields
for f in ("guest_name", "guest_email", "guest_phone", "service_name"):
d.pop(f, None)
return d
def _fmt_time(t) -> str:
if t is None:
return ""
s = str(t)
# "HH:MM:SS" → "HH:MM"
return s[:5] if len(s) >= 5 else s
async def _log(db, actor_id, action, entity_type, entity_id, metadata=None):
await db.execute(
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, $3, $4, $5)",
actor_id, action, entity_type, entity_id, metadata,
)

View File

@@ -0,0 +1,71 @@
import aiosmtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from app.config import get_settings
async def send_email(to: str, subject: str, html_body: str, text_body: str | None = None):
settings = get_settings()
if not settings.SMTP_HOST:
return # Email non configuré, on ignore silencieusement
message = MIMEMultipart("alternative")
message["Subject"] = subject
message["From"] = settings.EMAIL_FROM
message["To"] = to
if text_body:
message.attach(MIMEText(text_body, "plain"))
message.attach(MIMEText(html_body, "html"))
try:
async with aiosmtplib.SMTP(
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
start_tls=True,
) as smtp:
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
await smtp.send_message(message)
except Exception:
pass # Ne jamais laisser un échec d'email planter la requête
async def send_booking_confirmed(to: str, booking_date: str, booking_time: str):
settings = get_settings()
html = f"""
<h2>Votre rendez-vous est confirmé !</h2>
<p>Bonjour,</p>
<p>Votre rendez-vous chez <strong>{settings.BUSINESS_NAME}</strong> est confirmé.</p>
<p><strong>Date :</strong> {booking_date}<br>
<strong>Heure :</strong> {booking_time}</p>
<p>Nous vous contacterons pour finaliser le paiement. À très bientôt !</p>
<p>— L'équipe {settings.BUSINESS_NAME}</p>
"""
await send_email(to, f"Rendez-vous confirmé {settings.BUSINESS_NAME}", html)
async def send_booking_cancelled(to: str, booking_date: str, booking_time: str):
settings = get_settings()
html = f"""
<h2>Annulation de votre rendez-vous</h2>
<p>Bonjour,</p>
<p>Votre rendez-vous du {booking_date} à {booking_time} a été annulé.</p>
<p>Pour toute question, n'hésitez pas à nous contacter.</p>
<p>— L'équipe {settings.BUSINESS_NAME}</p>
"""
await send_email(to, f"Rendez-vous annulé {settings.BUSINESS_NAME}", html)
async def send_order_confirmed(to: str, order_id: str, total: float):
settings = get_settings()
html = f"""
<h2>Commande enregistrée !</h2>
<p>Bonjour,</p>
<p>Merci pour votre commande chez <strong>{settings.BUSINESS_NAME}</strong>.</p>
<p><strong>Numéro de commande :</strong> {order_id}<br>
<strong>Total :</strong> {total:.2f} €</p>
<p>Nous vous contacterons pour confirmer les modalités de livraison et de paiement.</p>
<p>— L'équipe {settings.BUSINESS_NAME}</p>
"""
await send_email(to, f"Commande confirmée {settings.BUSINESS_NAME}", html)

View File

@@ -0,0 +1,194 @@
from uuid import UUID
import asyncpg
from app.exceptions import NotFoundError, OutOfStockError
from app.models.orders import OrderCreate
async def create_order(db: asyncpg.Connection, user_id: str, data: OrderCreate) -> dict:
async with db.transaction():
total = 0.0
line_items = []
for item in data.items:
product = await db.fetchrow(
"SELECT id, name, price, stock_quantity FROM products WHERE id = $1 AND is_hidden = false",
str(item.product_id),
)
if not product:
raise NotFoundError("product")
if product["stock_quantity"] < item.quantity:
raise OutOfStockError(product["name"])
line_items.append({
"product_id": str(item.product_id),
"product_name": product["name"],
"quantity": item.quantity,
"unit_price": float(product["price"]),
})
total += float(product["price"]) * item.quantity
await db.execute(
"UPDATE products SET stock_quantity = stock_quantity - $2 WHERE id = $1",
str(item.product_id), item.quantity,
)
total = round(total, 2)
order = await db.fetchrow(
"""
INSERT INTO orders (user_id, status, total_amount, shipping_address, notes)
VALUES ($1, 'pending', $2, $3, $4)
RETURNING *
""",
user_id, total,
data.shipping_address, data.notes,
)
order_id = str(order["id"])
for li in line_items:
await db.execute(
"""
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES ($1, $2, $3, $4)
""",
order_id, li["product_id"], li["quantity"], li["unit_price"],
)
return {"order_id": order_id, "amount": total}
async def get_order(db: asyncpg.Connection, order_id: UUID, user_id: str | None = None) -> dict:
query = "SELECT o.* FROM orders o WHERE o.id = $1"
params: list = [str(order_id)]
if user_id:
query += " AND o.user_id = $2"
params.append(user_id)
row = await db.fetchrow(query, *params)
if not row:
raise NotFoundError("order")
items = await db.fetch(
"""
SELECT oi.id, oi.product_id, p.name as product_name, oi.quantity, oi.unit_price
FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = $1
""",
str(order_id),
)
result = dict(row)
result["items"] = [dict(i) for i in items]
return result
async def list_orders(
db: asyncpg.Connection,
page: int,
per_page: int,
offset: int,
user_id: str | None = None,
status: str | None = None,
) -> tuple[list[dict], int]:
conditions: list[str] = []
params: list = []
if user_id:
params.append(user_id)
conditions.append(f"o.user_id = ${len(params)}")
if status:
params.append(status)
conditions.append(f"o.status = ${len(params)}")
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
total = await db.fetchval(f"SELECT COUNT(*) FROM orders o {where}", *params)
params.extend([per_page, offset])
rows = await db.fetch(
f"""
SELECT o.id, o.user_id, o.status, o.total_amount,
o.shipping_address, o.notes, o.created_at, o.updated_at,
p.full_name AS client_name, p.email AS client_email, p.phone AS client_phone
FROM orders o
LEFT JOIN profiles p ON p.id = o.user_id
{where}
ORDER BY o.created_at DESC
LIMIT ${len(params) - 1} OFFSET ${len(params)}
""",
*params,
)
return [dict(r) for r in rows], total
async def update_order_status(db: asyncpg.Connection, order_id: UUID, status: str, actor_id: str) -> dict:
row = await db.fetchrow(
"UPDATE orders SET status = $2, updated_at = now() WHERE id = $1 RETURNING *",
str(order_id), status,
)
if not row:
raise NotFoundError("order")
await _log(db, actor_id, "order.status_updated", "order", str(order_id), {"status": status})
return dict(row)
async def refund_order(db: asyncpg.Connection, order_id: UUID, actor_id: str, amount: float | None, reason: str | None) -> dict:
order = await db.fetchrow("SELECT * FROM orders WHERE id = $1", str(order_id))
if not order:
raise NotFoundError("order")
if not order["stripe_payment_intent_id"]:
from app.exceptions import AppError
raise AppError("NO_PAYMENT", "No payment found for this order", 400)
refund = await stripe_service.create_refund(
order["stripe_payment_intent_id"],
amount=amount,
reason=reason,
)
new_status = "refunded"
row = await db.fetchrow(
"UPDATE orders SET status = $2, stripe_refund_id = $3, updated_at = now() WHERE id = $1 RETURNING *",
str(order_id), new_status, refund.id,
)
await _log(db, actor_id, "order.refunded", "order", str(order_id), {"refund_id": refund.id})
return dict(row)
async def handle_payment_succeeded(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
row = await db.fetchrow(
"UPDATE orders SET status = 'paid', updated_at = now() WHERE id = $1 AND stripe_payment_intent_id = $2 RETURNING *",
entity_id, payment_intent_id,
)
if row:
user = await db.fetchrow("SELECT email FROM profiles WHERE id = $1", str(row["user_id"]))
if user:
from app.services.email_service import send_order_confirmed
await send_order_confirmed(user["email"], entity_id, float(row["total_amount"]))
async def handle_payment_failed(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
order = await db.fetchrow("SELECT * FROM orders WHERE id = $1", entity_id)
if not order or order["status"] != "pending":
return
await db.execute(
"UPDATE orders SET status = 'cancelled', updated_at = now() WHERE id = $1",
entity_id,
)
# Restore stock
items = await db.fetch("SELECT product_id, quantity FROM order_items WHERE order_id = $1", entity_id)
for item in items:
await db.execute(
"UPDATE products SET stock_quantity = stock_quantity + $2 WHERE id = $1",
str(item["product_id"]), item["quantity"],
)
async def _log(db, actor_id, action, entity_type, entity_id, metadata=None):
await db.execute(
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, $3, $4, $5)",
actor_id, action, entity_type, entity_id, metadata,
)

View File

@@ -0,0 +1,159 @@
import json
from uuid import UUID
import asyncpg
from app.exceptions import NotFoundError
from app.models.products import ProductCreate, ProductUpdate
async def list_products(
db: asyncpg.Connection,
page: int,
per_page: int,
offset: int,
include_hidden: bool = False,
category: str | None = None,
bestseller: bool = False,
is_new: bool = False,
search: str | None = None,
exclude_id: str | None = None,
) -> tuple[list[dict], int]:
conditions: list[str] = []
params: list = []
if not include_hidden:
conditions.append("is_hidden = false")
if category:
params.append(category)
conditions.append(f"category = ${len(params)}")
if bestseller:
conditions.append("is_bestseller = true")
if is_new:
conditions.append("is_new = true")
if search:
params.append(f"%{search}%")
conditions.append(f"(name ILIKE ${len(params)} OR description ILIKE ${len(params)})")
if exclude_id:
params.append(exclude_id)
conditions.append(f"id != ${len(params)}")
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
total = await db.fetchval(f"SELECT COUNT(*) FROM products {where}", *params)
params.extend([per_page, offset])
rows = await db.fetch(
f"""
SELECT id, name, description, price, original_price, category,
images, colors, lengths, features, stock_quantity,
is_featured, is_hidden, is_new, is_bestseller,
rating, review_count, created_at, updated_at
FROM products {where}
ORDER BY is_bestseller DESC, is_new DESC, created_at DESC
LIMIT ${len(params) - 1} OFFSET ${len(params)}
""",
*params,
)
return [_row(r) for r in rows], total
async def get_product(db: asyncpg.Connection, product_id: str) -> dict:
row = await db.fetchrow("SELECT * FROM products WHERE id = $1", product_id)
if not row:
raise NotFoundError("product")
return _row(row)
async def create_product(db: asyncpg.Connection, data: ProductCreate) -> dict:
row = await db.fetchrow(
"""
INSERT INTO products (
name, description, price, original_price, category,
colors, lengths, features, stock_quantity,
is_featured, is_hidden, is_new, is_bestseller, images
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'[]'::jsonb)
RETURNING *
""",
data.name, data.description, data.price, data.original_price, data.category,
data.colors, data.lengths, data.features,
data.stock_quantity, data.is_featured, data.is_hidden, data.is_new, data.is_bestseller,
)
return _row(row)
async def update_product(db: asyncpg.Connection, product_id: str, data: ProductUpdate) -> dict:
await get_product(db, product_id)
updates = {k: v for k, v in data.model_dump().items() if v is not None}
if not updates:
return await get_product(db, product_id)
json_fields = {"colors", "lengths", "features"}
set_parts = []
values = []
for i, (k, v) in enumerate(updates.items()):
set_parts.append(f"{k} = ${i + 2}")
values.append(v)
row = await db.fetchrow(
f"UPDATE products SET {', '.join(set_parts)}, updated_at = now() WHERE id = $1 RETURNING *",
product_id, *values,
)
return _row(row)
async def delete_product(db: asyncpg.Connection, product_id: str):
result = await db.execute("DELETE FROM products WHERE id = $1", product_id)
if result == "DELETE 0":
raise NotFoundError("product")
async def add_image(db: asyncpg.Connection, product_id: str, url: str) -> dict:
await get_product(db, product_id)
row = await db.fetchrow(
"""
UPDATE products
SET images = images || $2, updated_at = now()
WHERE id = $1
RETURNING *
""",
product_id, [url],
)
return _row(row)
async def remove_image(db: asyncpg.Connection, product_id: str, url: str) -> dict:
product = await get_product(db, product_id)
images = [img for img in product["_raw_images"] if img != url]
row = await db.fetchrow(
"UPDATE products SET images = $2, updated_at = now() WHERE id = $1 RETURNING *",
product_id, images,
)
return _row(row)
async def bulk_stock_update(db: asyncpg.Connection, updates: list[dict]):
async with db.transaction():
for u in updates:
await db.execute(
"UPDATE products SET stock_quantity = $2, updated_at = now() WHERE id = $1",
str(u["id"]), u["stock_quantity"],
)
def _row(r) -> dict:
d = dict(r)
# Decode JSONB lists
for field in ("images", "colors", "lengths", "features"):
val = d.get(field, [])
if isinstance(val, str):
val = json.loads(val)
d[field] = val if isinstance(val, list) else []
# images stored as plain URL strings; keep a raw copy for internal use
d["_raw_images"] = d["images"]
# Derive convenience `image` field (first URL for product cards)
d["image"] = d["images"][0] if d["images"] else ""
return d

View File

@@ -0,0 +1,175 @@
from datetime import date, timedelta, datetime, time
import asyncpg
import pytz
from app.config import get_settings
from app.exceptions import NotFoundError, AppError
from app.models.bookings import WeeklyScheduleCreate, SlotCreate
def _fmt_time(t) -> str:
if t is None:
return ""
s = str(t)
return s[:5]
async def get_schedule(db: asyncpg.Connection) -> list[dict]:
rows = await db.fetch("SELECT * FROM weekly_schedule WHERE is_active = true ORDER BY day_of_week, start_time")
return [dict(r) for r in rows]
async def create_schedule_entry(db: asyncpg.Connection, data: WeeklyScheduleCreate) -> dict:
row = await db.fetchrow(
"""
INSERT INTO weekly_schedule (day_of_week, start_time, end_time, slot_duration_minutes)
VALUES ($1, $2, $3, $4)
RETURNING *
""",
data.day_of_week, data.start_time, data.end_time, data.slot_duration_minutes,
)
return dict(row)
async def delete_schedule_entry(db: asyncpg.Connection, schedule_id: str):
result = await db.execute("DELETE FROM weekly_schedule WHERE id = $1", schedule_id)
if result == "DELETE 0":
raise NotFoundError("schedule entry")
async def generate_slots(db: asyncpg.Connection, from_date: date, to_date: date) -> int:
if (to_date - from_date).days > 90:
raise AppError("RANGE_TOO_LARGE", "Date range cannot exceed 90 days", 400)
schedule = await db.fetch("SELECT * FROM weekly_schedule WHERE is_active = true")
if not schedule:
raise AppError("NO_SCHEDULE", "No active weekly schedule configured", 400)
blocked = await db.fetch(
"SELECT date FROM blocked_dates WHERE date BETWEEN $1 AND $2",
from_date, to_date,
)
blocked_set = {r["date"] for r in blocked}
tz = pytz.timezone(get_settings().BUSINESS_TIMEZONE)
created = 0
current = from_date
async with db.transaction():
while current <= to_date:
if current in blocked_set:
current += timedelta(days=1)
continue
day_schedules = [s for s in schedule if s["day_of_week"] == current.weekday()]
for sched in day_schedules:
cursor = datetime.combine(current, sched["start_time"])
end_limit = datetime.combine(current, sched["end_time"])
duration = timedelta(minutes=sched["slot_duration_minutes"])
while cursor + duration <= end_limit:
slot_end = cursor + duration
exists = await db.fetchval(
"SELECT 1 FROM time_slots WHERE date = $1 AND start_time = $2",
current, cursor.time(),
)
if not exists:
await db.execute(
"INSERT INTO time_slots (date, start_time, end_time) VALUES ($1, $2, $3)",
current, cursor.time(), slot_end.time(),
)
created += 1
cursor += duration
current += timedelta(days=1)
return created
async def create_slot(db: asyncpg.Connection, data: SlotCreate) -> dict:
if data.start_time >= data.end_time:
raise AppError("INVALID_SLOT", "start_time must be before end_time", 400)
row = await db.fetchrow(
"INSERT INTO time_slots (date, start_time, end_time) VALUES ($1, $2, $3) RETURNING *",
data.date, data.start_time, data.end_time,
)
return dict(row)
async def delete_slot(db: asyncpg.Connection, slot_id: str):
booked = await db.fetchval(
"SELECT 1 FROM bookings WHERE slot_id = $1 AND status IN ('pending', 'confirmed')",
slot_id,
)
if booked:
raise AppError("SLOT_BOOKED", "Cannot delete a slot with active bookings", 409)
result = await db.execute("DELETE FROM time_slots WHERE id = $1", slot_id)
if result == "DELETE 0":
raise NotFoundError("slot")
async def update_slot(db: asyncpg.Connection, slot_id: str, is_blocked: bool, block_reason: str | None) -> dict:
row = await db.fetchrow(
"UPDATE time_slots SET is_blocked = $2, block_reason = $3 WHERE id = $1 RETURNING *",
slot_id, is_blocked, block_reason,
)
if not row:
raise NotFoundError("slot")
return dict(row)
async def list_slots(
db: asyncpg.Connection,
from_date: date,
to_date: date,
available_only: bool = False,
) -> list[dict]:
query = """
SELECT
ts.id, ts.date, ts.start_time, ts.end_time,
ts.is_blocked, ts.block_reason,
EXISTS(
SELECT 1 FROM bookings b
WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed')
) AS is_booked
FROM time_slots ts
WHERE ts.date BETWEEN $1 AND $2
"""
params: list = [from_date, to_date]
if available_only:
query += " AND ts.is_blocked = false"
query += """
AND NOT EXISTS(
SELECT 1 FROM blocked_dates bd WHERE bd.date = ts.date
)
AND NOT EXISTS(
SELECT 1 FROM bookings b WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed')
)
AND ts.date >= CURRENT_DATE
"""
query += " ORDER BY ts.date, ts.start_time"
rows = await db.fetch(query, *params)
return [dict(r) for r in rows]
async def get_blocked_dates(db: asyncpg.Connection) -> list[dict]:
rows = await db.fetch("SELECT * FROM blocked_dates ORDER BY date")
return [dict(r) for r in rows]
async def add_blocked_date(db: asyncpg.Connection, date: date, reason: str | None) -> dict:
row = await db.fetchrow(
"INSERT INTO blocked_dates (date, reason) VALUES ($1, $2) ON CONFLICT (date) DO UPDATE SET reason = $2 RETURNING *",
date, reason,
)
return dict(row)
async def remove_blocked_date(db: asyncpg.Connection, blocked_date_id: str):
result = await db.execute("DELETE FROM blocked_dates WHERE id = $1", blocked_date_id)
if result == "DELETE 0":
raise NotFoundError("blocked date")

View File

@@ -0,0 +1,36 @@
import io
import uuid
from supabase import create_client
from app.config import get_settings
BUCKET = "product-images"
def _client():
s = get_settings()
return create_client(s.SUPABASE_URL, s.SUPABASE_SERVICE_ROLE_KEY)
async def upload_product_image(product_id: str, file_bytes: bytes, content_type: str) -> dict:
ext = "jpg" if "jpeg" in content_type else content_type.split("/")[-1]
path = f"{product_id}/{uuid.uuid4()}.{ext}"
client = _client()
result = client.storage.from_(BUCKET).upload(
path,
file_bytes,
{"content-type": content_type, "upsert": False},
)
# supabase-py v2 raises StorageException on failure, but guard against
# unexpected response shapes that silently skip the upload
if hasattr(result, "error") and result.error:
raise RuntimeError(f"Storage upload failed: {result.error}")
url = client.storage.from_(BUCKET).get_public_url(path)
return {"url": url, "storage_path": path}
async def delete_product_image(storage_path: str):
_client().storage.from_(BUCKET).remove([storage_path])

View File

@@ -0,0 +1,61 @@
import asyncio
import stripe
from app.config import get_settings
from app.exceptions import PaymentError
_stripe_initialized = False
def _init_stripe():
global _stripe_initialized
if not _stripe_initialized:
stripe.api_key = get_settings().STRIPE_SECRET_KEY
_stripe_initialized = True
async def create_payment_intent(
amount: float,
metadata: dict,
description: str = "",
) -> stripe.PaymentIntent:
_init_stripe()
settings = get_settings()
try:
intent = await asyncio.to_thread(
stripe.PaymentIntent.create,
amount=int(round(amount * 100)),
currency=settings.STRIPE_CURRENCY,
metadata=metadata,
description=description,
automatic_payment_methods={"enabled": True},
)
return intent
except stripe.StripeError as e:
raise PaymentError(str(e))
async def create_refund(
payment_intent_id: str,
amount: float | None = None,
reason: str | None = None,
) -> stripe.Refund:
_init_stripe()
try:
kwargs: dict = {"payment_intent": payment_intent_id}
if amount is not None:
kwargs["amount"] = int(round(amount * 100))
if reason:
kwargs["reason"] = reason
return await asyncio.to_thread(stripe.Refund.create, **kwargs)
except stripe.StripeError as e:
raise PaymentError(str(e))
def verify_webhook(payload: bytes, sig_header: str) -> stripe.Event:
_init_stripe()
settings = get_settings()
try:
return stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_WEBHOOK_SECRET)
except stripe.SignatureVerificationError:
raise PaymentError("Invalid webhook signature")

3
conftest.py Normal file
View File

@@ -0,0 +1,3 @@
import sys, os
# Ensure the project root is on the path so `from main import app` works
sys.path.insert(0, os.path.dirname(__file__))

117
main.py Normal file
View File

@@ -0,0 +1,117 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.config import get_settings
from app.database import get_pool, close_pool
from app.exceptions import AppError
from app.routers import auth, products, orders, bookings, payments, services, contact
from app.routers.admin import dashboard, products as admin_products, orders as admin_orders
from app.routers.admin import bookings as admin_bookings, customers, settings as admin_settings
from app.routers.admin import services as admin_services
@asynccontextmanager
async def lifespan(app: FastAPI):
await get_pool() # warm up the pool on startup
yield
await close_pool()
def create_app() -> FastAPI:
cfg = get_settings()
app = FastAPI(
title="Bado Hair API",
version="1.0.0",
docs_url="/docs" if not cfg.is_production else None,
redoc_url="/redoc" if not cfg.is_production else None,
lifespan=lifespan,
)
# ── CORS ──────────────────────────────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=cfg.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Error handlers ────────────────────────────────────────────────────────
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(
status_code=exc.status_code,
content={
"success": False,
"error": {
"code": exc.code,
"message": exc.message,
"details": exc.details,
},
},
)
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"success": False,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": exc.errors(),
},
},
)
@app.exception_handler(Exception)
async def unhandled_error_handler(request: Request, exc: Exception):
import traceback
if not cfg.is_production:
traceback.print_exc()
return JSONResponse(
status_code=500,
content={
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred",
"details": None,
},
},
)
# ── Customer routes (/api/v1) ─────────────────────────────────────────────
v1 = "/api/v1"
app.include_router(auth.router, prefix=v1)
app.include_router(products.router, prefix=v1)
app.include_router(orders.router, prefix=v1)
app.include_router(bookings.router, prefix=v1)
app.include_router(payments.router, prefix=v1)
app.include_router(services.router, prefix=v1)
app.include_router(contact.router, prefix=v1)
# ── Admin routes (/api/v1/admin) ──────────────────────────────────────────
adm = f"{v1}/admin"
app.include_router(dashboard.router, prefix=adm)
app.include_router(admin_products.router, prefix=adm)
app.include_router(admin_orders.router, prefix=adm)
app.include_router(admin_bookings.router, prefix=adm)
app.include_router(customers.router, prefix=adm)
app.include_router(admin_services.router, prefix=adm)
app.include_router(admin_settings.router, prefix=adm)
@app.get("/health")
async def health():
return {"status": "ok"}
return app
app = create_app()

44
pyproject.toml Normal file
View File

@@ -0,0 +1,44 @@
[project]
name = "badohair-be"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.136.1",
"uvicorn[standard]>=0.46.0",
"pydantic[email]>=2.9.0",
"pydantic-settings>=2.5.0",
"asyncpg>=0.30.0",
"supabase>=2.15.0",
"stripe>=12.0.0",
"PyJWT>=2.9.0",
"cryptography>=42.0.0",
"python-multipart>=0.0.18",
"aiosmtplib>=3.0.0",
"jinja2>=3.1.0",
"python-dateutil>=2.9.0",
"pytz>=2024.2",
"httpx>=0.28.0",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
test = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=5.0.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.coverage.run]
source = ["app"]
omit = ["app/services/email_service.py"]
[dependency-groups]
dev = [
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.1.0",
]

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

202
tests/conftest.py Normal file
View File

@@ -0,0 +1,202 @@
"""
Shared fixtures for all tests.
Strategy:
- Override `get_db` with an async generator that yields a mock asyncpg connection.
- Override `get_current_user` / `require_admin` directly with plain async functions.
- Patch `app.database.get_pool` so the lifespan startup never touches a real DB.
- Patch `app.services.stripe_service` globally so no real Stripe calls are made.
"""
import asyncio
from datetime import datetime, date, time
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
from app.dependencies import get_db, get_current_user, get_current_user_optional, require_admin
# ── Sample data ───────────────────────────────────────────────────────────────
USER_ID = "11111111-1111-1111-1111-111111111111"
ADMIN_ID = "22222222-2222-2222-2222-222222222222"
PRODUCT_ID = "33333333-3333-3333-3333-333333333333"
SLOT_ID = "44444444-4444-4444-4444-444444444444"
BOOKING_ID = "55555555-5555-5555-5555-555555555555"
ORDER_ID = "66666666-6666-6666-6666-666666666666"
SAMPLE_USER = {
"id": USER_ID,
"email": "user@test.com",
"full_name": "Test User",
"phone": "+49123456789",
"role": "client",
"is_blocked": False,
}
SAMPLE_ADMIN = {
"id": ADMIN_ID,
"email": "admin@test.com",
"full_name": "Admin User",
"phone": None,
"role": "admin",
"is_blocked": False,
}
SAMPLE_PRODUCT = {
"id": UUID(PRODUCT_ID),
"name": "Extensions Clip-In Luxe",
"description": "Top quality",
"price": 189.0,
"original_price": 229.0,
"category": "clip-in",
"images": ["https://example.com/img.jpg"],
"_raw_images": ["https://example.com/img.jpg"],
"image": "https://example.com/img.jpg",
"colors": ["Black", "Brown"],
"lengths": ["40cm", "50cm"],
"features": ["100% Remy"],
"stock_quantity": 10,
"is_featured": False,
"is_hidden": False,
"is_new": True,
"is_bestseller": True,
"rating": 4.8,
"review_count": 124,
"created_at": datetime(2026, 1, 1),
"updated_at": datetime(2026, 1, 1),
}
SAMPLE_SLOT = {
"id": UUID(SLOT_ID),
"date": date(2026, 6, 1),
"start_time": time(10, 0),
"end_time": time(11, 0),
"is_blocked": False,
"block_reason": None,
"is_booked": False,
}
SAMPLE_BOOKING = {
"id": UUID(BOOKING_ID),
"user_id": UUID(USER_ID),
"slot_id": UUID(SLOT_ID),
"slot_date": date(2026, 6, 1),
"slot_start": "10:00",
"slot_end": "11:00",
"service_note": "Box braids",
"client_name": "Test User",
"client_email": "user@test.com",
"client_phone": "+49123456789",
"status": "confirmed",
"amount_paid": 50.0,
"stripe_payment_intent_id": "pi_test123",
"admin_notes": None,
"created_at": datetime(2026, 1, 1),
"updated_at": datetime(2026, 1, 1),
}
SAMPLE_SERVICE = {
"id": UUID("77777777-7777-7777-7777-777777777777"),
"name": "Pose complète",
"description": "Full extension installation",
"duration_minutes": 150,
"price": 150.0,
}
# ── Mock asyncpg transaction context manager ──────────────────────────────────
class MockTransaction:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
# ── DB mock factory ───────────────────────────────────────────────────────────
def make_mock_db() -> AsyncMock:
db = AsyncMock()
db.fetchrow = AsyncMock(return_value=None)
db.fetch = AsyncMock(return_value=[])
db.fetchval = AsyncMock(return_value=0)
db.execute = AsyncMock(return_value="INSERT 1")
db.transaction = MagicMock(return_value=MockTransaction())
return db
def db_override(mock_conn):
async def _override():
yield mock_conn
return _override
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def mock_db():
return make_mock_db()
@pytest.fixture(autouse=True)
def patch_pool():
"""Prevent lifespan from opening a real DB pool."""
with patch("app.database.get_pool", new_callable=AsyncMock) as m:
m.return_value = AsyncMock()
yield m
@pytest.fixture(autouse=True)
def patch_stripe():
"""Prevent any real Stripe calls."""
intent = MagicMock()
intent.id = "pi_test123"
intent.client_secret = "pi_test123_secret"
with patch("app.services.stripe_service.create_payment_intent", new_callable=AsyncMock, return_value=intent):
with patch("app.services.stripe_service.create_refund", new_callable=AsyncMock):
with patch("app.services.stripe_service.verify_webhook") as mock_wh:
mock_wh.return_value = MagicMock(
type="payment_intent.succeeded",
get=MagicMock(return_value={
"object": {"id": "pi_test123", "metadata": {"entity_type": "order", "entity_id": ORDER_ID}}
}),
)
yield
@pytest.fixture(autouse=True)
def patch_stripe_modify():
"""Patch the inline stripe.PaymentIntent.modify call in booking_service."""
with patch("stripe.PaymentIntent.modify", return_value=MagicMock()):
yield
@pytest.fixture
async def anon_client(mock_db):
app.dependency_overrides[get_db] = db_override(mock_db)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture
async def auth_client(mock_db):
app.dependency_overrides[get_db] = db_override(mock_db)
app.dependency_overrides[get_current_user] = lambda: SAMPLE_USER
app.dependency_overrides[get_current_user_optional] = lambda: SAMPLE_USER
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture
async def admin_client(mock_db):
app.dependency_overrides[get_db] = db_override(mock_db)
app.dependency_overrides[get_current_user] = lambda: SAMPLE_ADMIN
app.dependency_overrides[require_admin] = lambda: SAMPLE_ADMIN
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()

View File

@@ -0,0 +1,57 @@
"""Admin dashboard stats — verify all fields the frontend needs are present."""
from unittest.mock import AsyncMock
async def test_overview_returns_all_frontend_fields(admin_client, mock_db):
mock_db.fetchval = AsyncMock(return_value=0)
r = await admin_client.get("/api/v1/admin/stats/overview")
assert r.status_code == 200
data = r.json()["data"]
# Revenue (extra, for future admin analytics)
assert "revenue_today" in data
assert "revenue_week" in data
assert "revenue_month" in data
# Fields the frontend dashboard currently displays
assert "products_count" in data
assert "bookings_pending" in data
assert "bookings_confirmed" in data
assert "catalog_value" in data
# Bonus fields
assert "low_stock_count" in data
assert "new_customers_month" in data
async def test_overview_requires_admin(auth_client, mock_db):
mock_db.fetchval = AsyncMock(return_value=0)
r = await auth_client.get("/api/v1/admin/stats/overview")
assert r.status_code == 403
async def test_overview_requires_auth(anon_client):
r = await anon_client.get("/api/v1/admin/stats/overview")
assert r.status_code == 401
async def test_revenue_stats(admin_client, mock_db):
mock_db.fetch = AsyncMock(return_value=[])
r = await admin_client.get("/api/v1/admin/stats/revenue?period=month")
assert r.status_code == 200
data = r.json()["data"]
assert "orders" in data
assert "bookings" in data
async def test_revenue_invalid_period(admin_client):
r = await admin_client.get("/api/v1/admin/stats/revenue?period=decade")
assert r.status_code == 422
async def test_activity_feed(admin_client, mock_db):
mock_db.fetch = AsyncMock(return_value=[])
r = await admin_client.get("/api/v1/admin/stats/activity")
assert r.status_code == 200
assert isinstance(r.json()["data"], list)

113
tests/test_auth.py Normal file
View File

@@ -0,0 +1,113 @@
"""Auth endpoint tests."""
from unittest.mock import AsyncMock, MagicMock, patch
from tests.conftest import SAMPLE_USER
def _supabase_session(access_token="tok_access", refresh_token="tok_refresh"):
session = MagicMock()
session.access_token = access_token
session.refresh_token = refresh_token
session.expires_in = 3600
user = MagicMock()
user.id = "11111111-1111-1111-1111-111111111111"
result = MagicMock()
result.session = session
result.user = user
return result
async def test_login_success(anon_client):
with patch("app.services.auth_service._client") as mock_client:
mock_client.return_value.auth.sign_in_with_password.return_value = _supabase_session()
r = await anon_client.post("/api/v1/auth/login", json={"email": "user@test.com", "password": "password123"})
assert r.status_code == 200
body = r.json()
assert body["success"] is True
assert "access_token" in body["data"]
assert body["data"]["token_type"] == "bearer"
async def test_login_invalid_credentials(anon_client):
with patch("app.services.auth_service._client") as mock_client:
mock_client.return_value.auth.sign_in_with_password.side_effect = Exception("Invalid login")
r = await anon_client.post("/api/v1/auth/login", json={"email": "wrong@test.com", "password": "bad"})
assert r.status_code == 401
body = r.json()
assert body["success"] is False
assert body["error"]["code"] == "UNAUTHORIZED"
async def test_register_with_name_field(anon_client, mock_db):
"""Frontend sends `name`, not `full_name` — both must be accepted."""
mock_db.execute = AsyncMock(return_value="INSERT 1")
with patch("app.services.auth_service._client") as mock_client:
mock_client.return_value.auth.sign_up.return_value = _supabase_session()
r = await anon_client.post("/api/v1/auth/register", json={
"email": "new@test.com",
"password": "password123",
"name": "Jane Doe",
})
assert r.status_code == 201
assert r.json()["success"] is True
async def test_register_with_full_name_field(anon_client, mock_db):
"""full_name field also accepted."""
mock_db.execute = AsyncMock(return_value="INSERT 1")
with patch("app.services.auth_service._client") as mock_client:
mock_client.return_value.auth.sign_up.return_value = _supabase_session()
r = await anon_client.post("/api/v1/auth/register", json={
"email": "new2@test.com",
"password": "password123",
"full_name": "Jane Doe",
})
assert r.status_code == 201
async def test_register_password_too_short(anon_client):
r = await anon_client.post("/api/v1/auth/register", json={
"email": "new@test.com",
"password": "short",
"name": "Jane",
})
assert r.status_code == 422
body = r.json()
assert body["success"] is False
assert body["error"]["code"] == "VALIDATION_ERROR"
async def test_get_me(auth_client):
r = await auth_client.get("/api/v1/auth/me")
assert r.status_code == 200
data = r.json()["data"]
assert data["email"] == SAMPLE_USER["email"]
assert data["role"] == "client"
async def test_get_me_unauthenticated(anon_client):
r = await anon_client.get("/api/v1/auth/me")
assert r.status_code == 401
async def test_forgot_password_always_200(anon_client):
"""Never reveals whether email exists."""
with patch("app.services.auth_service._client"):
r = await anon_client.post("/api/v1/auth/forgot-password", json={"email": "anyone@test.com"})
assert r.status_code == 200
assert r.json()["success"] is True
async def test_update_profile(auth_client, mock_db):
from tests.conftest import SAMPLE_USER
updated = dict(SAMPLE_USER, full_name="Updated Name")
mock_db.fetchrow = AsyncMock(return_value=updated)
r = await auth_client.patch("/api/v1/auth/me", json={"full_name": "Updated Name"})
assert r.status_code == 200
assert r.json()["data"]["full_name"] == "Updated Name"

190
tests/test_bookings.py Normal file
View File

@@ -0,0 +1,190 @@
"""Booking endpoint tests — guest booking, auth booking, slots, admin actions."""
from datetime import date, time
from unittest.mock import AsyncMock
from tests.conftest import SAMPLE_BOOKING, SAMPLE_SLOT, SLOT_ID, BOOKING_ID, USER_ID
# ── Available slots ───────────────────────────────────────────────────────────
async def test_get_available_slots(anon_client, mock_db):
mock_db.fetch = AsyncMock(return_value=[SAMPLE_SLOT])
r = await anon_client.get("/api/v1/bookings/slots?from_date=2026-06-01&to_date=2026-06-07")
assert r.status_code == 200
body = r.json()
assert body["success"] is True
assert isinstance(body["data"], list)
async def test_get_slots_missing_date_param(anon_client):
r = await anon_client.get("/api/v1/bookings/slots?from_date=2026-06-01")
assert r.status_code == 422
# ── Guest booking ─────────────────────────────────────────────────────────────
async def test_guest_booking_success(anon_client, mock_db):
mock_db.fetchrow = AsyncMock(side_effect=[
SAMPLE_SLOT, # slot availability check
SAMPLE_BOOKING, # INSERT booking
])
mock_db.fetchval = AsyncMock(return_value=50.0) # default booking price
r = await anon_client.post("/api/v1/bookings", json={
"slot_id": SLOT_ID,
"service_note": "Box braids",
"guest_name": "Marie Dupont",
"guest_email": "marie@test.com",
"guest_phone": "+49123456789",
})
assert r.status_code == 201
body = r.json()
assert body["success"] is True
assert "client_secret" in body["data"]
assert "booking_id" in body["data"]
async def test_guest_booking_missing_email_rejected(anon_client, mock_db):
"""Unauthenticated booking without guest_email must fail with 422."""
r = await anon_client.post("/api/v1/bookings", json={
"slot_id": SLOT_ID,
"guest_name": "Marie",
# guest_email missing
})
assert r.status_code == 422
async def test_guest_booking_slot_unavailable(anon_client, mock_db):
mock_db.fetchrow = AsyncMock(return_value=None) # slot check returns nothing → unavailable
r = await anon_client.post("/api/v1/bookings", json={
"slot_id": SLOT_ID,
"guest_name": "Marie",
"guest_email": "marie@test.com",
})
assert r.status_code == 409
assert r.json()["error"]["code"] == "SLOT_UNAVAILABLE"
# ── Authenticated booking ─────────────────────────────────────────────────────
async def test_auth_booking_success(auth_client, mock_db):
mock_db.fetchrow = AsyncMock(side_effect=[
SAMPLE_SLOT,
SAMPLE_BOOKING,
])
mock_db.fetchval = AsyncMock(return_value=50.0)
r = await auth_client.post("/api/v1/bookings", json={
"slot_id": SLOT_ID,
"service_note": "Tape-in maintenance",
})
assert r.status_code == 201
assert r.json()["success"] is True
# ── List / get my bookings ────────────────────────────────────────────────────
async def test_list_my_bookings(auth_client, mock_db):
mock_db.fetchval = AsyncMock(return_value=1)
mock_db.fetch = AsyncMock(return_value=[SAMPLE_BOOKING])
r = await auth_client.get("/api/v1/bookings")
assert r.status_code == 200
body = r.json()
assert body["meta"]["total"] == 1
assert body["data"][0]["status"] == "confirmed"
async def test_get_booking_by_id(auth_client, mock_db):
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_BOOKING)
r = await auth_client.get(f"/api/v1/bookings/{BOOKING_ID}")
assert r.status_code == 200
assert r.json()["data"]["id"] == BOOKING_ID
async def test_get_booking_not_found(auth_client, mock_db):
mock_db.fetchrow = AsyncMock(return_value=None)
r = await auth_client.get(f"/api/v1/bookings/00000000-0000-0000-0000-000000000000")
assert r.status_code == 404
async def test_list_bookings_requires_auth(anon_client):
r = await anon_client.get("/api/v1/bookings")
assert r.status_code == 401
# ── Cancel booking ────────────────────────────────────────────────────────────
async def test_cancel_booking_success(auth_client, mock_db):
pending_booking = {**SAMPLE_BOOKING, "status": "pending", "user_id": USER_ID}
mock_db.fetchrow = AsyncMock(return_value=pending_booking)
mock_db.execute = AsyncMock(return_value="UPDATE 1")
r = await auth_client.delete(f"/api/v1/bookings/{BOOKING_ID}")
assert r.status_code == 204
async def test_cancel_already_cancelled_booking(auth_client, mock_db):
cancelled = {**SAMPLE_BOOKING, "status": "cancelled", "user_id": USER_ID}
mock_db.fetchrow = AsyncMock(return_value=cancelled)
r = await auth_client.delete(f"/api/v1/bookings/{BOOKING_ID}")
assert r.status_code == 400
assert r.json()["error"]["code"] == "CANNOT_CANCEL"
async def test_cancel_booking_not_found(auth_client, mock_db):
mock_db.fetchrow = AsyncMock(return_value=None)
r = await auth_client.delete(f"/api/v1/bookings/00000000-0000-0000-0000-000000000000")
assert r.status_code == 404
# ── Admin booking management ──────────────────────────────────────────────────
async def test_admin_list_bookings(admin_client, mock_db):
mock_db.fetchval = AsyncMock(return_value=1)
mock_db.fetch = AsyncMock(return_value=[SAMPLE_BOOKING])
r = await admin_client.get("/api/v1/admin/bookings")
assert r.status_code == 200
body = r.json()
assert body["meta"]["total"] == 1
# Verify admin response includes resolved client info
record = body["data"][0]
assert "client_name" in record
assert "client_email" in record
assert "slot_date" in record
assert "slot_start" in record
async def test_admin_confirm_booking(admin_client, mock_db):
mock_db.fetchrow = AsyncMock(side_effect=[
SAMPLE_BOOKING, # UPDATE bookings RETURNING *
None, # SELECT email FROM profiles (skip email sending)
SAMPLE_BOOKING, # get_booking JOIN query at the end
])
mock_db.execute = AsyncMock(return_value="INSERT 1") # activity_log
r = await admin_client.patch(f"/api/v1/admin/bookings/{BOOKING_ID}", json={"status": "confirmed"})
assert r.status_code == 200
async def test_admin_delete_booking(admin_client, mock_db):
mock_db.execute = AsyncMock(return_value="DELETE 1")
r = await admin_client.delete(f"/api/v1/admin/bookings/{BOOKING_ID}")
assert r.status_code == 204
async def test_admin_delete_booking_not_found(admin_client, mock_db):
mock_db.execute = AsyncMock(return_value="DELETE 0")
r = await admin_client.delete(f"/api/v1/admin/bookings/00000000-0000-0000-0000-000000000000")
assert r.status_code == 404
async def test_admin_bookings_list_filter_by_status(admin_client, mock_db):
mock_db.fetchval = AsyncMock(return_value=0)
mock_db.fetch = AsyncMock(return_value=[])
r = await admin_client.get("/api/v1/admin/bookings?status=pending")
assert r.status_code == 200

View File

@@ -0,0 +1,59 @@
"""Verify the error envelope is consistent across all error types."""
async def test_404_uses_error_envelope(anon_client, mock_db):
from unittest.mock import AsyncMock
mock_db.fetchrow = AsyncMock(return_value=None)
r = await anon_client.get("/api/v1/products/00000000-0000-0000-0000-000000000000")
assert r.status_code == 404
body = r.json()
assert body["success"] is False
assert "code" in body["error"]
assert "message" in body["error"]
assert "data" not in body or body.get("data") is None
async def test_422_uses_error_envelope(auth_client):
r = await auth_client.post("/api/v1/orders", json={"items": []})
assert r.status_code == 422
body = r.json()
assert body["success"] is False
assert body["error"]["code"] == "VALIDATION_ERROR"
assert isinstance(body["error"]["details"], list)
async def test_401_uses_error_envelope(anon_client):
from unittest.mock import patch
from app.exceptions import UnauthorizedError
# Provide a bad token
r = await anon_client.get("/api/v1/auth/me", headers={"Authorization": "Bearer invalid.token.here"})
assert r.status_code == 401
body = r.json()
assert body["success"] is False
assert body["error"]["code"] == "UNAUTHORIZED"
async def test_403_uses_error_envelope(auth_client):
r = await auth_client.get("/api/v1/admin/products")
assert r.status_code == 403
body = r.json()
assert body["success"] is False
assert body["error"]["code"] == "FORBIDDEN"
async def test_409_out_of_stock_uses_error_envelope(auth_client, mock_db):
from unittest.mock import AsyncMock
mock_db.fetchrow = AsyncMock(return_value={
"id": "33333333-3333-3333-3333-333333333333",
"name": "Test Product",
"price": 100.0,
"stock_quantity": 0,
})
r = await auth_client.post("/api/v1/orders", json={
"items": [{"product_id": "33333333-3333-3333-3333-333333333333", "quantity": 1}]
})
assert r.status_code == 409
body = r.json()
assert body["success"] is False
assert body["error"]["code"] == "OUT_OF_STOCK"

4
tests/test_health.py Normal file
View File

@@ -0,0 +1,4 @@
async def test_health(anon_client):
r = await anon_client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"

123
tests/test_orders.py Normal file
View File

@@ -0,0 +1,123 @@
"""Order creation, stock deduction, and webhook handling."""
from unittest.mock import AsyncMock
from tests.conftest import PRODUCT_ID, ORDER_ID
SAMPLE_PRODUCT_ROW = {
"id": PRODUCT_ID,
"name": "Extensions Clip-In Luxe",
"price": 189.0,
"stock_quantity": 10,
}
SAMPLE_ORDER_ROW = {
"id": ORDER_ID,
"user_id": "11111111-1111-1111-1111-111111111111",
"status": "pending",
"total_amount": 189.0,
"stripe_payment_intent_id": "pi_test123",
"shipping_address": None,
"notes": None,
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00",
}
async def test_create_order_success(auth_client, mock_db):
mock_db.fetchrow = AsyncMock(side_effect=[
SAMPLE_PRODUCT_ROW, # product lookup
{"id": ORDER_ID, **SAMPLE_ORDER_ROW}, # INSERT order
])
mock_db.execute = AsyncMock(return_value="UPDATE 1")
r = await auth_client.post("/api/v1/orders", json={
"items": [{"product_id": PRODUCT_ID, "quantity": 1}],
})
assert r.status_code == 201
body = r.json()
assert body["success"] is True
assert "client_secret" in body["data"]
assert "order_id" in body["data"]
assert body["data"]["amount"] == 189.0
async def test_create_order_requires_auth(anon_client):
r = await anon_client.post("/api/v1/orders", json={
"items": [{"product_id": PRODUCT_ID, "quantity": 1}],
})
assert r.status_code == 401
async def test_create_order_empty_items_rejected(auth_client):
r = await auth_client.post("/api/v1/orders", json={"items": []})
assert r.status_code == 422
async def test_create_order_product_not_found(auth_client, mock_db):
mock_db.fetchrow = AsyncMock(return_value=None)
r = await auth_client.post("/api/v1/orders", json={
"items": [{"product_id": "00000000-0000-0000-0000-000000000000", "quantity": 1}],
})
assert r.status_code == 404
assert r.json()["error"]["code"] == "PRODUCT_NOT_FOUND"
async def test_create_order_out_of_stock(auth_client, mock_db):
low_stock = {**SAMPLE_PRODUCT_ROW, "stock_quantity": 0}
mock_db.fetchrow = AsyncMock(return_value=low_stock)
r = await auth_client.post("/api/v1/orders", json={
"items": [{"product_id": PRODUCT_ID, "quantity": 1}],
})
assert r.status_code == 409
assert r.json()["error"]["code"] == "OUT_OF_STOCK"
async def test_list_my_orders(auth_client, mock_db):
mock_db.fetchval = AsyncMock(return_value=1)
mock_db.fetch = AsyncMock(return_value=[SAMPLE_ORDER_ROW])
r = await auth_client.get("/api/v1/orders")
assert r.status_code == 200
body = r.json()
assert body["meta"]["total"] == 1
async def test_get_order_by_id(auth_client, mock_db):
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_ORDER_ROW)
mock_db.fetch = AsyncMock(return_value=[]) # order items
r = await auth_client.get(f"/api/v1/orders/{ORDER_ID}")
assert r.status_code == 200
async def test_get_order_not_found(auth_client, mock_db):
mock_db.fetchrow = AsyncMock(return_value=None)
r = await auth_client.get(f"/api/v1/orders/00000000-0000-0000-0000-000000000000")
assert r.status_code == 404
# ── Admin order management ────────────────────────────────────────────────────
async def test_admin_list_orders(admin_client, mock_db):
mock_db.fetchval = AsyncMock(return_value=1)
mock_db.fetch = AsyncMock(return_value=[SAMPLE_ORDER_ROW])
r = await admin_client.get("/api/v1/admin/orders")
assert r.status_code == 200
async def test_admin_update_order_status(admin_client, mock_db):
updated = {**SAMPLE_ORDER_ROW, "status": "shipped"}
mock_db.fetchrow = AsyncMock(return_value=updated)
mock_db.execute = AsyncMock(return_value="INSERT 1")
r = await admin_client.patch(f"/api/v1/admin/orders/{ORDER_ID}/status", json={"status": "shipped"})
assert r.status_code == 200
assert r.json()["data"]["status"] == "shipped"
async def test_admin_update_order_invalid_status(admin_client):
r = await admin_client.patch(f"/api/v1/admin/orders/{ORDER_ID}/status", json={"status": "exploded"})
assert r.status_code == 422

154
tests/test_products.py Normal file
View File

@@ -0,0 +1,154 @@
"""Product endpoint tests — both public and admin."""
from unittest.mock import AsyncMock, patch
from tests.conftest import SAMPLE_PRODUCT, PRODUCT_ID
# ── Public endpoints ──────────────────────────────────────────────────────────
async def test_list_products_returns_paginated(anon_client, mock_db):
mock_db.fetchval = AsyncMock(return_value=1)
mock_db.fetch = AsyncMock(return_value=[SAMPLE_PRODUCT])
r = await anon_client.get("/api/v1/products")
assert r.status_code == 200
body = r.json()
assert body["success"] is True
assert isinstance(body["data"], list)
assert body["meta"]["total"] == 1
assert body["data"][0]["name"] == "Extensions Clip-In Luxe"
async def test_list_products_category_filter(anon_client, mock_db):
mock_db.fetchval = AsyncMock(return_value=0)
mock_db.fetch = AsyncMock(return_value=[])
r = await anon_client.get("/api/v1/products?category=tape-in")
assert r.status_code == 200
assert r.json()["meta"]["total"] == 0
async def test_list_products_bestseller_filter(anon_client, mock_db):
mock_db.fetchval = AsyncMock(return_value=1)
mock_db.fetch = AsyncMock(return_value=[SAMPLE_PRODUCT])
r = await anon_client.get("/api/v1/products?bestseller=true")
assert r.status_code == 200
assert r.json()["data"][0]["is_bestseller"] is True
async def test_list_products_exclude_param(anon_client, mock_db):
"""Used to fetch similar products on the product detail page."""
mock_db.fetchval = AsyncMock(return_value=0)
mock_db.fetch = AsyncMock(return_value=[])
r = await anon_client.get(f"/api/v1/products?category=clip-in&exclude={PRODUCT_ID}")
assert r.status_code == 200
async def test_get_product_found(anon_client, mock_db):
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_PRODUCT)
r = await anon_client.get(f"/api/v1/products/{PRODUCT_ID}")
assert r.status_code == 200
data = r.json()["data"]
assert data["id"] == PRODUCT_ID
assert data["category"] == "clip-in"
assert isinstance(data["colors"], list)
assert isinstance(data["images"], list)
assert isinstance(data["image"], str)
async def test_get_product_not_found(anon_client, mock_db):
mock_db.fetchrow = AsyncMock(return_value=None)
r = await anon_client.get(f"/api/v1/products/00000000-0000-0000-0000-000000000000")
assert r.status_code == 404
body = r.json()
assert body["success"] is False
assert body["error"]["code"] == "PRODUCT_NOT_FOUND"
async def test_product_response_has_required_frontend_fields(anon_client, mock_db):
"""Verify every field the frontend Product interface needs is present."""
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_PRODUCT)
r = await anon_client.get(f"/api/v1/products/{PRODUCT_ID}")
data = r.json()["data"]
required = ["id", "name", "category", "price", "originalPrice", "image", "images",
"colors", "lengths", "description", "features", "isNew", "isBestseller",
"rating", "reviewCount"]
# Map snake_case API → camelCase frontend expectations
api_fields = set(data.keys())
# These must exist (snake_case form in our API)
for field in ["id", "name", "category", "price", "original_price", "image", "images",
"colors", "lengths", "description", "features", "is_new", "is_bestseller",
"rating", "review_count"]:
assert field in api_fields, f"Missing field: {field}"
# ── Admin product endpoints ───────────────────────────────────────────────────
async def test_admin_list_products_includes_hidden(admin_client, mock_db):
mock_db.fetchval = AsyncMock(return_value=2)
mock_db.fetch = AsyncMock(return_value=[SAMPLE_PRODUCT, {**SAMPLE_PRODUCT, "is_hidden": True}])
r = await admin_client.get("/api/v1/admin/products")
assert r.status_code == 200
assert r.json()["meta"]["total"] == 2
async def test_admin_create_product(admin_client, mock_db):
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_PRODUCT)
r = await admin_client.post("/api/v1/admin/products", json={
"name": "New Extensions",
"price": 199.0,
"category": "tape-in",
"colors": ["Blond", "Brun"],
"lengths": ["50cm"],
"features": ["100% Remy"],
"stock_quantity": 5,
})
assert r.status_code == 201
assert r.json()["success"] is True
async def test_admin_update_product(admin_client, mock_db):
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_PRODUCT)
r = await admin_client.put(f"/api/v1/admin/products/{PRODUCT_ID}", json={"price": 175.0})
assert r.status_code == 200
async def test_admin_delete_product(admin_client, mock_db):
mock_db.execute = AsyncMock(return_value="DELETE 1")
r = await admin_client.delete(f"/api/v1/admin/products/{PRODUCT_ID}")
assert r.status_code == 204
async def test_admin_delete_product_not_found(admin_client, mock_db):
mock_db.execute = AsyncMock(return_value="DELETE 0")
r = await admin_client.delete(f"/api/v1/admin/products/00000000-0000-0000-0000-000000000000")
assert r.status_code == 404
async def test_admin_bulk_stock_update(admin_client, mock_db):
r = await admin_client.post("/api/v1/admin/products/bulk-stock", json={
"updates": [{"id": PRODUCT_ID, "stock_quantity": 20}]
})
assert r.status_code == 200
assert r.json()["data"]["updated"] == 1
async def test_admin_products_requires_admin_role(auth_client):
"""A client-role user must get 403."""
r = await auth_client.get("/api/v1/admin/products")
assert r.status_code == 403
assert r.json()["error"]["code"] == "FORBIDDEN"
async def test_admin_products_requires_auth(anon_client):
r = await anon_client.get("/api/v1/admin/products")
assert r.status_code == 401

View File

@@ -0,0 +1,55 @@
"""Services list and contact form endpoint tests."""
from unittest.mock import AsyncMock
from tests.conftest import SAMPLE_SERVICE
async def test_list_services(anon_client, mock_db):
mock_db.fetch = AsyncMock(return_value=[SAMPLE_SERVICE])
r = await anon_client.get("/api/v1/services")
assert r.status_code == 200
body = r.json()
assert body["success"] is True
assert isinstance(body["data"], list)
assert body["data"][0]["name"] == "Pose complète"
assert "duration_minutes" in body["data"][0]
assert "price" in body["data"][0]
async def test_list_services_empty(anon_client, mock_db):
mock_db.fetch = AsyncMock(return_value=[])
r = await anon_client.get("/api/v1/services")
assert r.status_code == 200
assert r.json()["data"] == []
async def test_contact_form_success(anon_client, mock_db):
mock_db.execute = AsyncMock(return_value="INSERT 1")
r = await anon_client.post("/api/v1/contact", json={
"name": "Marie Dupont",
"email": "marie@test.com",
"message": "Je voudrais prendre rendez-vous.",
})
assert r.status_code == 200
assert r.json()["success"] is True
mock_db.execute.assert_called_once()
async def test_contact_form_invalid_email(anon_client):
r = await anon_client.post("/api/v1/contact", json={
"name": "Marie",
"email": "not-an-email",
"message": "Hello",
})
assert r.status_code == 422
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
async def test_contact_form_missing_field(anon_client):
r = await anon_client.post("/api/v1/contact", json={
"name": "Marie",
"email": "marie@test.com",
# message missing
})
assert r.status_code == 422

114
tests/test_slot_service.py Normal file
View File

@@ -0,0 +1,114 @@
"""Unit tests for slot generation and calendar logic — no HTTP involved."""
import asyncio
from datetime import date, time, timedelta
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.services.slot_service import generate_slots, _fmt_time # type: ignore
from app.exceptions import AppError
def make_mock_db(schedule=None, blocked=None):
db = AsyncMock()
db.transaction = MagicMock(return_value=_TxCtx())
db.fetch = AsyncMock(side_effect=lambda q, *a: _route_fetch(q, schedule, blocked))
db.fetchval = AsyncMock(return_value=None)
db.execute = AsyncMock(return_value="INSERT 1")
return db
def _route_fetch(query, schedule, blocked):
if "weekly_schedule" in query:
return schedule or []
if "blocked_dates" in query:
return blocked or []
return []
class _TxCtx:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
MONDAY = 0 # weekday() value for Monday
SCHED_ROW = {
"day_of_week": MONDAY,
"start_time": time(9, 0),
"end_time": time(11, 0),
"slot_duration_minutes": 60,
}
async def test_generate_slots_creates_correct_count():
"""Mon 09:0011:00 with 60-min slots → 2 slots per Monday."""
db = make_mock_db(schedule=[SCHED_ROW])
# Find the next Monday
today = date.today()
days_ahead = (7 - today.weekday()) % 7 or 7
next_monday = today + timedelta(days=days_ahead)
db.fetchval = AsyncMock(return_value=None) # no existing slots
created = await generate_slots(db, next_monday, next_monday)
assert created == 2
async def test_generate_slots_skips_blocked_dates():
"""A blocked Monday produces 0 slots."""
today = date.today()
days_ahead = (7 - today.weekday()) % 7 or 7
next_monday = today + timedelta(days=days_ahead)
blocked = [{"date": next_monday}]
db = make_mock_db(schedule=[SCHED_ROW], blocked=blocked)
db.fetchval = AsyncMock(return_value=None)
created = await generate_slots(db, next_monday, next_monday)
assert created == 0
async def test_generate_slots_skips_existing_slots():
"""If a slot already exists, it is not duplicated."""
today = date.today()
days_ahead = (7 - today.weekday()) % 7 or 7
next_monday = today + timedelta(days=days_ahead)
db = make_mock_db(schedule=[SCHED_ROW])
db.fetchval = AsyncMock(return_value=1) # all slots already exist
created = await generate_slots(db, next_monday, next_monday)
assert created == 0
async def test_generate_slots_range_over_90_days_rejected():
with pytest.raises(AppError) as exc_info:
db = AsyncMock()
await generate_slots(db, date(2026, 1, 1), date(2026, 5, 1))
assert exc_info.value.code == "RANGE_TOO_LARGE"
async def test_generate_slots_no_schedule_raises():
db = make_mock_db(schedule=[])
with pytest.raises(AppError) as exc_info:
await generate_slots(db, date(2026, 6, 1), date(2026, 6, 7))
assert exc_info.value.code == "NO_SCHEDULE"
# ── Time formatting ───────────────────────────────────────────────────────────
def test_fmt_time_strips_seconds():
from datetime import time as t
assert _fmt_time(t(10, 0, 0)) == "10:00"
assert _fmt_time(t(9, 30)) == "09:30"
def test_fmt_time_string_input():
assert _fmt_time("14:30:00") == "14:30"
assert _fmt_time("08:00") == "08:00"
def test_fmt_time_none():
assert _fmt_time(None) == ""

1917
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff