1134 lines
56 KiB
HTML
1134 lines
56 KiB
HTML
<!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 & 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 > WARNING > 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>
|