Skip to content

Architecture

Overview

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

wairo/
├── 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
    └── email              Resend wrapper + React Email templates

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

@repo/email

  • sendEmail({ to, subject, react, replyTo? }) — renders a React Email component and sends it via Resend. No-ops when RESEND_API_KEY is not set; throws on Resend errors so BullMQ retries the job.
  • Eight ready-made React Email templates (see Email Templates)
  • FROM_EMAIL constant — defaults to no-reply@wairo.app, overridable via RESEND_FROM_EMAIL
  • Used exclusively by media-api notification workers and backoffice-api support handler; never called from route handlers directly

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
  → generate_thumbnail completes → enqueues cluster_faces for each project member
  → Workers process asynchronously

Face Clustering

User uploads selfie (main-web)
  → POST /v1/selfie/upload-url  (media-api, requires biometric consent)
  → Browser PUTs selfie to R2
  → BullMQ: process_selfie
    → Calls Retina service (Google Cloud Run) with selfie image
    → Stores 512-dim embedding; face detection score >= 0.9 → approved
    → Enqueues cluster_faces for each of the user's project memberships

User joins project via QR
  → POST /v1/qr/join  (main-api)
  → Enqueues cluster_faces for that user + project

BullMQ: cluster_faces  (payload: { userId, projectId })
  → Sends selfie + all project thumbnails to Retina service (50-image chunks)
  → Photos sharing the selfie's cluster_id → photoTag confirmed
  → All matches are confirmed (binary cluster membership — no pending queue)

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)
Google Cloud Run (Retina)Stateless face clustering service
Google Maps PlatformLocation autocomplete (Places API New) and timezone auto-detection (Time Zone API) in the backoffice
ResendTransactional email
StripePayment processing

Wairo — Internal Documentation