1119 lines
75 KiB
HTML
1119 lines
75 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 — Developer Guide (Part 3 of 3)</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0d1117; --bg2: #161b22; --bg3: #21262d;
|
|
--border: #30363d; --text: #e6edf3; --muted: #8b949e;
|
|
--blue: #58a6ff; --green: #3fb950; --orange: #d29922;
|
|
--red: #f85149; --purple: #bc8cff; --cyan: #79c0ff;
|
|
--yellow: #e3b341; --pink: #f778ba;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html { scroll-behavior: smooth; }
|
|
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 15px; line-height: 1.7; display: flex; }
|
|
nav { width: 280px; min-width: 280px; background: var(--bg2); border-right: 1px solid var(--border); height: 100vh; position: sticky; top: 0; overflow-y: auto; padding: 24px 0; }
|
|
nav h2 { font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); padding: 0 20px 8px; margin-bottom: 4px; }
|
|
nav ul { list-style: none; }
|
|
nav ul li a { display: block; padding: 5px 20px; color: var(--muted); text-decoration: none; font-size: 13px; border-left: 2px solid transparent; transition: all .15s; }
|
|
nav ul li a:hover, nav ul li a.active { color: var(--text); border-left-color: var(--blue); background: rgba(88,166,255,.06); }
|
|
.nav-group { margin-bottom: 20px; }
|
|
.nav-part-label { font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--purple); padding: 12px 20px 4px; font-weight: 700; }
|
|
main { flex: 1; max-width: 900px; padding: 48px 56px; overflow-x: hidden; }
|
|
h1 { font-size: 2.2rem; font-weight: 800; margin-bottom: 8px; background: linear-gradient(135deg, var(--orange), var(--pink)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
|
h2 { font-size: 1.55rem; font-weight: 700; color: var(--text); margin: 56px 0 16px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
|
h3 { font-size: 1.15rem; font-weight: 600; color: var(--cyan); margin: 32px 0 12px; }
|
|
h4 { font-size: .95rem; font-weight: 600; color: var(--yellow); margin: 20px 0 8px; }
|
|
p { color: #cdd5df; margin-bottom: 14px; }
|
|
strong { color: var(--text); font-weight: 600; }
|
|
em { color: var(--orange); font-style: normal; }
|
|
a { color: var(--blue); text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
pre { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; padding: 20px 24px; margin: 16px 0 24px; overflow-x: auto; font-size: 13.5px; line-height: 1.65; }
|
|
code { font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; }
|
|
p code, li code { background: var(--bg3); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px; font-size: 13px; color: var(--cyan); }
|
|
.kw { color: var(--red); } .fn { color: var(--blue); } .str { color: var(--green); }
|
|
.cm { color: var(--muted); font-style: italic; } .dec { color: var(--purple); }
|
|
.cls { color: var(--yellow); } .num { color: var(--orange); } .typ { color: var(--cyan); }
|
|
.iface { color: var(--pink); }
|
|
.callout { border-radius: 8px; padding: 16px 20px; margin: 20px 0; border-left: 4px solid; }
|
|
.callout.info { background: rgba(88,166,255,.08); border-color: var(--blue); }
|
|
.callout.warn { background: rgba(210,153,34,.08); border-color: var(--orange); }
|
|
.callout.tip { background: rgba(63,185,80,.08); border-color: var(--green); }
|
|
.callout.key { background: rgba(188,140,255,.08); border-color: var(--purple); }
|
|
.callout strong { display: block; margin-bottom: 6px; font-size: .85rem; letter-spacing: .05em; text-transform: uppercase; }
|
|
.callout.info strong { color: var(--blue); } .callout.warn strong { color: var(--orange); }
|
|
.callout.tip strong { color: var(--green); } .callout.key strong { color: var(--purple); }
|
|
table { width: 100%; border-collapse: collapse; margin: 16px 0 28px; font-size: 13.5px; }
|
|
th { background: var(--bg3); color: var(--muted); font-size: 11px; letter-spacing: .06em; text-transform: uppercase; padding: 10px 14px; text-align: left; border-bottom: 2px solid var(--border); }
|
|
td { padding: 10px 14px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
tr:last-child td { border-bottom: none; }
|
|
tr:hover td { background: rgba(255,255,255,.025); }
|
|
.flow { display: flex; align-items: center; gap: 0; flex-wrap: wrap; margin: 16px 0; }
|
|
.flow-step { background: var(--bg3); border: 1px solid var(--border); border-radius: 6px; padding: 8px 14px; font-size: 13px; font-weight: 500; white-space: nowrap; }
|
|
.flow-arrow { color: var(--muted); padding: 0 8px; font-size: 18px; }
|
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 700; }
|
|
.badge.get { background: rgba(63,185,80,.15); color: var(--green); }
|
|
.badge.post { background: rgba(88,166,255,.15); color: var(--blue); }
|
|
.badge.patch { background: rgba(210,153,34,.15); color: var(--orange); }
|
|
.badge.put { background: rgba(188,140,255,.15); color: var(--purple); }
|
|
.badge.delete { background: rgba(248,81,73,.15); color: var(--red); }
|
|
.rule-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 16px 0 24px; }
|
|
.rule-card { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
|
|
.rule-card .code { font-family: monospace; font-size: 11px; color: var(--yellow); margin-bottom: 4px; font-weight: 700; }
|
|
.rule-card .severity-e { color: var(--red); font-size: 11px; font-weight: 700; }
|
|
.rule-card .severity-w { color: var(--orange); font-size: 11px; font-weight: 700; }
|
|
.rule-card p { font-size: 13px; color: var(--muted); margin: 6px 0 0; }
|
|
.queue-card { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; margin: 12px 0; }
|
|
.queue-card h4 { margin: 0 0 6px; color: var(--purple); font-size: .9rem; }
|
|
.queue-card p { font-size: 13px; margin: 0; }
|
|
.part-nav { display: flex; gap: 12px; margin-top: 56px; padding-top: 24px; border-top: 1px solid var(--border); }
|
|
.part-nav a { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; padding: 12px 20px; color: var(--text); font-weight: 500; }
|
|
.part-nav a:hover { border-color: var(--blue); text-decoration: none; }
|
|
ul.spaced { color: #cdd5df; padding-left: 24px; margin-bottom: 16px; }
|
|
ul.spaced li { margin-bottom: 8px; }
|
|
.env-table td:first-child { font-family: monospace; color: var(--cyan); font-size: 13px; }
|
|
.env-table td:nth-child(2) { color: var(--green); font-size: 13px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<nav>
|
|
<div class="nav-part-label">Part 3 of 3</div>
|
|
<div class="nav-group">
|
|
<h2>Render Engine</h2>
|
|
<ul>
|
|
<li><a href="#render-what">How Rendering Works</a></li>
|
|
<li><a href="#render-pipeline">Pipeline Steps</a></li>
|
|
<li><a href="#render-hash">Change Detection</a></li>
|
|
</ul>
|
|
</div>
|
|
<div class="nav-group">
|
|
<h2>Linting</h2>
|
|
<ul>
|
|
<li><a href="#lint-what">Linting Overview</a></li>
|
|
<li><a href="#lint-rules">All 10 Rules</a></li>
|
|
<li><a href="#lint-service">LintingService</a></li>
|
|
</ul>
|
|
</div>
|
|
<div class="nav-group">
|
|
<h2>Queue System</h2>
|
|
<ul>
|
|
<li><a href="#queue-what">BullMQ Overview</a></li>
|
|
<li><a href="#queue-5">The 5 Queues</a></li>
|
|
<li><a href="#queue-youtube-sync">YouTube Sync Processor</a></li>
|
|
<li><a href="#queue-bulk">Bulk Metadata Processor</a></li>
|
|
<li><a href="#queue-render">Render + Lint Chain</a></li>
|
|
<li><a href="#youtube-api-client">YouTubeApiClient & OAuth</a></li>
|
|
<li><a href="#quota">YouTube Quota Management</a></li>
|
|
</ul>
|
|
</div>
|
|
<div class="nav-group">
|
|
<h2>API Modules</h2>
|
|
<ul>
|
|
<li><a href="#api-videos">Videos</a></li>
|
|
<li><a href="#api-blocks">Blocks & Versioning</a></li>
|
|
<li><a href="#api-bulk">Bulk Jobs</a></li>
|
|
<li><a href="#api-import-export">Import & Export</a></li>
|
|
<li><a href="#api-calendar">Calendar</a></li>
|
|
<li><a href="#api-audit">Audit & Quota API</a></li>
|
|
</ul>
|
|
</div>
|
|
<div class="nav-group">
|
|
<h2>Setup</h2>
|
|
<ul>
|
|
<li><a href="#env">Environment Variables</a></li>
|
|
<li><a href="#getting-started">Getting Started</a></li>
|
|
<li><a href="#all-endpoints">All Endpoints</a></li>
|
|
</ul>
|
|
</div>
|
|
<div class="nav-group">
|
|
<h2>Navigation</h2>
|
|
<ul>
|
|
<li><a href="guide-part1.html">← Part 1: TypeScript & NestJS</a></li>
|
|
<li><a href="guide-part2.html">← Part 2: Database & Auth</a></li>
|
|
</ul>
|
|
</div>
|
|
</nav>
|
|
|
|
<main>
|
|
|
|
<h1>StudioFlow Backend</h1>
|
|
<p style="color:var(--muted); margin-bottom:4px;">Developer Guide — Part 3 of 3</p>
|
|
<p style="color:var(--muted);">Render Engine · Linting · BullMQ Queues · API Reference · Setup</p>
|
|
|
|
<!-- ════════════════════════════════════════════ RENDER ENGINE ════ -->
|
|
<h2 id="render-what">How Rendering Works</h2>
|
|
|
|
<p>The render engine is the heart of StudioFlow. It takes a video's configuration and assembles the final description text that gets pushed to YouTube. It is a <strong>pure TypeScript function</strong> — no database calls, no I/O, just data in and text out. This makes it fast, testable, and deterministic.</p>
|
|
|
|
<p>A rendered description is built from <strong>blocks</strong> — reusable text chunks that are assembled in a specific order. Each block can have its content overridden per video, variables filled in, and conditional logic applied.</p>
|
|
|
|
<h3>Input and output types</h3>
|
|
<pre><code><span class="cm">// src/shared/render-engine/render-engine.service.ts</span>
|
|
|
|
<span class="cm">// INPUT: everything the engine needs</span>
|
|
<span class="kw">export interface</span> <span class="iface">RenderInput</span> {
|
|
videoTitle: <span class="typ">string</span>;
|
|
videoTags: <span class="typ">string</span>[];
|
|
videoCategoryId?: <span class="typ">string</span> | <span class="kw">null</span>;
|
|
config: {
|
|
blockOrder: <span class="typ">string</span>[]; <span class="cm">// ordered list of block IDs</span>
|
|
blockOverrides: <span class="cls">Record</span><<span class="typ">string</span>, { <span class="cm">// per-video overrides</span>
|
|
content?: <span class="typ">string</span>;
|
|
active?: <span class="typ">boolean</span>;
|
|
}>;
|
|
variableValues: <span class="cls">Record</span><<span class="typ">string</span>, <span class="typ">string</span>>; <span class="cm">// { sponsorName: "Squarespace" }</span>
|
|
collaboratorIds: <span class="typ">string</span>[];
|
|
};
|
|
blocks: <span class="iface">RenderBlock</span>[]; <span class="cm">// fetched from DB before calling render()</span>
|
|
collaborators: <span class="iface">RenderCollaborator</span>[];
|
|
}
|
|
|
|
<span class="cm">// OUTPUT: the rendered text + a hash of the result</span>
|
|
<span class="kw">export interface</span> <span class="iface">RenderResult</span> {
|
|
rendered: <span class="typ">string</span>; <span class="cm">// the final description ready to send to YouTube</span>
|
|
hash: <span class="typ">string</span>; <span class="cm">// SHA-256 fingerprint of { title, description, tags, categoryId }</span>
|
|
}</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ PIPELINE ════ -->
|
|
<h2 id="render-pipeline">Render Pipeline — Step by Step</h2>
|
|
|
|
<p>The <code>render(input)</code> method processes each block ID in <code>blockOrder</code> from top to bottom:</p>
|
|
|
|
<pre><code><span class="fn">render</span>(input: <span class="iface">RenderInput</span>): <span class="iface">RenderResult</span> {
|
|
<span class="kw">const</span> blockMap = <span class="kw">new</span> <span class="cls">Map</span>(blocks.<span class="fn">map</span>((b) => [b.id, b]));
|
|
<span class="kw">const</span> lines: <span class="typ">string</span>[] = [];
|
|
|
|
<span class="kw">for</span> (<span class="kw">const</span> blockId <span class="kw">of</span> config.blockOrder) {
|
|
<span class="kw">const</span> block = blockMap.<span class="fn">get</span>(blockId);
|
|
<span class="kw">if</span> (!block) <span class="kw">continue</span>; <span class="cm">// block deleted from DB but still in config</span>
|
|
|
|
<span class="cm">// ① Apply override: per-video active flag and content</span>
|
|
<span class="kw">const</span> override = config.blockOverrides[blockId] ?? {};
|
|
<span class="kw">const</span> isActive = override.active !== <span class="kw">undefined</span> ? override.active : block.active;
|
|
<span class="kw">if</span> (!isActive) <span class="kw">continue</span>; <span class="cm">// skip disabled blocks</span>
|
|
|
|
<span class="kw">let</span> content = override.content !== <span class="kw">undefined</span> ? override.content : block.content;
|
|
|
|
<span class="cm">// ② Resolve {variable} placeholders from variableValues</span>
|
|
content = <span class="kw">this</span>.<span class="fn">resolveVariables</span>(content, config.variableValues);
|
|
|
|
<span class="cm">// ③ Inject collaborator data ({collab_name}, {@youtube_handle})</span>
|
|
content = <span class="kw">this</span>.<span class="fn">resolveCollaborators</span>(content, collaborators, config.collaboratorIds);
|
|
|
|
<span class="cm">// ④ CONDITIONAL blocks — skip if the condition variable is falsy</span>
|
|
<span class="kw">if</span> (block.type === BlockType.CONDITIONAL) {
|
|
<span class="kw">const</span> condVar = <span class="kw">this</span>.<span class="fn">extractConditionVar</span>(content); <span class="cm">// parses [if:varName]</span>
|
|
<span class="kw">if</span> (condVar && !config.variableValues[condVar]) <span class="kw">continue</span>;
|
|
}
|
|
|
|
<span class="cm">// ⑤ COLLABORATOR blocks — expand once per collaborator assigned to this video</span>
|
|
<span class="kw">if</span> (block.type === BlockType.COLLABORATOR) {
|
|
lines.<span class="fn">push</span>(<span class="kw">this</span>.<span class="fn">expandCollaboratorBlock</span>(content, collaborators, config.collaboratorIds));
|
|
<span class="kw">continue</span>;
|
|
}
|
|
|
|
lines.<span class="fn">push</span>(content);
|
|
}
|
|
|
|
<span class="kw">const</span> rendered = lines.<span class="fn">join</span>(<span class="str">'\n\n'</span>); <span class="cm">// blocks separated by blank lines</span>
|
|
|
|
<span class="cm">// ⑥ Compute hash of the entire metadata set</span>
|
|
<span class="kw">const</span> hash = <span class="fn">hashMetadata</span>({ title: videoTitle, description: rendered, tags, categoryId });
|
|
|
|
<span class="kw">return</span> { rendered, hash };
|
|
}</code></pre>
|
|
|
|
<h3>Variable resolution</h3>
|
|
<pre><code><span class="cm">// Replaces {variableName} with the corresponding value</span>
|
|
<span class="cm">// Unresolved placeholders remain in the text (caught by the DESC_EMPTY_PLACEHOLDER lint rule)</span>
|
|
|
|
<span class="cm">// Regex: { followed by a letter/underscore, then letters/digits/underscores, then }</span>
|
|
<span class="cm">// The g flag replaces ALL occurrences in the string</span>
|
|
content.<span class="fn">replace</span>(<span class="str">/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g</span>, (match, key) => {
|
|
<span class="kw">return</span> values[key] !== <span class="kw">undefined</span> ? values[key] : match;
|
|
});
|
|
|
|
<span class="cm">// Example:</span>
|
|
<span class="cm">// content: "Sponsored by {sponsorName} — use code {promoCode} for 20% off!"</span>
|
|
<span class="cm">// values: { sponsorName: "Squarespace", promoCode: "STUDIO20" }</span>
|
|
<span class="cm">// result: "Sponsored by Squarespace — use code STUDIO20 for 20% off!"</span></code></pre>
|
|
|
|
<h3>Collaborator expansion</h3>
|
|
<pre><code><span class="cm">// COLLABORATOR blocks are rendered once per collaborator assigned to the video</span>
|
|
<span class="kw">private</span> <span class="fn">expandCollaboratorBlock</span>(template, collaborators, collaboratorIds) {
|
|
<span class="kw">const</span> active = collaborators.<span class="fn">filter</span>((c) => collaboratorIds.<span class="fn">includes</span>(c.id));
|
|
|
|
<span class="cm">// Map each collaborator to a copy of the block with their data substituted</span>
|
|
<span class="kw">return</span> active.<span class="fn">map</span>((c) =>
|
|
template
|
|
.<span class="fn">replace</span>(<span class="str">/\{collab_name\}/g</span>, c.name)
|
|
.<span class="fn">replace</span>(<span class="str">/\{@youtube_handle\}/g</span>, c.youtubeHandle)
|
|
.<span class="fn">replace</span>(<span class="str">/\{twitch_link\}/g</span>, c.twitchLink ?? <span class="str">''</span>)
|
|
).<span class="fn">join</span>(<span class="str">'\n\n'</span>);
|
|
}
|
|
|
|
<span class="cm">// Block template: "Big thanks to {collab_name} — subscribe: {#youtube_handle}"
|
|
// With 2 collaborators: produces two paragraphs, one per collaborator</span></code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ HASH ════ -->
|
|
<h2 id="render-hash">Change Detection with Hashing</h2>
|
|
|
|
<p>Before pushing a video to YouTube (which costs 50 API quota units), the sync processor checks whether anything actually changed. If the rendered text, title, tags, and category are identical to the last sync, the update is skipped entirely.</p>
|
|
|
|
<pre><code><span class="cm">// src/shared/render-engine/hash.ts</span>
|
|
|
|
<span class="cm">// SHA-256 hash of the stringified metadata object</span>
|
|
<span class="cm">// JSON.stringify produces the same string for the same object
|
|
// (as long as key order is consistent — it is here because it's hardcoded)</span>
|
|
<span class="kw">export function</span> <span class="fn">hashMetadata</span>(data: {
|
|
title: <span class="typ">string</span>;
|
|
description: <span class="typ">string</span>;
|
|
tags: <span class="typ">string</span>[];
|
|
categoryId?: <span class="typ">string</span> | <span class="kw">null</span>;
|
|
}): <span class="typ">string</span> {
|
|
<span class="kw">return</span> <span class="fn">createHash</span>(<span class="str">'sha256'</span>)
|
|
.<span class="fn">update</span>(<span class="cls">JSON</span>.<span class="fn">stringify</span>(data))
|
|
.<span class="fn">digest</span>(<span class="str">'hex'</span>); <span class="cm">// 64-character hex string</span>
|
|
}
|
|
|
|
<span class="kw">export function</span> <span class="fn">hasChanged</span>(lastSyncedHash: <span class="typ">string</span> | <span class="kw">null</span> | <span class="kw">undefined</span>, newHash: <span class="typ">string</span>): <span class="typ">boolean</span> {
|
|
<span class="kw">return</span> lastSyncedHash !== newHash;
|
|
}
|
|
|
|
<span class="cm">// In the YouTube sync processor:</span>
|
|
<span class="kw">if</span> (!<span class="fn">hasChanged</span>(video.lastSyncedHash, newHash)) {
|
|
<span class="kw">return</span> { skipped: <span class="kw">true</span>, reason: <span class="str">'no-change'</span> }; <span class="cm">// 50 quota units saved!</span>
|
|
}</code></pre>
|
|
|
|
<div class="callout tip">
|
|
<strong>Why SHA-256?</strong>
|
|
SHA-256 produces a fixed 64-character string regardless of input length. Any change — even a single character in the description — produces a completely different hash. Comparing two 64-character strings is O(1), no matter how long the description is.
|
|
</div>
|
|
|
|
<!-- ════════════════════════════════════════════ LINTING ════ -->
|
|
<h2 id="lint-what">Linting Overview</h2>
|
|
|
|
<p>The linting system automatically checks the quality of every video's description, title, and configuration. It runs after every render and reports issues that editors need to fix before publishing.</p>
|
|
|
|
<p>The design uses the <strong>strategy pattern</strong>: a common interface (<code>LintRule</code>) that all rules implement. The <code>LintingService</code> holds an array of all rule instances and iterates over them — adding a new rule requires only creating a new file and adding it to the array.</p>
|
|
|
|
<pre><code><span class="cm">// src/modules/linting/rules/base.rule.ts</span>
|
|
<span class="kw">export interface</span> <span class="iface">LintIssue</span> {
|
|
message: <span class="typ">string</span>;
|
|
targetField?: <span class="typ">string</span>; <span class="cm">// 'title', 'description', 'sync'</span>
|
|
fixSuggestion?: <span class="typ">string</span>; <span class="cm">// human-readable hint</span>
|
|
}
|
|
|
|
<span class="kw">export interface</span> <span class="iface">LintRule</span> {
|
|
code: <span class="typ">string</span>; <span class="cm">// e.g. 'TITLE_WEAK'</span>
|
|
severity: <span class="typ">LintSeverity</span>; <span class="cm">// INFO | WARNING | ERROR</span>
|
|
<span class="fn">check</span>(video: <span class="typ">any</span>): <span class="iface">LintIssue</span> | <span class="kw">null</span>;
|
|
}</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ RULES ════ -->
|
|
<h2 id="lint-rules">All 10 Lint Rules</h2>
|
|
|
|
<div class="rule-grid">
|
|
<div class="rule-card">
|
|
<div class="code">TITLE_WEAK</div>
|
|
<div class="severity-w">WARNING</div>
|
|
<p>Title shorter than 20 characters or contains generic words like "video", "test", "untitled".</p>
|
|
</div>
|
|
<div class="rule-card">
|
|
<div class="code">TITLE_TOO_LONG</div>
|
|
<div class="severity-w">WARNING</div>
|
|
<p>Title exceeds 100 characters. YouTube truncates long titles in search results.</p>
|
|
</div>
|
|
<div class="rule-card">
|
|
<div class="code">DESC_MISSING_CTA</div>
|
|
<div class="severity-w">WARNING</div>
|
|
<p>The rendered description contains none of: subscribe, like, follow, comment, cta.</p>
|
|
</div>
|
|
<div class="rule-card">
|
|
<div class="code">DESC_MISSING_CHAPTERS</div>
|
|
<div class="severity-w">WARNING</div>
|
|
<p>Fewer than 2 timestamp patterns (e.g. "0:00", "1:23") found in the description.</p>
|
|
</div>
|
|
<div class="rule-card">
|
|
<div class="code">DESC_EMPTY_PLACEHOLDER</div>
|
|
<div class="severity-e">ERROR</div>
|
|
<p>The rendered description still contains unresolved <code>{variable}</code> placeholders.</p>
|
|
</div>
|
|
<div class="rule-card">
|
|
<div class="code">DESC_DUPLICATE_HASHTAG</div>
|
|
<div class="severity-w">WARNING</div>
|
|
<p>The same <code>#hashtag</code> appears more than once in the description.</p>
|
|
</div>
|
|
<div class="rule-card">
|
|
<div class="code">DESC_REQUIRED_LINK_MISSING</div>
|
|
<div class="severity-e">ERROR</div>
|
|
<p>The video's template defines required links (e.g. merch URL) that are absent from the description.</p>
|
|
</div>
|
|
<div class="rule-card">
|
|
<div class="code">DESC_OUTDATED_SPONSOR_COPY</div>
|
|
<div class="severity-e">ERROR</div>
|
|
<p>A CAMPAIGN block is present and the linked campaign's <code>endAt</code> date has passed.</p>
|
|
</div>
|
|
<div class="rule-card">
|
|
<div class="code">COLLAB_REFERENCE_INVALID</div>
|
|
<div class="severity-e">ERROR</div>
|
|
<p>A collaborator ID listed in the video config doesn't match any VideoCollaborator row in the DB.</p>
|
|
</div>
|
|
<div class="rule-card">
|
|
<div class="code">REMOTE_CONFLICT</div>
|
|
<div class="severity-e">ERROR</div>
|
|
<p>The video's <code>remoteConflict</code> flag is true — YouTube's metadata differs from our last known sync.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<h3>Example rule implementation</h3>
|
|
<pre><code><span class="cm">// src/modules/linting/rules/desc-empty-placeholder.rule.ts</span>
|
|
|
|
<span class="cm">// This regex matches any {variable} that remains in the description after rendering</span>
|
|
<span class="kw">const</span> PLACEHOLDER_REGEX = <span class="str">/\{[a-z_][a-z0-9_]*\}/g</span>;
|
|
|
|
<span class="kw">export class</span> <span class="cls">DescEmptyPlaceholderRule</span> <span class="kw">implements</span> <span class="iface">LintRule</span> {
|
|
code = <span class="str">'DESC_EMPTY_PLACEHOLDER'</span>;
|
|
severity = LintSeverity.ERROR; <span class="cm">// ERROR = blocks publishing</span>
|
|
|
|
<span class="fn">check</span>(video: <span class="typ">any</span>): <span class="iface">LintIssue</span> | <span class="kw">null</span> {
|
|
<span class="kw">const</span> desc: <span class="typ">string</span> = video.renderedDescription ?? <span class="str">''</span>;
|
|
|
|
<span class="cm">// String.match() with a /g regex returns ALL matches as an array</span>
|
|
<span class="kw">const</span> unresolved = desc.<span class="fn">match</span>(PLACEHOLDER_REGEX);
|
|
|
|
<span class="kw">if</span> (unresolved?.length) {
|
|
<span class="kw">return</span> {
|
|
message: <span class="str">`Unresolved placeholders: </span>${unresolved.<span class="fn">join</span>(<span class="str">', '</span>)}<span class="str">`</span>,
|
|
targetField: <span class="str">'description'</span>,
|
|
fixSuggestion: <span class="str">'Fill in all variable values before publishing.'</span>,
|
|
};
|
|
}
|
|
<span class="kw">return null</span>; <span class="cm">// null = no issue</span>
|
|
}
|
|
}</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ LINT SERVICE ════ -->
|
|
<h2 id="lint-service">LintingService</h2>
|
|
|
|
<p>The service runs all rules against a video and replaces the previous lint results atomically. The overall <code>lintStatus</code> on the Video record is then updated to reflect the worst severity found.</p>
|
|
|
|
<pre><code><span class="dec">@Injectable</span>()
|
|
<span class="kw">export class</span> <span class="cls">LintingService</span> {
|
|
<span class="cm">// All rules as singleton instances — no DI needed, they have no dependencies</span>
|
|
<span class="kw">private readonly</span> rules: <span class="iface">LintRule</span>[] = [
|
|
<span class="kw">new</span> <span class="cls">TitleWeakRule</span>(), <span class="kw">new</span> <span class="cls">TitleTooLongRule</span>(),
|
|
<span class="kw">new</span> <span class="cls">DescMissingCtaRule</span>(), <span class="kw">new</span> <span class="cls">DescMissingChaptersRule</span>(),
|
|
<span class="kw">new</span> <span class="cls">DescEmptyPlaceholderRule</span>(), <span class="kw">new</span> <span class="cls">DescDuplicateHashtagRule</span>(),
|
|
<span class="kw">new</span> <span class="cls">DescRequiredLinkMissingRule</span>(), <span class="kw">new</span> <span class="cls">DescOutdatedSponsorRule</span>(),
|
|
<span class="kw">new</span> <span class="cls">CollabReferenceInvalidRule</span>(), <span class="kw">new</span> <span class="cls">RemoteConflictRule</span>(),
|
|
];
|
|
|
|
<span class="kw">async</span> <span class="fn">lintVideo</span>(videoId: <span class="typ">string</span>) {
|
|
<span class="cm">// Load video with everything the rules might need</span>
|
|
<span class="kw">const</span> video = <span class="kw">await</span> <span class="kw">this</span>.prisma.video.<span class="fn">findUniqueOrThrow</span>({
|
|
where: { id: videoId },
|
|
include: { config: <span class="kw">true</span>, template: <span class="kw">true</span>, collaborators: { include: { collaborator: <span class="kw">true</span> } } },
|
|
});
|
|
|
|
<span class="cm">// Run ALL rules and collect non-null results</span>
|
|
<span class="kw">const</span> issues = <span class="kw">this</span>.rules
|
|
.<span class="fn">map</span>((rule) => rule.<span class="fn">check</span>(video)) <span class="cm">// [null, issue, null, issue, ...]</span>
|
|
.<span class="fn">filter</span>(Boolean) <span class="cm">// remove nulls</span>
|
|
.<span class="fn">map</span>((issue, i) => ({ <span class="cm">// shape for createMany</span>
|
|
videoId,
|
|
ruleCode: <span class="kw">this</span>.rules[i].code,
|
|
severity: <span class="kw">this</span>.rules[i].severity,
|
|
...issue,
|
|
}));
|
|
|
|
<span class="cm">// Atomically: delete old results + insert new ones + update video status</span>
|
|
<span class="kw">await</span> <span class="kw">this</span>.prisma.<span class="fn">$transaction</span>([
|
|
<span class="kw">this</span>.prisma.lintResult.<span class="fn">deleteMany</span>({ where: { videoId, resolvedAt: <span class="kw">null</span> } }),
|
|
...(issues.length ? [<span class="kw">this</span>.prisma.lintResult.<span class="fn">createMany</span>({ data: issues })] : []),
|
|
<span class="kw">this</span>.prisma.video.<span class="fn">update</span>({
|
|
where: { id: videoId },
|
|
data: { lintStatus: <span class="kw">this</span>.<span class="fn">computeStatus</span>(issues.<span class="fn">map</span>((i) => i.severity)) },
|
|
}),
|
|
]);
|
|
|
|
<span class="kw">return</span> issues;
|
|
}
|
|
|
|
<span class="cm">// Escalation: ERROR > WARNING > OK</span>
|
|
<span class="kw">private</span> <span class="fn">computeStatus</span>(severities: <span class="typ">LintSeverity</span>[]): <span class="typ">LintStatus</span> {
|
|
<span class="kw">if</span> (severities.<span class="fn">includes</span>(LintSeverity.ERROR)) <span class="kw">return</span> LintStatus.ERROR;
|
|
<span class="kw">if</span> (severities.<span class="fn">includes</span>(LintSeverity.WARNING)) <span class="kw">return</span> LintStatus.WARNING;
|
|
<span class="kw">return</span> LintStatus.OK;
|
|
}
|
|
}</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ QUEUES ════ -->
|
|
<h2 id="queue-what">BullMQ Overview</h2>
|
|
|
|
<p><strong>BullMQ</strong> is a queue library that uses Redis as its backing store. A queue is like a to-do list — producers add jobs to it, consumers (processors) pick them up and execute them. This separates the HTTP request cycle (fast) from the actual work (slow).</p>
|
|
|
|
<p>Key concepts:</p>
|
|
<table>
|
|
<thead><tr><th>Concept</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
<tr><td><strong>Queue</strong></td><td>A named list of jobs stored in Redis. Producers add to it.</td></tr>
|
|
<tr><td><strong>Job</strong></td><td>A unit of work with a JSON payload. Has a status: waiting → active → completed/failed.</td></tr>
|
|
<tr><td><strong>Processor</strong></td><td>A class decorated with <code>@Processor(queueName)</code> that implements <code>process(job)</code>.</td></tr>
|
|
<tr><td><strong>Worker</strong></td><td>The running instance that pulls jobs from the queue and calls the processor.</td></tr>
|
|
<tr><td><strong>Concurrency</strong></td><td>How many jobs a processor handles simultaneously. Default is unlimited.</td></tr>
|
|
<tr><td><strong>Delayed jobs</strong></td><td><code>job.moveToDelayed(timestamp)</code> puts a job on hold until a future time.</td></tr>
|
|
<tr><td><strong>Rate limiter</strong></td><td>Restricts how many jobs start in a time window (e.g. max 5 per 10 seconds).</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- ════════════════════════════════════════════ 5 QUEUES ════ -->
|
|
<h2 id="queue-5">The 5 Queues</h2>
|
|
|
|
<div class="queue-card">
|
|
<h4>youtube-sync</h4>
|
|
<p><strong>Producer:</strong> <code>VideosService.enqueueSyncJob()</code> — called when a user clicks "Sync to YouTube"<br>
|
|
<strong>Processor:</strong> <code>YouTubeSyncProcessor</code><br>
|
|
<strong>Payload:</strong> <code>{ videoId: string; userId: string }</code><br>
|
|
<strong>Special:</strong> concurrency=1 (one sync at a time), rate-limited to 5 per 10 seconds. <code>userId</code> identifies which user's stored OAuth token to use for the YouTube API call.</p>
|
|
</div>
|
|
|
|
<div class="queue-card">
|
|
<h4>bulk-metadata</h4>
|
|
<p><strong>Producer:</strong> <code>BulkJobsService.enqueueItems()</code> — one job per video in the bulk operation<br>
|
|
<strong>Processor:</strong> <code>BulkMetadataProcessor</code><br>
|
|
<strong>Payload:</strong> <code>{ bulkJobId: string, itemId: string }</code></p>
|
|
</div>
|
|
|
|
<div class="queue-card">
|
|
<h4>render</h4>
|
|
<p><strong>Producer:</strong> <code>VideoConfigsService</code> when <code>autoRender: true</code><br>
|
|
<strong>Processor:</strong> <code>RenderProcessor</code><br>
|
|
<strong>Payload:</strong> <code>{ videoId: string }</code><br>
|
|
<strong>Special:</strong> automatically enqueues a lint job when finished</p>
|
|
</div>
|
|
|
|
<div class="queue-card">
|
|
<h4>lint</h4>
|
|
<p><strong>Producer:</strong> <code>RenderProcessor</code> (chained) or <code>LintingController</code><br>
|
|
<strong>Processor:</strong> <code>LintProcessor</code><br>
|
|
<strong>Payload:</strong> <code>{ videoId: string }</code></p>
|
|
</div>
|
|
|
|
<div class="queue-card">
|
|
<h4>import</h4>
|
|
<p><strong>Producer:</strong> <code>ImportsService.commitCsv()</code><br>
|
|
<strong>Processor:</strong> <code>ImportProcessor</code><br>
|
|
<strong>Payload:</strong> <code>{ importJobId: string }</code></p>
|
|
</div>
|
|
|
|
<!-- ════════════════════════════════════════════ YOUTUBE SYNC ════ -->
|
|
<h2 id="queue-youtube-sync">YouTube Sync Processor — Full Flow</h2>
|
|
|
|
<p>This is the most complex processor. It renders the description, checks if anything changed, checks quota availability, and only then calls YouTube's API.</p>
|
|
|
|
<pre><code><span class="dec">@Processor</span>(QUEUES.YOUTUBE_SYNC, {
|
|
concurrency: <span class="num">1</span>, <span class="cm">// one sync at a time per worker</span>
|
|
limiter: { max: <span class="num">5</span>, duration: <span class="num">10_000</span> }, <span class="cm">// max 5 jobs per 10 seconds</span>
|
|
})
|
|
<span class="kw">export class</span> <span class="cls">YouTubeSyncProcessor</span> <span class="kw">extends</span> <span class="cls">WorkerHost</span> {
|
|
|
|
<span class="kw">async</span> <span class="fn">process</span>(job: <span class="cls">Job</span><{ videoId: <span class="typ">string</span>; userId: <span class="typ">string</span> }>) {
|
|
<span class="kw">const</span> { videoId, userId } = job.data; <span class="cm">// userId identifies whose OAuth token to use</span>
|
|
|
|
<span class="cm">// Step 1: Load everything needed for rendering</span>
|
|
<span class="kw">const</span> video = <span class="kw">await</span> prisma.video.<span class="fn">findUniqueOrThrow</span>({
|
|
where: { id: videoId },
|
|
include: { config: <span class="kw">true</span>, collaborators: { include: { collaborator: <span class="kw">true</span> } } },
|
|
});
|
|
|
|
<span class="cm">// Step 2: Render the description</span>
|
|
<span class="kw">const</span> renderResult = renderEngine.<span class="fn">render</span>({ ... });
|
|
|
|
<span class="cm">// Step 3: Hash the metadata and compare with last synced state</span>
|
|
<span class="kw">const</span> newHash = <span class="fn">hashMetadata</span>({ title: video.title, description: renderResult.rendered, ... });
|
|
<span class="kw">if</span> (!<span class="fn">hasChanged</span>(video.lastSyncedHash, newHash)) {
|
|
<span class="kw">return</span> { skipped: <span class="kw">true</span>, reason: <span class="str">'no-change'</span> }; <span class="cm">// ✓ saves 50 quota units</span>
|
|
}
|
|
|
|
<span class="cm">// Step 4: Check YouTube API quota</span>
|
|
<span class="kw">if</span> (!(await quota.<span class="fn">canSpend</span>(<span class="num">50</span>))) {
|
|
<span class="cm">// Delay the job until quota resets (midnight Pacific Time)</span>
|
|
<span class="kw">await</span> job.<span class="fn">moveToDelayed</span>(Date.<span class="fn">now</span>() + quota.<span class="fn">msUntilQuotaReset</span>());
|
|
<span class="kw">return</span> { deferred: <span class="kw">true</span> };
|
|
}
|
|
|
|
<span class="cm">// Step 5: Push to YouTube using the triggering user's OAuth token (costs 50 units)</span>
|
|
<span class="kw">await</span> ytSync.<span class="fn">pushUpdate</span>(video, { title, description: renderResult.rendered, ... }, userId);
|
|
|
|
<span class="cm">// Step 6: Record the quota spend</span>
|
|
<span class="kw">await</span> quota.<span class="fn">spend</span>(<span class="num">50</span>, <span class="str">'videos.update'</span>, videoId);
|
|
|
|
<span class="cm">// Step 7: Save the hash so we can skip next time if nothing changed</span>
|
|
<span class="kw">await</span> prisma.video.<span class="fn">update</span>({
|
|
where: { id: videoId },
|
|
data: { lastSyncedHash: newHash, lastSyncedAt: <span class="kw">new</span> <span class="cls">Date</span>(), remoteConflict: <span class="kw">false</span> },
|
|
});
|
|
}
|
|
}</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ BULK PROCESSOR ════ -->
|
|
<h2 id="queue-bulk">Bulk Metadata Processor</h2>
|
|
|
|
<p>Each bulk job item (one video in a batch operation) becomes its own queue job. The processor applies the action, records before/after snapshots, and updates the parent BulkJob's counters.</p>
|
|
|
|
<pre><code><span class="kw">async</span> <span class="fn">process</span>(job: <span class="cls">Job</span><{ bulkJobId: <span class="typ">string</span>; itemId: <span class="typ">string</span> }>) {
|
|
<span class="kw">const</span> item = <span class="kw">await</span> prisma.bulkJobItem.<span class="fn">findUniqueOrThrow</span>({
|
|
where: { id: itemId },
|
|
include: { bulkJob: <span class="kw">true</span>, video: <span class="kw">true</span> },
|
|
});
|
|
|
|
<span class="kw">const</span> before = { title: video.title, tags: video.tags, privacyStatus: video.privacyStatus };
|
|
|
|
<span class="kw">try</span> {
|
|
<span class="kw">const</span> after = <span class="kw">this</span>.<span class="fn">applyAction</span>(before, bulkJob.type, bulkJob.filterSnapshot);
|
|
|
|
<span class="kw">await</span> prisma.<span class="fn">$transaction</span>([
|
|
prisma.video.<span class="fn">update</span>({ where: { id: video.id }, data: after }),
|
|
prisma.bulkJobItem.<span class="fn">update</span>({
|
|
where: { id: itemId },
|
|
data: { status: <span class="str">'done'</span>, beforeSnapshot: before, afterSnapshot: after },
|
|
}),
|
|
prisma.bulkJob.<span class="fn">update</span>({
|
|
where: { id: bulkJobId },
|
|
data: { successCount: { increment: <span class="num">1</span> } }, <span class="cm">// atomic increment</span>
|
|
}),
|
|
]);
|
|
} <span class="kw">catch</span> (err) {
|
|
<span class="cm">// Record the failure without crashing the whole batch</span>
|
|
<span class="kw">await</span> prisma.<span class="fn">$transaction</span>([
|
|
prisma.bulkJobItem.<span class="fn">update</span>({ where: { id: itemId }, data: { status: <span class="str">'error'</span>, errorMessage: err.message } }),
|
|
prisma.bulkJob.<span class="fn">update</span>({ where: { id: bulkJobId }, data: { errorCount: { increment: <span class="num">1</span> } } }),
|
|
]);
|
|
}
|
|
|
|
<span class="cm">// Check if all items are done — if so, mark the parent job complete</span>
|
|
<span class="kw">const</span> pending = <span class="kw">await</span> prisma.bulkJobItem.<span class="fn">count</span>({ where: { bulkJobId, status: <span class="str">'pending'</span> } });
|
|
<span class="kw">if</span> (pending === <span class="num">0</span>) {
|
|
<span class="kw">const</span> errors = <span class="kw">await</span> prisma.bulkJobItem.<span class="fn">count</span>({ where: { bulkJobId, status: <span class="str">'error'</span> } });
|
|
<span class="kw">await</span> prisma.bulkJob.<span class="fn">update</span>({
|
|
where: { id: bulkJobId },
|
|
data: { status: errors > <span class="num">0</span> ? <span class="str">'FAILED'</span> : <span class="str">'DONE'</span>, completedAt: <span class="kw">new</span> <span class="cls">Date</span>() },
|
|
});
|
|
}
|
|
}</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ RENDER CHAIN ════ -->
|
|
<h2 id="queue-render">Render → Lint Job Chaining</h2>
|
|
|
|
<p>The RenderProcessor automatically triggers a lint job when rendering finishes. This chaining keeps the two concerns (rendering and quality-checking) separate while ensuring lint always runs after a render.</p>
|
|
|
|
<pre><code><span class="dec">@Processor</span>(QUEUES.RENDER)
|
|
<span class="kw">export class</span> <span class="cls">RenderProcessor</span> <span class="kw">extends</span> <span class="cls">WorkerHost</span> {
|
|
<span class="kw">constructor</span>(
|
|
<span class="kw">private readonly</span> prisma: <span class="cls">PrismaService</span>,
|
|
<span class="kw">private readonly</span> renderEngine: <span class="cls">RenderEngineService</span>,
|
|
<span class="dec">@InjectQueue</span>(QUEUES.LINT) <span class="kw">private readonly</span> lintQueue: <span class="cls">Queue</span>,
|
|
<span class="cm">// ↑ This processor is ALSO a producer of lint jobs</span>
|
|
) { <span class="kw">super</span>(); }
|
|
|
|
<span class="kw">async</span> <span class="fn">process</span>(job: <span class="cls">Job</span><{ videoId: <span class="typ">string</span> }>) {
|
|
<span class="cm">// ... render and save to DB ...</span>
|
|
|
|
<span class="cm">// Chain: enqueue a lint job immediately after render completes</span>
|
|
<span class="kw">await</span> <span class="kw">this</span>.lintQueue.<span class="fn">add</span>(<span class="str">'lint'</span>, { videoId }, {
|
|
jobId: <span class="str">`lint-</span>${videoId}<span class="str">-after-render`</span>
|
|
<span class="cm">// Named jobId prevents duplicate lint jobs if render fires multiple times quickly</span>
|
|
});
|
|
|
|
<span class="kw">return</span> { rendered: <span class="kw">true</span> };
|
|
}
|
|
}</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ QUOTA ════ -->
|
|
<h2 id="youtube-api-client">YouTubeApiClient — OAuth-based YouTube Access</h2>
|
|
|
|
<p>All YouTube API calls use the <strong>logged-in user's OAuth token</strong>, stored encrypted in the database after the Google login flow. There is no separate YouTube API key — the YouTube Data API v3 scope is requested at login time, so the user's access token already grants full read/write access to their channel.</p>
|
|
|
|
<div class="callout callout-info">
|
|
<strong>Why no API key?</strong> A standalone API key only allows unauthenticated <em>read</em> access to public data. Since StudioFlow writes video metadata (titles, descriptions, tags), it needs OAuth — and once you have OAuth, the API key is redundant. Removing it simplifies config and reduces secrets to manage.
|
|
</div>
|
|
|
|
<pre><code><span class="kw">private async</span> <span class="fn">getYouTubeClient</span>(userId: <span class="typ">string</span>) {
|
|
<span class="kw">const</span> user = <span class="kw">await</span> prisma.user.<span class="fn">findUniqueOrThrow</span>({ where: { id: userId } });
|
|
|
|
<span class="cm">// OAuth2 client needs client ID + secret so it can auto-refresh expired tokens</span>
|
|
<span class="kw">const</span> oauth2 = <span class="kw">new</span> google.auth.<span class="cls">OAuth2</span>(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
|
|
|
oauth2.<span class="fn">setCredentials</span>({
|
|
access_token: authService.<span class="fn">decryptToken</span>(user.youtubeAccessToken),
|
|
refresh_token: authService.<span class="fn">decryptToken</span>(user.youtubeRefreshToken),
|
|
});
|
|
|
|
<span class="cm">// When Google silently rotates the tokens, persist the new ones back to DB</span>
|
|
oauth2.<span class="fn">on</span>(<span class="str">'tokens'</span>, <span class="kw">async</span> (tokens) => {
|
|
<span class="kw">await</span> prisma.user.<span class="fn">update</span>({
|
|
where: { id: userId },
|
|
data: {
|
|
youtubeAccessToken: authService.<span class="fn">encryptToken</span>(tokens.access_token),
|
|
youtubeTokenExpiry: <span class="kw">new</span> <span class="cls">Date</span>(tokens.expiry_date),
|
|
<span class="cm">// refresh_token only included when Google issues a new one</span>
|
|
...(tokens.refresh_token && {
|
|
youtubeRefreshToken: authService.<span class="fn">encryptToken</span>(tokens.refresh_token),
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
|
|
<span class="kw">return</span> google.<span class="fn">youtube</span>({ version: <span class="str">'v3'</span>, auth: oauth2 });
|
|
}</code></pre>
|
|
|
|
<p>The <code>'tokens'</code> event fires automatically whenever the Google OAuth2 client performs a token refresh. By persisting the new tokens back to the database, the user never needs to log in again unless they explicitly revoke access.</p>
|
|
|
|
<p>Every public method on <code>YouTubeApiClient</code> now requires a <code>userId</code> parameter, which flows from the HTTP request through the queue job payload all the way to this method:</p>
|
|
|
|
<pre><code><span class="cm">// Controller — user triggers sync via HTTP</span>
|
|
<span class="fn">sync</span>(@Param(<span class="str">'id'</span>) id: <span class="typ">string</span>, @Req() req: <span class="kw">any</span>) {
|
|
<span class="kw">return</span> <span class="kw">this</span>.service.<span class="fn">enqueueSyncJob</span>(id, req.user.sub); <span class="cm">// sub = user's DB id from JWT</span>
|
|
}
|
|
|
|
<span class="cm">// Service — puts userId into the queue job payload</span>
|
|
<span class="kw">async</span> <span class="fn">enqueueSyncJob</span>(videoId: <span class="typ">string</span>, userId: <span class="typ">string</span>) {
|
|
<span class="kw">await</span> syncQueue.<span class="fn">add</span>(<span class="str">'sync'</span>, { videoId, userId });
|
|
}
|
|
|
|
<span class="cm">// Processor — reads userId from job and passes it to the API client</span>
|
|
<span class="kw">async</span> <span class="fn">process</span>(job: <span class="cls">Job</span><{ videoId: <span class="typ">string</span>; userId: <span class="typ">string</span> }>) {
|
|
<span class="kw">const</span> { videoId, userId } = job.data;
|
|
<span class="cm">// ... render, hash check, quota check ...</span>
|
|
<span class="kw">await</span> ytSync.<span class="fn">pushUpdate</span>(video, meta, userId); <span class="cm">// userId carried through</span>
|
|
}</code></pre>
|
|
|
|
<h2 id="quota">YouTube Quota Management</h2>
|
|
|
|
<p>YouTube's Data API v3 has a daily quota of 10,000 units (this project uses 9,000 to leave a buffer). Different API operations cost different amounts:</p>
|
|
|
|
<table>
|
|
<thead><tr><th>Operation</th><th>Cost</th><th>Method called</th></tr></thead>
|
|
<tbody>
|
|
<tr><td><code>videos.list</code> (read metadata)</td><td>1 unit</td><td><code>YouTubeSyncService.fetchRemote()</code></td></tr>
|
|
<tr><td><code>videos.update</code> (write metadata)</td><td>50 units</td><td><code>YouTubeSyncService.pushUpdate()</code></td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h3>QuotaService design</h3>
|
|
<pre><code><span class="dec">@Injectable</span>()
|
|
<span class="kw">export class</span> <span class="cls">QuotaService</span> {
|
|
<span class="kw">private readonly</span> DAILY_LIMIT = <span class="num">9_000</span>;
|
|
|
|
<span class="cm">// Checks if we can afford to spend `units` without exceeding the daily limit</span>
|
|
<span class="kw">async</span> <span class="fn">canSpend</span>(units: <span class="typ">number</span>): Promise<<span class="typ">boolean</span>> {
|
|
<span class="kw">return</span> (await <span class="kw">this</span>.<span class="fn">getTodayUsage</span>()) + units <= <span class="kw">this</span>.DAILY_LIMIT;
|
|
}
|
|
|
|
<span class="cm">// Records a quota spend in the database</span>
|
|
<span class="kw">async</span> <span class="fn">spend</span>(units: <span class="typ">number</span>, operation: <span class="typ">string</span>, videoId?: <span class="typ">string</span>): Promise<<span class="kw">void</span>> {
|
|
<span class="kw">await</span> <span class="kw">this</span>.prisma.quotaLog.<span class="fn">create</span>({
|
|
data: { units, operation, videoId, datePt: <span class="kw">this</span>.<span class="fn">getTodayPT</span>() }
|
|
});
|
|
}
|
|
|
|
<span class="cm">// Sums all quota used today (Pacific Time day)</span>
|
|
<span class="kw">async</span> <span class="fn">getTodayUsage</span>(): Promise<<span class="typ">number</span>> {
|
|
<span class="kw">const</span> result = <span class="kw">await</span> <span class="kw">this</span>.prisma.quotaLog.<span class="fn">aggregate</span>({
|
|
_sum: { units: <span class="kw">true</span> },
|
|
where: { datePt: <span class="kw">this</span>.<span class="fn">getTodayPT</span>() }, <span class="cm">// only today's rows</span>
|
|
});
|
|
<span class="kw">return</span> result._sum.units ?? <span class="num">0</span>;
|
|
}
|
|
|
|
<span class="cm">// Returns ms until midnight Pacific Time (YouTube's reset hour)</span>
|
|
<span class="fn">msUntilQuotaReset</span>(): <span class="typ">number</span> {
|
|
<span class="kw">const</span> PT_OFFSET_MS = <span class="num">8</span> * <span class="num">60</span> * <span class="num">60</span> * <span class="num">1000</span>; <span class="cm">// UTC-8 (PST)</span>
|
|
<span class="kw">const</span> nowPT = <span class="kw">new</span> <span class="cls">Date</span>(Date.<span class="fn">now</span>() - PT_OFFSET_MS);
|
|
<span class="kw">const</span> tomorrowMidnightPT = <span class="kw">new</span> <span class="cls">Date</span>(nowPT);
|
|
tomorrowMidnightPT.<span class="fn">setUTCHours</span>(<span class="num">24</span>, <span class="num">0</span>, <span class="num">0</span>, <span class="num">0</span>);
|
|
<span class="kw">return</span> tomorrowMidnightPT.<span class="fn">getTime</span>() - nowPT.<span class="fn">getTime</span>();
|
|
}
|
|
}</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ API MODULES ════ -->
|
|
<h2 id="api-videos">Videos Module — Full API</h2>
|
|
|
|
<table>
|
|
<thead><tr><th>Method</th><th>Path</th><th>Role</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/videos</code></td><td>READONLY+</td><td>Paginated video list with filters: search, lintStatus, privacyStatus, channelId, templateId, collaboratorId, from, to, sort, order</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/videos/:id</code></td><td>READONLY+</td><td>Single video with full includes: config, collaborators, lintResults</td></tr>
|
|
<tr><td><span class="badge patch">PATCH</span></td><td><code>/videos/:id</code></td><td>EDITOR+</td><td>Partial update: title, tags, privacyStatus, scheduledAt, categoryId, templateId</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/videos/bulk-preview</code></td><td>EDITOR+</td><td>Dry-run a bulk action — returns before/after diffs per video, no writes</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/videos/bulk-apply</code></td><td>EDITOR+</td><td>Creates a BulkJob and enqueues one job per video</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/videos/:id/render</code></td><td>EDITOR+</td><td>Synchronously renders description and saves to DB</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/videos/:id/sync</code></td><td>EDITOR+</td><td>Enqueues a youtube-sync job for this video</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h3>Bulk action types</h3>
|
|
<table>
|
|
<thead><tr><th>Type</th><th>Payload</th><th>What it does</th></tr></thead>
|
|
<tbody>
|
|
<tr><td><code>SET_PRIVACY</code></td><td><code>{ privacyStatus: "PUBLIC" }</code></td><td>Changes privacy status on all target videos</td></tr>
|
|
<tr><td><code>SET_TEMPLATE</code></td><td><code>{ templateId: "abc" }</code></td><td>Assigns a template to all target videos</td></tr>
|
|
<tr><td><code>ADD_TAGS</code></td><td><code>{ tags: ["tutorial"] }</code></td><td>Appends tags to each video's tag array</td></tr>
|
|
<tr><td><code>REMOVE_TAGS</code></td><td><code>{ tags: ["old-tag"] }</code></td><td>Removes specified tags from each video</td></tr>
|
|
<tr><td><code>SEARCH_REPLACE_TITLE</code></td><td><code>{ search: "2024", replace: "2025" }</code></td><td>String replace in each video's title</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- ════════════════════════════════════════════ BLOCKS ════ -->
|
|
<h2 id="api-blocks">Blocks & Automatic Versioning</h2>
|
|
|
|
<p>Every time a block is edited, the system automatically creates a <code>BlockVersion</code> snapshot of the block's state <em>before</em> the change. This gives you a full edit history you can browse and roll back to.</p>
|
|
|
|
<pre><code><span class="cm">// src/modules/blocks/blocks.service.ts — update() method</span>
|
|
<span class="kw">async</span> <span class="fn">update</span>(id: <span class="typ">string</span>, dto: <span class="cls">UpdateBlockDto</span>, actorId: <span class="typ">string</span>) {
|
|
<span class="cm">// 1. Load the CURRENT state before changing anything</span>
|
|
<span class="kw">const</span> before = <span class="kw">await</span> <span class="kw">this</span>.prisma.descriptionBlock.<span class="fn">findUnique</span>({ where: { id } });
|
|
|
|
<span class="cm">// 2. Snapshot the current state into BlockVersion</span>
|
|
<span class="kw">await</span> <span class="kw">this</span>.prisma.blockVersion.<span class="fn">create</span>({
|
|
data: {
|
|
blockId: id,
|
|
version: before.version, <span class="cm">// version number at time of snapshot</span>
|
|
contentSnapshot: before <span class="kw">as any</span>, <span class="cm">// full block object stored as JSON</span>
|
|
createdBy: actorId,
|
|
},
|
|
});
|
|
|
|
<span class="cm">// 3. Apply the changes and increment the version counter</span>
|
|
<span class="kw">const</span> updated = <span class="kw">await</span> <span class="kw">this</span>.prisma.descriptionBlock.<span class="fn">update</span>({
|
|
where: { id },
|
|
data: { ...dto, version: { increment: <span class="num">1</span> } }, <span class="cm">// atomic increment in DB</span>
|
|
});
|
|
|
|
<span class="cm">// 4. Write an audit log entry</span>
|
|
<span class="kw">await</span> <span class="kw">this</span>.audit.<span class="fn">log</span>(actorId, <span class="str">'DescriptionBlock'</span>, id, <span class="str">'update'</span>, before, updated);
|
|
<span class="kw">return</span> updated;
|
|
}</code></pre>
|
|
|
|
<h3>Block usage lookup</h3>
|
|
<pre><code><span class="cm">// "Which videos and templates use this block?"</span>
|
|
<span class="cm">// Uses Prisma's JSON array_contains filter on JSONB columns</span>
|
|
<span class="kw">async</span> <span class="fn">getUsage</span>(id: <span class="typ">string</span>) {
|
|
<span class="kw">const</span> [videoConfigs, templates] = <span class="kw">await</span> Promise.<span class="fn">all</span>([
|
|
prisma.videoConfig.<span class="fn">findMany</span>({
|
|
where: { blockOrder: { array_contains: id } }, <span class="cm">// JSON array contains this ID</span>
|
|
select: { videoId: <span class="kw">true</span> },
|
|
}),
|
|
prisma.template.<span class="fn">findMany</span>({
|
|
where: { defaultBlocks: { array_contains: id } },
|
|
select: { id: <span class="kw">true</span>, name: <span class="kw">true</span> },
|
|
}),
|
|
]);
|
|
<span class="kw">return</span> { videoIds: videoConfigs.<span class="fn">map</span>((c) => c.videoId), templates };
|
|
}</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ BULK JOBS ════ -->
|
|
<h2 id="api-bulk">Bulk Jobs — Rollback</h2>
|
|
|
|
<p>Every bulk job item stores a <code>beforeSnapshot</code> of the video's state before the change. The rollback endpoint uses these snapshots to restore each video to its pre-operation state.</p>
|
|
|
|
<pre><code><span class="cm">// src/modules/bulk-jobs/bulk-jobs.service.ts</span>
|
|
<span class="kw">async</span> <span class="fn">rollback</span>(id: <span class="typ">string</span>, actorId: <span class="typ">string</span>) {
|
|
<span class="kw">const</span> job = <span class="kw">await</span> prisma.bulkJob.<span class="fn">findUniqueOrThrow</span>({
|
|
where: { id },
|
|
include: { items: { where: { status: <span class="str">'done'</span> } } }, <span class="cm">// only items that succeeded</span>
|
|
});
|
|
|
|
<span class="kw">for</span> (<span class="kw">const</span> item <span class="kw">of</span> job.items) {
|
|
<span class="kw">if</span> (!item.beforeSnapshot) <span class="kw">continue</span>;
|
|
|
|
<span class="cm">// Restore the video to its state before the bulk action</span>
|
|
<span class="kw">await</span> prisma.video.<span class="fn">update</span>({
|
|
where: { id: item.videoId },
|
|
data: item.beforeSnapshot <span class="kw">as any</span>, <span class="cm">// { title, tags, privacyStatus, ... }</span>
|
|
});
|
|
<span class="kw">await</span> prisma.bulkJobItem.<span class="fn">update</span>({
|
|
where: { id: item.id },
|
|
data: { status: <span class="str">'rolled_back'</span> },
|
|
});
|
|
}
|
|
|
|
<span class="kw">return</span> prisma.bulkJob.<span class="fn">update</span>({ where: { id }, data: { status: <span class="str">'ROLLED_BACK'</span> } });
|
|
}</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ IMPORT / EXPORT ════ -->
|
|
<h2 id="api-import-export">Import & Export</h2>
|
|
|
|
<h3>CSV Import — two-step process</h3>
|
|
<p>Imports use a preview-then-commit pattern. The user uploads a CSV first, the server validates it and stores a report, then the user explicitly commits. This prevents partial imports from bad data.</p>
|
|
|
|
<pre><code><span class="cm">// Step 1: POST /imports/csv/preview — validates rows with Zod</span>
|
|
<span class="kw">const</span> CsvRowSchema = z.<span class="fn">object</span>({
|
|
youtube_video_id: z.<span class="fn">string</span>().<span class="fn">min</span>(<span class="num">1</span>),
|
|
title: z.<span class="fn">string</span>().<span class="fn">optional</span>(),
|
|
privacy_status: z.<span class="fn">enum</span>([<span class="str">'PUBLIC'</span>, <span class="str">'PRIVATE'</span>, <span class="str">'UNLISTED'</span>]).<span class="fn">optional</span>(),
|
|
<span class="cm">// ... other fields</span>
|
|
});
|
|
|
|
<span class="cm">// csv-parse/sync parses the uploaded file buffer into row objects</span>
|
|
<span class="kw">const</span> rows = <span class="fn">parse</span>(fileBuffer, { columns: <span class="kw">true</span>, skip_empty_lines: <span class="kw">true</span>, trim: <span class="kw">true</span> });
|
|
|
|
rows.<span class="fn">forEach</span>((row, i) => {
|
|
<span class="kw">const</span> result = CsvRowSchema.<span class="fn">safeParse</span>(row);
|
|
<span class="kw">if</span> (result.success) {
|
|
validRows.<span class="fn">push</span>(result.data);
|
|
} <span class="kw">else</span> {
|
|
<span class="cm">// Zod's error contains field path + message</span>
|
|
result.error.errors.<span class="fn">forEach</span>((e) =>
|
|
errors.<span class="fn">push</span>({ row: i + <span class="num">1</span>, field: e.path.<span class="fn">join</span>(<span class="str">'.'</span>), message: e.message })
|
|
);
|
|
}
|
|
});
|
|
|
|
<span class="cm">// Returns: { importJobId, validRows: 47, errors: [{ row: 3, field: 'privacy_status', message: ... }] }</span>
|
|
|
|
<span class="cm">// Step 2: POST /imports/csv/commit — enqueues the actual DB upserts</span>
|
|
<span class="kw">await</span> importQueue.<span class="fn">add</span>(<span class="str">'import'</span>, { importJobId });</code></pre>
|
|
|
|
<h3>JSON Export</h3>
|
|
<pre><code><span class="cm">// POST /exports/json — full workspace snapshot</span>
|
|
<span class="kw">async</span> <span class="fn">exportJson</span>() {
|
|
<span class="cm">// All 6 resource types fetched in parallel</span>
|
|
<span class="kw">const</span> [videos, videoConfigs, blocks, templates, collaborators, savedViews] =
|
|
<span class="kw">await</span> Promise.<span class="fn">all</span>([
|
|
prisma.video.<span class="fn">findMany</span>(),
|
|
prisma.videoConfig.<span class="fn">findMany</span>(),
|
|
prisma.descriptionBlock.<span class="fn">findMany</span>(),
|
|
prisma.template.<span class="fn">findMany</span>(),
|
|
prisma.collaborator.<span class="fn">findMany</span>(),
|
|
prisma.savedView.<span class="fn">findMany</span>(),
|
|
]);
|
|
|
|
<span class="kw">return</span> {
|
|
version: <span class="str">'1.0'</span>,
|
|
exportedAt: <span class="kw">new</span> <span class="cls">Date</span>().<span class="fn">toISOString</span>(),
|
|
videos, videoConfigs, blocks, templates, collaborators, savedViews,
|
|
};
|
|
}</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ CALENDAR ════ -->
|
|
<h2 id="api-calendar">Calendar Endpoint</h2>
|
|
|
|
<p>The calendar endpoint returns videos that fall within a date range, shaped as calendar entries the frontend can display.</p>
|
|
|
|
<pre><code><span class="cm">// GET /calendar?view=month&date=2026-04</span>
|
|
<span class="cm">// view: 'month' | 'week' | 'agenda'</span>
|
|
<span class="cm">// date: 'YYYY-MM' for month/week, 'YYYY-MM-DD' for agenda</span>
|
|
|
|
<span class="kw">private</span> <span class="fn">parseRange</span>(view: <span class="typ">string</span>, date: <span class="typ">string</span>): { start: <span class="cls">Date</span>; end: <span class="cls">Date</span> } {
|
|
<span class="kw">const</span> [year, month] = date.<span class="fn">split</span>(<span class="str">'-'</span>).<span class="fn">map</span>(Number);
|
|
|
|
<span class="kw">if</span> (view === <span class="str">'month'</span>) {
|
|
<span class="kw">return</span> {
|
|
start: <span class="kw">new</span> <span class="cls">Date</span>(year, month - <span class="num">1</span>, <span class="num">1</span>), <span class="cm">// first of month</span>
|
|
end: <span class="kw">new</span> <span class="cls">Date</span>(year, month, <span class="num">0</span>, <span class="num">23</span>, <span class="num">59</span>, <span class="num">59</span>), <span class="cm">// last day (day 0 of next month)</span>
|
|
};
|
|
}
|
|
<span class="cm">// 'agenda': next 30 days from the given date</span>
|
|
<span class="kw">const</span> start = <span class="kw">new</span> <span class="cls">Date</span>(date);
|
|
<span class="kw">const</span> end = <span class="kw">new</span> <span class="cls">Date</span>(start);
|
|
end.<span class="fn">setDate</span>(start.<span class="fn">getDate</span>() + <span class="num">30</span>);
|
|
<span class="kw">return</span> { start, end };
|
|
}
|
|
|
|
<span class="cm">// Returns videos scheduled or published in the range</span>
|
|
where: {
|
|
OR: [
|
|
{ scheduledAt: { gte: start, lte: end } },
|
|
{ publishedAt: { gte: start, lte: end } },
|
|
],
|
|
}</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ AUDIT ════ -->
|
|
<h2 id="api-audit">Audit & Quota HTTP Endpoints</h2>
|
|
|
|
<table>
|
|
<thead><tr><th>Method</th><th>Path</th><th>Response</th></tr></thead>
|
|
<tbody>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/quota/today</code></td><td><code>{ used: 1250, remaining: 7750, limit: 9000, resetAt: "...", percentUsed: 14 }</code></td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/audit-logs?page=1&limit=50</code></td><td>Paginated list of all audit log entries, newest first</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/audit-logs/:entityType/:entityId</code></td><td>All audit entries for a specific entity (e.g. <code>/audit-logs/Video/abc123</code>)</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<p>The AuditService is called by every write operation in the codebase to create a tamper-evident log of who changed what:</p>
|
|
|
|
<pre><code><span class="cm">// Every update operation follows this pattern:</span>
|
|
<span class="kw">const</span> before = <span class="kw">await</span> <span class="kw">this</span>.<span class="fn">findOne</span>(id); <span class="cm">// snapshot before</span>
|
|
<span class="kw">const</span> after = <span class="kw">await</span> prisma.video.<span class="fn">update</span>({ where: { id }, data }); <span class="cm">// apply change</span>
|
|
<span class="kw">await</span> <span class="kw">this</span>.audit.<span class="fn">log</span>(actorId, <span class="str">'Video'</span>, id, <span class="str">'update'</span>, before, after);</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ ENV ════ -->
|
|
<h2 id="env">Environment Variables</h2>
|
|
|
|
<p>Copy <code>.env.example</code> to <code>.env</code> and fill in these values before running anything:</p>
|
|
|
|
<table class="env-table">
|
|
<thead><tr><th>Variable</th><th>Example value</th><th>Purpose</th></tr></thead>
|
|
<tbody>
|
|
<tr><td>DATABASE_URL</td><td>postgresql://user:pass@localhost:5432/studioflow</td><td>PostgreSQL connection string</td></tr>
|
|
<tr><td>REDIS_URL</td><td>redis://:password@localhost:6379</td><td>Redis connection for BullMQ</td></tr>
|
|
<tr><td>JWT_SECRET</td><td>at-least-32-random-chars</td><td>Signs access tokens (15 min)</td></tr>
|
|
<tr><td>JWT_REFRESH_SECRET</td><td>different-32-random-chars</td><td>Signs refresh tokens (7 days)</td></tr>
|
|
<tr><td>GOOGLE_CLIENT_ID</td><td>123456.apps.googleusercontent.com</td><td>From Google Cloud Console</td></tr>
|
|
<tr><td>GOOGLE_CLIENT_SECRET</td><td>GOCSPX-...</td><td>From Google Cloud Console</td></tr>
|
|
<tr><td>GOOGLE_CALLBACK_URL</td><td>http://localhost:3001/api/v1/auth/google/callback</td><td>Must match Google OAuth config</td></tr>
|
|
<tr><td>TOKEN_ENCRYPTION_KEY</td><td>exactly-32-random-chars-here-xx</td><td>AES-256 key for YouTube token encryption</td></tr>
|
|
<tr><td>PORT</td><td>3001</td><td>HTTP server port</td></tr>
|
|
<tr><td>FRONTEND_URL</td><td>http://localhost:3000</td><td>CORS origin + OAuth redirect target</td></tr>
|
|
<tr><td>NODE_ENV</td><td>development</td><td>Set to <code>production</code> to enable secure cookies</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- ════════════════════════════════════════════ GETTING STARTED ════ -->
|
|
<h2 id="getting-started">Getting Started</h2>
|
|
|
|
<h3>Prerequisites</h3>
|
|
<ul class="spaced" style="padding-left:24px;">
|
|
<li>Node.js ≥ 20</li>
|
|
<li>PostgreSQL 16 running locally</li>
|
|
<li>Redis 7 running locally</li>
|
|
<li>A Google Cloud project with OAuth 2.0 credentials and YouTube Data API v3 enabled — no API key needed, all YouTube calls use the logged-in user's OAuth token</li>
|
|
</ul>
|
|
|
|
<h3>First-time setup</h3>
|
|
<pre><code><span class="cm"># 1. Install dependencies</span>
|
|
npm install
|
|
|
|
<span class="cm"># 2. Copy environment variables</span>
|
|
cp .env.example .env
|
|
<span class="cm"># Edit .env and fill in all values</span>
|
|
|
|
<span class="cm"># 3. Generate Prisma client (must run after every schema.prisma change)</span>
|
|
npx prisma generate
|
|
|
|
<span class="cm"># 4. Create database tables</span>
|
|
npx prisma migrate dev --name init
|
|
|
|
<span class="cm"># 5. Verify the TypeScript compiles with no errors</span>
|
|
npm run build</code></pre>
|
|
|
|
<h3>Development (two terminals)</h3>
|
|
<pre><code><span class="cm"># Terminal 1 — HTTP API server with hot reload</span>
|
|
npm run start:dev
|
|
|
|
<span class="cm"># Terminal 2 — Background worker with hot reload</span>
|
|
npm run start:worker</code></pre>
|
|
|
|
<h3>Verify it works</h3>
|
|
<pre><code><span class="cm"># Health check — should return {"status":"ok"}</span>
|
|
curl http://localhost:3001/api/v1/health
|
|
|
|
<span class="cm"># Swagger UI — interactive API documentation</span>
|
|
<span class="cm"># Open in browser:</span>
|
|
http://localhost:3001/api/docs</code></pre>
|
|
|
|
<h3>Useful Prisma commands</h3>
|
|
<pre><code><span class="cm"># Open Prisma Studio — GUI to browse and edit the database</span>
|
|
npx prisma studio
|
|
|
|
<span class="cm"># Create a new migration after changing schema.prisma</span>
|
|
npx prisma migrate dev --name describe-your-change
|
|
|
|
<span class="cm"># Apply pending migrations in production (no interactive prompt)</span>
|
|
npx prisma migrate deploy
|
|
|
|
<span class="cm"># Reset the database (drops all tables and recreates)</span>
|
|
npx prisma migrate reset</code></pre>
|
|
|
|
<!-- ════════════════════════════════════════════ ALL ENDPOINTS ════ -->
|
|
<h2 id="all-endpoints">All API Endpoints</h2>
|
|
|
|
<table>
|
|
<thead><tr><th>Method</th><th>Path</th><th>Module</th><th>Min Role</th></tr></thead>
|
|
<tbody>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/health</code></td><td>Health</td><td>Public</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/auth/google</code></td><td>Auth</td><td>Public</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/auth/google/callback</code></td><td>Auth</td><td>Public</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/auth/me</code></td><td>Auth</td><td>Any JWT</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/auth/refresh</code></td><td>Auth</td><td>Cookie</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/videos</code></td><td>Videos</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/videos/:id</code></td><td>Videos</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge patch">PATCH</span></td><td><code>/videos/:id</code></td><td>Videos</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/videos/bulk-preview</code></td><td>Videos</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/videos/bulk-apply</code></td><td>Videos</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/videos/:id/render</code></td><td>Videos</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/videos/:id/sync</code></td><td>Videos</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/blocks</code></td><td>Blocks</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/blocks</code></td><td>Blocks</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge patch">PATCH</span></td><td><code>/blocks/:id</code></td><td>Blocks</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/blocks/:id/versions</code></td><td>Blocks</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/blocks/:id/usage</code></td><td>Blocks</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/templates</code></td><td>Templates</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/templates</code></td><td>Templates</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge patch">PATCH</span></td><td><code>/templates/:id</code></td><td>Templates</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/templates/:id/render-preview</code></td><td>Templates</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/video-configs/:videoId</code></td><td>VideoConfigs</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge put">PUT</span></td><td><code>/video-configs/:videoId</code></td><td>VideoConfigs</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/video-configs/:videoId/render-preview</code></td><td>VideoConfigs</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/collaborators</code></td><td>Collaborators</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/collaborators</code></td><td>Collaborators</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge patch">PATCH</span></td><td><code>/collaborators/:id</code></td><td>Collaborators</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/collaborators/:id/videos</code></td><td>Collaborators</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/videos/:id/collaborators</code></td><td>Collaborators</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge delete">DELETE</span></td><td><code>/videos/:id/collaborators/:cId</code></td><td>Collaborators</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/bulk-jobs</code></td><td>BulkJobs</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/bulk-jobs/:id</code></td><td>BulkJobs</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/bulk-jobs/:id/rollback</code></td><td>BulkJobs</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/saved-views</code></td><td>SavedViews</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/saved-views</code></td><td>SavedViews</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge patch">PATCH</span></td><td><code>/saved-views/:id</code></td><td>SavedViews</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge delete">DELETE</span></td><td><code>/saved-views/:id</code></td><td>SavedViews</td><td>ADMIN</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/saved-views/:id/execute</code></td><td>SavedViews</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/lint/videos/:id</code></td><td>Linting</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/lint/bulk</code></td><td>Linting</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/lint/results</code></td><td>Linting</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/calendar</code></td><td>Calendar</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/imports/csv/preview</code></td><td>Imports</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/imports/csv/commit</code></td><td>Imports</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/imports/json/preview</code></td><td>Imports</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/imports/json/commit</code></td><td>Imports</td><td>EDITOR</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/exports/csv</code></td><td>Exports</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge post">POST</span></td><td><code>/exports/json</code></td><td>Exports</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/quota/today</code></td><td>Quota</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/audit-logs</code></td><td>AuditLogs</td><td>READONLY</td></tr>
|
|
<tr><td><span class="badge get">GET</span></td><td><code>/audit-logs/:entityType/:entityId</code></td><td>AuditLogs</td><td>READONLY</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<p style="color:var(--muted); margin-top: 24px;">All routes (except <code>/health</code>, <code>/auth/google</code>, <code>/auth/google/callback</code>, and <code>/auth/refresh</code>) require a valid JWT in the <code>Authorization: Bearer <token></code> header. The interactive Swagger UI at <code>/api/docs</code> includes an "Authorize" button to paste your token and test all endpoints directly.</p>
|
|
|
|
<div class="part-nav">
|
|
<a href="guide-part1.html">← Part 1: TypeScript & NestJS</a>
|
|
<a href="guide-part2.html">← Part 2: Database & Authentication</a>
|
|
</div>
|
|
|
|
</main>
|
|
|
|
<script>
|
|
const links = document.querySelectorAll('nav a[href^="#"]');
|
|
const observer = new IntersectionObserver(entries => {
|
|
entries.forEach(e => {
|
|
if (e.isIntersecting) {
|
|
links.forEach(l => l.classList.remove('active'));
|
|
const active = document.querySelector(`nav a[href="#${e.target.id}"]`);
|
|
if (active) active.classList.add('active');
|
|
}
|
|
});
|
|
}, { rootMargin: '-20% 0px -70% 0px' });
|
|
document.querySelectorAll('h2[id], h3[id]').forEach(h => observer.observe(h));
|
|
</script>
|
|
</body>
|
|
</html>
|