Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -0,0 +1,883 @@
|
||||
<!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 1 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; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
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; }
|
||||
|
||||
/* ── Content ── */
|
||||
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(--blue), var(--purple)); -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; }
|
||||
|
||||
/* ── Code ── */
|
||||
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', 'Cascadia 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); }
|
||||
|
||||
/* ── Callouts ── */
|
||||
.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); }
|
||||
|
||||
/* ── Tables ── */
|
||||
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); }
|
||||
|
||||
/* ── Badges ── */
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 700; letter-spacing: .04em; }
|
||||
.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); }
|
||||
|
||||
/* ── File tree ── */
|
||||
.tree { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; padding: 20px 24px; font-family: 'JetBrains Mono', Consolas, monospace; font-size: 13px; line-height: 2; }
|
||||
.tree .dir { color: var(--blue); font-weight: 600; }
|
||||
.tree .file { color: var(--text); }
|
||||
.tree .dim { color: var(--muted); }
|
||||
.tree .hl { color: var(--yellow); }
|
||||
|
||||
/* ── Concept box ── */
|
||||
.concept { background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 20px 24px; margin: 20px 0; }
|
||||
.concept h4 { margin-top: 0; color: var(--pink); }
|
||||
|
||||
/* ── Flow diagram ── */
|
||||
.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; }
|
||||
|
||||
.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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<div class="nav-part-label">Part 1 of 3</div>
|
||||
<div class="nav-group">
|
||||
<h2>Overview</h2>
|
||||
<ul>
|
||||
<li><a href="#what-is-this">What Is This Project</a></li>
|
||||
<li><a href="#tech-stack">Technology Stack</a></li>
|
||||
<li><a href="#file-structure">File Structure</a></li>
|
||||
<li><a href="#two-entry-points">Two Entry Points</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<h2>TypeScript</h2>
|
||||
<ul>
|
||||
<li><a href="#ts-why">Why TypeScript</a></li>
|
||||
<li><a href="#ts-types">Types & Interfaces</a></li>
|
||||
<li><a href="#ts-classes">Classes & OOP</a></li>
|
||||
<li><a href="#ts-generics">Generics</a></li>
|
||||
<li><a href="#ts-decorators">Decorators</a></li>
|
||||
<li><a href="#ts-async">async / await</a></li>
|
||||
<li><a href="#ts-enums">Enums</a></li>
|
||||
<li><a href="#ts-utility">Utility Types</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<h2>NestJS Core</h2>
|
||||
<ul>
|
||||
<li><a href="#nest-what">What Is NestJS</a></li>
|
||||
<li><a href="#nest-modules">Modules</a></li>
|
||||
<li><a href="#nest-di">Dependency Injection</a></li>
|
||||
<li><a href="#nest-controllers">Controllers</a></li>
|
||||
<li><a href="#nest-providers">Providers & Services</a></li>
|
||||
<li><a href="#nest-guards">Guards</a></li>
|
||||
<li><a href="#nest-pipes">Pipes & Validation</a></li>
|
||||
<li><a href="#nest-decorators">Built-in Decorators</a></li>
|
||||
<li><a href="#nest-lifecycle">Lifecycle Hooks</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<h2>Navigation</h2>
|
||||
<ul>
|
||||
<li><a href="guide-part2.html">→ Part 2: Database & Auth</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 1 of 3</p>
|
||||
<p style="color:var(--muted);">Project Overview · TypeScript · NestJS Core Concepts</p>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="what-is-this">What Is This Project</h2>
|
||||
|
||||
<p>StudioFlow is a <strong>YouTube content management system</strong> for creators who manage large video libraries. Instead of editing every video description by hand on YouTube's website, StudioFlow lets you build reusable <em>description blocks</em>, combine them into <em>templates</em>, attach them to individual videos, and push updates to YouTube's API in bulk — all while enforcing quality rules through an automated linting system.</p>
|
||||
|
||||
<p>The backend you are reading about is the <strong>server-side application</strong> — it handles:</p>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Responsibility</th><th>How it works</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>User authentication</td><td>Google OAuth 2.0 — users log in with their Google/YouTube account</td></tr>
|
||||
<tr><td>Data storage</td><td>PostgreSQL database accessed through the Prisma ORM</td></tr>
|
||||
<tr><td>Description rendering</td><td>A pure TypeScript engine that assembles blocks into final text</td></tr>
|
||||
<tr><td>Quality checks</td><td>10 pluggable lint rules run after every render</td></tr>
|
||||
<tr><td>YouTube sync</td><td>Pushes rendered descriptions to YouTube via their Data API v3</td></tr>
|
||||
<tr><td>Background jobs</td><td>BullMQ queues on Redis handle long-running work asynchronously</td></tr>
|
||||
<tr><td>Bulk operations</td><td>Apply changes to hundreds of videos at once, with rollback support</td></tr>
|
||||
<tr><td>Import / export</td><td>CSV and JSON workspace snapshots</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="tech-stack">Technology Stack</h2>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Technology</th><th>Version</th><th>Role</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Node.js</strong></td><td>≥ 20</td><td>JavaScript runtime — executes the compiled TypeScript</td></tr>
|
||||
<tr><td><strong>TypeScript</strong></td><td>5.x</td><td>Adds static types to JavaScript; compiled to plain JS for Node.js to run</td></tr>
|
||||
<tr><td><strong>NestJS</strong></td><td>11.x</td><td>Application framework — organises the code into modules, controllers, services</td></tr>
|
||||
<tr><td><strong>PostgreSQL</strong></td><td>16</td><td>Relational database — stores all persistent data</td></tr>
|
||||
<tr><td><strong>Prisma</strong></td><td>6.x</td><td>ORM (Object-Relational Mapper) — TypeScript-first database client</td></tr>
|
||||
<tr><td><strong>Redis</strong></td><td>7</td><td>In-memory data store — used as the BullMQ queue backend</td></tr>
|
||||
<tr><td><strong>BullMQ</strong></td><td>5.x</td><td>Queue library — runs background jobs (sync, render, lint, import)</td></tr>
|
||||
<tr><td><strong>Passport.js</strong></td><td>0.7</td><td>Authentication middleware — handles Google OAuth and JWT strategies</td></tr>
|
||||
<tr><td><strong>googleapis</strong></td><td>171.x</td><td>Google's official Node.js SDK — calls the YouTube Data API v3</td></tr>
|
||||
<tr><td><strong>Zod</strong></td><td>3.x</td><td>Schema validation — validates CSV import rows at runtime</td></tr>
|
||||
<tr><td><strong>class-validator</strong></td><td>0.14</td><td>Decorator-based validation — validates HTTP request bodies via NestJS pipes</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="file-structure">File Structure</h2>
|
||||
|
||||
<div class="tree">
|
||||
<span class="dir">backend/</span><br>
|
||||
├── <span class="dim">prisma/</span><br>
|
||||
│ └── <span class="hl">schema.prisma</span> <span class="dim">← database schema (tables, enums, relations)</span><br>
|
||||
├── <span class="dim">src/</span><br>
|
||||
│ ├── <span class="hl">main.ts</span> <span class="dim">← HTTP server entry point</span><br>
|
||||
│ ├── <span class="hl">worker.ts</span> <span class="dim">← background worker entry point</span><br>
|
||||
│ ├── <span class="hl">app.module.ts</span> <span class="dim">← root module (wires everything together)</span><br>
|
||||
│ ├── <span class="hl">worker.module.ts</span> <span class="dim">← root module for the worker process</span><br>
|
||||
│ ├── <span class="hl">health.controller.ts</span> <span class="dim">← GET /health endpoint</span><br>
|
||||
│ │<br>
|
||||
│ ├── <span class="dir">shared/</span> <span class="dim">← cross-cutting services used by many modules</span><br>
|
||||
│ │ ├── <span class="dir">prisma/</span> <span class="dim">← database connection (PrismaService)</span><br>
|
||||
│ │ ├── <span class="dir">render-engine/</span> <span class="dim">← description assembly engine</span><br>
|
||||
│ │ ├── <span class="dir">quota/</span> <span class="dim">← YouTube API quota tracker</span><br>
|
||||
│ │ └── <span class="dir">audit/</span> <span class="dim">← audit log writer</span><br>
|
||||
│ │<br>
|
||||
│ ├── <span class="dir">queues/</span><br>
|
||||
│ │ ├── <span class="hl">queues.constants.ts</span> <span class="dim">← queue name strings</span><br>
|
||||
│ │ └── <span class="dir">processors/</span> <span class="dim">← background job handlers (one file per queue)</span><br>
|
||||
│ │<br>
|
||||
│ └── <span class="dir">modules/</span> <span class="dim">← feature modules (one folder per domain)</span><br>
|
||||
│ ├── <span class="dir">auth/</span> <span class="dim">← Google OAuth + JWT</span><br>
|
||||
│ ├── <span class="dir">videos/</span> <span class="dim">← video CRUD, render, sync</span><br>
|
||||
│ ├── <span class="dir">blocks/</span> <span class="dim">← description block management</span><br>
|
||||
│ ├── <span class="dir">templates/</span> <span class="dim">← template management</span><br>
|
||||
│ ├── <span class="dir">video-configs/</span> <span class="dim">← per-video configuration</span><br>
|
||||
│ ├── <span class="dir">collaborators/</span> <span class="dim">← collaborator management</span><br>
|
||||
│ ├── <span class="dir">linting/</span> <span class="dim">← 10 lint rules + service</span><br>
|
||||
│ ├── <span class="dir">bulk-jobs/</span> <span class="dim">← bulk operation tracking</span><br>
|
||||
│ ├── <span class="dir">saved-views/</span> <span class="dim">← saved filter presets</span><br>
|
||||
│ ├── <span class="dir">calendar/</span> <span class="dim">← calendar view endpoint</span><br>
|
||||
│ ├── <span class="dir">imports/</span> <span class="dim">← CSV + JSON import</span><br>
|
||||
│ ├── <span class="dir">exports/</span> <span class="dim">← CSV + JSON export</span><br>
|
||||
│ ├── <span class="dir">youtube-sync/</span> <span class="dim">← YouTube API wrapper</span><br>
|
||||
│ ├── <span class="dir">quota/</span> <span class="dim">← HTTP quota status endpoint</span><br>
|
||||
│ └── <span class="dir">audit-logs/</span> <span class="dim">← HTTP audit log endpoints</span><br>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="two-entry-points">Two Entry Points</h2>
|
||||
|
||||
<p>One of the most important architectural decisions in this project: the backend runs as <strong>two separate processes</strong>.</p>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Process</th><th>File</th><th>Port</th><th>Does</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>API server</strong></td><td><code>src/main.ts</code></td><td>3001</td><td>Handles HTTP requests from the frontend. Returns JSON.</td></tr>
|
||||
<tr><td><strong>Worker</strong></td><td><code>src/worker.ts</code></td><td>none</td><td>Listens to Redis queues and processes background jobs.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>Why split them? Background jobs (rendering hundreds of descriptions, syncing to YouTube) can take seconds or minutes. If they ran in the same process as the HTTP server, they would block responses. By separating them, the API server stays fast and responsive while the worker does the heavy work in the background.</p>
|
||||
|
||||
<pre><code><span class="cm">// src/main.ts — HTTP API server</span>
|
||||
<span class="kw">async function</span> <span class="fn">bootstrap</span>() {
|
||||
<span class="kw">const</span> app = <span class="kw">await</span> NestFactory.<span class="fn">create</span>(AppModule); <span class="cm">// creates a full HTTP server</span>
|
||||
|
||||
app.<span class="fn">use</span>(<span class="fn">cookieParser</span>()); <span class="cm">// parse cookies (for refresh tokens)</span>
|
||||
app.<span class="fn">enableCors</span>({ origin: <span class="str">'http://localhost:3000'</span>, credentials: <span class="kw">true</span> });
|
||||
app.<span class="fn">setGlobalPrefix</span>(<span class="str">'api/v1'</span>); <span class="cm">// all routes become /api/v1/...</span>
|
||||
app.<span class="fn">useGlobalPipes</span>(<span class="kw">new</span> <span class="cls">ValidationPipe</span>({ whitelist: <span class="kw">true</span>, transform: <span class="kw">true</span> }));
|
||||
<span class="kw">await</span> app.<span class="fn">listen</span>(<span class="num">3001</span>);
|
||||
}
|
||||
|
||||
<span class="cm">// src/worker.ts — background worker (no HTTP)</span>
|
||||
<span class="kw">async function</span> <span class="fn">bootstrap</span>() {
|
||||
<span class="kw">const</span> app = <span class="kw">await</span> NestFactory.<span class="fn">createApplicationContext</span>(WorkerModule);
|
||||
app.<span class="fn">enableShutdownHooks</span>(); <span class="cm">// graceful shutdown on SIGTERM</span>
|
||||
}</code></pre>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Key concept</strong>
|
||||
<code>NestFactory.create()</code> starts an HTTP server. <code>NestFactory.createApplicationContext()</code> starts the NestJS dependency injection container without any HTTP server — just the services and queue processors.
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="ts-why">Why TypeScript</h2>
|
||||
|
||||
<p>JavaScript is <em>dynamically typed</em> — you can write <code>let x = 5; x = "hello";</code> and it will just work. This is convenient but causes bugs that only appear at runtime.</p>
|
||||
|
||||
<p>TypeScript adds a <em>type system</em> on top of JavaScript. The TypeScript compiler checks your code before it runs and catches entire categories of bugs at compile time. When you run <code>npm run build</code>, the TypeScript compiler (<code>tsc</code>) reads all <code>.ts</code> files, checks the types, and outputs plain <code>.js</code> files that Node.js actually executes.</p>
|
||||
|
||||
<pre><code><span class="cm">// JavaScript: this fails silently at runtime</span>
|
||||
<span class="kw">function</span> <span class="fn">greet</span>(user) {
|
||||
<span class="kw">return</span> <span class="str">`Hello, </span>${user.name}<span class="str">`</span>; <span class="cm">// crashes if user is undefined</span>
|
||||
}
|
||||
|
||||
<span class="cm">// TypeScript: the compiler warns you before it runs</span>
|
||||
<span class="kw">function</span> <span class="fn">greet</span>(user: { name: <span class="typ">string</span> }): <span class="typ">string</span> {
|
||||
<span class="kw">return</span> <span class="str">`Hello, </span>${user.name}<span class="str">`</span>; <span class="cm">// ✓ safe — TypeScript knows name is a string</span>
|
||||
}
|
||||
|
||||
<span class="fn">greet</span>(<span class="kw">undefined</span>); <span class="cm">// ✗ ERROR at compile time: Argument of type 'undefined' is
|
||||
// not assignable to parameter of type '{ name: string }'</span></code></pre>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="ts-types">Types & Interfaces</h2>
|
||||
|
||||
<p>TypeScript gives you several ways to describe the <em>shape</em> of data.</p>
|
||||
|
||||
<h3>Primitive types</h3>
|
||||
<pre><code><span class="kw">let</span> name: <span class="typ">string</span> = <span class="str">"Alice"</span>;
|
||||
<span class="kw">let</span> count: <span class="typ">number</span> = 42;
|
||||
<span class="kw">let</span> active: <span class="typ">boolean</span> = <span class="kw">true</span>;
|
||||
<span class="kw">let</span> data: <span class="typ">unknown</span> = <span class="fn">fetchSomething</span>(); <span class="cm">// type unknown until you narrow it</span>
|
||||
<span class="kw">let</span> anything: <span class="typ">any</span> = <span class="fn">legacyFunction</span>(); <span class="cm">// escapes type checking — use sparingly</span></code></pre>
|
||||
|
||||
<h3>Interfaces — describing object shapes</h3>
|
||||
<p>An interface is a <strong>contract</strong>. It says "any object that claims to be this type must have these properties." Interfaces exist only in TypeScript — they disappear completely in the compiled JavaScript.</p>
|
||||
|
||||
<pre><code><span class="cm">// From src/shared/render-engine/render-engine.service.ts</span>
|
||||
<span class="kw">interface</span> <span class="iface">RenderBlock</span> {
|
||||
id: <span class="typ">string</span>;
|
||||
type: <span class="typ">BlockType</span>; <span class="cm">// BlockType is a Prisma-generated enum</span>
|
||||
content: <span class="typ">string</span>;
|
||||
active: <span class="typ">boolean</span>;
|
||||
campaignId?: <span class="typ">string</span> | <span class="kw">null</span>; <span class="cm">// ? = optional property</span>
|
||||
}
|
||||
|
||||
<span class="kw">interface</span> <span class="iface">RenderResult</span> {
|
||||
rendered: <span class="typ">string</span>;
|
||||
hash: <span class="typ">string</span>;
|
||||
}</code></pre>
|
||||
|
||||
<div class="callout tip">
|
||||
<strong>The ? and | null pattern</strong>
|
||||
In TypeScript with <code>strictNullChecks: true</code> (which this project uses), <code>string</code> means the value is definitely a string — it can never be <code>null</code> or <code>undefined</code>. If null is possible, you must explicitly write <code>string | null</code> or <code>string?</code> (shorthand for <code>string | undefined</code>).
|
||||
</div>
|
||||
|
||||
<h3>Union types</h3>
|
||||
<pre><code><span class="cm">// A value can be one of several specific types</span>
|
||||
<span class="kw">type</span> <span class="cls">Order</span> = <span class="str">'asc'</span> | <span class="str">'desc'</span>; <span class="cm">// only these two strings</span>
|
||||
<span class="kw">type</span> <span class="cls">MaybeString</span> = <span class="typ">string</span> | <span class="kw">null</span> | <span class="kw">undefined</span>; <span class="cm">// string or absent</span>
|
||||
|
||||
<span class="cm">// Used in query-videos.dto.ts:</span>
|
||||
order?: <span class="str">'asc'</span> | <span class="str">'desc'</span> = <span class="str">'desc'</span>; <span class="cm">// optional, defaults to 'desc'</span></code></pre>
|
||||
|
||||
<h3>Type aliases</h3>
|
||||
<pre><code><span class="cm">// Type aliases give a name to any type expression</span>
|
||||
<span class="kw">type</span> <span class="cls">VideoId</span> = <span class="typ">string</span>; <span class="cm">// just a named string</span>
|
||||
<span class="kw">type</span> <span class="cls">BulkAction</span> = <span class="str">'SET_PRIVACY'</span> | <span class="str">'ADD_TAGS'</span> | <span class="str">'SET_TEMPLATE'</span>;
|
||||
|
||||
<span class="cm">// Record<K, V> — an object whose keys are K and values are V</span>
|
||||
<span class="kw">const</span> overrides: <span class="cls">Record</span><<span class="typ">string</span>, { content?: <span class="typ">string</span>; active?: <span class="typ">boolean</span> }> = {};</code></pre>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="ts-classes">Classes & OOP</h2>
|
||||
|
||||
<p>TypeScript classes are the backbone of NestJS. They combine <strong>data</strong> (properties) and <strong>behaviour</strong> (methods) in one place, and the type system understands them fully.</p>
|
||||
|
||||
<h3>Class fundamentals</h3>
|
||||
<pre><code><span class="cm">// A class defines a blueprint; instances are created with `new`</span>
|
||||
<span class="kw">class</span> <span class="cls">QuotaService</span> {
|
||||
<span class="cm">// private = only accessible inside this class</span>
|
||||
<span class="kw">private readonly</span> DAILY_LIMIT = <span class="num">9_000</span>;
|
||||
|
||||
<span class="cm">// constructor = runs when you do `new QuotaService(prisma)`</span>
|
||||
<span class="kw">constructor</span>(<span class="kw">private readonly</span> prisma: <span class="cls">PrismaService</span>) {}
|
||||
<span class="cm">// ↑ TypeScript shorthand: declares AND assigns this.prisma in one step</span>
|
||||
|
||||
<span class="cm">// async method — returns a Promise</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">const</span> used = <span class="kw">await</span> <span class="kw">this</span>.<span class="fn">getTodayUsage</span>();
|
||||
<span class="kw">return</span> used + units <= <span class="kw">this</span>.DAILY_LIMIT;
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<h3>extends — inheritance</h3>
|
||||
<p><code>extends</code> means "this class IS a kind of that class — it inherits all its methods and properties."</p>
|
||||
|
||||
<pre><code><span class="cm">// src/shared/prisma/prisma.service.ts</span>
|
||||
<span class="kw">class</span> <span class="cls">PrismaService</span> <span class="kw">extends</span> <span class="cls">PrismaClient</span>
|
||||
<span class="kw">implements</span> <span class="iface">OnModuleInit</span>, <span class="iface">OnModuleDestroy</span> {
|
||||
|
||||
<span class="cm">// PrismaService IS a PrismaClient — it has all its database methods</span>
|
||||
<span class="cm">// PLUS it adds NestJS lifecycle hooks</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">// inherited from PrismaClient</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>();
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<h3>implements — contracts</h3>
|
||||
<p><code>implements</code> says "this class promises to have all the methods that this interface requires." The TypeScript compiler will error if any method is missing.</p>
|
||||
|
||||
<pre><code><span class="cm">// src/modules/linting/rules/base.rule.ts</span>
|
||||
<span class="kw">interface</span> <span class="iface">LintRule</span> {
|
||||
code: <span class="typ">string</span>;
|
||||
severity: <span class="typ">LintSeverity</span>;
|
||||
<span class="fn">check</span>(video: <span class="typ">any</span>): <span class="iface">LintIssue</span> | <span class="kw">null</span>; <span class="cm">// return null if no issue</span>
|
||||
}
|
||||
|
||||
<span class="cm">// Every rule class must implement this interface</span>
|
||||
<span class="kw">class</span> <span class="cls">TitleWeakRule</span> <span class="kw">implements</span> <span class="iface">LintRule</span> {
|
||||
code = <span class="str">'TITLE_WEAK'</span>;
|
||||
severity = LintSeverity.WARNING;
|
||||
|
||||
<span class="fn">check</span>(video: <span class="typ">any</span>): <span class="iface">LintIssue</span> | <span class="kw">null</span> {
|
||||
<span class="kw">if</span> (video.title.length < <span class="num">20</span>) {
|
||||
<span class="kw">return</span> { message: <span class="str">'Title too short'</span>, targetField: <span class="str">'title'</span> };
|
||||
}
|
||||
<span class="kw">return</span> <span class="kw">null</span>;
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Interface vs Class</strong>
|
||||
An interface is only a compile-time contract — it generates zero JavaScript. A class generates real JavaScript code (a constructor function) that creates objects at runtime. Use interfaces to describe shapes you don't own; use classes for things you instantiate.
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="ts-generics">Generics</h2>
|
||||
|
||||
<p>Generics let you write code that works with <em>any type</em> while still being type-safe. Think of <code><T></code> as a type variable — a placeholder you fill in when you actually use the function or class.</p>
|
||||
|
||||
<pre><code><span class="cm">// Without generics — not type-safe, returns `any`</span>
|
||||
<span class="kw">function</span> <span class="fn">first</span>(arr: <span class="typ">any</span>[]): <span class="typ">any</span> {
|
||||
<span class="kw">return</span> arr[<span class="num">0</span>];
|
||||
}
|
||||
|
||||
<span class="cm">// With generics — type-safe, TypeScript knows the return type</span>
|
||||
<span class="kw">function</span> <span class="fn">first</span><<span class="typ">T</span>>(arr: <span class="typ">T</span>[]): <span class="typ">T</span> {
|
||||
<span class="kw">return</span> arr[<span class="num">0</span>];
|
||||
}
|
||||
|
||||
<span class="kw">const</span> num = <span class="fn">first</span>([<span class="num">1</span>, <span class="num">2</span>, <span class="num">3</span>]); <span class="cm">// TypeScript infers: num is number</span>
|
||||
<span class="kw">const</span> str = <span class="fn">first</span>([<span class="str">'a'</span>, <span class="str">'b'</span>]); <span class="cm">// TypeScript infers: str is string</span></code></pre>
|
||||
|
||||
<h3>Generics in this project</h3>
|
||||
<pre><code><span class="cm">// Promise<T> — a future value of type T</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="cm">// will resolve to a boolean</span>
|
||||
<span class="kw">async</span> <span class="fn">findOne</span>(id: <span class="typ">string</span>): Promise<<span class="cls">Video</span>> <span class="cm">// will resolve to a Video</span>
|
||||
|
||||
<span class="cm">// Record<K, V> — object with keys of type K and values of type V</span>
|
||||
blockOverrides: <span class="cls">Record</span><<span class="typ">string</span>, { content?: <span class="typ">string</span>; active?: <span class="typ">boolean</span> }>
|
||||
|
||||
<span class="cm">// Map<K, V> — JavaScript Map with typed keys and values</span>
|
||||
<span class="kw">const</span> blockMap = <span class="kw">new</span> <span class="cls">Map</span><<span class="typ">string</span>, <span class="iface">RenderBlock</span>>(
|
||||
blocks.<span class="fn">map</span>((b) => [b.id, b])
|
||||
);
|
||||
|
||||
<span class="cm">// BullMQ Job<T> — a queue job whose data payload is of type T</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="kw">const</span> { videoId } = job.data; <span class="cm">// TypeScript knows this is a string</span>
|
||||
}</code></pre>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="ts-decorators">Decorators</h2>
|
||||
|
||||
<p>Decorators are the most important TypeScript feature to understand for NestJS. They are functions that <em>annotate</em> classes, methods, or properties — they run at startup and attach metadata or modify behaviour.</p>
|
||||
|
||||
<p>In TypeScript, a decorator is written with an <code>@</code> prefix directly above what it decorates.</p>
|
||||
|
||||
<pre><code><span class="cm">// A decorator is just a function that receives the target it decorates</span>
|
||||
<span class="kw">function</span> <span class="fn">Injectable</span>() {
|
||||
<span class="kw">return function</span>(target: <span class="typ">any</span>) {
|
||||
<span class="cm">// Reflect.metadata stores information about the class</span>
|
||||
Reflect.<span class="fn">defineMetadata</span>(<span class="str">'injectable'</span>, <span class="kw">true</span>, target);
|
||||
};
|
||||
}
|
||||
|
||||
<span class="cm">// Usage — NestJS reads this metadata to know it can inject this class</span>
|
||||
<span class="dec">@Injectable</span>()
|
||||
<span class="kw">class</span> <span class="cls">QuotaService</span> { ... }
|
||||
</code></pre>
|
||||
|
||||
<h3>How NestJS uses decorators</h3>
|
||||
<pre><code><span class="cm">// CONTROLLER DECORATOR — marks this class as an HTTP controller</span>
|
||||
<span class="cm">// and sets the route prefix to /videos</span>
|
||||
<span class="dec">@Controller</span>(<span class="str">'videos'</span>)
|
||||
<span class="kw">export class</span> <span class="cls">VideosController</span> {
|
||||
|
||||
<span class="cm">// METHOD DECORATOR — this method handles GET /videos</span>
|
||||
<span class="dec">@Get</span>()
|
||||
<span class="fn">findAll</span>() { ... }
|
||||
|
||||
<span class="cm">// PARAM DECORATOR — extracts :id from the URL path</span>
|
||||
<span class="dec">@Get</span>(<span class="str">':id'</span>)
|
||||
<span class="fn">findOne</span>(<span class="dec">@Param</span>(<span class="str">'id'</span>) id: <span class="typ">string</span>) { ... }
|
||||
|
||||
<span class="cm">// QUERY DECORATOR — extracts ?search=... from the URL</span>
|
||||
<span class="dec">@Get</span>()
|
||||
<span class="fn">search</span>(<span class="dec">@Query</span>() query: <span class="cls">QueryVideosDto</span>) { ... }
|
||||
|
||||
<span class="cm">// BODY DECORATOR — extracts the JSON request body</span>
|
||||
<span class="dec">@Post</span>()
|
||||
<span class="fn">create</span>(<span class="dec">@Body</span>() dto: <span class="cls">CreateBlockDto</span>) { ... }
|
||||
|
||||
<span class="cm">// REQ DECORATOR — injects the full Express request object</span>
|
||||
<span class="dec">@Get</span>(<span class="str">'me'</span>)
|
||||
<span class="fn">me</span>(<span class="dec">@Req</span>() req: <span class="cls">Request</span>) {
|
||||
<span class="kw">return</span> req.user; <span class="cm">// attached by JwtStrategy</span>
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<h3>Stacking decorators</h3>
|
||||
<pre><code><span class="cm">// Multiple decorators are applied bottom-up (closest to the function first)</span>
|
||||
<span class="dec">@UseGuards</span>(JwtAuthGuard, RolesGuard) <span class="cm">// applied second</span>
|
||||
<span class="dec">@Controller</span>(<span class="str">'videos'</span>) <span class="cm">// applied first</span>
|
||||
<span class="kw">export class</span> <span class="cls">VideosController</span> { ... }
|
||||
|
||||
<span class="cm">// On a method, decorators describe what middleware runs and what Swagger shows</span>
|
||||
<span class="dec">@Patch</span>(<span class="str">':id'</span>)
|
||||
<span class="dec">@Roles</span>(UserRole.EDITOR) <span class="cm">// custom decorator — attaches metadata</span>
|
||||
<span class="dec">@ApiOperation</span>({ summary: <span class="str">'Update video fields'</span> })
|
||||
<span class="fn">update</span>(<span class="dec">@Param</span>(<span class="str">'id'</span>) id: <span class="typ">string</span>) { ... }</code></pre>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Requires tsconfig flags</strong>
|
||||
Decorators require <code>"experimentalDecorators": true</code> and <code>"emitDecoratorMetadata": true</code> in <code>tsconfig.json</code>. The project already has both set. Without them, NestJS's dependency injection system cannot function.
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="ts-async">async / await</h2>
|
||||
|
||||
<p>Almost every database call and HTTP request in this project is asynchronous — it takes time and the Node.js runtime should not sit idle waiting. JavaScript handles this with <strong>Promises</strong> and the <code>async/await</code> syntax.</p>
|
||||
|
||||
<pre><code><span class="cm">// A Promise is a value that will be available in the future</span>
|
||||
<span class="kw">const</span> p: Promise<<span class="cls">Video</span>> = prisma.video.<span class="fn">findFirst</span>(...);
|
||||
<span class="cm">// p.then(video => ...) — the old way to get the value</span>
|
||||
|
||||
<span class="cm">// async/await — the modern, readable way</span>
|
||||
<span class="kw">async function</span> <span class="fn">getVideo</span>(id: <span class="typ">string</span>): Promise<<span class="cls">Video</span>> {
|
||||
<span class="cm">// await pauses this function until the Promise resolves</span>
|
||||
<span class="cm">// but does NOT block other requests — Node.js handles other work meanwhile</span>
|
||||
<span class="kw">const</span> video = <span class="kw">await</span> prisma.video.<span class="fn">findUnique</span>({ where: { id } });
|
||||
<span class="kw">if</span> (!video) <span class="kw">throw new</span> <span class="cls">NotFoundException</span>();
|
||||
<span class="kw">return</span> video;
|
||||
}
|
||||
|
||||
<span class="cm">// Run two queries in parallel — much faster than sequential awaits</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="cm">// Both queries execute simultaneously; we wait for BOTH to finish</span></code></pre>
|
||||
|
||||
<div class="callout tip">
|
||||
<strong>Promise.all for parallel queries</strong>
|
||||
This pattern appears throughout the codebase (e.g., in <code>VideosService.findAll()</code>). When two operations don't depend on each other, running them in parallel roughly halves the wait time.
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="ts-enums">Enums</h2>
|
||||
|
||||
<p>An enum is a set of named constants. Prisma generates TypeScript enums from <code>schema.prisma</code> automatically — you import them and use them instead of raw strings.</p>
|
||||
|
||||
<pre><code><span class="cm">// In schema.prisma:</span>
|
||||
<span class="cm">// enum UserRole { ADMIN EDITOR REVIEWER READONLY }</span>
|
||||
|
||||
<span class="cm">// Prisma generates this TypeScript enum:</span>
|
||||
<span class="kw">enum</span> <span class="cls">UserRole</span> {
|
||||
ADMIN = <span class="str">'ADMIN'</span>,
|
||||
EDITOR = <span class="str">'EDITOR'</span>,
|
||||
REVIEWER = <span class="str">'REVIEWER'</span>,
|
||||
READONLY = <span class="str">'READONLY'</span>,
|
||||
}
|
||||
|
||||
<span class="cm">// Usage — much safer than raw strings</span>
|
||||
<span class="dec">@Roles</span>(UserRole.EDITOR) <span class="cm">// ✓ compiler checks this is a valid role</span>
|
||||
<span class="dec">@Roles</span>(<span class="str">'ediotr'</span>) <span class="cm">// ✗ typo would cause a runtime bug, not a compile error</span>
|
||||
|
||||
<span class="cm">// The RolesGuard maps roles to priority numbers</span>
|
||||
<span class="kw">const</span> ROLE_PRIORITY: <span class="cls">Record</span><<span class="cls">UserRole</span>, <span class="typ">number</span>> = {
|
||||
[UserRole.ADMIN]: <span class="num">4</span>,
|
||||
[UserRole.EDITOR]: <span class="num">3</span>,
|
||||
[UserRole.REVIEWER]: <span class="num">2</span>,
|
||||
[UserRole.READONLY]: <span class="num">1</span>,
|
||||
};</code></pre>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="ts-utility">Utility Types</h2>
|
||||
|
||||
<p>TypeScript ships with built-in generic "utility types" that transform existing types into new ones. This project uses several of them via Prisma's generated types.</p>
|
||||
|
||||
<pre><code><span class="cm">// Partial<T> — makes all properties of T optional</span>
|
||||
<span class="cm">// Used in UpdateVideoDto: you only need to provide fields you want to change</span>
|
||||
<span class="kw">type</span> <span class="cls">UpdateVideoDto</span> = <span class="cls">Partial</span><{ title: <span class="typ">string</span>; privacyStatus: <span class="typ">PrivacyStatus</span> }>;
|
||||
|
||||
<span class="cm">// Omit<T, K> — removes keys K from type T</span>
|
||||
<span class="cm">// Prisma uses this to create "CreateInput" vs "UpdateInput" types</span>
|
||||
<span class="kw">type</span> <span class="cls">CreateInput</span> = <span class="cls">Omit</span><<span class="cls">Video</span>, <span class="str">'id'</span> | <span class="str">'createdAt'</span> | <span class="str">'updatedAt'</span>>;
|
||||
|
||||
<span class="cm">// Pick<T, K> — keeps only keys K from type T</span>
|
||||
prisma.video.<span class="fn">findMany</span>({
|
||||
select: { id: <span class="kw">true</span>, title: <span class="kw">true</span> } <span class="cm">// returns Pick<Video, 'id' | 'title'></span>
|
||||
});
|
||||
|
||||
<span class="cm">// ReturnType<T> — the type that function T returns</span>
|
||||
<span class="kw">type</span> <span class="cls">ServiceResult</span> = <span class="cls">ReturnType</span><<span class="kw">typeof</span> videosService.findAll>;</code></pre>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="nest-what">What Is NestJS</h2>
|
||||
|
||||
<p>NestJS is an <strong>opinionated framework</strong> for building server-side applications with TypeScript. "Opinionated" means it makes strong decisions about how to structure your code — where files go, how services talk to each other, how requests get validated — so you don't have to reinvent that every project.</p>
|
||||
|
||||
<p>NestJS is built on top of <strong>Express.js</strong> (the traditional Node.js HTTP library) and adds:</p>
|
||||
<ul style="color:#cdd5df; padding-left:24px; margin-bottom:16px; line-height:2.2;">
|
||||
<li>A <strong>module system</strong> for organising code into cohesive features</li>
|
||||
<li><strong>Dependency injection</strong> so services can share each other without manual wiring</li>
|
||||
<li><strong>Decorators</strong> that describe routes, guards, and validation declaratively</li>
|
||||
<li>A standard pattern for <strong>middleware, guards, interceptors, and pipes</strong></li>
|
||||
</ul>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="nest-modules">Modules</h2>
|
||||
|
||||
<p>A <strong>module</strong> is a cohesive unit of functionality. Every NestJS application has at least one module (the root module). Modules declare which controllers and services they contain, and which other modules they depend on.</p>
|
||||
|
||||
<pre><code><span class="cm">// A minimal module — declares its own parts and exports PrismaService</span>
|
||||
<span class="cm">// so other modules can use it without re-declaring it</span>
|
||||
<span class="dec">@Global</span>() <span class="cm">// makes this module's exports available everywhere without importing</span>
|
||||
<span class="dec">@Module</span>({
|
||||
providers: [PrismaService], <span class="cm">// services this module creates and manages</span>
|
||||
exports: [PrismaService], <span class="cm">// services this module shares with others</span>
|
||||
})
|
||||
<span class="kw">export class</span> <span class="cls">PrismaModule</span> {}
|
||||
|
||||
<span class="cm">// A feature module — imports what it needs, declares its own parts</span>
|
||||
<span class="dec">@Module</span>({
|
||||
imports: [QuotaModule, AuthModule], <span class="cm">// other modules whose exports we need</span>
|
||||
providers: [YouTubeSyncService, YouTubeApiClient],
|
||||
exports: [YouTubeSyncService, YouTubeApiClient], <span class="cm">// share with other modules</span>
|
||||
})
|
||||
<span class="kw">export class</span> <span class="cls">YouTubeSyncModule</span> {}</code></pre>
|
||||
|
||||
<div class="callout key">
|
||||
<strong>The module graph</strong>
|
||||
NestJS builds a directed graph of all modules at startup. A module can only use (inject) services that it declared in <code>providers</code> or that are exported by modules it listed in <code>imports</code>. This is what prevents spaghetti dependencies.
|
||||
</div>
|
||||
|
||||
<h3>The root AppModule</h3>
|
||||
<p>The <code>AppModule</code> imports every feature module. It is the entry point of the module graph — NestJS walks it to discover everything the application needs.</p>
|
||||
|
||||
<pre><code><span class="dec">@Module</span>({
|
||||
imports: [
|
||||
ConfigModule.<span class="fn">forRoot</span>({ isGlobal: <span class="kw">true</span> }), <span class="cm">// reads .env file</span>
|
||||
BullModule.<span class="fn">forRootAsync</span>({ ... }), <span class="cm">// Redis connection</span>
|
||||
PrismaModule, RenderEngineModule, QuotaModule, <span class="cm">// shared layer</span>
|
||||
AuthModule, VideosModule, BlocksModule, <span class="cm">// feature modules</span>
|
||||
<span class="cm">// ... all other modules</span>
|
||||
],
|
||||
controllers: [HealthController, QuotaController, AuditLogsController],
|
||||
})
|
||||
<span class="kw">export class</span> <span class="cls">AppModule</span> {}</code></pre>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="nest-di">Dependency Injection</h2>
|
||||
|
||||
<p>Dependency Injection (DI) is the mechanism that connects services together. Instead of a service creating its own dependencies with <code>new</code>, NestJS creates them and <em>injects</em> them through the constructor.</p>
|
||||
|
||||
<pre><code><span class="cm">// WITHOUT dependency injection — tightly coupled, hard to test</span>
|
||||
<span class="kw">class</span> <span class="cls">VideosService</span> {
|
||||
<span class="kw">private</span> prisma = <span class="kw">new</span> <span class="cls">PrismaService</span>(); <span class="cm">// creates its own instance</span>
|
||||
<span class="kw">private</span> audit = <span class="kw">new</span> <span class="cls">AuditService</span>(); <span class="cm">// creates its own instance</span>
|
||||
}
|
||||
|
||||
<span class="cm">// WITH dependency injection — NestJS handles creation and sharing</span>
|
||||
<span class="dec">@Injectable</span>()
|
||||
<span class="kw">class</span> <span class="cls">VideosService</span> {
|
||||
<span class="kw">constructor</span>(
|
||||
<span class="kw">private readonly</span> prisma: <span class="cls">PrismaService</span>, <span class="cm">// NestJS provides the singleton</span>
|
||||
<span class="kw">private readonly</span> audit: <span class="cls">AuditService</span>, <span class="cm">// same instance used everywhere</span>
|
||||
<span class="kw">private readonly</span> renderEngine: <span class="cls">RenderEngineService</span>,
|
||||
<span class="dec">@InjectQueue</span>(QUEUES.YOUTUBE_SYNC) <span class="kw">private readonly</span> syncQueue: <span class="cls">Queue</span>,
|
||||
<span class="cm">// ↑ special injection for BullMQ queues — uses a token, not a class name</span>
|
||||
) {}
|
||||
}</code></pre>
|
||||
|
||||
<p>NestJS reads the type annotations on the constructor parameters (thanks to <code>emitDecoratorMetadata</code> in tsconfig) and knows exactly which singleton to inject. The <code>@Injectable()</code> decorator marks a class as something NestJS can manage.</p>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="nest-controllers">Controllers</h2>
|
||||
|
||||
<p>A controller maps HTTP routes to service methods. It handles request parsing and response formatting, but contains <em>no business logic</em> — it delegates to services for that.</p>
|
||||
|
||||
<pre><code><span class="dec">@ApiTags</span>(<span class="str">'videos'</span>) <span class="cm">// Swagger grouping</span>
|
||||
<span class="dec">@ApiBearerAuth</span>() <span class="cm">// Swagger shows lock icon on these endpoints</span>
|
||||
<span class="dec">@UseGuards</span>(JwtAuthGuard, RolesGuard) <span class="cm">// ALL routes require JWT + role check</span>
|
||||
<span class="dec">@Controller</span>(<span class="str">'videos'</span>) <span class="cm">// prefix: /api/v1/videos</span>
|
||||
<span class="kw">export class</span> <span class="cls">VideosController</span> {
|
||||
<span class="kw">constructor</span>(<span class="kw">private readonly</span> service: <span class="cls">VideosService</span>) {}
|
||||
|
||||
<span class="dec">@Get</span>()
|
||||
<span class="fn">findAll</span>(<span class="dec">@Query</span>() query: <span class="cls">QueryVideosDto</span>) {
|
||||
<span class="kw">return</span> <span class="kw">this</span>.service.<span class="fn">findAll</span>(query);
|
||||
<span class="cm">// NestJS automatically serializes the returned object to JSON</span>
|
||||
}
|
||||
|
||||
<span class="dec">@Patch</span>(<span class="str">':id'</span>)
|
||||
<span class="dec">@Roles</span>(UserRole.EDITOR) <span class="cm">// additionally require EDITOR role</span>
|
||||
<span class="fn">update</span>(
|
||||
<span class="dec">@Param</span>(<span class="str">'id'</span>) id: <span class="typ">string</span>, <span class="cm">// from URL path</span>
|
||||
<span class="dec">@Body</span>() dto: <span class="cls">UpdateVideoDto</span>, <span class="cm">// from request body (validated by pipe)</span>
|
||||
<span class="dec">@Req</span>() req: <span class="typ">any</span>, <span class="cm">// full request — we need req.user.id</span>
|
||||
) {
|
||||
<span class="kw">return</span> <span class="kw">this</span>.service.<span class="fn">update</span>(id, dto, req.user.id);
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="nest-providers">Providers & Services</h2>
|
||||
|
||||
<p>Any class decorated with <code>@Injectable()</code> is a <strong>provider</strong>. Services are the most common kind of provider — they contain the business logic.</p>
|
||||
|
||||
<p>By default, NestJS creates providers as <strong>singletons</strong> — one instance per module. The same <code>PrismaService</code> instance is shared by every service that injects it. This is efficient and means database connection pooling works correctly.</p>
|
||||
|
||||
<pre><code><span class="dec">@Injectable</span>() <span class="cm">// ← this is what makes it a provider</span>
|
||||
<span class="kw">export class</span> <span class="cls">AuditService</span> {
|
||||
<span class="kw">constructor</span>(<span class="kw">private readonly</span> prisma: <span class="cls">PrismaService</span>) {}
|
||||
|
||||
<span class="kw">async</span> <span class="fn">log</span>(
|
||||
actorId: <span class="typ">string</span>,
|
||||
entityType: <span class="typ">string</span>,
|
||||
entityId: <span class="typ">string</span>,
|
||||
action: <span class="typ">string</span>,
|
||||
before?: <span class="typ">object</span> | <span class="kw">null</span>,
|
||||
after?: <span class="typ">object</span> | <span class="kw">null</span>,
|
||||
): Promise<<span class="kw">void</span>> {
|
||||
<span class="kw">await</span> <span class="kw">this</span>.prisma.auditLog.<span class="fn">create</span>({
|
||||
data: { actorId, entityType, entityId, action, beforeJson: before, afterJson: after },
|
||||
});
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="nest-guards">Guards</h2>
|
||||
|
||||
<p>A guard is a class that implements <code>CanActivate</code>. It runs <em>before</em> a controller method and decides whether the request should proceed. If it returns <code>false</code> (or throws), NestJS returns 403 Forbidden.</p>
|
||||
|
||||
<h3>JwtAuthGuard — verifies the JWT token</h3>
|
||||
<pre><code><span class="cm">// src/modules/auth/guards/jwt-auth.guard.ts</span>
|
||||
<span class="dec">@Injectable</span>()
|
||||
<span class="kw">export class</span> <span class="cls">JwtAuthGuard</span> <span class="kw">extends</span> AuthGuard(<span class="str">'jwt'</span>) {}
|
||||
<span class="cm">// That's it — Passport's AuthGuard does the heavy lifting:
|
||||
// 1. Extracts Bearer token from the Authorization header
|
||||
// 2. Verifies signature using JWT_SECRET
|
||||
// 3. Calls JwtStrategy.validate() to load the user from DB
|
||||
// 4. Attaches user to req.user for controllers to access</span></code></pre>
|
||||
|
||||
<h3>RolesGuard — checks user permissions</h3>
|
||||
<pre><code><span class="dec">@Injectable</span>()
|
||||
<span class="kw">export class</span> <span class="cls">RolesGuard</span> <span class="kw">implements</span> <span class="iface">CanActivate</span> {
|
||||
<span class="kw">constructor</span>(<span class="kw">private readonly</span> reflector: <span class="cls">Reflector</span>) {}
|
||||
<span class="cm">// Reflector reads metadata attached by decorators</span>
|
||||
|
||||
<span class="fn">canActivate</span>(context: <span class="cls">ExecutionContext</span>): <span class="typ">boolean</span> {
|
||||
<span class="cm">// 1. Read the @Roles(...) metadata from the route handler</span>
|
||||
<span class="kw">const</span> required = <span class="kw">this</span>.reflector.<span class="fn">getAllAndOverride</span><<span class="cls">UserRole</span>[]>(
|
||||
ROLES_KEY,
|
||||
[context.<span class="fn">getHandler</span>(), context.<span class="fn">getClass</span>()]
|
||||
);
|
||||
|
||||
<span class="kw">if</span> (!required?.length) <span class="kw">return true</span>; <span class="cm">// no @Roles = open to all authenticated users</span>
|
||||
|
||||
<span class="cm">// 2. Get the user from req.user (attached by JwtAuthGuard)</span>
|
||||
<span class="kw">const</span> { user } = context.<span class="fn">switchToHttp</span>().<span class="fn">getRequest</span>();
|
||||
|
||||
<span class="cm">// 3. Compare priorities: ADMIN=4, EDITOR=3, REVIEWER=2, READONLY=1</span>
|
||||
<span class="kw">const</span> userPriority = ROLE_PRIORITY[user.role] ?? <span class="num">0</span>;
|
||||
<span class="kw">const</span> minRequired = Math.<span class="fn">min</span>(...required.<span class="fn">map</span>((r) => ROLE_PRIORITY[r]));
|
||||
|
||||
<span class="kw">if</span> (userPriority < minRequired) <span class="kw">throw new</span> <span class="cls">ForbiddenException</span>(<span class="str">'Insufficient role'</span>);
|
||||
<span class="kw">return true</span>;
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<h3>Custom @Roles decorator</h3>
|
||||
<pre><code><span class="cm">// src/modules/auth/decorators/roles.decorator.ts</span>
|
||||
<span class="kw">export const</span> ROLES_KEY = <span class="str">'roles'</span>;
|
||||
|
||||
<span class="cm">// SetMetadata attaches data to a route so guards can read it with Reflector</span>
|
||||
<span class="kw">export const</span> Roles = (...roles: <span class="cls">UserRole</span>[]) => <span class="fn">SetMetadata</span>(ROLES_KEY, roles);
|
||||
|
||||
<span class="cm">// Usage:</span>
|
||||
<span class="dec">@Roles</span>(UserRole.EDITOR) <span class="cm">// stores ['EDITOR'] in metadata under the key 'roles'</span>
|
||||
<span class="fn">update</span>() { ... }
|
||||
<span class="cm">// RolesGuard then reads this metadata and checks the user's role against it</span></code></pre>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="nest-pipes">Pipes & Validation</h2>
|
||||
|
||||
<p>A pipe runs before the controller method and transforms or validates the incoming data. The global <code>ValidationPipe</code> set up in <code>main.ts</code> automatically validates every <code>@Body()</code>, <code>@Query()</code>, and <code>@Param()</code> argument that uses a DTO class.</p>
|
||||
|
||||
<h3>Data Transfer Objects (DTOs)</h3>
|
||||
<p>A DTO (Data Transfer Object) is a plain class decorated with <code>class-validator</code> decorators. The ValidationPipe reads these decorators at runtime and rejects requests that don't match.</p>
|
||||
|
||||
<pre><code><span class="cm">// src/modules/videos/dto/query-videos.dto.ts</span>
|
||||
<span class="kw">export class</span> <span class="cls">QueryVideosDto</span> {
|
||||
<span class="dec">@IsOptional</span>() <span class="cm">// field may be absent — but if present, must pass other validators</span>
|
||||
<span class="dec">@IsString</span>() <span class="cm">// must be a string</span>
|
||||
search?: <span class="typ">string</span>;
|
||||
|
||||
<span class="dec">@IsOptional</span>()
|
||||
<span class="dec">@IsEnum</span>(LintStatus) <span class="cm">// must be one of the LintStatus enum values</span>
|
||||
lintStatus?: <span class="cls">LintStatus</span>;
|
||||
|
||||
<span class="dec">@IsOptional</span>()
|
||||
<span class="dec">@Type</span>(() => <span class="cls">Number</span>) <span class="cm">// transforms the string "10" from the URL into the number 10</span>
|
||||
<span class="dec">@IsInt</span>() <span class="cm">// must be an integer</span>
|
||||
<span class="dec">@Min</span>(<span class="num">1</span>) <span class="cm">// must be >= 1</span>
|
||||
page?: <span class="typ">number</span> = <span class="num">1</span>; <span class="cm">// defaults to 1 if not provided</span>
|
||||
}</code></pre>
|
||||
|
||||
<p>If someone sends <code>GET /api/v1/videos?page=abc</code>, the ValidationPipe returns 400 Bad Request with a clear error message — the controller method never runs.</p>
|
||||
|
||||
<div class="callout tip">
|
||||
<strong>whitelist: true</strong>
|
||||
The global ValidationPipe is configured with <code>whitelist: true</code>. This strips any properties from the request body that are NOT declared in the DTO class. This prevents attackers from injecting unexpected fields.
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="nest-decorators">Built-in Decorators Reference</h2>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Decorator</th><th>Where used</th><th>What it does</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>@Module()</code></td><td>Class</td><td>Marks a class as a NestJS module</td></tr>
|
||||
<tr><td><code>@Injectable()</code></td><td>Class</td><td>Marks a class as a provider (can be injected)</td></tr>
|
||||
<tr><td><code>@Controller('path')</code></td><td>Class</td><td>Marks a class as an HTTP controller with a route prefix</td></tr>
|
||||
<tr><td><code>@Global()</code></td><td>Module class</td><td>Module's exports available everywhere without importing</td></tr>
|
||||
<tr><td><code>@Get() @Post() @Patch() @Put() @Delete()</code></td><td>Method</td><td>Maps method to an HTTP route</td></tr>
|
||||
<tr><td><code>@Param('name')</code></td><td>Parameter</td><td>Extracts a URL path parameter</td></tr>
|
||||
<tr><td><code>@Query()</code></td><td>Parameter</td><td>Extracts query string parameters as an object</td></tr>
|
||||
<tr><td><code>@Body()</code></td><td>Parameter</td><td>Extracts and validates the request body</td></tr>
|
||||
<tr><td><code>@Req()</code></td><td>Parameter</td><td>Injects the full Express Request object</td></tr>
|
||||
<tr><td><code>@Res()</code></td><td>Parameter</td><td>Injects the full Express Response object</td></tr>
|
||||
<tr><td><code>@UseGuards(...)</code></td><td>Class/Method</td><td>Attaches guards to a controller or method</td></tr>
|
||||
<tr><td><code>@InjectQueue('name')</code></td><td>Parameter</td><td>Injects a BullMQ Queue by name</td></tr>
|
||||
<tr><td><code>@Processor('name')</code></td><td>Class</td><td>Marks a class as a BullMQ queue processor</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="nest-lifecycle">Lifecycle Hooks</h2>
|
||||
|
||||
<p>NestJS calls special methods at specific points in the application's life. You implement them by adding the interface to your class.</p>
|
||||
|
||||
<pre><code><span class="cm">// OnModuleInit — runs once after the module's dependencies are resolved</span>
|
||||
<span class="kw">export class</span> <span class="cls">PrismaService</span> <span class="kw">extends</span> <span class="cls">PrismaClient</span>
|
||||
<span class="kw">implements</span> <span class="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">// connect to database when app starts</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">// close connection when app shuts down</span>
|
||||
}
|
||||
}
|
||||
|
||||
<span class="cm">// OnModuleInit used to seed default data</span>
|
||||
<span class="kw">export class</span> <span class="cls">SavedViewsService</span> <span class="kw">implements</span> <span class="iface">OnModuleInit</span> {
|
||||
<span class="kw">async</span> <span class="fn">onModuleInit</span>() {
|
||||
<span class="cm">// Create the 4 default saved views if they don't already exist</span>
|
||||
<span class="kw">for</span> (<span class="kw">const</span> view <span class="kw">of</span> DEFAULT_VIEWS) {
|
||||
<span class="kw">const</span> existing = <span class="kw">await</span> <span class="kw">this</span>.prisma.savedView.<span class="fn">findFirst</span>({ where: { name: view.name } });
|
||||
<span class="kw">if</span> (!existing) <span class="kw">await</span> <span class="kw">this</span>.prisma.savedView.<span class="fn">create</span>({ data: view });
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<div class="part-nav">
|
||||
<a href="guide-part2.html">→ Part 2: Database, Prisma & Authentication</a>
|
||||
<a href="guide-part3.html">→ Part 3: Business Logic, Queues & 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>
|
||||
Reference in New Issue
Block a user