โ† Testing 17 / 18 Next: All Together โ†’

Architecture Patterns

Module 6 ยท Quality ยท โฑ ~30 min
Why this matters: This file connects the dots โ€” the patterns that cut across multiple systems and explain WHY things are designed the way they are. Multi-tenancy, subdomain routing, the proxy pattern, CORS โ€” these are the decisions that make the whole system hold together.

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:

python โ€” every admin query filters by shop_id
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

PropertyAsset (courts)Staff (grooming/salon/spa)
resource_type"asset""staff"
Pricingprice_per_hour ร— hoursService.price (fixed)
DurationCustomer choosesFixed by the Service
service_id required?NoYes
ExampleBadminton Court A, เธฟ150/hr, 1-2hr blocksKhun Nid, Bath & Dry, 60min, เธฟ350 fixed
slot_increment_min60 (BKK Sports Club)30 (most staff shops)
python โ€” pricing logic in create_booking
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:

python โ€” FastAPI CORS configuration
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=["*"],
)
env โ€” ALLOWED_ORIGINS must be JSON array
# 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:

python โ€” background job pattern
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?

The JWT token contains the admin's 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?

Shop pages had links like 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?

To keep the JWT out of browser-accessible JavaScript. The JWT is stored in an httpOnly cookie โ€” browser JS can't read it. The Next.js API route runs server-side, can read the cookie, and adds the Authorization header before calling FastAPI. If the browser called FastAPI directly, it would need the JWT in JavaScript (localStorage or a readable cookie), making it vulnerable to XSS attacks.

4. Why must ALLOWED_ORIGINS be a JSON array format in the .env file?

Pydantic-settings parses environment variables for 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?

Background jobs run independently from HTTP requests โ€” there is no "current request" and no request-scoped session to reuse. APScheduler fires the job on a schedule, not in response to a request. The job must create its own database connection lifecycle. Also, HTTP request sessions are designed to be short-lived and tied to one request โ€” sharing them across requests or background jobs would cause race conditions and unexpected state.