Architecture Patterns
Multi-Tenancy: One Database, Many Shops
JongYang is a marketplace SaaS โ one running application serves all shops. Every booking, resource, and service belongs to a specific shop via shop_id. This is multi-tenancy with row-level isolation:
async def list_bookings(
admin: ShopAdmin = Depends(get_current_admin), # JWT contains shop_id
session: AsyncSession = Depends(get_db),
):
q = (
select(Booking)
.where(Booking.shop_id == admin.shop_id) # โ isolation enforced here
.order_by(Booking.booking_date)
)
# BKK Sports Club admin can never see Paw & Groom's bookings
# because admin.shop_id is always BKK's UUID
The shop_id is extracted from the JWT (set at login time) and embedded in every query. There's no way for an admin to query another shop's data โ even if they tried to craft a malicious request, the dependency injection always applies their shop_id filter.
The Two Scheduling Models
| Property | Asset (courts) | Staff (grooming/salon/spa) |
|---|---|---|
| resource_type | "asset" | "staff" |
| Pricing | price_per_hour ร hours | Service.price (fixed) |
| Duration | Customer chooses | Fixed by the Service |
| service_id required? | No | Yes |
| Example | Badminton Court A, เธฟ150/hr, 1-2hr blocks | Khun Nid, Bath & Dry, 60min, เธฟ350 fixed |
| slot_increment_min | 60 (BKK Sports Club) | 30 (most staff shops) |
if resource.resource_type == "staff":
if not body.service_id:
raise HTTPException(422, "service_id is required for staff resources")
service = ... # look up the service
duration_min = service.duration_min # OVERRIDE whatever was in the request
total_price = service.price # fixed price from service record
else: # asset
duration_min = body.duration_min # customer's choice (60/90/120 min)
total_price = float(resource.price_per_hour) * duration_min / 60
service_id = None # no service for asset bookings
Subdomain Routing: The Full Picture
The middleware in proxy.ts handles three scenarios:
Scenario 1: Shop subdomain
Request: bkk-sports-club.jongyang.xyz/
Middleware:
- pathname = "/"
- slug = "bkk-sports-club" (not in RESERVED, not BASE_DOMAIN)
- pathname does NOT start with "/shop/"
โ NextResponse.rewrite("/shop/bkk-sports-club")
โ Next.js serves app/shop/[slug]/page.tsx with slug="bkk-sports-club"
โ URL bar still shows bkk-sports-club.jongyang.xyz/
Scenario 2: Shop subdomain + booking link
Request: bkk-sports-club.jongyang.xyz/shop/bkk-sports-club/book?...
Middleware:
- slug = "bkk-sports-club"
- pathname STARTS WITH "/shop/" โ skip rewrite (prevents double-rewrite)
โ NextResponse.next() โ serves /shop/bkk-sports-club/book directly
โ Next.js serves app/shop/[slug]/book/page.tsx
Scenario 3: Admin subdomain
Request: admin.jongyang.xyz/
Middleware:
- pathname = "/"
- does NOT start with "/admin" โ skip admin guard
- slug = "admin" โ match admin subdomain condition
- pathname does NOT start with "/api"
โ NextResponse.redirect("/admin/login")
โ Browser navigates to admin.jongyang.xyz/admin/login
โ Middleware runs again, pathname = "/admin/login"
โ admin guard: no token โ NextResponse.next() โ shows login page
The Admin Proxy Pattern
Why does the admin panel make API calls through Next.js (/api/admin/proxy/*) instead of directly to FastAPI? Security:
WITHOUT proxy (insecure):
Browser JS โ directly calls https://api.jongyang.xyz/api/admin/bookings
Browser must store the JWT somewhere JS can read โ XSS risk
WITH proxy (what we do):
Browser JS โ POST /api/admin/proxy/bookings (Next.js API route)
โ
Next.js server reads httpOnly cookie (JS can't do this)
Adds: Authorization: Bearer {JWT}
Forwards to: https://api.jongyang.xyz/api/admin/bookings
โ
FastAPI verifies JWT โ returns bookings
โ
Next.js returns JSON to browser
Browser receives data โ never saw the JWT
CORS: Cross-Origin Resource Sharing
Browsers enforce the "same-origin policy" โ JavaScript on jongyang.xyz can't make requests to api.jongyang.xyz without permission. CORS is the mechanism for granting that permission:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS, # list of allowed origins
allow_credentials=True, # allow cookies to be included in cross-origin requests
allow_methods=["*"], # GET, POST, PATCH, DELETE, etc.
allow_headers=["*"],
)
# CORRECT: pydantic-settings parses list[str] fields as JSON
ALLOWED_ORIGINS=["https://jongyang.xyz","https://admin.jongyang.xyz","https://bkk-sports-club.jongyang.xyz"]
# WRONG: comma-separated string causes JSONDecodeError
ALLOWED_ORIGINS=https://jongyang.xyz,https://admin.jongyang.xyz
Background Tasks: APScheduler Pattern
Background tasks (expiry, reminders) need a database session, but they're not HTTP request handlers. The pattern: create a new async session for each job run:
async def expire_pending_bookings() -> None:
async with AsyncSessionLocal() as session: # create a fresh session
await run_expire_task(session)
# session closes automatically
# Registered with APScheduler:
scheduler.add_job(expire_pending_bookings, "interval", minutes=5)
The session is scoped to the job execution โ it opens when the job starts and closes when it finishes. This is the same pattern as get_db() for HTTP requests, just without the request lifecycle managing it.
๐ง Self-Check Quiz
1. How does multi-tenancy prevent BKK Sports Club's admin from seeing Paw & Groom's bookings?
shop_id. The get_current_admin dependency decodes the JWT and returns a ShopAdmin object with their shop_id. Every admin route adds .where(Booking.shop_id == admin.shop_id) to all queries. An admin can only ever see data where shop_id matches their shop โ even if they could somehow craft a request asking for another shop's data, the WHERE clause would return empty results.2. What is the double-rewrite bug and how was it fixed?
href="/shop/bkk-sports-club/book?...". When clicked from the shop subdomain, the middleware saw slug = "bkk-sports-club" and rewrote the path to /shop/bkk-sports-club/shop/bkk-sports-club/book โ doubling the slug. Fix: added && !pathname.startsWith('/shop/') to the rewrite condition. If the path already has /shop/, skip the rewrite and let Next.js handle it directly.3. Why does the admin panel use the proxy pattern (/api/admin/proxy/*) instead of calling FastAPI directly from the browser?
4. Why must ALLOWED_ORIGINS be a JSON array format in the .env file?
list[str] fields as JSON before calling any validators. If the value is comma-separated (not JSON), pydantic-settings calls json.loads() on it, gets a JSONDecodeError, and crashes on startup. The fix is to store it as ["https://...","https://..."] โ valid JSON that pydantic-settings can parse successfully.5. The expiry background job creates its own AsyncSessionLocal session. Why can't it reuse the session from an ongoing HTTP request?