> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useduro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Architecture

> A NestJS 11 + Drizzle backend behind a same-origin Next.js BFF, with BullMQ workers and R2 media. What each module does and how state is laid out.

Quill is two deployables: a NestJS 11 API (`quill-backend`) and a Next.js 16 App Router frontend (`quill-frontend`). The API owns all product state in a single Postgres database via Drizzle ORM over `postgres.js`; the frontend is a thin rendering layer that reaches the API only through a same-origin proxy.

## The whole system

```mermaid theme={null}
flowchart TB
    subgraph browser["Browser"]
        UI["Next.js 16 App Router<br/>TanStack Query · TipTap editor · hls.js"]
    end

    subgraph vercel["Vercel (quill.useduro.com)"]
        BFF["BFF proxy<br/>app/api/[...path]/route.ts"]
        UP["app/api/upload → R2<br/>(image uploads)"]
    end

    subgraph api["NestJS 11 (quill-api.useduro.com, PM2)"]
        HTTP["Controllers (/api/v1)<br/>JWT-cookie guard · Roles guard · Throttler"]
        SVC["Services<br/>auth · publications · posts · subscriptions ·<br/>comments · earnings · payouts · vas · webhooks · duro · email"]
        WORK["BullMQ processors<br/>emails · video-transcode"]
    end

    subgraph data["State"]
        PG[("Postgres<br/>Drizzle schema")]
        RDS[("Redis<br/>BullMQ queues")]
        R2[("Cloudflare R2<br/>images · HLS segments")]
    end

    subgraph ext["External"]
        DURO["Duro public API<br/>customers · plans · checkout ·<br/>subscriptions · payouts · vas"]
        SMTP["SMTP (ZeptoMail)"]
    end

    UI --> BFF --> HTTP
    UI --> UP --> R2
    HTTP --> SVC
    SVC --> PG
    SVC -. enqueue .-> RDS
    RDS == jobs ==> WORK
    WORK --> R2 & SMTP
    SVC --> DURO
    DURO -. signed webhook .-> HTTP
```

Everything under the API is one process. Unlike Duro's three-service split, Quill is a single NestJS app: it is the merchant, not the processor, so it has one trust boundary and one runtime shape. The heavy lifting that would justify a separate worker (renewals, dunning) lives inside Duro, not Quill.

## The NestJS module map

`AppModule` wires a `ConfigModule` (global), a `ThrottlerModule` (120 req / 60s), a `BullModule` bound to Redis, and the feature modules. Three global guards and one global interceptor run on every request. Source: `src/app.module.ts`.

| Module                                   | Responsibility                                                                                     | Notable Duro touchpoint              |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `AuthModule`                             | Register/login, email OTP verification, password reset, optional email-based 2FA, demo quick-login | none (local JWT)                     |
| `PublicationsModule`                     | A creator's publication: name, slug, monthly price, accent color; caches the Duro plan id          | stores `duroPlanId`                  |
| `PostsModule`                            | Posts (Markdown/TipTap), `free` vs `premium` visibility, public feed                               | none                                 |
| `SubscriptionsModule`                    | Reader subscribe/verify/cancel/pause/resume/recover; creator subscriber + recovery views           | the core integration                 |
| `CommentsModule`                         | Threaded comments on posts                                                                         | none                                 |
| `EarningsModule`                         | Creator earnings summary, MRR, monthly series, transactions                                        | reads `ledger_entries`               |
| `PayoutsModule`                          | Settlement balance, bank accounts, OTP, payout requests                                            | Duro **transfers** + bank resolution |
| `VasModule`                              | Airtime / data / betting / cable TV / electricity vending                                          | Duro **VAS**                         |
| `VideosModule`                           | Upload, transcode-to-HLS, encrypted premium video                                                  | enqueues `video-transcode`           |
| `WebhooksModule`                         | Receives Duro's signed webhooks; reconciles subscriptions + payouts                                | HMAC verification                    |
| `DuroModule`                             | `@Global` module exposing `DuroService` (the Duro client)                                          | wraps the adapters                   |
| `EmailModule`                            | Transactional email via BullMQ + nodemailer                                                        | none                                 |
| `NotificationsModule`, `AnalyticsModule` | In-app notifications, post analytics                                                               | none                                 |

`DuroModule` is `@Global`, so any service can inject `DuroService` without importing the module. It is the single seam between Quill and Duro (`src/duro/duro.module.ts`).

## Request pipeline

Every request passes the same short chain, defined once in `AppModule` providers and `main.ts`:

```mermaid theme={null}
flowchart LR
    REQ["Request → /api/v1/*"] --> HELM["helmet + cookieParser"]
    HELM --> JWT["JwtAuthGuard<br/>read quill_session cookie<br/>(or Bearer), verify JWT"]
    JWT --> ROLE["RolesGuard<br/>@Roles('creator') checks"]
    ROLE --> THR["ThrottlerGuard<br/>120 / 60s"]
    THR --> CTRL["Controller + ValidationPipe<br/>(whitelist, forbidNonWhitelisted)"]
    CTRL --> RESP["ResponseInterceptor<br/>wrap → { success, data }"]
    CTRL --> ERR["HttpExceptionFilter<br/>errors → { success:false, message }"]
```

* **Global prefix** `api/v1` is set in `main.ts`, so every route is `/api/v1/...`.
* **Auth** is a JWT carried in an httpOnly cookie named `quill_session` (a `Bearer` header is also accepted). The guard extracts and verifies it, attaching `{ id, email, role }` to the request. `@Public()` opts a route out (used by the webhook receiver). Source: `src/common/guards/jwt-auth.guard.ts`.
* **Response envelope**: `ResponseInterceptor` wraps every successful body as `{ success: true, data }`; `HttpExceptionFilter` shapes errors. This is the same envelope the frontend's axios client unwraps.

## The Drizzle schema

Schema is plain Drizzle `pgTable` definitions under `src/database/schema/`, composed by `schema/index.ts` and handed to `drizzle(postgres(url, { max: 10 }))` in a `@Global` `DatabaseModule`. Primary keys are prefixed, collision-resistant ids (`newId('sub')` → `sub_<21-char nanoid>`), generated in `src/common/id.ts`.

```mermaid theme={null}
erDiagram
    users ||--o{ publications : owns
    users ||--o{ subscriptions : subscribes
    publications ||--o{ subscriptions : has
    publications ||--o{ posts : has
    publications ||--o{ videos : has
    publications ||--o{ ledger_entries : accrues
    publications ||--o{ payouts : requests
    publications ||--o{ payout_accounts : saves
    publications ||--o{ bill_transactions : spends
    subscriptions ||--o{ ledger_entries : charges
```

Tables that carry the money integration:

* **`subscriptions`** links a `subscriberId` and `publicationId` and mirrors Duro state: `status` (a `pgEnum`: `pending | active | past_due | paused | canceled | expired`), plus `duroSubscriptionId`, `duroCustomerId`, `duroCheckoutId`, `priceKobo`, `currentPeriodStart/End`. Source: `src/database/schema/subscriptions.ts`.
* **`ledger_entries`** is an append-only revenue log (`type`: `subscription_charge | payout | refund | adjustment`, `amountKobo`, `description`, `reference`). Quill records a charge here on activation; recovery charges are tagged `description = 'Subscription recovery'` so recovery revenue is a filtered `SUM`.
* **`publications`** caches `duroPlanId` so the Duro plan is created once per publication, then reused.
* **`payouts`** / **`payout_accounts`** / **`bill_transactions`** back the money-out and VAS flows (see [Payouts & VAS](/quill/payouts-vas)).
* **`webhook_events`** stores every received Duro event keyed by a unique `eventId` for idempotent processing.

All amounts are integer **kobo** end to end (`priceKobo`, `amountKobo`); `src/common/money.ts` holds the `PLATFORM_FEE_BPS = 1000` (10%) fee helpers and `formatNaira`.

## The same-origin BFF frontend

The Next.js app (`quill-frontend`) follows the Guild/Duro BFF pattern: the browser only ever calls its **own** origin.

```mermaid theme={null}
flowchart LR
    COMP["React component"] --> HOOK["TanStack Query hook"]
    HOOK --> AX["axios client<br/>baseURL '/api', withCredentials"]
    AX --> PROXY["app/api/[...path]/route.ts<br/>(force-dynamic, node runtime)"]
    PROXY -->|"relay cookie + body"| BE["NestJS BACKEND_API_URL"]
    BE --> PROXY
    PROXY -->|"relay Set-Cookie"| AX
```

* **Client** (`lib/api/client.ts`) is an axios instance with `baseURL: '/api'` and `withCredentials: true`; it unwraps the `{ success, data }` envelope and normalises errors to an `ApiError`.
* **Proxy** (`app/api/[...path]/route.ts`) forwards `GET/POST/PATCH/PUT/DELETE` to `BACKEND_API_URL` (default `http://localhost:7010/api/v1`), copying through the `cookie`, `content-type`, `x-forwarded-for`, and `user-agent` headers, and relaying `Set-Cookie` back with `getSetCookie()`. It is `runtime = "nodejs"` and `dynamic = "force-dynamic"`.
* **Data layer** is TanStack Query (`@tanstack/react-query`) over that client; the editor is TipTap; premium video plays via `hls.js`.
* **Uploads** are the one exception that does not proxy to Nest: `app/api/upload/route.ts` checks the session against the backend, then writes the image straight to R2 (5 MB cap, image types only).

The frontend runs on port 7013 in dev (`next dev --port 7013`); the backend on 7010.

## BullMQ workers and R2 media

Two queues run in-process via `@nestjs/bullmq` `WorkerHost` processors:

<CardGroup cols={2}>
  <Card title="emails" icon="envelope">
    `EmailService.deliver()` enqueues a `send` job (3 attempts, exponential backoff); `EmailProcessor` calls `nodemailer`. Without `SMTP_HOST` it logs instead of sending. Templates are inlined HTML with a "powered by Duro" footer. Source: `src/email/*`.
  </Card>

  <Card title="video-transcode" icon="film">
    On upload, `VideosService.create()` enqueues a `transcode` job (2 attempts). `TranscodeService` shells out to `ffmpeg-static`: it produces AES-128 encrypted HLS (`-hls_key_info_file`), a short unencrypted preview, and a thumbnail, then uploads segments to R2. Source: `src/videos/*`.
  </Card>
</CardGroup>

Premium video is gated at the key: the HLS encryption key is only served (`GET .../key`) to a user with an `active` subscription to that publication; non-subscribers get the preview playlist. R2 access uses `@aws-sdk/client-s3` against the R2 S3 endpoint (`src/storage/storage.service.ts`).

Next: [Duro Integration](/quill/duro-integration), the seam where all of this meets Duro's money layer.
