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

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