Skip to content

Architecture

Overview

Acme Photo Platform is a pnpm + Turborepo monorepo containing three Fastify APIs, two Next.js 15 frontends, and three shared packages.

acme-photo-platform/
├── apps/
│   ├── backoffice-api     Fastify — operators, photographers, analytics
│   ├── main-api           Fastify — end users, auth, purchases, QR join
│   ├── media-api          Fastify — uploads, watermarking, face recognition
│   ├── backoffice-web     Next.js 15 — operator & photographer dashboard
│   └── main-web           Next.js 15 — end user app
└── packages/
    ├── db                 Drizzle ORM schema, migrations, db client
    ├── auth               JWT sign/verify, bcrypt, Fastify middleware
    └── config             Zod-validated env schema, job name constants

Rule: apps import from packages, never from other apps.

Apps

backoffice-api (port 3001)

Serves operators and photographers. Handles project management, package pricing, photographer assignment, and analytics.

JWT audience: backoffice

main-api (port 3002)

Serves end users. Handles registration, QR-based project joining, biometric consent, photo browsing, and Stripe purchases.

JWT audience: main

media-api (port 3003)

Handles all file operations. Issues presigned R2 URLs for uploads, and runs BullMQ workers for thumbnail generation, watermarking, and face recognition.

JWT audience: media (for end users) and backoffice (for operator/photographer uploads)

backoffice-web (port 4001)

Next.js dashboard for operators (project/package/photographer management, analytics) and photographers (assigned projects, photo upload).

main-web (port 4002)

Next.js app for end users: registration, biometric consent, QR project joining, selfie upload, photo gallery, and purchases.

Shared Packages

@repo/db

  • Drizzle ORM schema — one file per domain entity in src/schema/
  • Postgres client (drizzle-orm/postgres-js)
  • Exports all schema tables, types, and Drizzle query helpers

@repo/auth

  • signToken / verifyToken — JWT with audience validation
  • hashPassword / verifyPassword — bcrypt
  • authenticate — Fastify plugin that validates Bearer tokens and attaches request.user
  • requireRole(...roles) — preHandler factory for role-based access control

@repo/config

  • Zod-validated env object — imported instead of reading process.env directly
  • JobName const enum — all BullMQ job names live here; never hardcode strings

Data Flow

Photo Upload

Photographer (backoffice-web)
  → POST /v1/photographers/me/photos/upload-url  (media-api)
  → DB: creates photo record, returns presigned PUT URL
  → Browser PUTs file directly to Cloudflare R2
  → BullMQ enqueues: generate_thumbnail, generate_watermark, recognize_faces
  → Workers process asynchronously

Face Recognition

User uploads selfie (main-web)
  → POST /v1/selfie/upload-url  (media-api, requires biometric consent)
  → Browser PUTs selfie to R2
  → BullMQ: process_selfie
    → AWS Rekognition IndexFaces (per-user collection)
    → selfie.status = 'approved'
  → On each photo upload: BullMQ: recognize_faces
    → Rekognition SearchFacesByImage against project collection
    → confidence >= 75 → photoTag confirmed
    → confidence 55–74 → photoTag pending (manual review)
    → confidence < 55 → discarded

Purchase

User selects package (main-web)
  → POST /v1/purchases  (main-api)
  → Stripe PaymentIntent created
  → Stripe webhook confirms payment
  → purchase.status = 'paid'
  → User can download watermarked photos

Auth Model

Three separate JWT audiences prevent token cross-use:

AudienceUsed byIssued by
backofficeOperators, photographersbackoffice-api
mainEnd usersmain-api
mediaEnd users (selfie upload)main-api

Roles: operator, photographer, user, superadmin

Infrastructure

ServicePurpose
PostgreSQL + pgvectorPrimary database, face embeddings
RedisBullMQ job queue backend
Cloudflare R2Object storage (photos, selfies, thumbnails)
AWS RekognitionFace indexing and search
ResendTransactional email
StripePayment processing

Acme Photo Platform — Internal Documentation