Files
youtube-studio-flow/backend/docs/architecture.html
T

1134 lines
56 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StudioFlow Backend — Architecture & Code Reference</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #22263a;
--border: #2e3250;
--accent: #6c63ff;
--accent2: #00d4aa;
--accent3: #ff6b6b;
--accent4: #ffd166;
--text: #e2e8f0;
--text-muted: #8892b0;
--code-bg: #0d1117;
--tag-green: #0d3320;
--tag-green-text: #4ade80;
--tag-blue: #0d1f3a;
--tag-blue-text: #60a5fa;
--tag-orange: #3a1f0d;
--tag-orange-text: #fb923c;
--tag-red: #3a0d0d;
--tag-red-text: #f87171;
--tag-purple: #1f0d3a;
--tag-purple-text: #c084fc;
--sidebar-width: 280px;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
display: flex;
}
/* ── SIDEBAR ── */
#sidebar {
width: var(--sidebar-width);
min-height: 100vh;
background: var(--surface);
border-right: 1px solid var(--border);
position: fixed;
top: 0; left: 0;
overflow-y: auto;
padding: 24px 0;
z-index: 100;
}
#sidebar .logo {
padding: 0 20px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
#sidebar .logo h1 { font-size: 16px; font-weight: 700; color: var(--accent); }
#sidebar .logo p { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
#sidebar nav a {
display: block;
padding: 6px 20px;
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
transition: color 0.15s, background 0.15s;
border-left: 2px solid transparent;
}
#sidebar nav a:hover { color: var(--text); background: var(--surface2); }
#sidebar nav a.active { color: var(--accent); border-left-color: var(--accent); }
#sidebar nav .section-label {
padding: 16px 20px 4px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
}
/* ── MAIN ── */
#main {
margin-left: var(--sidebar-width);
flex: 1;
padding: 48px 56px;
max-width: 960px;
}
section { margin-bottom: 72px; }
h1.page-title {
font-size: 36px;
font-weight: 800;
background: linear-gradient(135deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
}
.page-subtitle { color: var(--text-muted); font-size: 16px; margin-bottom: 48px; }
h2 {
font-size: 22px;
font-weight: 700;
color: var(--text);
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
h2 .icon { font-size: 18px; }
h3 { font-size: 15px; font-weight: 600; color: var(--accent2); margin: 24px 0 10px; }
p { color: var(--text-muted); margin-bottom: 12px; font-size: 14px; }
p strong { color: var(--text); }
/* ── CODE BLOCKS ── */
pre {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
overflow-x: auto;
margin: 12px 0 20px;
font-size: 13px;
line-height: 1.5;
}
code {
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
color: var(--text);
}
pre code { color: inherit; }
.kw { color: #c792ea; }
.fn { color: #82aaff; }
.str { color: #c3e88d; }
.cm { color: #546e7a; font-style: italic; }
.dec { color: #ffcb6b; }
.cls { color: #ffcb6b; }
.num { color: #f78c6c; }
/* ── FILE TREE ── */
.file-tree {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px 24px;
font-family: 'Cascadia Code', monospace;
font-size: 12.5px;
line-height: 1.8;
margin: 12px 0 20px;
}
.file-tree .dir { color: var(--accent2); }
.file-tree .file { color: var(--text-muted); }
.file-tree .note { color: #546e7a; font-style: italic; }
/* ── CARDS ── */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
margin: 16px 0 24px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
}
.card h4 { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 6px; }
.card p { font-size: 12px; margin: 0; }
/* ── TABLES ── */
.table-wrap { overflow-x: auto; margin: 12px 0 24px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
background: var(--surface2);
color: var(--text);
font-weight: 600;
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
td { padding: 9px 14px; border-bottom: 1px solid var(--border); color: var(--text-muted); vertical-align: top; }
tr:last-child td { border-bottom: none; }
td code { background: var(--surface2); padding: 2px 6px; border-radius: 4px; font-size: 12px; color: var(--accent2); }
td strong { color: var(--text); }
/* ── TAGS ── */
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
margin: 2px;
}
.tag-get { background: var(--tag-green); color: var(--tag-green-text); }
.tag-post { background: var(--tag-blue); color: var(--tag-blue-text); }
.tag-patch { background: var(--tag-orange); color: var(--tag-orange-text); }
.tag-put { background: var(--tag-purple); color: var(--tag-purple-text); }
.tag-delete { background: var(--tag-red); color: var(--tag-red-text); }
.tag-error { background: var(--tag-red); color: var(--tag-red-text); }
.tag-warn { background: var(--tag-orange); color: var(--tag-orange-text); }
.tag-ok { background: var(--tag-green); color: var(--tag-green-text); }
.tag-info { background: var(--tag-blue); color: var(--tag-blue-text); }
/* ── CALLOUT ── */
.callout {
border-left: 3px solid var(--accent);
background: var(--surface);
padding: 14px 18px;
border-radius: 0 8px 8px 0;
margin: 16px 0;
font-size: 13px;
}
.callout.warn { border-color: var(--accent4); }
.callout.danger { border-color: var(--accent3); }
.callout p { margin: 0; color: var(--text-muted); }
.callout strong { color: var(--text); }
/* ── FLOW DIAGRAM ── */
.flow {
display: flex;
align-items: center;
gap: 0;
flex-wrap: wrap;
margin: 16px 0;
}
.flow-step {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 16px;
font-size: 12px;
color: var(--text);
white-space: nowrap;
}
.flow-arrow {
color: var(--accent);
font-size: 18px;
padding: 0 8px;
font-weight: 300;
}
/* ── BADGE ── */
.badge {
display: inline-block;
padding: 1px 7px;
border-radius: 20px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
margin-left: 6px;
}
.badge-phase1 { background: #1a3a2a; color: #4ade80; }
.badge-phase2 { background: #1a2a3a; color: #60a5fa; }
.badge-phase3 { background: #2a1a3a; color: #c084fc; }
/* ── INLINE CODE ── */
p code, li code, td code {
background: var(--surface2);
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
color: var(--accent2);
}
ul, ol { padding-left: 20px; margin-bottom: 12px; }
li { font-size: 14px; color: var(--text-muted); margin-bottom: 4px; }
li strong { color: var(--text); }
.divider { border: none; border-top: 1px solid var(--border); margin: 40px 0; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<!-- ════════════════════════════════ SIDEBAR ════════════════════════════════ -->
<nav id="sidebar">
<div class="logo">
<h1>StudioFlow</h1>
<p>Backend Architecture Reference</p>
</div>
<nav>
<div class="section-label">Overview</div>
<a href="#overview">What is this?</a>
<a href="#stack">Tech Stack</a>
<a href="#structure">File Structure</a>
<div class="section-label">Core</div>
<a href="#entry-points">Entry Points</a>
<a href="#shared">Shared Layer</a>
<a href="#render-engine">Render Engine</a>
<a href="#quota">Quota Service</a>
<div class="section-label">Auth</div>
<a href="#auth">Auth Module</a>
<a href="#roles">Roles & Guards</a>
<div class="section-label">Feature Modules</div>
<a href="#videos">Videos</a>
<a href="#blocks">Blocks</a>
<a href="#templates">Templates</a>
<a href="#video-configs">Video Configs</a>
<a href="#collaborators">Collaborators</a>
<a href="#linting">Linting</a>
<a href="#bulk-jobs">Bulk Jobs</a>
<a href="#saved-views">Saved Views</a>
<a href="#calendar">Calendar</a>
<a href="#imports-exports">Import / Export</a>
<a href="#youtube-sync">YouTube Sync</a>
<div class="section-label">Infrastructure</div>
<a href="#queues">BullMQ Queues</a>
<a href="#quota-endpoint">Quota &amp; Audit API</a>
<a href="#env">Environment Vars</a>
<a href="#start">Getting Started</a>
</nav>
</nav>
<!-- ════════════════════════════════ MAIN ════════════════════════════════ -->
<main id="main">
<h1 class="page-title">StudioFlow Backend</h1>
<p class="page-subtitle">NestJS 10 · Prisma 6 · BullMQ · PostgreSQL 16 · Redis 7</p>
<!-- ── OVERVIEW ── -->
<section id="overview">
<h2><span class="icon">🗺</span> What is this?</h2>
<p>
StudioFlow is an internal YouTube metadata management platform. The backend handles all
business logic: structured description rendering, bulk metadata updates, YouTube API sync
with quota management, metadata linting, and import/export.
</p>
<p>
The codebase is a single NestJS monorepo with <strong>two independent entry points</strong>
an HTTP API server and an asynchronous BullMQ worker — sharing all modules and services.
</p>
<div class="card-grid">
<div class="card">
<h4>HTTP API (<code>main.ts</code>)</h4>
<p>REST endpoints, Swagger docs, auth, all CRUD operations. Runs on port 3001.</p>
</div>
<div class="card">
<h4>Worker (<code>worker.ts</code>)</h4>
<p>BullMQ consumer for async jobs: sync, render, lint, bulk edits, CSV imports.</p>
</div>
<div class="card">
<h4>Render Engine</h4>
<p>Pure TypeScript — no I/O. Turns a VideoConfig into a final description string.</p>
</div>
<div class="card">
<h4>Quota Guard</h4>
<p>Tracks YouTube API unit consumption per Pacific-Time day. Enforces 9,000-unit cap.</p>
</div>
</div>
</section>
<!-- ── TECH STACK ── -->
<section id="stack">
<h2><span class="icon">🔧</span> Tech Stack</h2>
<div class="table-wrap">
<table>
<tr><th>Layer</th><th>Library</th><th>Why</th></tr>
<tr><td><strong>Framework</strong></td><td><code>NestJS 10</code></td><td>Decorator-driven DI, modular architecture, TypeScript-first</td></tr>
<tr><td><strong>ORM</strong></td><td><code>Prisma 6</code></td><td>Type-safe queries, migrations, JSONB support</td></tr>
<tr><td><strong>Database</strong></td><td><code>PostgreSQL 16</code></td><td>JSONB for flexible config fields, GIN indexes for query performance</td></tr>
<tr><td><strong>Queue</strong></td><td><code>BullMQ + Redis 7</code></td><td>Rate limiting, delayed jobs (quota overflow), job retries</td></tr>
<tr><td><strong>Auth</strong></td><td><code>Passport + JWT + Google OAuth2</code></td><td>YouTube OAuth flow, JWT sessions (15 min), refresh tokens (7 d)</td></tr>
<tr><td><strong>Validation</strong></td><td><code>class-validator + zod</code></td><td>Request DTOs via decorators; zod for CSV row schema validation</td></tr>
<tr><td><strong>Docs</strong></td><td><code>@nestjs/swagger</code></td><td>Auto-generated OpenAPI at <code>/api/docs</code></td></tr>
<tr><td><strong>CSV</strong></td><td><code>csv-parse / csv-stringify</code></td><td>Sync parsing for import preview; streaming stringify for export</td></tr>
<tr><td><strong>YouTube API</strong></td><td><code>googleapis</code></td><td>Official Google client for <code>videos.list</code> and <code>videos.update</code></td></tr>
</table>
</div>
</section>
<!-- ── FILE STRUCTURE ── -->
<section id="structure">
<h2><span class="icon">📁</span> File Structure</h2>
<p>Every folder under <code>src/</code> has a single, clearly scoped responsibility.</p>
<div class="file-tree">
<span class="dir">backend/</span>
├── <span class="file">package.json</span> <span class="note">all dependencies + npm scripts</span>
├── <span class="file">tsconfig.json</span>
├── <span class="file">nest-cli.json</span> <span class="note">dual entry: main + worker</span>
├── <span class="file">.env.example</span>
├── <span class="dir">prisma/</span>
│ └── <span class="file">schema.prisma</span> <span class="note">17 models, enums, GIN indexes</span>
└── <span class="dir">src/</span>
├── <span class="file">main.ts</span> <span class="note">HTTP server bootstrap</span>
├── <span class="file">worker.ts</span> <span class="note">BullMQ worker bootstrap</span>
├── <span class="file">app.module.ts</span> <span class="note">root module for HTTP</span>
├── <span class="file">worker.module.ts</span> <span class="note">root module for worker</span>
├── <span class="file">health.controller.ts</span> <span class="note">GET /health → Docker healthcheck</span>
├── <span class="dir">shared/</span> <span class="note">cross-cutting, no HTTP endpoints</span>
│ ├── <span class="dir">prisma/</span> <span class="note">PrismaService (global)</span>
│ ├── <span class="dir">render-engine/</span> <span class="note">RenderEngineService + hash.ts</span>
│ ├── <span class="dir">quota/</span> <span class="note">QuotaService — daily unit tracking</span>
│ └── <span class="dir">audit/</span> <span class="note">AuditService — write AuditLog rows</span>
├── <span class="dir">queues/</span>
│ ├── <span class="file">queues.constants.ts</span> <span class="note">queue name constants</span>
│ └── <span class="dir">processors/</span>
│ ├── <span class="file">youtube-sync.processor.ts</span>
│ ├── <span class="file">bulk-metadata.processor.ts</span>
│ ├── <span class="file">render.processor.ts</span>
│ ├── <span class="file">lint.processor.ts</span>
│ └── <span class="file">import.processor.ts</span>
└── <span class="dir">modules/</span> <span class="note">one folder per domain</span>
├── <span class="dir">auth/</span>
├── <span class="dir">videos/</span>
├── <span class="dir">blocks/</span>
├── <span class="dir">templates/</span>
├── <span class="dir">video-configs/</span>
├── <span class="dir">collaborators/</span>
├── <span class="dir">bulk-jobs/</span>
├── <span class="dir">saved-views/</span>
├── <span class="dir">linting/</span> <span class="note">service + 10 rule files</span>
├── <span class="dir">calendar/</span>
├── <span class="dir">imports/</span>
├── <span class="dir">exports/</span>
├── <span class="dir">youtube-sync/</span>
├── <span class="dir">quota/</span> <span class="note">HTTP controller only</span>
└── <span class="dir">audit-logs/</span> <span class="note">HTTP controller only</span>
</div>
</section>
<!-- ── ENTRY POINTS ── -->
<section id="entry-points">
<h2><span class="icon">🚀</span> Entry Points</h2>
<h3>src/main.ts — HTTP Server</h3>
<p>Bootstraps the full NestJS application with all feature modules registered.</p>
<ul>
<li>Global <code>ValidationPipe</code> with <code>whitelist: true</code> and <code>transform: true</code> — strips unknown fields, auto-converts types</li>
<li>Global prefix <code>/api/v1</code> on all routes</li>
<li>Swagger UI available at <code>/api/docs</code> (OpenAPI 3)</li>
<li>CORS configured for <code>FRONTEND_URL</code> (default: <code>http://localhost:3000</code>)</li>
<li>Cookie parser for refresh token httpOnly cookies</li>
</ul>
<h3>src/worker.ts — BullMQ Worker</h3>
<p>Creates an <code>ApplicationContext</code> (no HTTP server) using <code>WorkerModule</code>.
Only loads the modules that contain job processors — no controllers are registered.</p>
<h3>src/app.module.ts vs worker.module.ts</h3>
<div class="table-wrap">
<table>
<tr><th>Module</th><th>Imports</th></tr>
<tr>
<td><strong>AppModule</strong></td>
<td>All 13 feature modules + Shared layer + BullModule (Redis) + ConfigModule</td>
</tr>
<tr>
<td><strong>WorkerModule</strong></td>
<td>YouTubeSyncModule, BulkJobsModule, LintingModule, ImportsModule + Shared layer + 5 queue registrations + 5 processor providers</td>
</tr>
</table>
</div>
</section>
<!-- ── SHARED LAYER ── -->
<section id="shared">
<h2><span class="icon"></span> Shared Layer</h2>
<p>These modules have <strong>no HTTP controllers</strong>. They are pure service classes used by feature modules and worker processors alike.</p>
<h3>PrismaService (<code>shared/prisma/</code>)</h3>
<p>Extends <code>PrismaClient</code> directly. Implements <code>OnModuleInit</code> and <code>OnModuleDestroy</code> to call <code>$connect()</code> and <code>$disconnect()</code>. Registered as a <code>@Global()</code> module so it never needs to be re-imported.</p>
<pre><code><span class="kw">@Injectable</span>()
<span class="kw">export class</span> <span class="cls">PrismaService</span> <span class="kw">extends</span> <span class="cls">PrismaClient</span>
<span class="kw">implements</span> <span class="cls">OnModuleInit</span>, <span class="cls">OnModuleDestroy</span> {
<span class="kw">async</span> <span class="fn">onModuleInit</span>() { <span class="kw">await</span> <span class="kw">this</span>.$connect(); }
<span class="kw">async</span> <span class="fn">onModuleDestroy</span>() { <span class="kw">await</span> <span class="kw">this</span>.$disconnect(); }
}</code></pre>
<h3>AuditService (<code>shared/audit/</code>)</h3>
<p>Single method <code>log(actorId, entityType, entityId, action, before?, after?)</code>. Called by every service that mutates data. Writes to the <code>AuditLog</code> table with full before/after JSON snapshots.</p>
<h3>QuotaService (<code>shared/quota/</code>) — see next section</h3>
<h3>RenderEngineService (<code>shared/render-engine/</code>) — see next section</h3>
</section>
<!-- ── RENDER ENGINE ── -->
<section id="render-engine">
<h2><span class="icon">🖨</span> Render Engine</h2>
<p>The render engine is <strong>pure TypeScript with zero I/O</strong>. It takes all data as input and returns a string. It lives in <code>shared/render-engine/</code> so both the API (for previews) and the worker (for bulk renders) can use it identically.</p>
<h3>render-engine.service.ts — Pipeline</h3>
<div class="flow">
<div class="flow-step">Load blockOrder</div><div class="flow-arrow"></div>
<div class="flow-step">Apply blockOverrides</div><div class="flow-arrow"></div>
<div class="flow-step">Resolve {variables}</div><div class="flow-arrow"></div>
<div class="flow-step">Inject collaborators</div><div class="flow-arrow"></div>
<div class="flow-step">Eval conditionals</div><div class="flow-arrow"></div>
<div class="flow-step">Expand repeatables</div><div class="flow-arrow"></div>
<div class="flow-step">Join + SHA-256 hash</div>
</div>
<p><strong>Input:</strong> <code>RenderInput</code> containing the VideoConfig fields, an array of resolved DescriptionBlock objects, and an array of Collaborator objects.</p>
<p><strong>Output:</strong> <code>{ rendered: string, hash: string }</code></p>
<h3>Block type handling</h3>
<div class="table-wrap">
<table>
<tr><th>BlockType</th><th>How it's rendered</th></tr>
<tr><td><code>STATIC</code></td><td>Content is used as-is after variable substitution</td></tr>
<tr><td><code>VARIABLE</code></td><td><code>{placeholder}</code> tokens replaced from <code>variableValues</code></td></tr>
<tr><td><code>CONDITIONAL</code></td><td>Skipped entirely if its condition variable is falsy (<code>[if:var_name]</code> prefix)</td></tr>
<tr><td><code>REPEATABLE</code></td><td>Rendered once per item in the relevant array</td></tr>
<tr><td><code>GLOBAL</code></td><td>Same as STATIC — content fetched from block library</td></tr>
<tr><td><code>CAMPAIGN</code></td><td>Linked to a Campaign entity; linting checks expiry</td></tr>
<tr><td><code>COLLABORATOR</code></td><td>Expanded once per assigned collaborator; injects <code>{collab_name}</code>, <code>{@youtube_handle}</code>, <code>{twitch_link}</code></td></tr>
</table>
</div>
<h3>hash.ts — SHA-256 metadata hash</h3>
<p>
<code>hashMetadata({ title, description, tags, categoryId })</code> produces a SHA-256 hex string over
<code>JSON.stringify(data)</code>. This hash is stored as <code>lastSyncedHash</code> on the Video and
<code>renderHash</code> on VideoConfig. Before any YouTube sync the worker compares the current hash
against the stored one — <strong>if identical, zero API units are consumed</strong>.
</p>
<pre><code><span class="kw">export function</span> <span class="fn">hashMetadata</span>(data: {
title: string; description: string;
tags: string[]; categoryId?: string | null;
}): string {
<span class="kw">return</span> createHash(<span class="str">'sha256'</span>)
.update(JSON.stringify(data))
.digest(<span class="str">'hex'</span>);
}</code></pre>
</section>
<!-- ── QUOTA ── -->
<section id="quota">
<h2><span class="icon">📊</span> Quota Service</h2>
<p>YouTube Data API v3 resets at midnight <strong>Pacific Time</strong>. The quota service enforces a 9,000-unit daily cap (10% reserve below the 10,000 limit).</p>
<div class="table-wrap">
<table>
<tr><th>Method</th><th>What it does</th></tr>
<tr><td><code>canSpend(units)</code></td><td>Returns true if today's usage + units ≤ 9,000</td></tr>
<tr><td><code>spend(units, op, videoId?)</code></td><td>Inserts a QuotaLog row with Pacific Time day key</td></tr>
<tr><td><code>getTodayUsage()</code></td><td>Aggregates <code>SUM(units)</code> for today's PT date</td></tr>
<tr><td><code>getRemainingToday()</code></td><td>9,000 today's usage</td></tr>
<tr><td><code>msUntilQuotaReset()</code></td><td>Milliseconds until midnight PT — used by worker to delay jobs</td></tr>
</table>
</div>
<div class="callout">
<p><strong>Unit costs:</strong> <code>videos.list</code> costs <strong>1 unit</strong> (up to 50 IDs per call).
<code>videos.update</code> costs <strong>50 units</strong> regardless of how many fields change.
The hash diff check prevents all writes when nothing has changed.</p>
</div>
<h3>Tranche scheduling</h3>
<p>When a Bulk Job would exceed the day's remaining quota, the YouTube Sync processor calls
<code>job.moveToDelayed(msUntilQuotaReset())</code> to defer those jobs to the next Pacific midnight
automatically — no manual intervention needed.</p>
</section>
<!-- ── AUTH ── -->
<section id="auth">
<h2><span class="icon">🔐</span> Auth Module</h2>
<p>Authentication uses <strong>Google OAuth 2.0</strong> for identity + YouTube token acquisition, then issues short-lived <strong>JWTs</strong> for API access.</p>
<h3>OAuth Flow</h3>
<div class="flow">
<div class="flow-step">GET /auth/google</div><div class="flow-arrow"></div>
<div class="flow-step">Google consent</div><div class="flow-arrow"></div>
<div class="flow-step">Callback with code</div><div class="flow-arrow"></div>
<div class="flow-step">Upsert User</div><div class="flow-arrow"></div>
<div class="flow-step">Issue JWT (15 min)</div><div class="flow-arrow"></div>
<div class="flow-step">Set refresh cookie (7 d)</div>
</div>
<h3>Key files</h3>
<div class="table-wrap">
<table>
<tr><th>File</th><th>Responsibility</th></tr>
<tr><td><code>strategies/google.strategy.ts</code></td><td>Passport strategy — calls <code>authService.upsertGoogleUser()</code> on callback</td></tr>
<tr><td><code>strategies/jwt.strategy.ts</code></td><td>Extracts JWT from Bearer header, validates against DB</td></tr>
<tr><td><code>guards/jwt-auth.guard.ts</code></td><td>Applied globally to all routes that need auth</td></tr>
<tr><td><code>guards/roles.guard.ts</code></td><td>Reads <code>@Roles()</code> decorator, compares user role priority</td></tr>
<tr><td><code>auth.service.ts</code></td><td>Token encryption/decryption (AES-256-CBC), JWT issuance, refresh</td></tr>
</table>
</div>
<h3>Token storage security</h3>
<p>YouTube access and refresh tokens are encrypted with <strong>AES-256-CBC</strong> before being written to the database. The encryption key comes from <code>TOKEN_ENCRYPTION_KEY</code> env var, derived via <code>scryptSync</code>.</p>
</section>
<!-- ── ROLES ── -->
<section id="roles">
<h2><span class="icon">🛡</span> Roles & Guards</h2>
<p>Four roles with a priority hierarchy. The <code>RolesGuard</code> checks that the user's priority meets the minimum required for the endpoint.</p>
<div class="table-wrap">
<table>
<tr><th>Role</th><th>Priority</th><th>Can</th></tr>
<tr><td><code>ADMIN</code></td><td>4</td><td>Everything including role management and YouTube sync push</td></tr>
<tr><td><code>EDITOR</code></td><td>3</td><td>CRUD on all entities, bulk jobs, import/export, render</td></tr>
<tr><td><code>REVIEWER</code></td><td>2</td><td>Read all data, see lint results</td></tr>
<tr><td><code>READONLY</code></td><td>1</td><td>GET endpoints only</td></tr>
</table>
</div>
<h3>Sensitive endpoints requiring EDITOR+</h3>
<ul>
<li><code>POST /videos/bulk-apply</code></li>
<li><code>POST /videos/:id/sync</code></li>
<li><code>POST /imports/csv/commit</code> and <code>/imports/json/commit</code></li>
<li><code>POST /bulk-jobs/:id/rollback</code></li>
</ul>
<h3>ADMIN-only</h3>
<ul>
<li><code>DELETE /saved-views/:id</code> (global views)</li>
</ul>
</section>
<!-- ── VIDEOS ── -->
<section id="videos">
<h2><span class="icon"></span> Videos Module</h2>
<p>Central module. All video list/filter/bulk operations live here.</p>
<div class="table-wrap">
<table>
<tr><th>Method</th><th>Path</th><th>Description</th></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/videos</code></td><td>Paginated list with filters: <code>search</code>, <code>lintStatus</code>, <code>privacyStatus</code>, <code>channelId</code>, <code>templateId</code>, <code>collaboratorId</code>, date range</td></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/videos/:id</code></td><td>Single video with config, collaborators, lint results</td></tr>
<tr><td><span class="tag tag-patch">PATCH</span></td><td><code>/videos/:id</code></td><td>Update title, tags, privacyStatus, scheduledAt, categoryId, templateId</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/videos/bulk-preview</code></td><td>Dry-run — returns before/after diff for each affected video</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/videos/bulk-apply</code></td><td>Creates BulkJob + BulkJobItems, enqueues to <code>bulk-metadata</code> queue</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/videos/:id/render</code></td><td>Synchronous render — returns rendered text immediately</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/videos/:id/sync</code></td><td>Enqueues a <code>youtube-sync</code> job (async)</td></tr>
</table>
</div>
<h3>Full-text search</h3>
<p>The <code>search</code> query param builds a Prisma <code>OR</code> condition across <code>title</code> (case-insensitive LIKE) and <code>tags</code> (array contains). No full-text index is required for moderate data volumes.</p>
<h3>Supported bulk action types</h3>
<ul>
<li><code>SET_PRIVACY</code> — change privacyStatus for all matched videos</li>
<li><code>SET_TEMPLATE</code> — assign a template</li>
<li><code>ADD_TAGS</code> / <code>REMOVE_TAGS</code> — array manipulation</li>
<li><code>SEARCH_REPLACE_TITLE</code> — string replace in title</li>
</ul>
</section>
<!-- ── BLOCKS ── -->
<section id="blocks">
<h2><span class="icon"></span> Blocks Module</h2>
<p>Description blocks are the atomic content units. They are <strong>versioned</strong> — every update creates an immutable <code>BlockVersion</code> snapshot before applying changes.</p>
<div class="table-wrap">
<table>
<tr><th>Method</th><th>Path</th><th>Description</th></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/blocks</code></td><td>All blocks sorted by name</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/blocks</code></td><td>Create new block</td></tr>
<tr><td><span class="tag tag-patch">PATCH</span></td><td><code>/blocks/:id</code></td><td>Update block → auto-creates BlockVersion → increments version</td></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/blocks/:id/versions</code></td><td>Full version history (newest first)</td></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/blocks/:id/usage</code></td><td>Which VideoConfigs and Templates reference this block</td></tr>
</table>
</div>
<h3>Version snapshot logic</h3>
<pre><code><span class="cm">// On every PATCH:</span>
<span class="num">1.</span> Load current block
<span class="num">2.</span> INSERT BlockVersion { contentSnapshot: { ...currentBlock }, version: block.version }
<span class="num">3.</span> UPDATE block SET { ...dto, version: version + 1 }
<span class="num">4.</span> AuditService.log()</code></pre>
</section>
<!-- ── TEMPLATES ── -->
<section id="templates">
<h2><span class="icon"></span> Templates Module</h2>
<p>Templates define a named, reusable block sequence. They also carry default variable values and rule definitions (e.g., required links). Like blocks, templates are <strong>versioned</strong>.</p>
<div class="table-wrap">
<table>
<tr><th>Method</th><th>Path</th><th>Description</th></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/templates</code></td><td>All active templates</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/templates</code></td><td>Create template</td></tr>
<tr><td><span class="tag tag-patch">PATCH</span></td><td><code>/templates/:id</code></td><td>Update + TemplateVersion snapshot</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/templates/:id/render-preview</code></td><td>Render with sample variable values (no DB write)</td></tr>
</table>
</div>
<h3>Template schema fields (JSONB)</h3>
<ul>
<li><code>defaultBlocks</code> — ordered array of block IDs</li>
<li><code>rules</code> — e.g. <code>{ "requiredLinks": ["https://..."] }</code> — used by lint rules</li>
<li><code>variables</code> — default values for <code>{placeholder}</code> tokens</li>
</ul>
</section>
<!-- ── VIDEO CONFIGS ── -->
<section id="video-configs">
<h2><span class="icon">🎛</span> Video Configs Module</h2>
<p>
A <code>VideoConfig</code> is the <strong>reconstruction key</strong> for a video's description.
It stores which blocks are active, in what order, with what variable values and which collaborators —
not the final text. The final text is always reconstructable from this config.
</p>
<div class="table-wrap">
<table>
<tr><th>Method</th><th>Path</th><th>Description</th></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/video-configs/:videoId</code></td><td>Get current config for a video</td></tr>
<tr><td><span class="tag tag-put">PUT</span></td><td><code>/video-configs/:videoId</code></td><td>Upsert config (create or replace), increments version</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/video-configs/:videoId/render-preview</code></td><td>Preview render without persisting</td></tr>
</table>
</div>
<h3>Config fields</h3>
<ul>
<li><code>blockOrder</code><code>string[]</code> — ordered block IDs</li>
<li><code>blockOverrides</code><code>{ [blockId]: { content?, active? } }</code> — per-video overrides</li>
<li><code>variableValues</code><code>{ [key]: value }</code> — fills <code>{placeholder}</code> tokens</li>
<li><code>collaboratorIds</code><code>string[]</code> — IDs used for collaborator block expansion</li>
<li><code>autoRender?: boolean</code> — if true, queues a render job immediately on save</li>
</ul>
</section>
<!-- ── COLLABORATORS ── -->
<section id="collaborators">
<h2><span class="icon"></span> Collaborators Module</h2>
<p>Collaborators are <strong>entities, not free text</strong>. They are referenced by ID in the VideoConfig and resolved at render time into their actual handle, name, and links.</p>
<div class="table-wrap">
<table>
<tr><th>Method</th><th>Path</th><th>Description</th></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/collaborators</code></td><td>All collaborators</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/collaborators</code></td><td>Create collaborator</td></tr>
<tr><td><span class="tag tag-patch">PATCH</span></td><td><code>/collaborators/:id</code></td><td>Update (archive via <code>active: false</code>)</td></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/collaborators/:id/videos</code></td><td>All videos linked to this collaborator</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/videos/:videoId/collaborators</code></td><td>Link collaborator to video (upsert)</td></tr>
<tr><td><span class="tag tag-delete">DELETE</span></td><td><code>/videos/:videoId/collaborators/:cId</code></td><td>Remove link</td></tr>
</table>
</div>
<h3>Placeholder tokens in blocks</h3>
<ul>
<li><code>{collab_name}</code><code>Rhea Vale</code></li>
<li><code>{@youtube_handle}</code><code>@RheaPlays</code></li>
<li><code>{twitch_link}</code><code>https://twitch.tv/rheavale</code></li>
</ul>
</section>
<!-- ── LINTING ── -->
<section id="linting">
<h2><span class="icon"></span> Linting Module</h2>
<p>Ten pluggable lint rules. Each is a class implementing <code>LintRule</code>. New rules can be added without touching existing code.</p>
<h3>LintRule interface</h3>
<pre><code><span class="kw">interface</span> <span class="cls">LintRule</span> {
code: string;
severity: LintSeverity; <span class="cm">// INFO | WARNING | ERROR</span>
<span class="fn">check</span>(video: <span class="kw">any</span>): LintIssue | <span class="kw">null</span>;
}</code></pre>
<h3>All 10 rules</h3>
<div class="table-wrap">
<table>
<tr><th>Code</th><th>Severity</th><th>What it checks</th></tr>
<tr><td><code>TITLE_WEAK</code></td><td><span class="tag tag-warn">WARNING</span></td><td>Title shorter than 20 chars or contains generic words (video, test, untitled)</td></tr>
<tr><td><code>TITLE_TOO_LONG</code></td><td><span class="tag tag-warn">WARNING</span></td><td>Title exceeds 100 characters</td></tr>
<tr><td><code>DESC_MISSING_CTA</code></td><td><span class="tag tag-warn">WARNING</span></td><td>No subscribe/follow/like keywords in rendered description</td></tr>
<tr><td><code>DESC_MISSING_CHAPTERS</code></td><td><span class="tag tag-warn">WARNING</span></td><td>Fewer than 2 timestamp patterns (e.g. <code>0:00</code>) in description</td></tr>
<tr><td><code>DESC_EMPTY_PLACEHOLDER</code></td><td><span class="tag tag-error">ERROR</span></td><td>Unresolved <code>{variable}</code> tokens remain in the rendered text</td></tr>
<tr><td><code>DESC_DUPLICATE_HASHTAG</code></td><td><span class="tag tag-warn">WARNING</span></td><td>Duplicate <code>#hashtag</code> tokens in description</td></tr>
<tr><td><code>DESC_REQUIRED_LINK_MISSING</code></td><td><span class="tag tag-error">ERROR</span></td><td>Template <code>rules.requiredLinks</code> contains a URL not present in description</td></tr>
<tr><td><code>DESC_OUTDATED_SPONSOR_COPY</code></td><td><span class="tag tag-error">ERROR</span></td><td>A campaign block in the config references a campaign that has expired</td></tr>
<tr><td><code>COLLAB_REFERENCE_INVALID</code></td><td><span class="tag tag-error">ERROR</span></td><td>A collaborator ID in <code>videoConfig.collaboratorIds</code> has no corresponding DB record</td></tr>
<tr><td><code>REMOTE_CONFLICT</code></td><td><span class="tag tag-error">ERROR</span></td><td><code>video.remoteConflict === true</code> — YouTube was edited outside StudioFlow</td></tr>
</table>
</div>
<h3>LintingService.lintVideo(videoId)</h3>
<pre><code><span class="num">1.</span> Load video with config + template + collaborators
<span class="num">2.</span> Load campaign blocks for expiry check
<span class="num">3.</span> Run all 10 rules → collect non-null LintIssue results
<span class="num">4.</span> DELETE existing unresolved LintResults for this video
<span class="num">5.</span> INSERT new LintResult rows
<span class="num">6.</span> Compute overall lintStatus: ERROR &gt; WARNING &gt; OK
<span class="num">7.</span> UPDATE video.lintStatus</code></pre>
<div class="table-wrap">
<table>
<tr><th>Method</th><th>Path</th><th>Description</th></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/lint/videos/:id</code></td><td>Lint one video synchronously</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/lint/bulk</code></td><td>Enqueue lint jobs for array of video IDs</td></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/lint/results</code></td><td>All open lint results, filterable by severity/ruleCode/videoId</td></tr>
</table>
</div>
</section>
<!-- ── BULK JOBS ── -->
<section id="bulk-jobs">
<h2><span class="icon"></span> Bulk Jobs Module</h2>
<p>Bulk jobs follow a <strong>dry-run → confirm → execute → rollback</strong> lifecycle.</p>
<h3>Lifecycle</h3>
<div class="flow">
<div class="flow-step">PENDING (dry-run)</div><div class="flow-arrow"></div>
<div class="flow-step">DRY_RUN (diff shown)</div><div class="flow-arrow"></div>
<div class="flow-step">CONFIRMED</div><div class="flow-arrow"></div>
<div class="flow-step">RUNNING</div><div class="flow-arrow"></div>
<div class="flow-step">DONE / FAILED</div>
</div>
<p style="margin-top:12px">Each BulkJob has one <code>BulkJobItem</code> per affected video. The item stores the before snapshot and after snapshot so rollback is a simple record-by-record restore.</p>
<div class="table-wrap">
<table>
<tr><th>Method</th><th>Path</th><th>Description</th></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/bulk-jobs</code></td><td>List all bulk jobs (filter by status)</td></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/bulk-jobs/:id</code></td><td>Single job with all items and video titles</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/bulk-jobs/:id/rollback</code></td><td>Restore all <code>beforeSnapshot</code> values; sets status to ROLLED_BACK</td></tr>
</table>
</div>
<div class="callout">
<p><strong>Rollback safety:</strong> Only items with <code>status: 'done'</code> are restored. Items that failed are skipped. The rollback itself is audited via <code>AuditService</code>.</p>
</div>
</section>
<!-- ── SAVED VIEWS ── -->
<section id="saved-views">
<h2><span class="icon"></span> Saved Views Module</h2>
<p>Saved views are reusable filter presets stored as JSON. They can be applied to the video table, used as a scope for exports, or fed into bulk jobs as a video selector.</p>
<h3>4 default views (seeded on startup)</h3>
<ul>
<li><strong>All sponsor videos</strong> — tags contain "sponsor"</li>
<li><strong>Needs chapters</strong> — lintStatus WARNING</li>
<li><strong>Missing CTA</strong> — lintStatus WARNING</li>
<li><strong>Published this month</strong> — publishedAt ≥ first day of current month (dynamic placeholder resolved at execute time)</li>
</ul>
<div class="table-wrap">
<table>
<tr><th>Method</th><th>Path</th><th>Description</th></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/saved-views</code></td><td>All views</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/saved-views</code></td><td>Create (sets ownerId from JWT)</td></tr>
<tr><td><span class="tag tag-patch">PATCH</span></td><td><code>/saved-views/:id</code></td><td>Update</td></tr>
<tr><td><span class="tag tag-delete">DELETE</span></td><td><code>/saved-views/:id</code></td><td>Delete (ADMIN only)</td></tr>
<tr><td><span class="tag tag-post">POST</span></td><td><code>/saved-views/:id/execute</code></td><td>Run queryJson against DB, return matching video IDs + count</td></tr>
</table>
</div>
</section>
<!-- ── CALENDAR ── -->
<section id="calendar">
<h2><span class="icon">📅</span> Calendar Module</h2>
<p>Returns calendar-shaped entries for scheduled and published videos. Frontend can use these to render month/week/agenda views.</p>
<div class="table-wrap">
<table>
<tr><th>View param</th><th>Date range</th></tr>
<tr><td><code>month</code></td><td>Full calendar month from the given <code>YYYY-MM</code></td></tr>
<tr><td><code>week</code></td><td>7-day week starting from the Sunday of the given date</td></tr>
<tr><td><code>agenda</code></td><td>30 days forward from the given date</td></tr>
</table>
</div>
<p>Each calendar entry shape: <code>{ videoId, title, date, templateName, collaborators[], lintStatus, channelId, privacyStatus }</code></p>
</section>
<!-- ── IMPORTS / EXPORTS ── -->
<section id="imports-exports">
<h2><span class="icon">↑↓</span> Import / Export</h2>
<h3>CSV Import</h3>
<p>Supports these columns: <code>youtube_video_id</code>, <code>title</code>, <code>tags</code>, <code>category_id</code>, <code>privacy_status</code>, <code>scheduled_at</code>, <code>template_name</code>, <code>collaborators</code>.</p>
<div class="flow">
<div class="flow-step">Upload file (multipart)</div><div class="flow-arrow"></div>
<div class="flow-step">POST /imports/csv/preview</div><div class="flow-arrow"></div>
<div class="flow-step">Validation report per row</div><div class="flow-arrow"></div>
<div class="flow-step">POST /imports/csv/commit</div><div class="flow-arrow"></div>
<div class="flow-step">Worker executes</div>
</div>
<p>Each row is validated with a <strong>Zod schema</strong>. Errors are returned as <code>[{ row, field, message }]</code>. An <code>ImportJob</code> record is created at preview time to track the state.</p>
<h3>JSON Import/Export</h3>
<p>Full workspace format covers all entity types:</p>
<pre><code>{
<span class="str">"version"</span>: <span class="str">"1.0"</span>,
<span class="str">"exportedAt"</span>: <span class="str">"2026-04-27T..."</span>,
<span class="str">"videos"</span>: [...],
<span class="str">"videoConfigs"</span>: [...],
<span class="str">"blocks"</span>: [...],
<span class="str">"templates"</span>: [...],
<span class="str">"collaborators"</span>: [...],
<span class="str">"savedViews"</span>: [...]
}</code></pre>
<p>Import upserts all entities by ID, so re-importing a backup is idempotent.</p>
<h3>CSV Export</h3>
<p>Accepts <code>{ videoIds?, savedViewId? }</code>. If neither is provided, exports all videos. Returns a streamed <code>text/csv</code> response with a <code>Content-Disposition: attachment</code> header.</p>
</section>
<!-- ── YOUTUBE SYNC ── -->
<section id="youtube-sync">
<h2><span class="icon">🔴</span> YouTube Sync Module</h2>
<p>Wraps the <code>googleapis</code> YouTube Data API v3 client. The <code>YouTubeApiClient</code> handles OAuth token decryption and the <code>YouTubeSyncService</code> adds quota accounting and conflict detection.</p>
<h3>YouTubeApiClient</h3>
<ul>
<li><code>getVideoMetadata(youtubeVideoId)</code><code>videos.list</code>, part: snippet + status, cost: <strong>1 unit</strong></li>
<li><code>updateVideoMetadata(id, meta)</code><code>videos.update</code>, part: snippet + status, cost: <strong>50 units</strong></li>
</ul>
<h3>YouTubeSyncService.detectConflict(videoId)</h3>
<p>Fetches the current YouTube snippet, hashes it, and compares with <code>lastSyncedHash</code>. If they differ, sets <code>video.remoteConflict = true</code> — which will be caught by the <code>REMOTE_CONFLICT</code> lint rule on the next lint run.</p>
<h3>YouTube Sync Worker (processor)</h3>
<pre><code><span class="num">1.</span> Load video + config from DB
<span class="num">2.</span> Render description with RenderEngineService
<span class="num">3.</span> Hash new metadata → compare with lastSyncedHash
→ identical? SKIP (0 units spent)
<span class="num">4.</span> quota.canSpend(50)?
→ no? moveToDelayed(msUntilQuotaReset())
<span class="num">5.</span> ytSync.pushUpdate(video, meta)
<span class="num">6.</span> quota.spend(50, 'videos.update', videoId)
<span class="num">7.</span> UPDATE video.lastSyncedHash + lastSyncedAt</code></pre>
<div class="callout">
<p><strong>Rate limiter:</strong> The processor is configured with <code>limiter: { max: 5, duration: 10_000 }</code> — maximum 5 YouTube API calls per 10 seconds — and <code>concurrency: 1</code> to prevent race conditions on quota tracking.</p>
</div>
</section>
<!-- ── QUEUES ── -->
<section id="queues">
<h2><span class="icon"></span> BullMQ Queues & Processors</h2>
<div class="table-wrap">
<table>
<tr><th>Queue</th><th>Processor</th><th>Job data</th><th>What it does</th></tr>
<tr>
<td><code>youtube-sync</code></td>
<td><code>YouTubeSyncProcessor</code></td>
<td><code>{ videoId }</code></td>
<td>Render → hash diff → quota check → YouTube update</td>
</tr>
<tr>
<td><code>bulk-metadata</code></td>
<td><code>BulkMetadataProcessor</code></td>
<td><code>{ bulkJobId, itemId }</code></td>
<td>Apply one BulkJobItem action, update success/error counts, mark job DONE when all items complete</td>
</tr>
<tr>
<td><code>render</code></td>
<td><code>RenderProcessor</code></td>
<td><code>{ videoId }</code></td>
<td>Render description, save to DB, then enqueue a lint job</td>
</tr>
<tr>
<td><code>lint</code></td>
<td><code>LintProcessor</code></td>
<td><code>{ videoId }</code></td>
<td>Run all 10 lint rules, persist results</td>
</tr>
<tr>
<td><code>import</code></td>
<td><code>ImportProcessor</code></td>
<td><code>{ importJobId }</code></td>
<td>Execute committed ImportJob (upsert rows to DB)</td>
</tr>
</table>
</div>
<h3>Default job options (bulk-metadata)</h3>
<pre><code>{
attempts: <span class="num">3</span>,
backoff: { type: <span class="str">'exponential'</span>, delay: <span class="num">2000</span> },
removeOnComplete: { age: <span class="num">7</span> * <span class="num">24</span> * <span class="num">60</span> * <span class="num">60</span> }, <span class="cm">// keep 7 days</span>
removeOnFail: <span class="kw">false</span> <span class="cm">// keep failed jobs for analysis</span>
}</code></pre>
<h3>Chained jobs: Render → Lint</h3>
<p>When the <code>render</code> processor finishes, it immediately enqueues a <code>lint</code> job so the lint status is always fresh after any render. This means a <code>PUT /video-configs/:videoId</code> with <code>autoRender: true</code> triggers: save config → render job → lint job.</p>
</section>
<!-- ── QUOTA ENDPOINT ── -->
<section id="quota-endpoint">
<h2><span class="icon">💹</span> Quota & Audit Log API</h2>
<h3>Quota endpoint</h3>
<div class="table-wrap">
<table>
<tr><th>Method</th><th>Path</th><th>Response</th></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/quota/today</code></td><td><code>{ used, remaining, limit: 9000, resetAt, percentUsed }</code></td></tr>
</table>
</div>
<h3>Audit log endpoints</h3>
<div class="table-wrap">
<table>
<tr><th>Method</th><th>Path</th><th>Description</th></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/audit-logs</code></td><td>Paginated full audit trail (newest first)</td></tr>
<tr><td><span class="tag tag-get">GET</span></td><td><code>/audit-logs/:entityType/:entityId</code></td><td>All audit events for a specific entity (e.g. <code>/audit-logs/Video/clxyz123</code>)</td></tr>
</table>
</div>
<p>Every mutation that goes through a service method calls <code>AuditService.log()</code> with a full before/after JSON snapshot. This gives a complete change history for any entity.</p>
</section>
<!-- ── ENV ── -->
<section id="env">
<h2><span class="icon">🔑</span> Environment Variables</h2>
<div class="table-wrap">
<table>
<tr><th>Variable</th><th>Required</th><th>Description</th></tr>
<tr><td><code>DATABASE_URL</code></td><td></td><td>PostgreSQL connection string</td></tr>
<tr><td><code>REDIS_URL</code></td><td></td><td>Redis connection string (with password)</td></tr>
<tr><td><code>JWT_SECRET</code></td><td></td><td>Secret for access JWTs (min 32 chars)</td></tr>
<tr><td><code>JWT_REFRESH_SECRET</code></td><td></td><td>Separate secret for refresh tokens</td></tr>
<tr><td><code>GOOGLE_CLIENT_ID</code></td><td></td><td>Google OAuth app client ID</td></tr>
<tr><td><code>GOOGLE_CLIENT_SECRET</code></td><td></td><td>Google OAuth app client secret</td></tr>
<tr><td><code>GOOGLE_CALLBACK_URL</code></td><td></td><td>Full callback URL e.g. <code>http://localhost:3001/api/v1/auth/google/callback</code></td></tr>
<tr><td><code>YOUTUBE_API_KEY</code></td><td></td><td>YouTube Data API v3 key (for public read ops)</td></tr>
<tr><td><code>TOKEN_ENCRYPTION_KEY</code></td><td></td><td>32-char key for AES-256-CBC encryption of stored OAuth tokens</td></tr>
<tr><td><code>PORT</code></td><td></td><td>API port, default <code>3001</code></td></tr>
<tr><td><code>FRONTEND_URL</code></td><td></td><td>CORS origin, default <code>http://localhost:3000</code></td></tr>
<tr><td><code>NODE_ENV</code></td><td></td><td><code>development</code> / <code>production</code></td></tr>
</table>
</div>
</section>
<!-- ── GETTING STARTED ── -->
<section id="start">
<h2><span class="icon"></span> Getting Started</h2>
<h3>1. Install dependencies</h3>
<pre><code>cd backend
npm install</code></pre>
<h3>2. Set up environment</h3>
<pre><code>cp .env.example .env
<span class="cm"># Fill in all required values in .env</span></code></pre>
<h3>3. Generate Prisma client</h3>
<pre><code>npx prisma generate</code></pre>
<h3>4. Run migrations (requires running PostgreSQL)</h3>
<pre><code>npx prisma migrate dev --name init</code></pre>
<h3>5. Start the API server</h3>
<pre><code>npm run start:dev
<span class="cm"># → http://localhost:3001/api/v1</span>
<span class="cm"># → http://localhost:3001/api/docs (Swagger UI)</span>
<span class="cm"># → http://localhost:3001/health (Docker healthcheck)</span></code></pre>
<h3>6. Start the worker (separate terminal)</h3>
<pre><code>npm run start:worker</code></pre>
<h3>Via Docker Compose</h3>
<pre><code><span class="cm"># First-time migration:</span>
docker compose --profile tools run --rm migrate
<span class="cm"># Start all services:</span>
docker compose up -d</code></pre>
<div class="callout warn">
<p><strong>Note:</strong> The worker and API use the same Docker image but different start commands.
The worker runs <code>node dist/worker.js</code> instead of the default <code>node dist/main.js</code>.</p>
</div>
<hr class="divider" />
<h3>MVP Phase summary</h3>
<div class="table-wrap">
<table>
<tr><th>Phase</th><th>Scope</th><th>Key deliverable</th></tr>
<tr>
<td><span class="badge badge-phase1">Phase 1</span></td>
<td>Structured core</td>
<td>Video config editor, block library, templates, collaborators, render + preview</td>
</tr>
<tr>
<td><span class="badge badge-phase2">Phase 2</span></td>
<td>Operations</td>
<td>Bulk jobs with dry-run/rollback, linting, YouTube sync, quota management</td>
</tr>
<tr>
<td><span class="badge badge-phase3">Phase 3</span></td>
<td>Extended features</td>
<td>Content calendar, CSV/JSON import-export, campaigns, quota history</td>
</tr>
</table>
</div>
</section>
</main>
<script>
// Highlight active sidebar link on scroll
const sections = document.querySelectorAll('section[id]');
const links = document.querySelectorAll('#sidebar nav a');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
const active = document.querySelector(`#sidebar nav a[href="#${entry.target.id}"]`);
if (active) active.classList.add('active');
}
});
}, { rootMargin: '-20% 0px -70% 0px' });
sections.forEach(s => observer.observe(s));
</script>
</body>
</html>