Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)

This commit is contained in:
2026-08-11 12:27:44 +02:00
commit d5af006443
304 changed files with 74604 additions and 0 deletions
+666
View File
@@ -0,0 +1,666 @@
<!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 2 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(--green), var(--cyan)); -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.danger { background: rgba(248,81,73,.08); border-color: var(--red); }
.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); }
.callout.danger strong { color: var(--red); }
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; }
.schema-box { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; padding: 20px 24px; margin: 16px 0; font-family: 'JetBrains Mono', Consolas, monospace; font-size: 13px; line-height: 2; }
.sf { color: var(--green); } .sk { color: var(--blue); } .st { color: var(--yellow); } .sd { color: var(--muted); }
.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; }
</style>
</head>
<body>
<nav>
<div class="nav-part-label">Part 2 of 3</div>
<div class="nav-group">
<h2>Database</h2>
<ul>
<li><a href="#prisma-what">What Is Prisma</a></li>
<li><a href="#schema-overview">Schema Overview</a></li>
<li><a href="#schema-models">Key Models</a></li>
<li><a href="#schema-relations">Relations</a></li>
<li><a href="#prisma-client">Prisma Client</a></li>
<li><a href="#prisma-service">PrismaService</a></li>
<li><a href="#prisma-queries">Query Patterns</a></li>
<li><a href="#prisma-transactions">Transactions</a></li>
</ul>
</div>
<div class="nav-group">
<h2>Authentication</h2>
<ul>
<li><a href="#auth-overview">Auth Overview</a></li>
<li><a href="#google-oauth">Google OAuth Flow</a></li>
<li><a href="#jwt">JWT Tokens</a></li>
<li><a href="#token-encryption">Token Encryption</a></li>
<li><a href="#passport">Passport Strategies</a></li>
<li><a href="#auth-service">AuthService</a></li>
<li><a href="#refresh-tokens">Refresh Tokens</a></li>
</ul>
</div>
<div class="nav-group">
<h2>Navigation</h2>
<ul>
<li><a href="guide-part1.html">← Part 1: TypeScript &amp; NestJS</a></li>
<li><a href="guide-part3.html">→ Part 3: Business Logic</a></li>
</ul>
</div>
</nav>
<main>
<h1>StudioFlow Backend</h1>
<p style="color:var(--muted); margin-bottom:4px;">Developer Guide — Part 2 of 3</p>
<p style="color:var(--muted);">Database with Prisma · Authentication with Google OAuth &amp; JWT</p>
<!-- ════════════════════════════════════════════ DATABASE ════════ -->
<h2 id="prisma-what">What Is Prisma</h2>
<p>Prisma is an <strong>ORM (Object-Relational Mapper)</strong> — a tool that lets you talk to a SQL database using TypeScript instead of raw SQL. You define your database schema in a <code>schema.prisma</code> file, and Prisma generates a fully type-safe client that knows the exact shape of every table.</p>
<p>The workflow is:</p>
<div class="flow">
<div class="flow-step">Edit <code>schema.prisma</code></div><div class="flow-arrow"></div>
<div class="flow-step"><code>npx prisma migrate dev</code></div><div class="flow-arrow"></div>
<div class="flow-step">SQL migration created &amp; applied</div><div class="flow-arrow"></div>
<div class="flow-step"><code>npx prisma generate</code></div><div class="flow-arrow"></div>
<div class="flow-step">Type-safe client in <code>node_modules/@prisma/client</code></div>
</div>
<p>The generated client knows your exact schema. If you write <code>prisma.video.findUnique({ where: { id } })</code>, TypeScript knows the return type includes <code>title</code>, <code>tags</code>, <code>lintStatus</code>, and every other field you defined.</p>
<!-- ════════════════════════════════════════════ SCHEMA ════════ -->
<h2 id="schema-overview">Schema Overview</h2>
<p>The database has <strong>17 tables</strong> (called <em>models</em> in Prisma). Here is the full picture:</p>
<table>
<thead><tr><th>Model</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td><strong>Video</strong></td><td>One row per YouTube video. Stores title, tags, privacy status, sync state, lint status.</td></tr>
<tr><td><strong>VideoConfig</strong></td><td>The rendering configuration for a video — which blocks to use, in what order, with what variable values.</td></tr>
<tr><td><strong>DescriptionBlock</strong></td><td>A reusable chunk of description text. Has a type (STATIC, VARIABLE, CONDITIONAL, etc.).</td></tr>
<tr><td><strong>BlockVersion</strong></td><td>Snapshot of a block at the moment it was edited — full history.</td></tr>
<tr><td><strong>Template</strong></td><td>A named set of default blocks and rules applied to a category of videos.</td></tr>
<tr><td><strong>TemplateVersion</strong></td><td>Snapshot of a template at the moment it was edited.</td></tr>
<tr><td><strong>Collaborator</strong></td><td>A person who appears in videos — stores name, YouTube handle, Twitch link.</td></tr>
<tr><td><strong>VideoCollaborator</strong></td><td>Junction table linking a Video to a Collaborator (many-to-many).</td></tr>
<tr><td><strong>SavedView</strong></td><td>A named filter preset — stores a Prisma <code>where</code> clause as JSON.</td></tr>
<tr><td><strong>LintResult</strong></td><td>One row per quality issue found by a lint rule.</td></tr>
<tr><td><strong>BulkJob</strong></td><td>Tracks a batch operation (e.g. "change privacy on 200 videos").</td></tr>
<tr><td><strong>BulkJobItem</strong></td><td>One row per video in a bulk job — stores before/after snapshots.</td></tr>
<tr><td><strong>Campaign</strong></td><td>A sponsor campaign with a date range. Blocks can belong to a campaign.</td></tr>
<tr><td><strong>ImportJob</strong></td><td>Tracks a CSV or JSON import — validation report and commit status.</td></tr>
<tr><td><strong>ExportJob</strong></td><td>Tracks an export operation.</td></tr>
<tr><td><strong>QuotaLog</strong></td><td>Every YouTube API call is logged here — used to enforce the 9,000 unit/day limit.</td></tr>
<tr><td><strong>AuditLog</strong></td><td>Every write operation is logged here — who did what, before and after state.</td></tr>
<tr><td><strong>User</strong></td><td>A logged-in user — stores Google OAuth data and encrypted YouTube tokens.</td></tr>
</tbody>
</table>
<!-- ════════════════════════════════════════════ KEY MODELS ════ -->
<h2 id="schema-models">Key Models in Detail</h2>
<h3>Video — the central entity</h3>
<div class="schema-box">
<span class="sk">model</span> <span class="sf">Video</span> {<br>
&nbsp;&nbsp;id <span class="st">String</span> <span class="sd">@id @default(cuid())</span> <span class="sd">// auto-generated unique ID</span><br>
&nbsp;&nbsp;youtubeVideoId <span class="st">String</span> <span class="sd">@unique</span> <span class="sd">// e.g. "dQw4w9WgXcQ"</span><br>
&nbsp;&nbsp;channelId <span class="st">String</span> <br>
&nbsp;&nbsp;title <span class="st">String</span> <br>
&nbsp;&nbsp;renderedDescription <span class="st">String?</span> <span class="sd">// null until first render</span><br>
&nbsp;&nbsp;tags <span class="st">String[]</span> <span class="sd">// PostgreSQL array</span><br>
&nbsp;&nbsp;privacyStatus <span class="st">PrivacyStatus</span> <span class="sd">@default(PRIVATE)</span> <br>
&nbsp;&nbsp;lintStatus <span class="st">LintStatus</span> <span class="sd">@default(OK)</span> <br>
&nbsp;&nbsp;lastSyncedHash <span class="st">String?</span> <span class="sd">// SHA-256 of last synced state</span><br>
&nbsp;&nbsp;remoteConflict <span class="st">Boolean</span> <span class="sd">@default(false)</span> <br>
&nbsp;&nbsp;template <span class="st">Template?</span> <span class="sd">@relation(...)</span> <span class="sd">// optional foreign key</span><br>
&nbsp;&nbsp;config <span class="st">VideoConfig?</span> <span class="sd">// one-to-one</span><br>
&nbsp;&nbsp;collaborators <span class="st">VideoCollaborator[]</span> <span class="sd">// many-to-many via junction table</span><br>
&nbsp;&nbsp;createdAt <span class="st">DateTime</span> <span class="sd">@default(now())</span><br>
&nbsp;&nbsp;updatedAt <span class="st">DateTime</span> <span class="sd">@updatedAt</span> <span class="sd">// auto-updated on every write</span><br>
}
</div>
<h3>VideoConfig — the rendering recipe</h3>
<div class="schema-box">
<span class="sk">model</span> <span class="sf">VideoConfig</span> {<br>
&nbsp;&nbsp;videoId <span class="st">String</span> <span class="sd">@unique</span> <span class="sd">// one config per video</span><br>
&nbsp;&nbsp;blockOrder <span class="st">Json</span> <span class="sd">// String[] — ordered block IDs e.g. ["abc", "def"]</span><br>
&nbsp;&nbsp;blockOverrides <span class="st">Json</span> <span class="sd">// { blockId: { content?: string, active?: bool } }</span><br>
&nbsp;&nbsp;variableValues <span class="st">Json</span> <span class="sd">// { sponsorName: "Squarespace", link: "..." }</span><br>
&nbsp;&nbsp;collaboratorIds <span class="st">Json</span> <span class="sd">// String[] — which collaborators appear in this video</span><br>
&nbsp;&nbsp;version <span class="st">Int</span> <span class="sd">@default(1)</span> <span class="sd">// incremented on every update</span><br>
&nbsp;&nbsp;renderHash <span class="st">String?</span> <span class="sd">// SHA-256 of last render output</span><br>
}
</div>
<p>The <code>Json</code> type stores arbitrary JSON in a PostgreSQL <code>JSONB</code> column. Prisma returns it as <code>unknown</code>, so the code casts it with <code>as string[]</code> or <code>as Record&lt;string, any&gt;</code> where needed.</p>
<h3>DescriptionBlock — reusable text chunks</h3>
<div class="schema-box">
<span class="sk">model</span> <span class="sf">DescriptionBlock</span> {<br>
&nbsp;&nbsp;id <span class="st">String</span> <span class="sd">@id @default(cuid())</span><br>
&nbsp;&nbsp;name <span class="st">String</span> <span class="sd">// human-readable name</span><br>
&nbsp;&nbsp;type <span class="st">BlockType</span> <span class="sd">// STATIC | VARIABLE | CONDITIONAL | ...</span><br>
&nbsp;&nbsp;content <span class="st">String</span> <span class="sd">// the text, may contain {variables}</span><br>
&nbsp;&nbsp;campaignId <span class="st">String?</span> <span class="sd">// optional link to a Campaign</span><br>
&nbsp;&nbsp;version <span class="st">Int</span> <span class="sd">@default(1)</span> <span class="sd">// incremented on every edit</span><br>
&nbsp;&nbsp;versions <span class="st">BlockVersion[]</span> <span class="sd">// full edit history</span><br>
}
</div>
<h4>Block types and what they do</h4>
<table>
<thead><tr><th>Type</th><th>Behaviour in the render engine</th></tr></thead>
<tbody>
<tr><td><code>STATIC</code></td><td>Plain text. Always included if active. Variables still resolved.</td></tr>
<tr><td><code>VARIABLE</code></td><td>Text with <code>{variable}</code> placeholders filled from <code>variableValues</code>.</td></tr>
<tr><td><code>CONDITIONAL</code></td><td>Starts with <code>[if:variableName]</code>. Skipped entirely if that variable is falsy.</td></tr>
<tr><td><code>REPEATABLE</code></td><td>Rendered once per item in an array variable.</td></tr>
<tr><td><code>GLOBAL</code></td><td>Shared across all videos — e.g. channel-wide footer.</td></tr>
<tr><td><code>CAMPAIGN</code></td><td>Linked to a Campaign. The outdated-sponsor lint rule checks its end date.</td></tr>
<tr><td><code>COLLABORATOR</code></td><td>Expanded once per assigned collaborator with their name/handle injected.</td></tr>
</tbody>
</table>
<!-- ════════════════════════════════════════════ RELATIONS ════ -->
<h2 id="schema-relations">Relations in Prisma</h2>
<p>Prisma relations mirror SQL foreign keys but add TypeScript types so you can navigate them in queries.</p>
<h3>One-to-one: Video ↔ VideoConfig</h3>
<pre><code><span class="cm">// In schema.prisma — each Video has at most one VideoConfig</span>
<span class="kw">model</span> Video {
config VideoConfig? <span class="cm">// the ? means it might not exist yet</span>
}
<span class="kw">model</span> VideoConfig {
videoId String @unique <span class="cm">// foreign key</span>
video Video @relation(fields: [videoId], references: [id])
}
<span class="cm">// In TypeScript — include loads the related record in one query</span>
<span class="kw">const</span> video = <span class="kw">await</span> prisma.video.<span class="fn">findUnique</span>({
where: { id },
include: { config: <span class="kw">true</span> }, <span class="cm">// video.config is now a VideoConfig object (or null)</span>
});</code></pre>
<h3>One-to-many: DescriptionBlock → BlockVersion</h3>
<pre><code><span class="cm">// One block has many versions (one per edit)</span>
<span class="kw">model</span> DescriptionBlock {
versions BlockVersion[] <span class="cm">// array relation</span>
}
<span class="kw">model</span> BlockVersion {
blockId String
block DescriptionBlock @relation(fields: [blockId], references: [id])
}
<span class="cm">// Querying: get the block with all its versions</span>
prisma.descriptionBlock.<span class="fn">findUnique</span>({
where: { id },
include: { versions: { orderBy: { version: <span class="str">'desc'</span> } } }
});</code></pre>
<h3>Many-to-many: Video ↔ Collaborator (via junction table)</h3>
<pre><code><span class="cm">// A video has many collaborators; a collaborator appears in many videos</span>
<span class="cm">// The junction table VideoCollaborator stores the link + extra data (role, sortOrder)</span>
<span class="kw">model</span> VideoCollaborator {
videoId String
collaboratorId String
role String? <span class="cm">// e.g. "guest", "editor"</span>
sortOrder Int
@@id([videoId, collaboratorId]) <span class="cm">// composite primary key</span>
}
<span class="cm">// Loading collaborators for a video</span>
prisma.video.<span class="fn">findUnique</span>({
where: { id },
include: {
collaborators: {
include: { collaborator: <span class="kw">true</span> } <span class="cm">// two levels of include</span>
}
}
});</code></pre>
<!-- ════════════════════════════════════════════ PRISMA CLIENT ════ -->
<h2 id="prisma-client">Prisma Client Query API</h2>
<p>The generated Prisma client exposes a consistent API for every model. Here are the methods used throughout this project:</p>
<table>
<thead><tr><th>Method</th><th>Returns</th><th>Use case</th></tr></thead>
<tbody>
<tr><td><code>findUnique({ where })</code></td><td>Record or <code>null</code></td><td>Find by ID or unique field</td></tr>
<tr><td><code>findUniqueOrThrow({ where })</code></td><td>Record (throws if not found)</td><td>When absence is an error</td></tr>
<tr><td><code>findFirst({ where })</code></td><td>Record or <code>null</code></td><td>Find first matching record</td></tr>
<tr><td><code>findMany({ where, orderBy, skip, take, include })</code></td><td>Array</td><td>Paginated list queries</td></tr>
<tr><td><code>create({ data })</code></td><td>New record</td><td>Insert a new row</td></tr>
<tr><td><code>update({ where, data })</code></td><td>Updated record</td><td>Update a specific row</td></tr>
<tr><td><code>upsert({ where, create, update })</code></td><td>Created or updated record</td><td>Insert or update atomically</td></tr>
<tr><td><code>delete({ where })</code></td><td>Deleted record</td><td>Delete a specific row</td></tr>
<tr><td><code>count({ where })</code></td><td>Number</td><td>Count matching rows</td></tr>
<tr><td><code>aggregate({ _sum, where })</code></td><td>Aggregation result</td><td>Sum, avg, min, max</td></tr>
<tr><td><code>createMany({ data })</code></td><td><code>{ count: number }</code></td><td>Bulk insert</td></tr>
<tr><td><code>deleteMany({ where })</code></td><td><code>{ count: number }</code></td><td>Bulk delete</td></tr>
</tbody>
</table>
<h3>Filtering with where</h3>
<pre><code><span class="cm">// Simple equality</span>
where: { id: <span class="str">'abc'</span>, lintStatus: LintStatus.ERROR }
<span class="cm">// String operators</span>
where: { title: { contains: <span class="str">'tutorial'</span>, mode: <span class="str">'insensitive'</span> } }
<span class="cm">// Array operators</span>
where: { tags: { has: <span class="str">'sponsor'</span> } } <span class="cm">// array contains value</span>
where: { blockOrder: { array_contains: id } } <span class="cm">// JSON array contains value</span>
<span class="cm">// Date range</span>
where: { publishedAt: { gte: <span class="kw">new</span> <span class="cls">Date</span>(<span class="str">'2026-01-01'</span>), lte: <span class="kw">new</span> <span class="cls">Date</span>(<span class="str">'2026-12-31'</span>) } }
<span class="cm">// OR — fulltext search across multiple fields</span>
where: {
OR: [
{ title: { contains: search, mode: <span class="str">'insensitive'</span> } },
{ tags: { has: search } },
],
}
<span class="cm">// Nested relation filter — videos that have this collaborator</span>
where: { collaborators: { some: { collaboratorId: id } } }</code></pre>
<h3>Pagination pattern</h3>
<pre><code><span class="cm">// Skip/take is SQL OFFSET/LIMIT</span>
prisma.video.<span class="fn">findMany</span>({
where,
orderBy: { [sort]: order }, <span class="cm">// dynamic sort column</span>
skip: (page - <span class="num">1</span>) * limit, <span class="cm">// skip the first N-1 pages</span>
take: limit, <span class="cm">// return at most `limit` rows</span>
});
<span class="cm">// Always fetch count and items in parallel for efficiency</span>
<span class="kw">const</span> [total, items] = <span class="kw">await</span> Promise.<span class="fn">all</span>([
prisma.video.<span class="fn">count</span>({ where }),
prisma.video.<span class="fn">findMany</span>({ where, skip, take }),
]);
<span class="kw">return</span> { total, page, limit, items };</code></pre>
<!-- ════════════════════════════════════════════ PRISMA SERVICE ════ -->
<h2 id="prisma-service">PrismaService</h2>
<p>Rather than using the generated <code>PrismaClient</code> directly, the project wraps it in a NestJS service. This gives NestJS control over the lifecycle — connecting when the app starts, disconnecting when it shuts down.</p>
<pre><code><span class="cm">// src/shared/prisma/prisma.service.ts</span>
<span class="dec">@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="cm">// IS a PrismaClient — inherits all query methods</span>
<span class="kw">implements</span> <span class="iface">OnModuleInit</span>, <span class="iface">OnModuleDestroy</span> {
<span class="kw">async</span> <span class="fn">onModuleInit</span>() {
<span class="kw">await</span> <span class="kw">this</span>.<span class="fn">$connect</span>();
<span class="cm">// From this moment, this.prisma.video.findMany() works</span>
}
<span class="kw">async</span> <span class="fn">onModuleDestroy</span>() {
<span class="kw">await</span> <span class="kw">this</span>.<span class="fn">$disconnect</span>();
<span class="cm">// Closes all database connections cleanly</span>
}
}
<span class="cm">// Because PrismaModule is @Global(), any service can inject PrismaService
// just by adding it to its constructor — no need to import PrismaModule everywhere:</span>
<span class="dec">@Injectable</span>()
<span class="kw">export class</span> <span class="cls">AnyService</span> {
<span class="kw">constructor</span>(<span class="kw">private readonly</span> prisma: <span class="cls">PrismaService</span>) {}
<span class="cm">// this.prisma.video.findMany() — works immediately</span>
}</code></pre>
<!-- ════════════════════════════════════════════ TRANSACTIONS ════ -->
<h2 id="prisma-transactions">Transactions</h2>
<p>A database transaction groups multiple writes into an atomic unit — either <em>all succeed</em> or <em>none are applied</em>. This prevents partial states (e.g. a lint result written but the video's lintStatus not updated).</p>
<pre><code><span class="cm">// src/modules/linting/linting.service.ts — atomic lint result replacement</span>
<span class="kw">await</span> <span class="kw">this</span>.prisma.<span class="fn">$transaction</span>([
<span class="cm">// Step 1: delete all old unresolved results</span>
<span class="kw">this</span>.prisma.lintResult.<span class="fn">deleteMany</span>({
where: { videoId, resolvedAt: <span class="kw">null</span> }
}),
<span class="cm">// Step 2: insert new results (if any)</span>
...(issues.length &gt; <span class="num">0</span>
? [<span class="kw">this</span>.prisma.lintResult.<span class="fn">createMany</span>({ data: issues })]
: []),
<span class="cm">// Step 3: update video's overall lint status</span>
<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>(severities) },
}),
]);
<span class="cm">// All three run in a single SQL transaction — atomic and consistent</span></code></pre>
<div class="callout info">
<strong>$transaction array vs callback</strong>
Passing an array of Prisma promises (as above) runs them in a single transaction. There's also an interactive transaction using a callback: <code>$transaction(async (tx) => { const x = await tx.foo.create(...); ... })</code> which lets you use results from one query in the next — at the cost of a longer-held lock.
</div>
<!-- ════════════════════════════════════════════ AUTH OVERVIEW ════ -->
<h2 id="auth-overview">Authentication Overview</h2>
<p>The project uses a two-layer authentication strategy:</p>
<table>
<thead><tr><th>Layer</th><th>Technology</th><th>Lifetime</th><th>How used</th></tr></thead>
<tbody>
<tr><td><strong>Access Token</strong></td><td>JWT (HS256)</td><td>15 minutes</td><td>Sent as Bearer token in Authorization header for every API request</td></tr>
<tr><td><strong>Refresh Token</strong></td><td>JWT (HS256, different secret)</td><td>7 days</td><td>Stored in httpOnly cookie; used to issue new access tokens silently</td></tr>
</tbody>
</table>
<p>For the initial login, the project uses Google OAuth 2.0 — users click "Login with Google", are redirected to Google's consent page, and come back with a code that the backend exchanges for tokens.</p>
<!-- ════════════════════════════════════════════ OAUTH ════ -->
<h2 id="google-oauth">Google OAuth Flow — Step by Step</h2>
<div class="flow" style="flex-direction:column; align-items:flex-start; gap:10px;">
<div style="display:flex;align-items:center;gap:12px;"><div class="flow-step" style="min-width:180px;text-align:center;">1. User clicks Login</div><span style="color:var(--muted);font-size:13px;">Frontend redirects to <code>GET /api/v1/auth/google</code></span></div>
<div style="display:flex;align-items:center;gap:12px;"><div class="flow-step" style="min-width:180px;text-align:center;">2. NestJS redirects</div><span style="color:var(--muted);font-size:13px;">Passport's GoogleStrategy builds the Google OAuth URL and redirects the browser</span></div>
<div style="display:flex;align-items:center;gap:12px;"><div class="flow-step" style="min-width:180px;text-align:center;">3. User consents</div><span style="color:var(--muted);font-size:13px;">User sees Google's permission screen and clicks Allow</span></div>
<div style="display:flex;align-items:center;gap:12px;"><div class="flow-step" style="min-width:180px;text-align:center;">4. Google callback</div><span style="color:var(--muted);font-size:13px;">Google sends the user to <code>GET /api/v1/auth/google/callback?code=...</code></span></div>
<div style="display:flex;align-items:center;gap:12px;"><div class="flow-step" style="min-width:180px;text-align:center;">5. Upsert user</div><span style="color:var(--muted);font-size:13px;">GoogleStrategy.validate() calls AuthService.upsertGoogleUser() — creates or updates the DB row</span></div>
<div style="display:flex;align-items:center;gap:12px;"><div class="flow-step" style="min-width:180px;text-align:center;">6. Issue tokens</div><span style="color:var(--muted);font-size:13px;">Controller issues JWT access token (15m) + sets refresh token in httpOnly cookie (7d)</span></div>
<div style="display:flex;align-items:center;gap:12px;"><div class="flow-step" style="min-width:180px;text-align:center;">7. Redirect to frontend</div><span style="color:var(--muted);font-size:13px;">Browser sent to <code>http://localhost:3000/auth/callback?token=eyJ...</code></span></div>
<div style="display:flex;align-items:center;gap:12px;"><div class="flow-step" style="min-width:180px;text-align:center;">8. Frontend stores token</div><span style="color:var(--muted);font-size:13px;">Frontend reads <code>?token=</code> from URL and stores in memory/localStorage for API requests</span></div>
</div>
<pre><code><span class="cm">// src/modules/auth/auth.controller.ts — the callback handler</span>
<span class="dec">@Get</span>(<span class="str">'google/callback'</span>)
<span class="dec">@UseGuards</span>(AuthGuard(<span class="str">'google'</span>)) <span class="cm">// Passport exchanges the code for tokens</span>
<span class="kw">async</span> <span class="fn">googleCallback</span>(<span class="dec">@Req</span>() req: <span class="cls">Request</span>, <span class="dec">@Res</span>() res: <span class="cls">Response</span>) {
<span class="kw">const</span> user = req.user <span class="kw">as any</span>; <span class="cm">// set by GoogleStrategy.validate()</span>
<span class="kw">const</span> accessToken = <span class="kw">this</span>.authService.<span class="fn">issueJwt</span>(user);
<span class="kw">const</span> refreshToken = <span class="kw">this</span>.authService.<span class="fn">issueRefreshToken</span>(user);
res.<span class="fn">cookie</span>(<span class="str">'refresh_token'</span>, refreshToken, {
httpOnly: <span class="kw">true</span>, <span class="cm">// JavaScript in the browser CANNOT read this cookie</span>
secure: process.env.NODE_ENV === <span class="str">'production'</span>, <span class="cm">// HTTPS only in prod</span>
sameSite: <span class="str">'lax'</span>, <span class="cm">// CSRF protection</span>
maxAge: <span class="num">7</span> * <span class="num">24</span> * <span class="num">60</span> * <span class="num">60</span> * <span class="num">1000</span>, <span class="cm">// 7 days in milliseconds</span>
});
res.<span class="fn">redirect</span>(<span class="str">`</span>${frontendUrl}<span class="str">/auth/callback?token=</span>${accessToken}<span class="str">`</span>);
}</code></pre>
<!-- ════════════════════════════════════════════ JWT ════ -->
<h2 id="jwt">JWT Tokens</h2>
<p>A <strong>JWT (JSON Web Token)</strong> is a self-contained credential. It has three parts separated by dots: <code>header.payload.signature</code>. The payload contains claims (data); the signature proves the token was issued by the server.</p>
<pre><code><span class="cm">// The JWT payload for this project looks like:</span>
{
<span class="str">"sub"</span>: <span class="str">"clx8abc123"</span>, <span class="cm">// subject = user.id</span>
<span class="str">"email"</span>: <span class="str">"alice@example.com"</span>,
<span class="str">"role"</span>: <span class="str">"EDITOR"</span>,
<span class="str">"iat"</span>: <span class="num">1714300000</span>, <span class="cm">// issued at (Unix timestamp)</span>
<span class="str">"exp"</span>: <span class="num">1714300900</span> <span class="cm">// expires at (15 minutes later)</span>
}
<span class="cm">// Issuing the JWT — signed with JWT_SECRET from .env</span>
<span class="fn">issueJwt</span>(user: <span class="cls">User</span>): <span class="typ">string</span> {
<span class="kw">return this</span>.jwt.<span class="fn">sign</span>(
{ sub: user.id, email: user.email, role: user.role },
{ expiresIn: <span class="str">'15m'</span> }
);
}
<span class="cm">// Verifying — JwtStrategy reads the token from Authorization: Bearer &lt;token&gt;</span>
<span class="kw">async</span> <span class="fn">validate</span>(payload: <span class="iface">JwtPayload</span>) {
<span class="cm">// The token's signature is already verified by passport-jwt</span>
<span class="cm">// We additionally load the user from DB to ensure they still exist</span>
<span class="kw">const</span> user = <span class="kw">await</span> <span class="kw">this</span>.prisma.user.<span class="fn">findUnique</span>({ where: { id: payload.sub } });
<span class="kw">if</span> (!user) <span class="kw">throw new</span> <span class="cls">UnauthorizedException</span>();
<span class="kw">return</span> user; <span class="cm">// attached to req.user</span>
}</code></pre>
<div class="callout tip">
<strong>Why 15 minutes?</strong>
Short-lived access tokens limit the damage if one is stolen — it becomes worthless quickly. The refresh token (7 days) lives in an httpOnly cookie which JavaScript cannot read, making it much harder to steal via XSS.
</div>
<!-- ════════════════════════════════════════════ ENCRYPTION ════ -->
<h2 id="token-encryption">YouTube Token Encryption</h2>
<p>Google issues OAuth tokens that grant access to the user's YouTube account. These are extremely sensitive — storing them in plaintext in the database would be a serious security vulnerability.</p>
<p>The project encrypts them with <strong>AES-256-CBC</strong> before storage and decrypts on demand.</p>
<pre><code><span class="cm">// src/modules/auth/auth.service.ts</span>
<span class="cm">// At startup — derive a 256-bit (32-byte) encryption key from the env variable
// scryptSync is a key derivation function — it's slow on purpose to resist brute force</span>
<span class="kw">private readonly</span> encKey: <span class="cls">Buffer</span>;
<span class="kw">constructor</span>(...) {
<span class="kw">const</span> raw = config.<span class="fn">get</span>&lt;<span class="typ">string</span>&gt;(<span class="str">'TOKEN_ENCRYPTION_KEY'</span>);
<span class="kw">this</span>.encKey = <span class="fn">scryptSync</span>(raw, <span class="str">'studioflow-salt'</span>, <span class="num">32</span>);
<span class="cm">// salt is a fixed string here — in production use a random per-key salt</span>
}
<span class="kw">private</span> <span class="fn">encrypt</span>(text: <span class="typ">string</span>): <span class="typ">string</span> {
<span class="kw">const</span> iv = <span class="fn">randomBytes</span>(<span class="num">16</span>); <span class="cm">// 16-byte random IV (Initialization Vector)</span>
<span class="kw">const</span> cipher = <span class="fn">createCipheriv</span>(<span class="str">'aes-256-cbc'</span>, <span class="kw">this</span>.encKey, iv);
<span class="kw">const</span> encrypted = Buffer.<span class="fn">concat</span>([cipher.<span class="fn">update</span>(text, <span class="str">'utf8'</span>), cipher.<span class="fn">final</span>()]);
<span class="kw">return</span> iv.<span class="fn">toString</span>(<span class="str">'hex'</span>) + <span class="str">':'</span> + encrypted.<span class="fn">toString</span>(<span class="str">'hex'</span>);
<span class="cm">// Stored as: "a1b2c3d4...:e5f6a7b8..." (iv:ciphertext, both hex-encoded)</span>
}
<span class="kw">private</span> <span class="fn">decrypt</span>(text: <span class="typ">string</span>): <span class="typ">string</span> {
<span class="kw">const</span> [ivHex, encHex] = text.<span class="fn">split</span>(<span class="str">':'</span>);
<span class="kw">const</span> iv = Buffer.<span class="fn">from</span>(ivHex, <span class="str">'hex'</span>);
<span class="kw">const</span> encrypted = Buffer.<span class="fn">from</span>(encHex, <span class="str">'hex'</span>);
<span class="kw">const</span> decipher = <span class="fn">createDecipheriv</span>(<span class="str">'aes-256-cbc'</span>, <span class="kw">this</span>.encKey, iv);
<span class="kw">return</span> Buffer.<span class="fn">concat</span>([decipher.<span class="fn">update</span>(encrypted), decipher.<span class="fn">final</span>()]).<span class="fn">toString</span>(<span class="str">'utf8'</span>);
}</code></pre>
<div class="callout warn">
<strong>Why a random IV each time?</strong>
AES-CBC without a random IV would produce the same ciphertext for the same plaintext. That leaks information — an attacker who sees two identical ciphertexts knows the underlying tokens match. A fresh random IV for every encryption ensures ciphertexts are always different, even for identical inputs.
</div>
<!-- ════════════════════════════════════════════ PASSPORT ════ -->
<h2 id="passport">Passport Strategies</h2>
<p>Passport.js is an authentication middleware library with a plugin model called <em>strategies</em>. Each strategy knows how to authenticate a specific way — Google OAuth, JWT, local username/password, etc.</p>
<h3>GoogleStrategy — handles OAuth dance</h3>
<pre><code><span class="cm">// src/modules/auth/strategies/google.strategy.ts</span>
<span class="dec">@Injectable</span>()
<span class="kw">export class</span> <span class="cls">GoogleStrategy</span> <span class="kw">extends</span> <span class="fn">PassportStrategy</span>(Strategy, <span class="str">'google'</span>) {
<span class="kw">constructor</span>(config: <span class="cls">ConfigService</span>, <span class="kw">private readonly</span> authService: <span class="cls">AuthService</span>) {
<span class="kw">super</span>({
clientID: config.<span class="fn">getOrThrow</span>(<span class="str">'GOOGLE_CLIENT_ID'</span>),
clientSecret: config.<span class="fn">getOrThrow</span>(<span class="str">'GOOGLE_CLIENT_SECRET'</span>),
callbackURL: config.<span class="fn">get</span>(<span class="str">'GOOGLE_CALLBACK_URL'</span>),
scope: [<span class="str">'email'</span>, <span class="str">'profile'</span>, <span class="str">'https://www.googleapis.com/auth/youtube'</span>],
<span class="cm">// The youtube scope lets us call YouTube Data API on the user's behalf</span>
});
}
<span class="kw">async</span> <span class="fn">validate</span>(accessToken: <span class="typ">string</span>, refreshToken: <span class="typ">string</span>, profile: <span class="typ">any</span>) {
<span class="cm">// Called after Google confirms the user authenticated successfully
// accessToken — short-lived token for YouTube API calls
// refreshToken — long-lived token to get new access tokens (only sent once!)
// profile — { id, displayName, emails, photos, ... }</span>
<span class="kw">const</span> user = <span class="kw">await</span> <span class="kw">this</span>.authService.<span class="fn">upsertGoogleUser</span>(profile, accessToken, refreshToken);
<span class="kw">return</span> user; <span class="cm">// attached to req.user by Passport</span>
}
}</code></pre>
<h3>JwtStrategy — validates every API request</h3>
<pre><code><span class="cm">// src/modules/auth/strategies/jwt.strategy.ts</span>
<span class="dec">@Injectable</span>()
<span class="kw">export class</span> <span class="cls">JwtStrategy</span> <span class="kw">extends</span> <span class="fn">PassportStrategy</span>(Strategy, <span class="str">'jwt'</span>) {
<span class="kw">constructor</span>(config: <span class="cls">ConfigService</span>, <span class="kw">private readonly</span> prisma: <span class="cls">PrismaService</span>) {
<span class="kw">super</span>({
jwtFromRequest: ExtractJwt.<span class="fn">fromAuthHeaderAsBearerToken</span>(),
<span class="cm">// Reads: Authorization: Bearer eyJhbGci...</span>
ignoreExpiration: <span class="kw">false</span>, <span class="cm">// reject expired tokens</span>
secretOrKey: config.<span class="fn">getOrThrow</span>(<span class="str">'JWT_SECRET'</span>),
});
}
<span class="kw">async</span> <span class="fn">validate</span>(payload: <span class="iface">JwtPayload</span>) {
<span class="cm">// At this point, passport-jwt has already verified the signature and expiry
// We do a final DB lookup to ensure the user still exists</span>
<span class="kw">const</span> user = <span class="kw">await</span> <span class="kw">this</span>.prisma.user.<span class="fn">findUnique</span>({ where: { id: payload.sub } });
<span class="kw">if</span> (!user) <span class="kw">throw new</span> <span class="cls">UnauthorizedException</span>();
<span class="kw">return</span> user; <span class="cm">// becomes req.user</span>
}
}</code></pre>
<!-- ════════════════════════════════════════════ AUTH SERVICE ════ -->
<h2 id="auth-service">AuthService — upsertGoogleUser</h2>
<p>The <code>upsertGoogleUser</code> method is called every time a user logs in via Google. "Upsert" means: create if new, update if exists. This handles both first-time signups and returning users transparently.</p>
<pre><code><span class="kw">async</span> <span class="fn">upsertGoogleUser</span>(profile: <span class="typ">any</span>, accessToken: <span class="typ">string</span>, refreshToken: <span class="typ">string</span>) {
<span class="kw">const</span> email = profile.emails?.[<span class="num">0</span>]?.value; <span class="cm">// primary email</span>
<span class="kw">const</span> googleId = profile.id; <span class="cm">// stable Google account ID</span>
<span class="kw">const</span> name = profile.displayName;
<span class="kw">return</span> <span class="kw">this</span>.prisma.user.<span class="fn">upsert</span>({
where: { googleId }, <span class="cm">// find by googleId (unique)</span>
create: { <span class="cm">// first login — create the row</span>
email, name, googleId,
youtubeAccessToken: <span class="kw">this</span>.<span class="fn">encrypt</span>(accessToken),
youtubeRefreshToken: refreshToken ? <span class="kw">this</span>.<span class="fn">encrypt</span>(refreshToken) : <span class="kw">undefined</span>,
youtubeTokenExpiry: <span class="kw">new</span> <span class="cls">Date</span>(Date.<span class="fn">now</span>() + <span class="num">3600</span> * <span class="num">1000</span>),
},
update: { <span class="cm">// returning user — refresh their tokens</span>
email, name,
youtubeAccessToken: <span class="kw">this</span>.<span class="fn">encrypt</span>(accessToken),
youtubeRefreshToken: refreshToken ? <span class="kw">this</span>.<span class="fn">encrypt</span>(refreshToken) : <span class="kw">undefined</span>,
youtubeTokenExpiry: <span class="kw">new</span> <span class="cls">Date</span>(Date.<span class="fn">now</span>() + <span class="num">3600</span> * <span class="num">1000</span>),
},
});
}</code></pre>
<!-- ════════════════════════════════════════════ REFRESH ════ -->
<h2 id="refresh-tokens">Refresh Token Flow</h2>
<p>When the frontend's 15-minute access token expires, it calls <code>POST /api/v1/auth/refresh</code> with the httpOnly cookie. The server verifies the refresh token and issues a new access token — the user doesn't need to log in again.</p>
<pre><code><span class="cm">// POST /auth/refresh — no body needed, cookie is sent automatically</span>
<span class="dec">@Post</span>(<span class="str">'refresh'</span>)
<span class="kw">async</span> <span class="fn">refresh</span>(<span class="dec">@Req</span>() req: <span class="cls">Request</span>) {
<span class="kw">const</span> token = req.cookies?.[<span class="str">'refresh_token'</span>];
<span class="kw">return</span> <span class="kw">this</span>.authService.<span class="fn">refreshAccessToken</span>(token);
}
<span class="cm">// AuthService.refreshAccessToken</span>
<span class="kw">async</span> <span class="fn">refreshAccessToken</span>(refreshToken: <span class="typ">string</span>): Promise&lt;{ accessToken: <span class="typ">string</span> }&gt; {
<span class="cm">// Verify the refresh token using the REFRESH secret (different from JWT_SECRET)</span>
<span class="kw">const</span> payload = <span class="kw">this</span>.jwt.<span class="fn">verify</span>&lt;{ sub: <span class="typ">string</span> }&gt;(refreshToken, {
secret: <span class="kw">this</span>.config.<span class="fn">get</span>(<span class="str">'JWT_REFRESH_SECRET'</span>),
});
<span class="cm">// Load the user and issue a fresh access token</span>
<span class="kw">const</span> user = <span class="kw">await</span> <span class="kw">this</span>.prisma.user.<span class="fn">findUniqueOrThrow</span>({ where: { id: payload.sub } });
<span class="kw">return</span> { accessToken: <span class="kw">this</span>.<span class="fn">issueJwt</span>(user) };
}</code></pre>
<div class="callout key">
<strong>Two secrets, two purposes</strong>
<code>JWT_SECRET</code> signs access tokens (15 min). <code>JWT_REFRESH_SECRET</code> signs refresh tokens (7 days). Using separate secrets means a compromised access token cannot be upgraded to a long-lived refresh token — the two are cryptographically independent.
</div>
<h3>Complete Auth API endpoints</h3>
<table>
<thead><tr><th>Method</th><th>Path</th><th>Auth required</th><th>What it does</th></tr></thead>
<tbody>
<tr><td><span style="color:var(--green);font-weight:700;">GET</span></td><td><code>/auth/google</code></td><td>No</td><td>Redirects browser to Google's consent screen</td></tr>
<tr><td><span style="color:var(--green);font-weight:700;">GET</span></td><td><code>/auth/google/callback</code></td><td>No (Google callback)</td><td>Exchanges code for tokens, issues JWT, sets cookie, redirects</td></tr>
<tr><td><span style="color:var(--green);font-weight:700;">GET</span></td><td><code>/auth/me</code></td><td>JWT</td><td>Returns the current user object from the database</td></tr>
<tr><td><span style="color:var(--blue);font-weight:700;">POST</span></td><td><code>/auth/refresh</code></td><td>Cookie</td><td>Issues a new access token using the refresh cookie</td></tr>
</tbody>
</table>
<div class="part-nav">
<a href="guide-part1.html">← Part 1: TypeScript &amp; NestJS</a>
<a href="guide-part3.html">→ Part 3: Business Logic, Queues &amp; APIs</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>