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/__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")