Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.development
|
||||
.env.production
|
||||
*.log
|
||||
.git
|
||||
coverage
|
||||
@@ -0,0 +1,27 @@
|
||||
# Database
|
||||
DATABASE_URL=postgresql://studioflow:change_me@localhost:5432/studioflow
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://:change_me@localhost:6379
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=change_me_min_32_chars_xxxxxxxxxxxxxxxxxxx
|
||||
JWT_REFRESH_SECRET=change_me_min_32_chars_refresh_xxxxxxxxx
|
||||
|
||||
# Google OAuth + YouTube
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
GOOGLE_CALLBACK_URL=http://localhost:3001/api/v1/auth/google/callback
|
||||
|
||||
# Token encryption (AES-256 for YouTube tokens stored in DB)
|
||||
TOKEN_ENCRYPTION_KEY=change_me_exactly_32chars_key_xxxx
|
||||
|
||||
# App
|
||||
PORT=3001
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Conflict detection (worker) — global kill switch + cron.
|
||||
# Per-team toggle and batch limits live in Team settings.
|
||||
CONFLICT_DETECTION_ENABLED=false
|
||||
CONFLICT_DETECTION_CRON=0 3 * * *
|
||||
@@ -0,0 +1,32 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
RUN npx prisma generate
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# prisma CLI is a devDependency so it's absent after --omit=dev.
|
||||
# Copy the CLI and the generated client from the builder stage so that
|
||||
# `npx prisma migrate deploy` works when the migrate service runs this image.
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
|
||||
COPY --from=builder /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -0,0 +1,33 @@
|
||||
# StudioFlow — Backend (API & Worker)
|
||||
|
||||
## Übersicht
|
||||
Dieses Projekt enthält die gesamte Geschäftslogik, die API-Endpunkte und den Hintergrund-Worker für StudioFlow. Es basiert auf NestJS und nutzt Prisma als ORM.
|
||||
|
||||
## Technologie-Stack
|
||||
- **Framework:** NestJS 10 (TypeScript)
|
||||
- **ORM:** Prisma 6
|
||||
- **Datenbank:** PostgreSQL 16
|
||||
- **Queue:** BullMQ (via Redis 7)
|
||||
- **Auth:** Passport.js + Google OAuth 2.0
|
||||
- **Validierung:** class-validator + zod
|
||||
- **Dokumentation:** Swagger (@nestjs/swagger)
|
||||
|
||||
## Kernmodule
|
||||
1. **Render-Engine:** Wandelt Video-Konfigurationen in finale Beschreibungen um.
|
||||
2. **Quota-Management:** Überwacht und plant YouTube API-Units (Hash-basierter Diff).
|
||||
3. **Bulk-Jobs:** Verarbeitet Massenänderungen asynchron via BullMQ.
|
||||
4. **Linting:** Validiert Metadaten gegen vordefinierte Regeln.
|
||||
5. **Sync-Service:** Kommuniziert mit der YouTube Data API v3.
|
||||
|
||||
## Setup & Start
|
||||
1. `npm install`
|
||||
2. Prisma-Client generieren: `npx prisma generate`
|
||||
3. Server starten: `npm run start:dev` (Main Entry: `src/main.ts`)
|
||||
4. Worker starten: `npm run start:worker` (Worker Entry: `src/worker.ts`)
|
||||
|
||||
## Verzeichnisstruktur
|
||||
- `src/main.ts`: Entrypoint für die REST API
|
||||
- `src/worker.ts`: Entrypoint für den BullMQ Worker
|
||||
- `src/shared/render-engine/`: Die zentrale Rendering-Logik
|
||||
- `src/modules/`: Alle fachlichen Module (Videos, Blocks, Templates, etc.)
|
||||
- `prisma/`: Datenbank-Schema und Migrationen
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
id <span class="st">String</span> <span class="sd">@id @default(cuid())</span> <span class="sd">// auto-generated unique ID</span><br>
|
||||
youtubeVideoId <span class="st">String</span> <span class="sd">@unique</span> <span class="sd">// e.g. "dQw4w9WgXcQ"</span><br>
|
||||
channelId <span class="st">String</span> <br>
|
||||
title <span class="st">String</span> <br>
|
||||
renderedDescription <span class="st">String?</span> <span class="sd">// null until first render</span><br>
|
||||
tags <span class="st">String[]</span> <span class="sd">// PostgreSQL array</span><br>
|
||||
privacyStatus <span class="st">PrivacyStatus</span> <span class="sd">@default(PRIVATE)</span> <br>
|
||||
lintStatus <span class="st">LintStatus</span> <span class="sd">@default(OK)</span> <br>
|
||||
lastSyncedHash <span class="st">String?</span> <span class="sd">// SHA-256 of last synced state</span><br>
|
||||
remoteConflict <span class="st">Boolean</span> <span class="sd">@default(false)</span> <br>
|
||||
template <span class="st">Template?</span> <span class="sd">@relation(...)</span> <span class="sd">// optional foreign key</span><br>
|
||||
config <span class="st">VideoConfig?</span> <span class="sd">// one-to-one</span><br>
|
||||
collaborators <span class="st">VideoCollaborator[]</span> <span class="sd">// many-to-many via junction table</span><br>
|
||||
createdAt <span class="st">DateTime</span> <span class="sd">@default(now())</span><br>
|
||||
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>
|
||||
videoId <span class="st">String</span> <span class="sd">@unique</span> <span class="sd">// one config per video</span><br>
|
||||
blockOrder <span class="st">Json</span> <span class="sd">// String[] — ordered block IDs e.g. ["abc", "def"]</span><br>
|
||||
blockOverrides <span class="st">Json</span> <span class="sd">// { blockId: { content?: string, active?: bool } }</span><br>
|
||||
variableValues <span class="st">Json</span> <span class="sd">// { sponsorName: "Squarespace", link: "..." }</span><br>
|
||||
collaboratorIds <span class="st">Json</span> <span class="sd">// String[] — which collaborators appear in this video</span><br>
|
||||
version <span class="st">Int</span> <span class="sd">@default(1)</span> <span class="sd">// incremented on every update</span><br>
|
||||
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<string, any></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>
|
||||
id <span class="st">String</span> <span class="sd">@id @default(cuid())</span><br>
|
||||
name <span class="st">String</span> <span class="sd">// human-readable name</span><br>
|
||||
type <span class="st">BlockType</span> <span class="sd">// STATIC | VARIABLE | CONDITIONAL | ...</span><br>
|
||||
content <span class="st">String</span> <span class="sd">// the text, may contain {variables}</span><br>
|
||||
campaignId <span class="st">String?</span> <span class="sd">// optional link to a Campaign</span><br>
|
||||
version <span class="st">Int</span> <span class="sd">@default(1)</span> <span class="sd">// incremented on every edit</span><br>
|
||||
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 > <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 <token></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><<span class="typ">string</span>>(<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<{ accessToken: <span class="typ">string</span> }> {
|
||||
<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><{ sub: <span class="typ">string</span> }>(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 & NestJS</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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"entryFile": "main",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
Generated
+9993
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "studioflow-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "StudioFlow API & Worker",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:worker": "nest start --entryFile worker --watch",
|
||||
"start:worker:prod": "node dist/worker.js",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"make-admin": "ts-node -e \"\" prisma/make-admin.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.0.0",
|
||||
"@nestjs/bullmq": "^11.0.0",
|
||||
"bullmq": "^5.4.2",
|
||||
"ioredis": "^5.3.2",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"zod": "^3.22.4",
|
||||
"csv-parse": "^5.5.5",
|
||||
"csv-stringify": "^6.4.6",
|
||||
"multer": "^2.1.1",
|
||||
"googleapis": "^171.0.0",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"cookie-parser": "^1.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/passport-google-oauth20": "^2.0.14",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"prisma": "^6.0.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Usage: npx ts-node prisma/make-admin.ts <email>
|
||||
* Promotes an existing user to app-level admin (isAppAdmin = true) by email.
|
||||
* Run this once after the first Google sign-in.
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const email = process.argv[2];
|
||||
if (!email) {
|
||||
console.error('Usage: npx ts-node prisma/make-admin.ts <email>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { email },
|
||||
data: { isAppAdmin: true },
|
||||
});
|
||||
|
||||
console.log(`✓ ${user.name} (${user.email}) is now an app admin`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,347 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PrivacyStatus" AS ENUM ('PUBLIC', 'PRIVATE', 'UNLISTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LintStatus" AS ENUM ('OK', 'WARNING', 'ERROR');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BlockType" AS ENUM ('STATIC', 'VARIABLE', 'CONDITIONAL', 'REPEATABLE', 'GLOBAL', 'CAMPAIGN', 'COLLABORATOR');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LintSeverity" AS ENUM ('INFO', 'WARNING', 'ERROR');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BulkJobStatus" AS ENUM ('PENDING', 'DRY_RUN', 'CONFIRMED', 'RUNNING', 'DONE', 'FAILED', 'ROLLED_BACK');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'EDITOR', 'REVIEWER', 'READONLY');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Video" (
|
||||
"id" TEXT NOT NULL,
|
||||
"youtubeVideoId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"renderedDescription" TEXT,
|
||||
"tags" TEXT[],
|
||||
"categoryId" TEXT,
|
||||
"privacyStatus" "PrivacyStatus" NOT NULL DEFAULT 'PRIVATE',
|
||||
"publishedAt" TIMESTAMP(3),
|
||||
"scheduledAt" TIMESTAMP(3),
|
||||
"templateId" TEXT,
|
||||
"lintStatus" "LintStatus" NOT NULL DEFAULT 'OK',
|
||||
"lastSyncedAt" TIMESTAMP(3),
|
||||
"lastSyncedHash" TEXT,
|
||||
"remoteConflict" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Video_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VideoConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"videoId" TEXT NOT NULL,
|
||||
"templateId" TEXT,
|
||||
"blockOrder" JSONB NOT NULL,
|
||||
"blockOverrides" JSONB NOT NULL,
|
||||
"variableValues" JSONB NOT NULL,
|
||||
"collaboratorIds" JSONB NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"renderHash" TEXT,
|
||||
"renderedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "VideoConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DescriptionBlock" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" "BlockType" NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"language" TEXT NOT NULL DEFAULT 'de',
|
||||
"campaignId" TEXT,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"tags" TEXT[],
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DescriptionBlock_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BlockVersion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"blockId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"contentSnapshot" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdBy" TEXT,
|
||||
|
||||
CONSTRAINT "BlockVersion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Template" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"defaultBlocks" JSONB NOT NULL,
|
||||
"rules" JSONB NOT NULL,
|
||||
"variables" JSONB NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TemplateVersion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"templateId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"snapshot" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TemplateVersion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Collaborator" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"youtubeHandle" TEXT NOT NULL,
|
||||
"twitchLink" TEXT,
|
||||
"aliases" TEXT[],
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Collaborator_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VideoCollaborator" (
|
||||
"videoId" TEXT NOT NULL,
|
||||
"collaboratorId" TEXT NOT NULL,
|
||||
"role" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "VideoCollaborator_pkey" PRIMARY KEY ("videoId","collaboratorId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SavedView" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"isGlobal" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ownerId" TEXT,
|
||||
"queryJson" JSONB NOT NULL,
|
||||
"columnsJson" JSONB NOT NULL,
|
||||
"sortJson" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SavedView_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LintResult" (
|
||||
"id" TEXT NOT NULL,
|
||||
"videoId" TEXT NOT NULL,
|
||||
"ruleCode" TEXT NOT NULL,
|
||||
"severity" "LintSeverity" NOT NULL,
|
||||
"targetField" TEXT,
|
||||
"message" TEXT NOT NULL,
|
||||
"fixSuggestion" TEXT,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LintResult_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BulkJob" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"initiatedBy" TEXT NOT NULL,
|
||||
"filterSnapshot" JSONB NOT NULL,
|
||||
"targetIds" TEXT[],
|
||||
"status" "BulkJobStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"dryRunResult" JSONB,
|
||||
"rollbackData" JSONB,
|
||||
"totalCount" INTEGER NOT NULL,
|
||||
"successCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"errorCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "BulkJob_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BulkJobItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bulkJobId" TEXT NOT NULL,
|
||||
"videoId" TEXT NOT NULL,
|
||||
"beforeSnapshot" JSONB,
|
||||
"afterSnapshot" JSONB,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"errorMessage" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "BulkJobItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Campaign" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"startAt" TIMESTAMP(3) NOT NULL,
|
||||
"endAt" TIMESTAMP(3),
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"notes" TEXT,
|
||||
|
||||
CONSTRAINT "Campaign_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImportJob" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"sourceName" TEXT NOT NULL,
|
||||
"mappingJson" JSONB,
|
||||
"validationReport" JSONB,
|
||||
"commitStatus" TEXT NOT NULL DEFAULT 'pending',
|
||||
"createdBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"committedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ImportJob_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ExportJob" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"scopeJson" JSONB NOT NULL,
|
||||
"fileReference" TEXT,
|
||||
"createdBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ExportJob_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "QuotaLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"datePt" TIMESTAMP(3) NOT NULL,
|
||||
"units" INTEGER NOT NULL,
|
||||
"operation" TEXT NOT NULL,
|
||||
"entityId" TEXT,
|
||||
"videoId" TEXT,
|
||||
"bulkJobId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "QuotaLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"entityType" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"beforeJson" JSONB,
|
||||
"afterJson" JSONB,
|
||||
"requestId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"googleId" TEXT NOT NULL,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'EDITOR',
|
||||
"youtubeAccessToken" TEXT,
|
||||
"youtubeRefreshToken" TEXT,
|
||||
"youtubeTokenExpiry" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Video_youtubeVideoId_key" ON "Video"("youtubeVideoId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VideoConfig_videoId_key" ON "VideoConfig"("videoId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LintResult_videoId_severity_idx" ON "LintResult"("videoId", "severity");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LintResult_ruleCode_idx" ON "LintResult"("ruleCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "QuotaLog_datePt_idx" ON "QuotaLog"("datePt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_googleId_key" ON "User"("googleId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Video" ADD CONSTRAINT "Video_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VideoConfig" ADD CONSTRAINT "VideoConfig_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DescriptionBlock" ADD CONSTRAINT "DescriptionBlock_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BlockVersion" ADD CONSTRAINT "BlockVersion_blockId_fkey" FOREIGN KEY ("blockId") REFERENCES "DescriptionBlock"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TemplateVersion" ADD CONSTRAINT "TemplateVersion_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VideoCollaborator" ADD CONSTRAINT "VideoCollaborator_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VideoCollaborator" ADD CONSTRAINT "VideoCollaborator_collaboratorId_fkey" FOREIGN KEY ("collaboratorId") REFERENCES "Collaborator"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LintResult" ADD CONSTRAINT "LintResult_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BulkJobItem" ADD CONSTRAINT "BulkJobItem_bulkJobId_fkey" FOREIGN KEY ("bulkJobId") REFERENCES "BulkJob"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BulkJobItem" ADD CONSTRAINT "BulkJobItem_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "QuotaLog" ADD CONSTRAINT "QuotaLog_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `role` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `youtubeAccessToken` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `youtubeRefreshToken` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `youtubeTokenExpiry` on the `User` table. All the data in the column will be lost.
|
||||
- Added the required column `teamId` to the `BulkJob` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `teamId` to the `Campaign` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `teamId` to the `Collaborator` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `teamId` to the `DescriptionBlock` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `teamId` to the `ExportJob` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `teamId` to the `ImportJob` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `teamId` to the `SavedView` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `teamId` to the `Template` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TeamRole" AS ENUM ('OWNER', 'ADMIN', 'EDITOR', 'REVIEWER', 'READONLY');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "BulkJob" ADD COLUMN "teamId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Campaign" ADD COLUMN "teamId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Collaborator" ADD COLUMN "teamId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DescriptionBlock" ADD COLUMN "teamId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ExportJob" ADD COLUMN "teamId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ImportJob" ADD COLUMN "teamId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "QuotaLog" ADD COLUMN "channelId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "SavedView" ADD COLUMN "teamId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" ADD COLUMN "teamId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "role",
|
||||
DROP COLUMN "youtubeAccessToken",
|
||||
DROP COLUMN "youtubeRefreshToken",
|
||||
DROP COLUMN "youtubeTokenExpiry",
|
||||
ADD COLUMN "isAppAdmin" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "UserRole";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Team" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamMember" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"role" "TeamRole" NOT NULL DEFAULT 'EDITOR',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("userId","teamId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Channel" (
|
||||
"id" TEXT NOT NULL,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"youtubeChannelId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"uploadsPlaylistId" TEXT,
|
||||
"youtubeAccessToken" TEXT,
|
||||
"youtubeRefreshToken" TEXT,
|
||||
"youtubeTokenExpiry" TIMESTAMP(3),
|
||||
"connectedBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Channel_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Team_slug_key" ON "Team"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamMember_teamId_idx" ON "TeamMember"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Channel_youtubeChannelId_key" ON "Channel"("youtubeChannelId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Channel_teamId_idx" ON "Channel"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BulkJob_teamId_idx" ON "BulkJob"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Campaign_teamId_idx" ON "Campaign"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Collaborator_teamId_idx" ON "Collaborator"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DescriptionBlock_teamId_idx" ON "DescriptionBlock"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ExportJob_teamId_idx" ON "ExportJob"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImportJob_teamId_idx" ON "ImportJob"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "QuotaLog_channelId_idx" ON "QuotaLog"("channelId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SavedView_teamId_idx" ON "SavedView"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Template_teamId_idx" ON "Template"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Video_channelId_idx" ON "Video"("channelId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Channel" ADD CONSTRAINT "Channel_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Video" ADD CONSTRAINT "Video_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DescriptionBlock" ADD CONSTRAINT "DescriptionBlock_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Template" ADD CONSTRAINT "Template_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Collaborator" ADD CONSTRAINT "Collaborator_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BulkJob" ADD CONSTRAINT "BulkJob_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Campaign" ADD CONSTRAINT "Campaign_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImportJob" ADD CONSTRAINT "ImportJob_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ExportJob" ADD CONSTRAINT "ExportJob_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "QuotaLog" ADD CONSTRAINT "QuotaLog_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "DescriptionBlock" ADD COLUMN "variableDefinitions" JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamVariable" (
|
||||
"id" TEXT NOT NULL,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamVariable_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamVariable_teamId_idx" ON "TeamVariable"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamVariable_teamId_name_key" ON "TeamVariable"("teamId", "name");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamVariable" ADD CONSTRAINT "TeamVariable_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Video" ADD COLUMN "youtubeDescription" TEXT;
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Video" ADD COLUMN "ageRestricted" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "containsPaidPromotion" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "defaultAudioLanguage" TEXT,
|
||||
ADD COLUMN "defaultLanguage" TEXT,
|
||||
ADD COLUMN "embeddable" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "gameTitle" TEXT,
|
||||
ADD COLUMN "license" TEXT NOT NULL DEFAULT 'youtube',
|
||||
ADD COLUMN "madeForKids" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "recordingDate" TIMESTAMP(3),
|
||||
ADD COLUMN "selfDeclaredMadeForKids" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Playlist" (
|
||||
"id" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"youtubePlaylistId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"itemCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"privacyStatus" TEXT NOT NULL DEFAULT 'public',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Playlist_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VideoPlaylist" (
|
||||
"videoId" TEXT NOT NULL,
|
||||
"playlistId" TEXT NOT NULL,
|
||||
"position" INTEGER,
|
||||
|
||||
CONSTRAINT "VideoPlaylist_pkey" PRIMARY KEY ("videoId","playlistId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Playlist_youtubePlaylistId_key" ON "Playlist"("youtubePlaylistId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Playlist_channelId_idx" ON "Playlist"("channelId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Playlist" ADD CONSTRAINT "Playlist_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VideoPlaylist" ADD CONSTRAINT "VideoPlaylist_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VideoPlaylist" ADD CONSTRAINT "VideoPlaylist_playlistId_fkey" FOREIGN KEY ("playlistId") REFERENCES "Playlist"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "preferences" JSONB;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Make youtubeHandle optional on Collaborator, consistent with all other platform fields
|
||||
ALTER TABLE "Collaborator" ALTER COLUMN "youtubeHandle" DROP NOT NULL;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Rename youtubeHandle -> youtubeLink for consistency with other platform fields
|
||||
ALTER TABLE "Collaborator" RENAME COLUMN "youtubeHandle" TO "youtubeLink";
|
||||
|
||||
-- Convert existing handle values (@handle or handle) to full YouTube URLs
|
||||
UPDATE "Collaborator"
|
||||
SET "youtubeLink" = 'https://www.youtube.com/@' || LTRIM("youtubeLink", '@')
|
||||
WHERE "youtubeLink" IS NOT NULL
|
||||
AND "youtubeLink" != ''
|
||||
AND "youtubeLink" NOT LIKE 'https://%';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "DescriptionBlock" ADD COLUMN "compact" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Template" ADD COLUMN "defaultOverrides" JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Add collaboratorIds directly to Video
|
||||
ALTER TABLE "Video" ADD COLUMN "collaboratorIds" JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
-- Migrate existing data from VideoConfig.collaboratorIds into Video.collaboratorIds
|
||||
UPDATE "Video" v
|
||||
SET "collaboratorIds" = vc."collaboratorIds"
|
||||
FROM "VideoConfig" vc
|
||||
WHERE vc."videoId" = v.id
|
||||
AND vc."collaboratorIds" IS NOT NULL
|
||||
AND vc."collaboratorIds"::text != '[]'
|
||||
AND vc."collaboratorIds"::text != 'null';
|
||||
|
||||
-- Drop VideoCollaborator join table
|
||||
DROP TABLE "VideoCollaborator";
|
||||
|
||||
-- Remove collaboratorIds from VideoConfig
|
||||
ALTER TABLE "VideoConfig" DROP COLUMN "collaboratorIds";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Video" ADD COLUMN "thumbnailUrl" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add YouTube baseline snapshot to Video for full-field diff
|
||||
ALTER TABLE "Video" ADD COLUMN "youtubeSnapshot" JSONB;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "QuotaLog" ADD COLUMN "actionId" TEXT;
|
||||
ALTER TABLE "QuotaLog" ADD COLUMN "actionType" TEXT;
|
||||
CREATE INDEX "QuotaLog_actionId_idx" ON "QuotaLog"("actionId");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "Team" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'UTC';
|
||||
ALTER TABLE "Team" ADD COLUMN "publishingSchedule" JSONB;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Team" ADD COLUMN "showCanvaLink" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Team" ADD COLUMN "disabledLintRules" TEXT[] NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Video" ADD COLUMN "youtubeDeletedAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Team" ADD COLUMN "showDeletedVideos" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Capture schema drift: columns added directly to DB without migration files
|
||||
|
||||
ALTER TABLE "Team" ADD COLUMN IF NOT EXISTS "dateFormat" TEXT;
|
||||
|
||||
ALTER TABLE "Channel" ADD COLUMN IF NOT EXISTS "supplementalVideoIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
ALTER TABLE "DescriptionBlock" ADD COLUMN IF NOT EXISTS "condition" JSONB;
|
||||
|
||||
ALTER TABLE "Template" ADD COLUMN IF NOT EXISTS "videoFields" JSONB;
|
||||
|
||||
ALTER TABLE "Collaborator" ADD COLUMN IF NOT EXISTS "instagramLink" TEXT;
|
||||
ALTER TABLE "Collaborator" ADD COLUMN IF NOT EXISTS "tiktokLink" TEXT;
|
||||
ALTER TABLE "Collaborator" ADD COLUMN IF NOT EXISTS "twitterLink" TEXT;
|
||||
ALTER TABLE "Collaborator" ADD COLUMN IF NOT EXISTS "blueskyLink" TEXT;
|
||||
ALTER TABLE "Collaborator" ADD COLUMN IF NOT EXISTS "discordHandle" TEXT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- Fix: remove the default from supplementalVideoIds — column was added without a default in DB
|
||||
ALTER TABLE "Channel" ALTER COLUMN "supplementalVideoIds" DROP DEFAULT;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SavedView" ADD COLUMN "pinnedAsTab" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "tabOrder" INTEGER;
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE "Team"
|
||||
ADD COLUMN "conflictDetectionEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "conflictDetectionBatchSize" INTEGER NOT NULL DEFAULT 50,
|
||||
ADD COLUMN "conflictDetectionMinAgeDays" INTEGER NOT NULL DEFAULT 7;
|
||||
|
||||
ALTER TABLE "Video"
|
||||
ADD COLUMN "pendingRemoteSnapshot" JSONB,
|
||||
ADD COLUMN "pendingRemoteDescription" TEXT;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,492 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── USERS ────────────────────────────────────────────────
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
googleId String @unique
|
||||
isAppAdmin Boolean @default(false)
|
||||
preferences Json?
|
||||
teams TeamMember[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ─── TEAMS ────────────────────────────────────────────────
|
||||
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
dateFormat String?
|
||||
timezone String @default("UTC")
|
||||
publishingSchedule Json?
|
||||
showCanvaLink Boolean @default(false)
|
||||
disabledLintRules String[] @default([])
|
||||
showDeletedVideos Boolean @default(false)
|
||||
conflictDetectionEnabled Boolean @default(false)
|
||||
conflictDetectionBatchSize Int @default(50)
|
||||
conflictDetectionMinAgeDays Int @default(7)
|
||||
members TeamMember[]
|
||||
channels Channel[]
|
||||
blocks DescriptionBlock[]
|
||||
templates Template[]
|
||||
collaborators Collaborator[]
|
||||
bulkJobs BulkJob[]
|
||||
savedViews SavedView[]
|
||||
importJobs ImportJob[]
|
||||
exportJobs ExportJob[]
|
||||
campaigns Campaign[]
|
||||
variables TeamVariable[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model TeamVariable {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
name String
|
||||
value String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([teamId, name])
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
userId String
|
||||
teamId String
|
||||
role TeamRole @default(EDITOR)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([userId, teamId])
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
enum TeamRole {
|
||||
OWNER
|
||||
ADMIN
|
||||
EDITOR
|
||||
REVIEWER
|
||||
READONLY
|
||||
}
|
||||
|
||||
// ─── CHANNELS ─────────────────────────────────────────────
|
||||
|
||||
model Channel {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
youtubeChannelId String @unique
|
||||
name String
|
||||
uploadsPlaylistId String?
|
||||
supplementalVideoIds String[]
|
||||
youtubeAccessToken String?
|
||||
youtubeRefreshToken String?
|
||||
youtubeTokenExpiry DateTime?
|
||||
connectedBy String
|
||||
videos Video[]
|
||||
playlists Playlist[]
|
||||
quotaLogs QuotaLog[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
// ─── VIDEOS ───────────────────────────────────────────────
|
||||
|
||||
model Video {
|
||||
id String @id @default(cuid())
|
||||
youtubeVideoId String @unique
|
||||
channelId String
|
||||
channel Channel @relation(fields: [channelId], references: [id])
|
||||
title String
|
||||
youtubeDescription String?
|
||||
renderedDescription String?
|
||||
thumbnailUrl String?
|
||||
tags String[]
|
||||
categoryId String?
|
||||
privacyStatus PrivacyStatus @default(PRIVATE)
|
||||
publishedAt DateTime?
|
||||
scheduledAt DateTime?
|
||||
templateId String?
|
||||
// Extended metadata
|
||||
madeForKids Boolean @default(false)
|
||||
selfDeclaredMadeForKids Boolean @default(false)
|
||||
containsPaidPromotion Boolean @default(false)
|
||||
ageRestricted Boolean @default(false)
|
||||
embeddable Boolean @default(true)
|
||||
license String @default("youtube")
|
||||
defaultLanguage String?
|
||||
defaultAudioLanguage String?
|
||||
recordingDate DateTime?
|
||||
gameTitle String?
|
||||
collaboratorIds Json @default("[]")
|
||||
youtubeSnapshot Json?
|
||||
lintStatus LintStatus @default(OK)
|
||||
lastSyncedAt DateTime?
|
||||
lastSyncedHash String?
|
||||
remoteConflict Boolean @default(false)
|
||||
pendingRemoteSnapshot Json?
|
||||
pendingRemoteDescription String?
|
||||
youtubeDeletedAt DateTime?
|
||||
template Template? @relation(fields: [templateId], references: [id])
|
||||
config VideoConfig?
|
||||
playlists VideoPlaylist[]
|
||||
lintResults LintResult[]
|
||||
bulkJobItems BulkJobItem[]
|
||||
quotaLogs QuotaLog[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([channelId])
|
||||
}
|
||||
|
||||
enum PrivacyStatus {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
UNLISTED
|
||||
}
|
||||
|
||||
enum LintStatus {
|
||||
OK
|
||||
WARNING
|
||||
ERROR
|
||||
}
|
||||
|
||||
// ─── VIDEO CONFIG ─────────────────────────────────────────
|
||||
|
||||
model VideoConfig {
|
||||
id String @id @default(cuid())
|
||||
videoId String @unique
|
||||
video Video @relation(fields: [videoId], references: [id])
|
||||
templateId String?
|
||||
blockOrder Json
|
||||
blockOverrides Json
|
||||
variableValues Json
|
||||
version Int @default(1)
|
||||
renderHash String?
|
||||
renderedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ─── DESCRIPTION BLOCKS ───────────────────────────────────
|
||||
|
||||
model DescriptionBlock {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
name String
|
||||
type BlockType
|
||||
content String
|
||||
language String @default("de")
|
||||
campaignId String?
|
||||
campaign Campaign? @relation(fields: [campaignId], references: [id])
|
||||
version Int @default(1)
|
||||
active Boolean @default(true)
|
||||
compact Boolean @default(false)
|
||||
tags String[]
|
||||
variableDefinitions Json @default("[]")
|
||||
condition Json?
|
||||
versions BlockVersion[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
enum BlockType {
|
||||
STATIC
|
||||
VARIABLE
|
||||
CONDITIONAL
|
||||
REPEATABLE
|
||||
GLOBAL
|
||||
CAMPAIGN
|
||||
COLLABORATOR
|
||||
}
|
||||
|
||||
model BlockVersion {
|
||||
id String @id @default(cuid())
|
||||
blockId String
|
||||
block DescriptionBlock @relation(fields: [blockId], references: [id])
|
||||
version Int
|
||||
contentSnapshot Json
|
||||
createdAt DateTime @default(now())
|
||||
createdBy String?
|
||||
}
|
||||
|
||||
// ─── TEMPLATES ────────────────────────────────────────────
|
||||
|
||||
model Template {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
name String
|
||||
description String?
|
||||
defaultBlocks Json
|
||||
defaultOverrides Json @default("{}")
|
||||
rules Json
|
||||
variables Json
|
||||
videoFields Json?
|
||||
version Int @default(1)
|
||||
active Boolean @default(true)
|
||||
videos Video[]
|
||||
versions TemplateVersion[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model TemplateVersion {
|
||||
id String @id @default(cuid())
|
||||
templateId String
|
||||
template Template @relation(fields: [templateId], references: [id])
|
||||
version Int
|
||||
snapshot Json
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// ─── COLLABORATORS ────────────────────────────────────────
|
||||
|
||||
model Collaborator {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
name String
|
||||
youtubeLink String?
|
||||
twitchLink String?
|
||||
instagramLink String?
|
||||
tiktokLink String?
|
||||
twitterLink String?
|
||||
blueskyLink String?
|
||||
discordHandle String?
|
||||
aliases String[]
|
||||
active Boolean @default(true)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
// ─── SAVED VIEWS ──────────────────────────────────────────
|
||||
|
||||
model SavedView {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
name String
|
||||
description String?
|
||||
isGlobal Boolean @default(false)
|
||||
ownerId String?
|
||||
queryJson Json
|
||||
columnsJson Json
|
||||
sortJson Json?
|
||||
pinnedAsTab Boolean @default(false)
|
||||
tabOrder Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
// ─── LINT RESULTS ─────────────────────────────────────────
|
||||
|
||||
model LintResult {
|
||||
id String @id @default(cuid())
|
||||
videoId String
|
||||
video Video @relation(fields: [videoId], references: [id])
|
||||
ruleCode String
|
||||
severity LintSeverity
|
||||
targetField String?
|
||||
message String
|
||||
fixSuggestion String?
|
||||
resolvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([videoId, severity])
|
||||
@@index([ruleCode])
|
||||
}
|
||||
|
||||
enum LintSeverity {
|
||||
INFO
|
||||
WARNING
|
||||
ERROR
|
||||
}
|
||||
|
||||
// ─── BULK JOBS ────────────────────────────────────────────
|
||||
|
||||
model BulkJob {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
type String
|
||||
initiatedBy String
|
||||
filterSnapshot Json
|
||||
targetIds String[]
|
||||
status BulkJobStatus @default(PENDING)
|
||||
dryRunResult Json?
|
||||
rollbackData Json?
|
||||
totalCount Int
|
||||
successCount Int @default(0)
|
||||
errorCount Int @default(0)
|
||||
items BulkJobItem[]
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
enum BulkJobStatus {
|
||||
PENDING
|
||||
DRY_RUN
|
||||
CONFIRMED
|
||||
RUNNING
|
||||
DONE
|
||||
FAILED
|
||||
ROLLED_BACK
|
||||
}
|
||||
|
||||
model BulkJobItem {
|
||||
id String @id @default(cuid())
|
||||
bulkJobId String
|
||||
bulkJob BulkJob @relation(fields: [bulkJobId], references: [id])
|
||||
videoId String
|
||||
video Video @relation(fields: [videoId], references: [id])
|
||||
beforeSnapshot Json?
|
||||
afterSnapshot Json?
|
||||
status String @default("pending")
|
||||
errorMessage String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// ─── CAMPAIGNS ────────────────────────────────────────────
|
||||
|
||||
model Campaign {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
name String
|
||||
startAt DateTime
|
||||
endAt DateTime?
|
||||
status String @default("active")
|
||||
notes String?
|
||||
blocks DescriptionBlock[]
|
||||
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
// ─── PLAYLISTS ────────────────────────────────────────────
|
||||
|
||||
model Playlist {
|
||||
id String @id @default(cuid())
|
||||
channelId String
|
||||
channel Channel @relation(fields: [channelId], references: [id])
|
||||
youtubePlaylistId String @unique
|
||||
title String
|
||||
description String?
|
||||
itemCount Int @default(0)
|
||||
privacyStatus String @default("public")
|
||||
videos VideoPlaylist[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([channelId])
|
||||
}
|
||||
|
||||
model VideoPlaylist {
|
||||
videoId String
|
||||
playlistId String
|
||||
position Int?
|
||||
video Video @relation(fields: [videoId], references: [id])
|
||||
playlist Playlist @relation(fields: [playlistId], references: [id])
|
||||
|
||||
@@id([videoId, playlistId])
|
||||
}
|
||||
|
||||
// ─── IMPORT / EXPORT JOBS ─────────────────────────────────
|
||||
|
||||
model ImportJob {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
type String
|
||||
sourceName String
|
||||
mappingJson Json?
|
||||
validationReport Json?
|
||||
commitStatus String @default("pending")
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
committedAt DateTime?
|
||||
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model ExportJob {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
type String
|
||||
scopeJson Json
|
||||
fileReference String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
// ─── YOUTUBE QUOTA LOG ────────────────────────────────────
|
||||
|
||||
model QuotaLog {
|
||||
id String @id @default(cuid())
|
||||
datePt DateTime
|
||||
units Int
|
||||
operation String
|
||||
entityId String?
|
||||
channelId String?
|
||||
channel Channel? @relation(fields: [channelId], references: [id])
|
||||
videoId String?
|
||||
video Video? @relation(fields: [videoId], references: [id])
|
||||
bulkJobId String?
|
||||
actionId String?
|
||||
actionType String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([datePt])
|
||||
@@index([channelId])
|
||||
@@index([actionId])
|
||||
}
|
||||
|
||||
// ─── AUDIT LOG ────────────────────────────────────────────
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
actorId String
|
||||
entityType String
|
||||
entityId String
|
||||
action String
|
||||
beforeJson Json?
|
||||
afterJson Json?
|
||||
requestId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([entityType, entityId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { createDecipheriv, scryptSync } from 'crypto';
|
||||
import { google } from 'googleapis';
|
||||
|
||||
const MISSING_IDS = [
|
||||
'4dcg46RQqnM',
|
||||
'9xsmLBY20wA',
|
||||
'DmmQ7HPxBRg',
|
||||
'G58lGGaMOOY',
|
||||
'I5u-8xk6BHM',
|
||||
'OdHheItBlyQ',
|
||||
'VtfmpQXPheA',
|
||||
'hIgaxCUX9ts',
|
||||
'nHWtSlE-kgE',
|
||||
'ye3b5J7zX0M',
|
||||
'zW7yrpUs34Q',
|
||||
];
|
||||
|
||||
const CHANNEL_ID = 'cmpfdm4el008nn3uoemascs0z';
|
||||
|
||||
const PRIVACY_MAP = { public: 'PUBLIC', private: 'PRIVATE', unlisted: 'UNLISTED' };
|
||||
|
||||
function decrypt(text) {
|
||||
const key = scryptSync('7017a681f5b32c3799d1bbf58ce58064', 'studioflow-salt', 32);
|
||||
const [ivHex, encHex] = text.split(':');
|
||||
const decipher = createDecipheriv('aes-256-cbc', key, Buffer.from(ivHex, 'hex'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(encHex, 'hex')), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const channel = await prisma.channel.findUniqueOrThrow({ where: { id: CHANNEL_ID } });
|
||||
|
||||
// Persist supplemental IDs on the channel
|
||||
const existing = new Set(channel.supplementalVideoIds ?? []);
|
||||
for (const id of MISSING_IDS) existing.add(id);
|
||||
await prisma.channel.update({
|
||||
where: { id: CHANNEL_ID },
|
||||
data: { supplementalVideoIds: [...existing] },
|
||||
});
|
||||
console.log(`Saved ${existing.size} supplemental video IDs on channel`);
|
||||
|
||||
// Set up YouTube client and refresh access token
|
||||
const oauth2 = new google.auth.OAuth2(
|
||||
'496320251583-rpbr13i0rh1112cdvv9ui5s373735qqj.apps.googleusercontent.com',
|
||||
'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',
|
||||
);
|
||||
oauth2.setCredentials({
|
||||
access_token: decrypt(channel.youtubeAccessToken),
|
||||
refresh_token: channel.youtubeRefreshToken ? decrypt(channel.youtubeRefreshToken) : undefined,
|
||||
});
|
||||
|
||||
// Force token refresh so we always have a valid access token
|
||||
const { credentials } = await oauth2.refreshAccessToken();
|
||||
oauth2.setCredentials(credentials);
|
||||
console.log('Access token refreshed');
|
||||
|
||||
const yt = google.youtube({ version: 'v3', auth: oauth2 });
|
||||
|
||||
// Fetch metadata for all missing IDs
|
||||
const res = await yt.videos.list({
|
||||
part: ['snippet', 'status'],
|
||||
id: MISSING_IDS,
|
||||
maxResults: 50,
|
||||
});
|
||||
const items = res.data.items ?? [];
|
||||
console.log(`YouTube returned ${items.length} of ${MISSING_IDS.length} requested IDs`);
|
||||
|
||||
const notFound = MISSING_IDS.filter((id) => !items.find((i) => i.id === id));
|
||||
if (notFound.length > 0) console.warn(`Not returned by YouTube API: ${notFound.join(', ')}`);
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const ytId = item.id;
|
||||
if (!ytId) continue;
|
||||
|
||||
const snippet = item.snippet;
|
||||
const status = item.status;
|
||||
|
||||
const ytFields = {
|
||||
title: snippet?.title ?? '(Untitled)',
|
||||
youtubeDescription: snippet?.description ?? undefined,
|
||||
tags: snippet?.tags ?? [],
|
||||
categoryId: snippet?.categoryId ?? undefined,
|
||||
publishedAt: snippet?.publishedAt ? new Date(snippet.publishedAt) : undefined,
|
||||
privacyStatus: PRIVACY_MAP[status?.privacyStatus ?? ''] ?? 'PRIVATE',
|
||||
defaultLanguage: snippet?.defaultLanguage ?? undefined,
|
||||
defaultAudioLanguage: snippet?.defaultAudioLanguage ?? undefined,
|
||||
selfDeclaredMadeForKids: status?.selfDeclaredMadeForKids ?? undefined,
|
||||
embeddable: status?.embeddable ?? undefined,
|
||||
license: status?.license ?? undefined,
|
||||
};
|
||||
|
||||
const existing = await prisma.video.findUnique({ where: { youtubeVideoId: ytId } });
|
||||
if (!existing) {
|
||||
await prisma.video.create({ data: { youtubeVideoId: ytId, channelId: CHANNEL_ID, ...ytFields } });
|
||||
console.log(` CREATED: ${ytId} — ${ytFields.title}`);
|
||||
created++;
|
||||
} else {
|
||||
await prisma.video.update({
|
||||
where: { youtubeVideoId: ytId },
|
||||
data: { channelId: CHANNEL_ID, ...ytFields },
|
||||
});
|
||||
console.log(` UPDATED: ${ytId} — ${ytFields.title}`);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone: ${created} created, ${updated} updated, ${notFound.length} not found on YouTube`);
|
||||
await prisma.$disconnect();
|
||||
@@ -0,0 +1,44 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { createDecipheriv, scryptSync } from 'crypto';
|
||||
import { google } from 'googleapis';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function decrypt(text) {
|
||||
const key = scryptSync('7017a681f5b32c3799d1bbf58ce58064', 'studioflow-salt', 32);
|
||||
const [ivHex, encHex] = text.split(':');
|
||||
const decipher = createDecipheriv('aes-256-cbc', key, Buffer.from(ivHex, 'hex'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(encHex, 'hex')), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findFirst({ where: { name: 'My Channel' } });
|
||||
const oauth2 = new google.auth.OAuth2(
|
||||
'496320251583-rpbr13i0rh1112cdvv9ui5s373735qqj.apps.googleusercontent.com',
|
||||
'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',
|
||||
);
|
||||
oauth2.setCredentials({
|
||||
access_token: decrypt(channel.youtubeAccessToken),
|
||||
refresh_token: channel.youtubeRefreshToken ? decrypt(channel.youtubeRefreshToken) : undefined,
|
||||
});
|
||||
|
||||
const yt = google.youtube({ version: 'v3', auth: oauth2 });
|
||||
|
||||
const pages = [];
|
||||
let pageToken;
|
||||
do {
|
||||
const res = await yt.playlistItems.list({
|
||||
part: ['contentDetails', 'snippet', 'status'],
|
||||
playlistId: channel.uploadsPlaylistId,
|
||||
maxResults: 50,
|
||||
...(pageToken ? { pageToken } : {}),
|
||||
});
|
||||
pages.push(res.data);
|
||||
pageToken = res.data.nextPageToken;
|
||||
} while (pageToken);
|
||||
|
||||
const outPath = new URL('./playlist-raw.json', import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1');
|
||||
writeFileSync(outPath, JSON.stringify(pages, null, 2));
|
||||
console.log(`Wrote ${pages.length} pages to ${outPath}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"CurrentProjectSetting": null
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"ExpandedNodes": [
|
||||
""
|
||||
],
|
||||
"SelectedNode": "\\app.module.ts",
|
||||
"PreviewInSolutionExplorer": false
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"Version": 1,
|
||||
"WorkspaceRootPath": "C:\\Users\\PSaggau.PSLaptop\\Documents\\Projekte\\Youtube_Studio_Flow\\backend\\src\\",
|
||||
"Documents": [
|
||||
{
|
||||
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\PSaggau.PSLaptop\\Documents\\Projekte\\Youtube_Studio_Flow\\backend\\src\\main.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
|
||||
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:main.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
|
||||
}
|
||||
],
|
||||
"DocumentGroupContainers": [
|
||||
{
|
||||
"Orientation": 0,
|
||||
"VerticalTabListWidth": 256,
|
||||
"DocumentGroups": [
|
||||
{
|
||||
"DockedWidth": 200,
|
||||
"SelectedChildIndex": 1,
|
||||
"Children": [
|
||||
{
|
||||
"$type": "Bookmark",
|
||||
"Name": "ST:0:0:{57d563b6-44a5-47df-85be-f4199ad6b651}"
|
||||
},
|
||||
{
|
||||
"$type": "Document",
|
||||
"DocumentIndex": 0,
|
||||
"Title": "main.ts",
|
||||
"DocumentMoniker": "C:\\Users\\PSaggau.PSLaptop\\Documents\\Projekte\\Youtube_Studio_Flow\\backend\\src\\main.ts",
|
||||
"RelativeDocumentMoniker": "main.ts",
|
||||
"ToolTip": "C:\\Users\\PSaggau.PSLaptop\\Documents\\Projekte\\Youtube_Studio_Flow\\backend\\src\\main.ts",
|
||||
"RelativeToolTip": "main.ts",
|
||||
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAMAAAAuAAAAAAAAAA==",
|
||||
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
|
||||
"WhenOpened": "2026-04-28T14:19:36.103Z",
|
||||
"EditorCaption": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { PrismaModule } from './shared/prisma/prisma.module';
|
||||
import { RenderEngineModule } from './shared/render-engine/render-engine.module';
|
||||
import { QuotaModule } from './shared/quota/quota.module';
|
||||
import { AuditModule } from './shared/audit/audit.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { VideosModule } from './modules/videos/videos.module';
|
||||
import { BlocksModule } from './modules/blocks/blocks.module';
|
||||
import { TemplatesModule } from './modules/templates/templates.module';
|
||||
import { VideoConfigsModule } from './modules/video-configs/video-configs.module';
|
||||
import { CollaboratorsModule } from './modules/collaborators/collaborators.module';
|
||||
import { BulkJobsModule } from './modules/bulk-jobs/bulk-jobs.module';
|
||||
import { SavedViewsModule } from './modules/saved-views/saved-views.module';
|
||||
import { LintingModule } from './modules/linting/linting.module';
|
||||
import { CalendarModule } from './modules/calendar/calendar.module';
|
||||
import { ImportsModule } from './modules/imports/imports.module';
|
||||
import { ExportsModule } from './modules/exports/exports.module';
|
||||
import { YouTubeSyncModule } from './modules/youtube-sync/youtube-sync.module';
|
||||
import { TeamsModule } from './modules/teams/teams.module';
|
||||
import { TeamVariablesModule } from './modules/team-variables/team-variables.module';
|
||||
import { PlaylistsModule } from './modules/playlists/playlists.module';
|
||||
import { SystemVariablesModule } from './shared/system-variables/system-variables.module';
|
||||
import { CampaignsModule } from './modules/campaigns/campaigns.module';
|
||||
import { QuotaController } from './modules/quota/quota.controller';
|
||||
import { AuditLogsController } from './modules/audit-logs/audit-logs.controller';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
BullModule.forRootAsync({
|
||||
useFactory: (config: ConfigService) => ({
|
||||
connection: { url: config.get<string>('REDIS_URL') ?? 'redis://localhost:6379' },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
PrismaModule,
|
||||
RenderEngineModule,
|
||||
QuotaModule,
|
||||
AuditModule,
|
||||
AuthModule,
|
||||
VideosModule,
|
||||
BlocksModule,
|
||||
TemplatesModule,
|
||||
VideoConfigsModule,
|
||||
CollaboratorsModule,
|
||||
BulkJobsModule,
|
||||
SavedViewsModule,
|
||||
LintingModule,
|
||||
CalendarModule,
|
||||
ImportsModule,
|
||||
ExportsModule,
|
||||
YouTubeSyncModule,
|
||||
TeamsModule,
|
||||
TeamVariablesModule,
|
||||
PlaylistsModule,
|
||||
SystemVariablesModule,
|
||||
CampaignsModule,
|
||||
],
|
||||
controllers: [HealthController, QuotaController, AuditLogsController],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
health() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
app.enableCors({
|
||||
origin: process.env.FRONTEND_URL ?? 'http://localhost:3000',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('StudioFlow API')
|
||||
.setDescription('YouTube Upload Manager — REST API')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = process.env.PORT ?? 3001;
|
||||
await app.listen(port);
|
||||
console.log(`StudioFlow API running on :${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
|
||||
@ApiTags('audit-logs')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('audit-logs')
|
||||
export class AuditLogsController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Team audit trail (paginated, filtered)' })
|
||||
async findAll(
|
||||
@Req() req: any,
|
||||
@Query('page') page = '1',
|
||||
@Query('limit') limit = '50',
|
||||
@Query('entityType') entityType?: string,
|
||||
@Query('action') action?: string,
|
||||
) {
|
||||
const members = await this.prisma.teamMember.findMany({
|
||||
where: { teamId: req.user.teamId },
|
||||
select: { userId: true },
|
||||
});
|
||||
const actorIds = members.map((m) => m.userId);
|
||||
|
||||
const where = {
|
||||
actorId: { in: actorIds },
|
||||
...(entityType ? { entityType } : {}),
|
||||
...(action ? { action } : {}),
|
||||
};
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (Number(page) - 1) * Number(limit),
|
||||
take: Number(limit),
|
||||
}),
|
||||
this.prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
const uniqueActorIds = [...new Set(logs.map((l) => l.actorId))];
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { id: { in: uniqueActorIds } },
|
||||
select: { id: true, name: true, email: true },
|
||||
});
|
||||
const userMap = Object.fromEntries(users.map((u) => [u.id, u]));
|
||||
|
||||
return {
|
||||
data: logs.map((log) => ({ ...log, actor: userMap[log.actorId] ?? null })),
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':entityType/:entityId')
|
||||
@ApiOperation({ summary: 'Audit history for a specific entity' })
|
||||
async findForEntity(
|
||||
@Param('entityType') entityType: string,
|
||||
@Param('entityId') entityId: string,
|
||||
) {
|
||||
const logs = await this.prisma.auditLog.findMany({
|
||||
where: { entityType, entityId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const uniqueActorIds = [...new Set(logs.map((l) => l.actorId))];
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { id: { in: uniqueActorIds } },
|
||||
select: { id: true, name: true, email: true },
|
||||
});
|
||||
const userMap = Object.fromEntries(users.map((u) => [u.id, u]));
|
||||
|
||||
return logs.map((log) => ({ ...log, actor: userMap[log.actorId] ?? null }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Controller, Get, Patch, Post, Req, Res, UseGuards, Body } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Response, Request } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Get('google')
|
||||
@ApiOperation({ summary: 'Redirect to Google OAuth' })
|
||||
@UseGuards(AuthGuard('google'))
|
||||
googleAuth() {}
|
||||
|
||||
@Get('google/callback')
|
||||
@ApiOperation({ summary: 'Google OAuth callback' })
|
||||
@UseGuards(AuthGuard('google'))
|
||||
async googleCallback(@Req() req: Request, @Res() res: Response) {
|
||||
const { user, teamId, teamRole } = req.user as any;
|
||||
const accessToken = this.authService.issueJwt(user, teamId, teamRole);
|
||||
const refreshToken = this.authService.issueRefreshToken(user);
|
||||
|
||||
res.cookie('refresh_token', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000';
|
||||
res.redirect(`${frontendUrl}/auth/callback?token=${accessToken}`);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Get current user with team context' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@Req() req: Request) {
|
||||
const user = req.user as any;
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
isAppAdmin: user.isAppAdmin,
|
||||
teamId: user.teamId,
|
||||
teamRole: user.teamRole,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
async refresh(@Req() req: Request) {
|
||||
const token = req.cookies?.['refresh_token'];
|
||||
return this.authService.refreshAccessToken(token);
|
||||
}
|
||||
|
||||
@Get('me/preferences')
|
||||
@ApiOperation({ summary: 'Get current user preferences' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
getPreferences(@Req() req: Request) {
|
||||
return this.authService.getPreferences((req.user as any).id);
|
||||
}
|
||||
|
||||
@Patch('me/preferences')
|
||||
@ApiOperation({ summary: 'Patch current user preferences (shallow merge)' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
updatePreferences(@Req() req: Request, @Body() body: Record<string, unknown>) {
|
||||
return this.authService.updatePreferences((req.user as any).id, body);
|
||||
}
|
||||
|
||||
@Post('switch-team')
|
||||
@ApiOperation({ summary: 'Switch active team — returns a new access token' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async switchTeam(@Req() req: Request, @Body('teamId') teamId: string) {
|
||||
const user = req.user as any;
|
||||
return this.authService.switchTeam(user.id, teamId);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@ApiOperation({ summary: 'Clear refresh token cookie' })
|
||||
logout(@Res() res: Response) {
|
||||
(res as any).clearCookie('refresh_token', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
});
|
||||
(res as any).json({ message: 'Logged out' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { GoogleStrategy } from './strategies/google.strategy';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '15m' },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, GoogleStrategy, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
||||
import { google } from 'googleapis';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { User, TeamRole } from '@prisma/client';
|
||||
|
||||
export interface AuthResult {
|
||||
user: User;
|
||||
teamId: string;
|
||||
teamRole: TeamRole;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly encKey: Buffer;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
const raw = this.config.get<string>('TOKEN_ENCRYPTION_KEY') ?? 'fallback_dev_key_32chars_padded!!';
|
||||
this.encKey = scryptSync(raw, 'studioflow-salt', 32);
|
||||
}
|
||||
|
||||
async upsertGoogleUser(
|
||||
profile: any,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
): Promise<AuthResult> {
|
||||
const email: string = profile.emails?.[0]?.value;
|
||||
const googleId: string = profile.id;
|
||||
const name: string = profile.displayName;
|
||||
|
||||
const user = await this.prisma.user.upsert({
|
||||
where: { googleId },
|
||||
create: { email, name, googleId },
|
||||
update: { email, name },
|
||||
});
|
||||
|
||||
const memberships = await this.prisma.teamMember.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
let teamId: string;
|
||||
let teamRole: TeamRole;
|
||||
|
||||
if (memberships.length === 0) {
|
||||
const { youtubeChannelId, channelName, uploadsPlaylistId } =
|
||||
await this.fetchYouTubeChannelInfo(accessToken, refreshToken);
|
||||
|
||||
const slug = this.slugify(name || email.split('@')[0]);
|
||||
const uniqueSlug = await this.ensureUniqueSlug(slug);
|
||||
|
||||
const team = await this.prisma.team.create({
|
||||
data: {
|
||||
name: name || email.split('@')[0],
|
||||
slug: uniqueSlug,
|
||||
members: {
|
||||
create: { userId: user.id, role: TeamRole.OWNER },
|
||||
},
|
||||
channels: {
|
||||
create: {
|
||||
youtubeChannelId,
|
||||
name: channelName,
|
||||
uploadsPlaylistId,
|
||||
youtubeAccessToken: this.encrypt(accessToken),
|
||||
youtubeRefreshToken: refreshToken ? this.encrypt(refreshToken) : undefined,
|
||||
youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000),
|
||||
connectedBy: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
teamId = team.id;
|
||||
teamRole = TeamRole.OWNER;
|
||||
} else {
|
||||
const primary = memberships[0];
|
||||
teamId = primary.teamId;
|
||||
teamRole = primary.role;
|
||||
|
||||
// Refresh tokens on channels this user connected.
|
||||
// Only overwrite youtubeRefreshToken when Google actually returns one
|
||||
// (it only does on first auth or when prompt=consent forces re-consent).
|
||||
await this.prisma.channel.updateMany({
|
||||
where: { teamId, connectedBy: user.id },
|
||||
data: {
|
||||
youtubeAccessToken: this.encrypt(accessToken),
|
||||
...(refreshToken && { youtubeRefreshToken: this.encrypt(refreshToken) }),
|
||||
youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { user, teamId, teamRole };
|
||||
}
|
||||
|
||||
issueJwt(user: User, teamId: string, teamRole: TeamRole): string {
|
||||
return this.jwt.sign(
|
||||
{ sub: user.id, email: user.email, teamId, teamRole },
|
||||
{ expiresIn: '15m' },
|
||||
);
|
||||
}
|
||||
|
||||
issueRefreshToken(user: User): string {
|
||||
return this.jwt.sign(
|
||||
{ sub: user.id },
|
||||
{
|
||||
secret: this.config.get<string>('JWT_REFRESH_SECRET') ?? this.config.get<string>('JWT_SECRET'),
|
||||
expiresIn: '7d',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async refreshAccessToken(refreshToken: string | undefined): Promise<{ accessToken: string }> {
|
||||
if (!refreshToken) throw new Error('No refresh token');
|
||||
const payload = this.jwt.verify<{ sub: string }>(refreshToken, {
|
||||
secret: this.config.get<string>('JWT_REFRESH_SECRET') ?? this.config.get<string>('JWT_SECRET'),
|
||||
});
|
||||
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: payload.sub } });
|
||||
const membership = await this.prisma.teamMember.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (!membership) throw new Error('User has no team');
|
||||
return { accessToken: this.issueJwt(user, membership.teamId, membership.role) };
|
||||
}
|
||||
|
||||
async getPreferences(userId: string): Promise<Record<string, unknown>> {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
return (user.preferences as Record<string, unknown>) ?? {};
|
||||
}
|
||||
|
||||
async updatePreferences(userId: string, patch: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const merged = { ...((user.preferences as Record<string, unknown>) ?? {}), ...patch };
|
||||
await this.prisma.user.update({ where: { id: userId }, data: { preferences: merged as any } });
|
||||
return merged;
|
||||
}
|
||||
|
||||
async switchTeam(userId: string, teamId: string): Promise<{ accessToken: string }> {
|
||||
const membership = await this.prisma.teamMember.findUnique({
|
||||
where: { userId_teamId: { userId, teamId } },
|
||||
});
|
||||
if (!membership) throw new Error('Not a member of this team');
|
||||
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
return { accessToken: this.issueJwt(user, membership.teamId, membership.role) };
|
||||
}
|
||||
|
||||
decryptToken(encrypted: string): string {
|
||||
return this.decrypt(encrypted);
|
||||
}
|
||||
|
||||
encryptToken(plain: string): string {
|
||||
return this.encrypt(plain);
|
||||
}
|
||||
|
||||
private async fetchYouTubeChannelInfo(accessToken: string, refreshToken: string) {
|
||||
const oauth2 = new google.auth.OAuth2(
|
||||
this.config.get<string>('GOOGLE_CLIENT_ID'),
|
||||
this.config.get<string>('GOOGLE_CLIENT_SECRET'),
|
||||
);
|
||||
oauth2.setCredentials({ access_token: accessToken, refresh_token: refreshToken });
|
||||
const yt = google.youtube({ version: 'v3', auth: oauth2 });
|
||||
|
||||
try {
|
||||
const res = await yt.channels.list({ part: ['snippet', 'contentDetails'], mine: true });
|
||||
const ch = res.data.items?.[0];
|
||||
return {
|
||||
youtubeChannelId: ch?.id ?? `unknown-${Date.now()}`,
|
||||
channelName: ch?.snippet?.title ?? 'My Channel',
|
||||
uploadsPlaylistId: ch?.contentDetails?.relatedPlaylists?.uploads ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
youtubeChannelId: `unknown-${Date.now()}`,
|
||||
channelName: 'My Channel',
|
||||
uploadsPlaylistId: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 50) || 'team';
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(base: string): Promise<string> {
|
||||
let slug = base;
|
||||
let i = 2;
|
||||
while (await this.prisma.team.findUnique({ where: { slug } })) {
|
||||
slug = `${base}-${i++}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
private encrypt(text: string): string {
|
||||
const iv = randomBytes(16);
|
||||
const cipher = createCipheriv('aes-256-cbc', this.encKey, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
return iv.toString('hex') + ':' + encrypted.toString('hex');
|
||||
}
|
||||
|
||||
private decrypt(text: string): string {
|
||||
const [ivHex, encHex] = text.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const encrypted = Buffer.from(encHex, 'hex');
|
||||
const decipher = createDecipheriv('aes-256-cbc', this.encKey, iv);
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { TeamRole } from '@prisma/client';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: TeamRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { TeamRole } from '@prisma/client';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
const ROLE_PRIORITY: Record<TeamRole, number> = {
|
||||
[TeamRole.OWNER]: 5,
|
||||
[TeamRole.ADMIN]: 4,
|
||||
[TeamRole.EDITOR]: 3,
|
||||
[TeamRole.REVIEWER]: 2,
|
||||
[TeamRole.READONLY]: 1,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<TeamRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!required || required.length === 0) return true;
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (!user) throw new ForbiddenException();
|
||||
|
||||
const userPriority = ROLE_PRIORITY[user.teamRole as TeamRole] ?? 0;
|
||||
const minRequired = Math.min(...required.map((r) => ROLE_PRIORITY[r]));
|
||||
|
||||
if (userPriority < minRequired) throw new ForbiddenException('Insufficient role');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {
|
||||
super({
|
||||
clientID: config.getOrThrow<string>('GOOGLE_CLIENT_ID'),
|
||||
clientSecret: config.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
|
||||
callbackURL: config.get<string>('GOOGLE_CALLBACK_URL') ?? 'http://localhost:3001/api/v1/auth/google/callback',
|
||||
scope: ['email', 'profile', 'https://www.googleapis.com/auth/youtube'],
|
||||
});
|
||||
}
|
||||
|
||||
override authorizationParams(options: any): Record<string, string> {
|
||||
const params: Record<string, string> = { access_type: 'offline' };
|
||||
if (options?.prompt) params.prompt = options.prompt;
|
||||
return params;
|
||||
}
|
||||
|
||||
override authenticate(req: any, options?: any) {
|
||||
this.resolvePrompt(req)
|
||||
.then((prompt) => super.authenticate(req, { ...options, ...(prompt ? { prompt } : {}) }))
|
||||
.catch(() => super.authenticate(req, options));
|
||||
}
|
||||
|
||||
private async resolvePrompt(req: any): Promise<string | null> {
|
||||
// Extract bearer token from Authorization header (sent by the frontend before redirect)
|
||||
const authHeader: string | undefined = req.headers?.authorization;
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
if (!token) return null; // new user — access_type=offline alone gives a refresh token on first auth
|
||||
|
||||
try {
|
||||
const payload = this.jwt.verify<{ sub: string; teamId: string }>(token, {
|
||||
secret: this.config.getOrThrow<string>('JWT_SECRET'),
|
||||
});
|
||||
const channel = await this.prisma.channel.findFirst({
|
||||
where: { teamId: payload.teamId, connectedBy: payload.sub },
|
||||
select: { youtubeRefreshToken: true },
|
||||
});
|
||||
// Only force consent when the refresh token is genuinely missing
|
||||
return channel && !channel.youtubeRefreshToken ? 'consent' : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async validate(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
profile: any,
|
||||
done: VerifyCallback,
|
||||
) {
|
||||
// authResult contains { user, teamId, teamRole } — passed as req.user to the controller
|
||||
const authResult = await this.authService.upsertGoogleUser(profile, accessToken, refreshToken);
|
||||
done(null, authResult as any);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { TeamRole } from '@prisma/client';
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
teamId: string;
|
||||
teamRole: TeamRole;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
|
||||
const membership = await this.prisma.teamMember.findUnique({
|
||||
where: { userId_teamId: { userId: user.id, teamId: payload.teamId } },
|
||||
});
|
||||
if (!membership) throw new UnauthorizedException('Not a member of this team');
|
||||
|
||||
return { ...user, teamId: payload.teamId, teamRole: payload.teamRole };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { TeamRole } from '@prisma/client';
|
||||
import { BlocksService } from './blocks.service';
|
||||
import { CreateBlockDto } from './dto/create-block.dto';
|
||||
import { UpdateBlockDto } from './dto/update-block.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('blocks')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Controller('blocks')
|
||||
export class BlocksController {
|
||||
constructor(private readonly service: BlocksService) {}
|
||||
|
||||
@Get() @ApiOperation({ summary: 'List all blocks' })
|
||||
findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); }
|
||||
|
||||
@Post() @ApiOperation({ summary: 'Create block' }) @Roles(TeamRole.EDITOR)
|
||||
create(@Body() dto: CreateBlockDto, @Req() req: any) { return this.service.create(dto, req.user.id, req.user.teamId); }
|
||||
|
||||
@Patch(':id') @ApiOperation({ summary: 'Update block (creates version snapshot)' }) @Roles(TeamRole.EDITOR)
|
||||
update(@Param('id') id: string, @Body() dto: UpdateBlockDto, @Req() req: any) { return this.service.update(id, dto, req.user.id, req.user.teamId); }
|
||||
|
||||
@Delete(':id') @ApiOperation({ summary: 'Delete block (only if not in use)' }) @Roles(TeamRole.EDITOR)
|
||||
remove(@Param('id') id: string, @Req() req: any) { return this.service.delete(id, req.user.teamId, req.user.id); }
|
||||
|
||||
@Get(':id/versions') @ApiOperation({ summary: 'Block version history' })
|
||||
versions(@Param('id') id: string) { return this.service.getVersions(id); }
|
||||
|
||||
@Get(':id/usage') @ApiOperation({ summary: 'Videos and templates using this block' })
|
||||
usage(@Param('id') id: string, @Req() req: any) { return this.service.getUsage(id, req.user.teamId); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { BlocksService } from './blocks.service';
|
||||
import { BlocksController } from './blocks.controller';
|
||||
import { AuditModule } from '../../shared/audit/audit.module';
|
||||
import { QUEUES } from '../../queues/queues.constants';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.RENDER })],
|
||||
providers: [BlocksService],
|
||||
controllers: [BlocksController],
|
||||
exports: [BlocksService],
|
||||
})
|
||||
export class BlocksModule {}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { AuditService } from '../../shared/audit/audit.service';
|
||||
import { CreateBlockDto } from './dto/create-block.dto';
|
||||
import { UpdateBlockDto } from './dto/update-block.dto';
|
||||
import { QUEUES } from '../../queues/queues.constants';
|
||||
|
||||
@Injectable()
|
||||
export class BlocksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
@InjectQueue(QUEUES.RENDER) private readonly renderQueue: Queue,
|
||||
) {}
|
||||
|
||||
findAll(teamId: string) {
|
||||
return this.prisma.descriptionBlock.findMany({ where: { teamId }, orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async create(dto: CreateBlockDto, actorId: string, teamId: string) {
|
||||
const block = await this.prisma.descriptionBlock.create({
|
||||
data: {
|
||||
...dto,
|
||||
variableDefinitions: (dto.variableDefinitions ?? []) as any,
|
||||
condition: dto.condition === null ? Prisma.JsonNull : (dto.condition as any),
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
await this.audit.log(actorId, 'DescriptionBlock', block.id, 'create', null, block);
|
||||
return block;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateBlockDto, actorId: string, teamId: string) {
|
||||
const before = await this.prisma.descriptionBlock.findFirst({ where: { id, teamId } });
|
||||
if (!before) throw new NotFoundException(`Block ${id} not found`);
|
||||
|
||||
await this.prisma.blockVersion.create({
|
||||
data: { blockId: id, version: before.version, contentSnapshot: before as any, createdBy: actorId },
|
||||
});
|
||||
|
||||
const updated = await this.prisma.descriptionBlock.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
variableDefinitions: dto.variableDefinitions !== undefined ? (dto.variableDefinitions as any) : undefined,
|
||||
condition: dto.condition === null ? Prisma.JsonNull : dto.condition !== undefined ? (dto.condition as any) : undefined,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
await this.audit.log(actorId, 'DescriptionBlock', id, 'update', before, updated);
|
||||
await this.enqueueBlockRenders(id, teamId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async enqueueBlockRenders(blockId: string, teamId: string) {
|
||||
const configs = await this.prisma.videoConfig.findMany({
|
||||
where: {
|
||||
blockOrder: { array_contains: blockId } as any,
|
||||
video: { channel: { teamId }, youtubeDeletedAt: null },
|
||||
},
|
||||
select: { videoId: true },
|
||||
});
|
||||
await Promise.all(
|
||||
configs.map((c) => this.renderQueue.add('render', { videoId: c.videoId }, { jobId: `render-${c.videoId}` })),
|
||||
);
|
||||
}
|
||||
|
||||
getVersions(id: string) {
|
||||
return this.prisma.blockVersion.findMany({ where: { blockId: id }, orderBy: { version: 'desc' } });
|
||||
}
|
||||
|
||||
async delete(id: string, teamId: string, actorId: string) {
|
||||
const block = await this.prisma.descriptionBlock.findFirst({ where: { id, teamId } });
|
||||
if (!block) throw new NotFoundException(`Block ${id} not found`);
|
||||
const usage = await this.getUsage(id, teamId);
|
||||
if (usage.videos.length > 0 || usage.templates.length > 0) {
|
||||
throw new ConflictException(
|
||||
`Block is used by ${usage.videos.length} video config(s) and ${usage.templates.length} template(s) and cannot be deleted`,
|
||||
);
|
||||
}
|
||||
await this.prisma.blockVersion.deleteMany({ where: { blockId: id } });
|
||||
await this.prisma.descriptionBlock.delete({ where: { id } });
|
||||
await this.audit.log(actorId, 'DescriptionBlock', id, 'delete', block, null);
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async getUsage(id: string, teamId: string) {
|
||||
const [videoConfigs, templates] = await Promise.all([
|
||||
this.prisma.videoConfig.findMany({
|
||||
where: { blockOrder: { array_contains: id }, video: { channel: { teamId } } },
|
||||
select: { videoId: true, video: { select: { title: true } } },
|
||||
}),
|
||||
this.prisma.template.findMany({
|
||||
where: { defaultBlocks: { array_contains: id }, teamId },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
videos: videoConfigs.map((c) => ({ id: c.videoId, title: c.video.title })),
|
||||
templates,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IsString, IsEnum, IsOptional, IsBoolean, IsArray } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { BlockType } from '@prisma/client';
|
||||
|
||||
export class BlockVariableDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export class CreateBlockDto {
|
||||
@ApiProperty() @IsString() name: string;
|
||||
@ApiProperty({ enum: BlockType }) @IsEnum(BlockType) type: BlockType;
|
||||
@ApiProperty() @IsString() content: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() language?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsBoolean() compact?: boolean;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() campaignId?: string;
|
||||
@ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
|
||||
@ApiPropertyOptional({ type: [BlockVariableDefinition] }) @IsOptional() @IsArray() variableDefinitions?: BlockVariableDefinition[];
|
||||
@ApiPropertyOptional() @IsOptional() condition?: Record<string, any> | null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional, IsBoolean, IsArray } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { BlockVariableDefinition } from './create-block.dto';
|
||||
|
||||
export class UpdateBlockDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() content?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() language?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsBoolean() active?: boolean;
|
||||
@ApiPropertyOptional() @IsOptional() @IsBoolean() compact?: boolean;
|
||||
@ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
|
||||
@ApiPropertyOptional({ type: [BlockVariableDefinition] }) @IsOptional() @IsArray() variableDefinitions?: BlockVariableDefinition[];
|
||||
@ApiPropertyOptional() @IsOptional() condition?: Record<string, any> | null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Controller, Get, Post, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { TeamRole } from '@prisma/client';
|
||||
import { BulkJobsService } from './bulk-jobs.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('bulk-jobs')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Controller('bulk-jobs')
|
||||
export class BulkJobsController {
|
||||
constructor(private readonly service: BulkJobsService) {}
|
||||
|
||||
@Get() @ApiOperation({ summary: 'List bulk jobs for active team' })
|
||||
findAll(@Req() req: any, @Query('status') status?: string) {
|
||||
return this.service.findAll(req.user.teamId, status);
|
||||
}
|
||||
|
||||
@Get(':id') @ApiOperation({ summary: 'Get bulk job with items' })
|
||||
findOne(@Param('id') id: string, @Req() req: any) { return this.service.findOne(id, req.user.teamId); }
|
||||
|
||||
@Post(':id/rollback') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Rollback a completed bulk job' })
|
||||
rollback(@Param('id') id: string, @Req() req: any) { return this.service.rollback(id, req.user.id, req.user.teamId); }
|
||||
|
||||
@Get('push-pending/preview') @ApiOperation({ summary: 'Preview all push-pending videos with field-level diffs' })
|
||||
previewPushPending(
|
||||
@Req() req: any,
|
||||
@Query('sort') sort?: string,
|
||||
@Query('order') order?: string,
|
||||
) { return this.service.previewPushPending(req.user.teamId, sort, order as 'asc' | 'desc' | undefined); }
|
||||
|
||||
@Post('push-pending') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Create a bulk push job for selected pending videos' })
|
||||
createPushPendingJob(@Req() req: any, @Body('videoIds') videoIds: string[]) {
|
||||
return this.service.createPushPendingJob(req.user.teamId, req.user.id, videoIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { BulkJobsService } from './bulk-jobs.service';
|
||||
import { BulkJobsController } from './bulk-jobs.controller';
|
||||
import { AuditModule } from '../../shared/audit/audit.module';
|
||||
import { QUEUES } from '../../queues/queues.constants';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.BULK_METADATA }), BullModule.registerQueue({ name: QUEUES.YOUTUBE_SYNC })],
|
||||
providers: [BulkJobsService],
|
||||
controllers: [BulkJobsController],
|
||||
exports: [BulkJobsService],
|
||||
})
|
||||
export class BulkJobsModule {}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { AuditService } from '../../shared/audit/audit.service';
|
||||
import { QUEUES } from '../../queues/queues.constants';
|
||||
import { computePendingChanges } from '../../shared/render-engine/pending-changes';
|
||||
|
||||
export type FieldDiff = { before: string | null; after: string | null };
|
||||
export type TagsDiff = { before: string[]; after: string[] };
|
||||
export type BoolDiff = { before: boolean; after: boolean };
|
||||
|
||||
export interface PushPendingPreviewItem {
|
||||
videoId: string;
|
||||
title: string;
|
||||
thumbnailUrl: string | null;
|
||||
firstSync: boolean;
|
||||
changedFields: string[];
|
||||
diff: {
|
||||
title?: FieldDiff;
|
||||
description?: FieldDiff;
|
||||
privacyStatus?: FieldDiff;
|
||||
tags?: TagsDiff;
|
||||
categoryId?: FieldDiff;
|
||||
defaultLanguage?: FieldDiff;
|
||||
defaultAudioLanguage?: FieldDiff;
|
||||
selfDeclaredMadeForKids?: BoolDiff;
|
||||
embeddable?: BoolDiff;
|
||||
license?: FieldDiff;
|
||||
recordingDate?: FieldDiff;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BulkJobsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
@InjectQueue(QUEUES.BULK_METADATA) private readonly bulkQueue: Queue,
|
||||
@InjectQueue(QUEUES.YOUTUBE_SYNC) private readonly syncQueue: Queue,
|
||||
) {}
|
||||
|
||||
findAll(teamId: string, status?: string) {
|
||||
return this.prisma.bulkJob.findMany({
|
||||
where: { teamId, ...(status ? { status: status as any } : {}) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string, teamId: string) {
|
||||
const job = await this.prisma.bulkJob.findFirst({
|
||||
where: { id, teamId },
|
||||
include: { items: { include: { video: { select: { id: true, title: true } } } } },
|
||||
});
|
||||
if (!job) throw new NotFoundException(`BulkJob ${id} not found`);
|
||||
return job;
|
||||
}
|
||||
|
||||
async rollback(id: string, actorId: string, teamId: string) {
|
||||
const job = await this.findOne(id, teamId);
|
||||
const doneItems = await this.prisma.bulkJobItem.findMany({
|
||||
where: { bulkJobId: id, status: 'done' },
|
||||
});
|
||||
|
||||
for (const item of doneItems) {
|
||||
if (!item.beforeSnapshot) continue;
|
||||
await this.prisma.video.update({ where: { id: item.videoId }, data: item.beforeSnapshot as any });
|
||||
await this.prisma.bulkJobItem.update({ where: { id: item.id }, data: { status: 'rolled_back' } });
|
||||
}
|
||||
|
||||
const updated = await this.prisma.bulkJob.update({ where: { id }, data: { status: 'ROLLED_BACK' } });
|
||||
await this.audit.log(actorId, 'BulkJob', id, 'rollback', { status: job.status }, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async enqueueItems(bulkJobId: string, teamId: string) {
|
||||
const job = await this.findOne(bulkJobId, teamId);
|
||||
const items = await this.prisma.bulkJobItem.findMany({
|
||||
where: { bulkJobId, status: 'pending' },
|
||||
});
|
||||
|
||||
await this.prisma.bulkJob.update({ where: { id: bulkJobId }, data: { status: 'RUNNING' } });
|
||||
|
||||
await Promise.all(
|
||||
items.map((item) => this.bulkQueue.add('process-item', { bulkJobId, itemId: item.id })),
|
||||
);
|
||||
return { enqueued: items.length };
|
||||
}
|
||||
|
||||
async previewPushPending(teamId: string, sort = 'publishedAt', order: 'asc' | 'desc' = 'desc'): Promise<PushPendingPreviewItem[]> {
|
||||
const ALLOWED = new Set(['title', 'publishedAt', 'privacyStatus', 'lintStatus', 'lastSyncedAt']);
|
||||
const sortField = ALLOWED.has(sort) ? sort : 'publishedAt';
|
||||
|
||||
const videos = await this.prisma.video.findMany({
|
||||
where: { channel: { teamId }, youtubeDeletedAt: null },
|
||||
select: {
|
||||
id: true, title: true, thumbnailUrl: true, tags: true, categoryId: true,
|
||||
privacyStatus: true, defaultLanguage: true, defaultAudioLanguage: true,
|
||||
selfDeclaredMadeForKids: true, embeddable: true, license: true, recordingDate: true,
|
||||
youtubeDescription: true, renderedDescription: true,
|
||||
lastSyncedHash: true, youtubeSnapshot: true,
|
||||
publishedAt: true, scheduledAt: true, createdAt: true, lintStatus: true, lastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pending = videos.filter((v) => computePendingChanges(v));
|
||||
|
||||
// Mirror the same sort logic used by findAll so the modal order matches the tab order.
|
||||
if (sortField === 'publishedAt') {
|
||||
const now = new Date();
|
||||
pending.sort((a, b) => {
|
||||
const aDate = (a.scheduledAt && a.scheduledAt > now ? a.scheduledAt : null) ?? a.publishedAt ?? a.createdAt;
|
||||
const bDate = (b.scheduledAt && b.scheduledAt > now ? b.scheduledAt : null) ?? b.publishedAt ?? b.createdAt;
|
||||
return order === 'desc' ? bDate.getTime() - aDate.getTime() : aDate.getTime() - bDate.getTime();
|
||||
});
|
||||
} else {
|
||||
const dir = order === 'asc' ? 1 : -1;
|
||||
pending.sort((a, b) => {
|
||||
const av = (a as any)[sortField] ?? '';
|
||||
const bv = (b as any)[sortField] ?? '';
|
||||
if (av < bv) return -dir;
|
||||
if (av > bv) return dir;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return pending.map((v) => {
|
||||
const snap = v.youtubeSnapshot as Record<string, any> | null;
|
||||
const allFields: string[] = ['title', 'description', 'privacyStatus', 'tags', 'categoryId',
|
||||
'defaultLanguage', 'defaultAudioLanguage', 'selfDeclaredMadeForKids', 'embeddable', 'license', 'recordingDate'];
|
||||
|
||||
if (!snap) {
|
||||
return {
|
||||
videoId: v.id, title: v.title, thumbnailUrl: v.thumbnailUrl,
|
||||
firstSync: true, changedFields: allFields,
|
||||
diff: {
|
||||
title: { before: null, after: v.title },
|
||||
description: { before: null, after: v.renderedDescription ?? '' },
|
||||
privacyStatus: { before: null, after: String(v.privacyStatus) },
|
||||
tags: { before: [], after: v.tags },
|
||||
categoryId: { before: null, after: v.categoryId ?? null },
|
||||
defaultLanguage: { before: null, after: v.defaultLanguage ?? null },
|
||||
defaultAudioLanguage: { before: null, after: v.defaultAudioLanguage ?? null },
|
||||
selfDeclaredMadeForKids: { before: false, after: v.selfDeclaredMadeForKids ?? false },
|
||||
embeddable: { before: true, after: v.embeddable ?? true },
|
||||
license: { before: null, after: v.license ?? null },
|
||||
recordingDate: { before: null, after: v.recordingDate ? v.recordingDate.toISOString().slice(0, 10) : null },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const diff: PushPendingPreviewItem['diff'] = {};
|
||||
const changedFields: string[] = [];
|
||||
|
||||
if (snap.title !== v.title) {
|
||||
changedFields.push('title');
|
||||
diff.title = { before: snap.title ?? null, after: v.title };
|
||||
}
|
||||
const descBefore = v.youtubeDescription ?? '';
|
||||
const descAfter = v.renderedDescription ?? '';
|
||||
if (descBefore !== descAfter) {
|
||||
changedFields.push('description');
|
||||
diff.description = { before: descBefore, after: descAfter };
|
||||
}
|
||||
const snapPrivacy = String(snap.privacyStatus ?? '').toUpperCase();
|
||||
if (snapPrivacy !== String(v.privacyStatus)) {
|
||||
changedFields.push('privacyStatus');
|
||||
diff.privacyStatus = { before: snap.privacyStatus ?? null, after: String(v.privacyStatus) };
|
||||
}
|
||||
if (JSON.stringify([...(snap.tags ?? [])].sort()) !== JSON.stringify([...v.tags].sort())) {
|
||||
changedFields.push('tags');
|
||||
diff.tags = { before: snap.tags ?? [], after: v.tags };
|
||||
}
|
||||
if ((snap.categoryId ?? null) !== (v.categoryId ?? null)) {
|
||||
changedFields.push('categoryId');
|
||||
diff.categoryId = { before: snap.categoryId ?? null, after: v.categoryId ?? null };
|
||||
}
|
||||
if ((snap.defaultLanguage ?? null) !== (v.defaultLanguage ?? null)) {
|
||||
changedFields.push('defaultLanguage');
|
||||
diff.defaultLanguage = { before: snap.defaultLanguage ?? null, after: v.defaultLanguage ?? null };
|
||||
}
|
||||
if ((snap.defaultAudioLanguage ?? null) !== (v.defaultAudioLanguage ?? null)) {
|
||||
changedFields.push('defaultAudioLanguage');
|
||||
diff.defaultAudioLanguage = { before: snap.defaultAudioLanguage ?? null, after: v.defaultAudioLanguage ?? null };
|
||||
}
|
||||
if ((snap.selfDeclaredMadeForKids ?? false) !== (v.selfDeclaredMadeForKids ?? false)) {
|
||||
changedFields.push('selfDeclaredMadeForKids');
|
||||
diff.selfDeclaredMadeForKids = { before: snap.selfDeclaredMadeForKids ?? false, after: v.selfDeclaredMadeForKids ?? false };
|
||||
}
|
||||
if ((snap.embeddable ?? true) !== (v.embeddable ?? true)) {
|
||||
changedFields.push('embeddable');
|
||||
diff.embeddable = { before: snap.embeddable ?? true, after: v.embeddable ?? true };
|
||||
}
|
||||
if ((snap.license ?? null) !== (v.license ?? null)) {
|
||||
changedFields.push('license');
|
||||
diff.license = { before: snap.license ?? null, after: v.license ?? null };
|
||||
}
|
||||
const snapRd = snap.recordingDate ?? null;
|
||||
const curRd = v.recordingDate ? v.recordingDate.toISOString().slice(0, 10) : null;
|
||||
if (snapRd !== curRd) {
|
||||
changedFields.push('recordingDate');
|
||||
diff.recordingDate = { before: snapRd, after: curRd };
|
||||
}
|
||||
|
||||
return { videoId: v.id, title: v.title, thumbnailUrl: v.thumbnailUrl, firstSync: false, changedFields, diff };
|
||||
});
|
||||
}
|
||||
|
||||
async createPushPendingJob(teamId: string, userId: string, videoIds: string[]): Promise<{ bulkJobId: string; count: number }> {
|
||||
// Verify all requested videoIds belong to this team
|
||||
const owned = await this.prisma.video.findMany({
|
||||
where: { id: { in: videoIds }, channel: { teamId } },
|
||||
select: { id: true },
|
||||
});
|
||||
const ownedIds = owned.map((v) => v.id);
|
||||
|
||||
const job = await this.prisma.bulkJob.create({
|
||||
data: {
|
||||
teamId, type: 'SYNC_PUSH', initiatedBy: userId,
|
||||
filterSnapshot: { type: 'SYNC_PUSH', videoIds: ownedIds },
|
||||
targetIds: ownedIds,
|
||||
totalCount: ownedIds.length,
|
||||
status: 'CONFIRMED',
|
||||
items: { create: ownedIds.map((id) => ({ videoId: id, status: 'pending' })) },
|
||||
},
|
||||
include: { items: { select: { id: true, videoId: true } } },
|
||||
});
|
||||
|
||||
await this.prisma.bulkJob.update({ where: { id: job.id }, data: { status: 'RUNNING' } });
|
||||
|
||||
for (const item of job.items) {
|
||||
await this.syncQueue.add('sync', { videoId: item.videoId, bulkJobItemId: item.id });
|
||||
}
|
||||
|
||||
return { bulkJobId: job.id, count: ownedIds.length };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { CalendarService } from './calendar.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('calendar')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('calendar')
|
||||
export class CalendarController {
|
||||
constructor(private readonly service: CalendarService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get calendar entries' })
|
||||
@ApiQuery({ name: 'view', enum: ['month', 'week', 'agenda'] })
|
||||
@ApiQuery({ name: 'date', example: '2026-04' })
|
||||
getEntries(@Query('view') view: string = 'month', @Query('date') date: string) {
|
||||
return this.service.getEntries(view, date ?? new Date().toISOString().slice(0, 7));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CalendarService } from './calendar.service';
|
||||
import { CalendarController } from './calendar.controller';
|
||||
|
||||
@Module({
|
||||
providers: [CalendarService],
|
||||
controllers: [CalendarController],
|
||||
})
|
||||
export class CalendarModule {}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getEntries(view: string, date: string) {
|
||||
const { start, end } = this.parseRange(view, date);
|
||||
|
||||
const videos = await this.prisma.video.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ scheduledAt: { gte: start, lte: end } },
|
||||
{ publishedAt: { gte: start, lte: end } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
template: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { scheduledAt: 'asc' },
|
||||
});
|
||||
|
||||
const allCollabIds = [...new Set(videos.flatMap((v) => (v.collaboratorIds as string[]) ?? []))];
|
||||
const collabs = allCollabIds.length > 0
|
||||
? await this.prisma.collaborator.findMany({
|
||||
where: { id: { in: allCollabIds } },
|
||||
select: { id: true, name: true, youtubeLink: true },
|
||||
})
|
||||
: [];
|
||||
const collabMap = new Map(collabs.map((c) => [c.id, c]));
|
||||
|
||||
return videos.map((v) => ({
|
||||
videoId: v.id,
|
||||
title: v.title,
|
||||
date: v.scheduledAt ?? v.publishedAt,
|
||||
templateName: v.template?.name,
|
||||
collaborators: ((v.collaboratorIds as string[]) ?? []).map((id) => collabMap.get(id)).filter(Boolean),
|
||||
lintStatus: v.lintStatus,
|
||||
channelId: v.channelId,
|
||||
privacyStatus: v.privacyStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
private parseRange(view: string, date: string): { start: Date; end: Date } {
|
||||
const [year, month] = date.split('-').map(Number);
|
||||
|
||||
if (view === 'month') {
|
||||
const start = new Date(year, month - 1, 1);
|
||||
const end = new Date(year, month, 0, 23, 59, 59);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
if (view === 'week') {
|
||||
const base = new Date(year, month - 1, 1);
|
||||
const start = new Date(base);
|
||||
start.setDate(base.getDate() - base.getDay());
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
end.setHours(23, 59, 59);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
// agenda: next 30 days from date
|
||||
const start = new Date(date);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 30);
|
||||
return { start, end };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
|
||||
@ApiTags('campaigns')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Controller('campaigns')
|
||||
export class CampaignsController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Req() req: any) {
|
||||
return this.prisma.campaign.findMany({
|
||||
where: { teamId: req.user.teamId },
|
||||
orderBy: { startAt: 'desc' },
|
||||
select: { id: true, name: true, startAt: true, endAt: true, status: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CampaignsController } from './campaigns.controller';
|
||||
import { PrismaModule } from '../../shared/prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [CampaignsController],
|
||||
})
|
||||
export class CampaignsModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { TeamRole } from '@prisma/client';
|
||||
import { CollaboratorsService } from './collaborators.service';
|
||||
import { CreateCollaboratorDto } from './dto/create-collaborator.dto';
|
||||
import { UpdateCollaboratorDto } from './dto/update-collaborator.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('collaborators')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Controller('collaborators')
|
||||
export class CollaboratorsController {
|
||||
constructor(private readonly service: CollaboratorsService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); }
|
||||
|
||||
@Post() @Roles(TeamRole.EDITOR)
|
||||
create(@Body() dto: CreateCollaboratorDto, @Req() req: any) { return this.service.create(dto, req.user.id, req.user.teamId); }
|
||||
|
||||
@Patch(':id') @Roles(TeamRole.EDITOR)
|
||||
update(@Param('id') id: string, @Body() dto: UpdateCollaboratorDto, @Req() req: any) { return this.service.update(id, dto, req.user.id, req.user.teamId); }
|
||||
|
||||
@Delete(':id') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Delete collaborator (only if not in use)' })
|
||||
remove(@Param('id') id: string, @Req() req: any) { return this.service.delete(id, req.user.teamId, req.user.id); }
|
||||
|
||||
@Get(':id/videos') @ApiOperation({ summary: 'Videos linked to this collaborator' })
|
||||
videos(@Param('id') id: string, @Req() req: any) { return this.service.getVideos(id, req.user.teamId); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { CollaboratorsService } from './collaborators.service';
|
||||
import { CollaboratorsController } from './collaborators.controller';
|
||||
import { AuditModule } from '../../shared/audit/audit.module';
|
||||
import { QUEUES } from '../../queues/queues.constants';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.RENDER })],
|
||||
providers: [CollaboratorsService],
|
||||
controllers: [CollaboratorsController],
|
||||
exports: [CollaboratorsService],
|
||||
})
|
||||
export class CollaboratorsModule {}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { AuditService } from '../../shared/audit/audit.service';
|
||||
import { CreateCollaboratorDto } from './dto/create-collaborator.dto';
|
||||
import { UpdateCollaboratorDto } from './dto/update-collaborator.dto';
|
||||
import { QUEUES } from '../../queues/queues.constants';
|
||||
|
||||
@Injectable()
|
||||
export class CollaboratorsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
@InjectQueue(QUEUES.RENDER) private readonly renderQueue: Queue,
|
||||
) {}
|
||||
|
||||
findAll(teamId: string) {
|
||||
return this.prisma.collaborator.findMany({ where: { teamId }, orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async create(dto: CreateCollaboratorDto, actorId: string, teamId: string) {
|
||||
const c = await this.prisma.collaborator.create({ data: { ...dto, teamId } });
|
||||
await this.audit.log(actorId, 'Collaborator', c.id, 'create', null, c);
|
||||
return c;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCollaboratorDto, actorId: string, teamId: string) {
|
||||
const before = await this.prisma.collaborator.findFirst({ where: { id, teamId } });
|
||||
if (!before) throw new NotFoundException(`Collaborator ${id} not found`);
|
||||
const updated = await this.prisma.collaborator.update({ where: { id }, data: dto });
|
||||
await this.audit.log(actorId, 'Collaborator', id, 'update', before, updated);
|
||||
await this.enqueueCollaboratorRenders(id, teamId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async enqueueCollaboratorRenders(collaboratorId: string, teamId: string) {
|
||||
const videos = await this.prisma.video.findMany({
|
||||
where: {
|
||||
collaboratorIds: { array_contains: collaboratorId } as any,
|
||||
channel: { teamId },
|
||||
youtubeDeletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await Promise.all(
|
||||
videos.map((v) => this.renderQueue.add('render', { videoId: v.id }, { jobId: `render-${v.id}` })),
|
||||
);
|
||||
}
|
||||
|
||||
async getVideos(id: string, teamId: string) {
|
||||
return this.prisma.video.findMany({
|
||||
where: { collaboratorIds: { array_contains: id }, channel: { teamId } } as any,
|
||||
select: { id: true, title: true },
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string, teamId: string, actorId: string) {
|
||||
const collab = await this.prisma.collaborator.findFirst({ where: { id, teamId } });
|
||||
if (!collab) throw new NotFoundException(`Collaborator ${id} not found`);
|
||||
const usageCount = await this.prisma.video.count({
|
||||
where: { collaboratorIds: { array_contains: id } } as any,
|
||||
});
|
||||
if (usageCount > 0) throw new ConflictException(`Collaborator is used in ${usageCount} video(s) and cannot be deleted`);
|
||||
await this.prisma.collaborator.delete({ where: { id } });
|
||||
await this.audit.log(actorId, 'Collaborator', id, 'delete', collab, null);
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsString, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateCollaboratorDto {
|
||||
@ApiProperty() @IsString() name: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() youtubeLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() twitchLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() instagramLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() tiktokLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() twitterLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() blueskyLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() discordHandle?: string;
|
||||
@ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) aliases?: string[];
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IsString, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateCollaboratorDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() youtubeLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() twitchLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() instagramLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() tiktokLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() twitterLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() blueskyLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() discordHandle?: string;
|
||||
@ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) aliases?: string[];
|
||||
@ApiPropertyOptional() @IsOptional() @IsBoolean() active?: boolean;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Controller, Post, Body, Res, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { ExportsService } from './exports.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('exports')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('exports')
|
||||
export class ExportsController {
|
||||
constructor(private readonly service: ExportsService) {}
|
||||
|
||||
@Post('csv')
|
||||
@ApiOperation({ summary: 'Export videos as CSV' })
|
||||
async exportCsv(@Body() body: { videoIds?: string[]; savedViewId?: string }, @Res() res: Response) {
|
||||
const csv = await this.service.exportCsv(body.videoIds, body.savedViewId);
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="studioflow-export-${Date.now()}.csv"`);
|
||||
res.send(csv);
|
||||
}
|
||||
|
||||
@Post('json')
|
||||
@ApiOperation({ summary: 'Export full workspace as JSON' })
|
||||
exportJson() {
|
||||
return this.service.exportJson();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExportsService } from './exports.service';
|
||||
import { ExportsController } from './exports.controller';
|
||||
|
||||
@Module({
|
||||
providers: [ExportsService],
|
||||
controllers: [ExportsController],
|
||||
})
|
||||
export class ExportsModule {}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { stringify } from 'csv-stringify/sync';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ExportsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async exportCsv(videoIds?: string[], savedViewId?: string): Promise<string> {
|
||||
let ids = videoIds ?? [];
|
||||
|
||||
if (!ids.length && savedViewId) {
|
||||
const view = await this.prisma.savedView.findUniqueOrThrow({ where: { id: savedViewId } });
|
||||
const videos = await this.prisma.video.findMany({
|
||||
where: view.queryJson as any,
|
||||
select: { id: true },
|
||||
});
|
||||
ids = videos.map((v) => v.id);
|
||||
}
|
||||
|
||||
const where = ids.length > 0 ? { id: { in: ids } } : undefined;
|
||||
const videos = await this.prisma.video.findMany({
|
||||
where,
|
||||
include: { template: { select: { name: true } } },
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
});
|
||||
|
||||
const rows = videos.map((v) => ({
|
||||
youtube_video_id: v.youtubeVideoId,
|
||||
title: v.title,
|
||||
tags: v.tags.join(','),
|
||||
category_id: v.categoryId ?? '',
|
||||
privacy_status: v.privacyStatus,
|
||||
published_at: v.publishedAt?.toISOString() ?? '',
|
||||
scheduled_at: v.scheduledAt?.toISOString() ?? '',
|
||||
template: v.template?.name ?? '',
|
||||
lint_status: v.lintStatus,
|
||||
}));
|
||||
|
||||
return stringify(rows, { header: true });
|
||||
}
|
||||
|
||||
async exportJson(): Promise<object> {
|
||||
const [videos, videoConfigs, blocks, templates, collaborators, savedViews] = await Promise.all([
|
||||
this.prisma.video.findMany(),
|
||||
this.prisma.videoConfig.findMany(),
|
||||
this.prisma.descriptionBlock.findMany(),
|
||||
this.prisma.template.findMany(),
|
||||
this.prisma.collaborator.findMany(),
|
||||
this.prisma.savedView.findMany(),
|
||||
]);
|
||||
|
||||
return {
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
videos,
|
||||
videoConfigs,
|
||||
blocks,
|
||||
templates,
|
||||
collaborators,
|
||||
savedViews,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Controller, Post, Body, UploadedFile, UseInterceptors, UseGuards, Req } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiConsumes } from '@nestjs/swagger';
|
||||
import { TeamRole } from '@prisma/client';
|
||||
import { ImportsService } from './imports.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('imports')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Controller('imports')
|
||||
export class ImportsController {
|
||||
constructor(private readonly service: ImportsService) {}
|
||||
|
||||
@Post('csv/preview')
|
||||
@Roles(TeamRole.EDITOR)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload CSV and get validation report' })
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
previewCsv(@UploadedFile() file: Express.Multer.File, @Body() body: any, @Req() req: any) {
|
||||
return this.service.previewCsv(file.buffer, body.mapping ? JSON.parse(body.mapping) : null, req.user.id, req.user.teamId);
|
||||
}
|
||||
|
||||
@Post('csv/commit')
|
||||
@Roles(TeamRole.EDITOR)
|
||||
@ApiOperation({ summary: 'Commit a previewed CSV import' })
|
||||
commitCsv(@Body() body: { importJobId: string }, @Req() req: any) {
|
||||
return this.service.commitCsv(body.importJobId, req.user.teamId);
|
||||
}
|
||||
|
||||
@Post('json/preview')
|
||||
@Roles(TeamRole.EDITOR)
|
||||
@ApiOperation({ summary: 'Validate JSON workspace payload' })
|
||||
previewJson(@Body() body: any, @Req() req: any) {
|
||||
return this.service.previewJson(body, req.user.id, req.user.teamId);
|
||||
}
|
||||
|
||||
@Post('json/commit')
|
||||
@Roles(TeamRole.EDITOR)
|
||||
@ApiOperation({ summary: 'Commit JSON workspace import' })
|
||||
commitJson(@Body() body: { importJobId: string; payload: any }, @Req() req: any) {
|
||||
return this.service.commitJson(body.importJobId, body.payload, req.user.teamId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { ImportsService } from './imports.service';
|
||||
import { ImportsController } from './imports.controller';
|
||||
import { AuditModule } from '../../shared/audit/audit.module';
|
||||
import { QUEUES } from '../../queues/queues.constants';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.IMPORT })],
|
||||
providers: [ImportsService],
|
||||
controllers: [ImportsController],
|
||||
exports: [ImportsService],
|
||||
})
|
||||
export class ImportsModule {}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { parse } from 'csv-parse/sync';
|
||||
import { z } from 'zod';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { AuditService } from '../../shared/audit/audit.service';
|
||||
import { QUEUES } from '../../queues/queues.constants';
|
||||
|
||||
const CsvRowSchema = z.object({
|
||||
youtube_video_id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
category_id: z.string().optional(),
|
||||
privacy_status: z.enum(['PUBLIC', 'PRIVATE', 'UNLISTED']).optional(),
|
||||
scheduled_at: z.string().optional(),
|
||||
template_name: z.string().optional(),
|
||||
collaborators: z.string().optional(),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class ImportsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
@InjectQueue(QUEUES.IMPORT) private readonly importQueue: Queue,
|
||||
) {}
|
||||
|
||||
async previewCsv(fileBuffer: Buffer, mappingJson: any, createdBy: string, teamId: string) {
|
||||
const rows: any[] = parse(fileBuffer, { columns: true, skip_empty_lines: true, trim: true });
|
||||
|
||||
const validRows: any[] = [];
|
||||
const errors: { row: number; field: string; message: string }[] = [];
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
const result = CsvRowSchema.safeParse(row);
|
||||
if (result.success) {
|
||||
validRows.push(result.data);
|
||||
} else {
|
||||
result.error.errors.forEach((e) => {
|
||||
errors.push({ row: i + 1, field: e.path.join('.'), message: e.message });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const importJob = await this.prisma.importJob.create({
|
||||
data: {
|
||||
teamId,
|
||||
type: 'csv',
|
||||
sourceName: 'upload',
|
||||
mappingJson: mappingJson ?? null,
|
||||
validationReport: { validCount: validRows.length, errorCount: errors.length, errors },
|
||||
commitStatus: 'pending',
|
||||
createdBy,
|
||||
},
|
||||
});
|
||||
|
||||
return { importJobId: importJob.id, validRows: validRows.length, errors };
|
||||
}
|
||||
|
||||
async commitCsv(importJobId: string, teamId: string) {
|
||||
const job = await this.prisma.importJob.findFirstOrThrow({ where: { id: importJobId, teamId } });
|
||||
if (job.commitStatus !== 'pending') throw new BadRequestException('Already committed');
|
||||
|
||||
await this.importQueue.add('import', { importJobId });
|
||||
return { queued: true, importJobId };
|
||||
}
|
||||
|
||||
async executeCommit(importJobId: string) {
|
||||
const job = await this.prisma.importJob.findUniqueOrThrow({ where: { id: importJobId } });
|
||||
await this.prisma.importJob.update({
|
||||
where: { id: importJobId },
|
||||
data: { commitStatus: 'committed', committedAt: new Date() },
|
||||
});
|
||||
await this.audit.log(job.createdBy, 'ImportJob', importJobId, 'commit', null, { status: 'committed' });
|
||||
}
|
||||
|
||||
async previewJson(payload: any, createdBy: string, teamId: string) {
|
||||
const WorkspaceSchema = z.object({
|
||||
version: z.string(),
|
||||
videos: z.array(z.any()).optional(),
|
||||
blocks: z.array(z.any()).optional(),
|
||||
templates: z.array(z.any()).optional(),
|
||||
collaborators: z.array(z.any()).optional(),
|
||||
});
|
||||
|
||||
const result = WorkspaceSchema.safeParse(payload);
|
||||
if (!result.success) throw new BadRequestException(result.error.message);
|
||||
|
||||
const importJob = await this.prisma.importJob.create({
|
||||
data: {
|
||||
teamId,
|
||||
type: 'json',
|
||||
sourceName: 'upload',
|
||||
validationReport: { valid: true },
|
||||
commitStatus: 'pending',
|
||||
createdBy,
|
||||
},
|
||||
});
|
||||
|
||||
return { importJobId: importJob.id, valid: true };
|
||||
}
|
||||
|
||||
async commitJson(importJobId: string, payload: any, teamId: string) {
|
||||
const job = await this.prisma.importJob.findFirstOrThrow({ where: { id: importJobId, teamId } });
|
||||
if (job.commitStatus !== 'pending') throw new BadRequestException('Already committed');
|
||||
|
||||
if (payload.collaborators) {
|
||||
for (const c of payload.collaborators) {
|
||||
await this.prisma.collaborator.upsert({
|
||||
where: { id: c.id },
|
||||
create: { ...c, teamId },
|
||||
update: c,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (payload.blocks) {
|
||||
for (const b of payload.blocks) {
|
||||
await this.prisma.descriptionBlock.upsert({
|
||||
where: { id: b.id },
|
||||
create: { ...b, teamId },
|
||||
update: b,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (payload.templates) {
|
||||
for (const t of payload.templates) {
|
||||
await this.prisma.template.upsert({
|
||||
where: { id: t.id },
|
||||
create: { ...t, teamId },
|
||||
update: t,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.importJob.update({
|
||||
where: { id: importJobId },
|
||||
data: { commitStatus: 'committed', committedAt: new Date() },
|
||||
});
|
||||
|
||||
return { committed: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Controller, Post, Get, Patch, Param, Body, Query, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { IsArray, IsString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { LintSeverity } from '@prisma/client';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { LintingService } from './linting.service';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { QUEUES } from '../../queues/queues.constants';
|
||||
|
||||
class BulkLintDto {
|
||||
@IsArray() @IsString({ each: true }) videoIds: string[];
|
||||
}
|
||||
|
||||
class BulkResolveDto {
|
||||
@IsArray() @IsString({ each: true }) ids: string[];
|
||||
}
|
||||
|
||||
class QueryLintResultsDto {
|
||||
@ApiPropertyOptional({ enum: LintSeverity }) @IsOptional() @IsEnum(LintSeverity) severity?: LintSeverity;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() ruleCode?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() videoId?: string;
|
||||
}
|
||||
|
||||
@ApiTags('lint')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('lint')
|
||||
export class LintingController {
|
||||
constructor(
|
||||
private readonly service: LintingService,
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectQueue(QUEUES.LINT) private readonly lintQueue: Queue,
|
||||
) {}
|
||||
|
||||
@Post('videos/:id')
|
||||
@ApiOperation({ summary: 'Lint a single video' })
|
||||
lintOne(@Param('id') id: string) {
|
||||
return this.service.lintVideo(id);
|
||||
}
|
||||
|
||||
@Post('bulk')
|
||||
@ApiOperation({ summary: 'Enqueue lint jobs for multiple videos' })
|
||||
async lintBulk(@Body() dto: BulkLintDto) {
|
||||
const jobs = dto.videoIds.map((videoId) =>
|
||||
this.lintQueue.add('lint', { videoId }, { jobId: `lint-${videoId}` }),
|
||||
);
|
||||
await Promise.all(jobs);
|
||||
return { queued: dto.videoIds.length };
|
||||
}
|
||||
|
||||
@Get('results')
|
||||
@ApiOperation({ summary: 'Get open lint results scoped to the current user\'s team' })
|
||||
async getResults(@Query() query: QueryLintResultsDto, @Req() req: any) {
|
||||
const teamId = await this.resolveTeamId(req.user.id);
|
||||
|
||||
return this.prisma.lintResult.findMany({
|
||||
where: {
|
||||
resolvedAt: null,
|
||||
video: { channel: { teamId } },
|
||||
...(query.severity ? { severity: query.severity } : {}),
|
||||
...(query.ruleCode ? { ruleCode: query.ruleCode } : {}),
|
||||
...(query.videoId ? { videoId: query.videoId } : {}),
|
||||
},
|
||||
include: { video: { select: { id: true, title: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('results/:id/resolve')
|
||||
@ApiOperation({ summary: 'Mark a lint result as resolved' })
|
||||
async resolveOne(@Param('id') id: string, @Req() req: any) {
|
||||
const result = await this.prisma.lintResult.update({
|
||||
where: { id },
|
||||
data: { resolvedAt: new Date() },
|
||||
include: { video: { select: { id: true } } },
|
||||
});
|
||||
|
||||
// Recompute video lintStatus
|
||||
await this.recomputeVideoStatus(result.video.id);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('results/bulk-resolve')
|
||||
@ApiOperation({ summary: 'Mark multiple lint results as resolved' })
|
||||
async bulkResolve(@Body() dto: BulkResolveDto) {
|
||||
await this.prisma.lintResult.updateMany({
|
||||
where: { id: { in: dto.ids } },
|
||||
data: { resolvedAt: new Date() },
|
||||
});
|
||||
|
||||
// Recompute lintStatus for all affected videos
|
||||
const affected = await this.prisma.lintResult.findMany({
|
||||
where: { id: { in: dto.ids } },
|
||||
select: { videoId: true },
|
||||
distinct: ['videoId'],
|
||||
});
|
||||
await Promise.all(affected.map((r) => this.recomputeVideoStatus(r.videoId)));
|
||||
|
||||
return { resolved: dto.ids.length };
|
||||
}
|
||||
|
||||
@Post('channel/:channelId')
|
||||
@ApiOperation({ summary: 'Enqueue lint jobs for all videos in a channel' })
|
||||
async lintChannel(@Param('channelId') channelId: string, @Req() req: any) {
|
||||
const teamId = await this.resolveTeamId(req.user.id);
|
||||
const videoIds = await this.service.getChannelVideoIds(channelId, teamId);
|
||||
await this.enqueueRerun(videoIds);
|
||||
return { queued: videoIds.length };
|
||||
}
|
||||
|
||||
@Post('team')
|
||||
@ApiOperation({ summary: 'Enqueue lint jobs for all videos in the current team' })
|
||||
async lintTeam(@Req() req: any) {
|
||||
const teamId = await this.resolveTeamId(req.user.id);
|
||||
const videoIds = await this.service.getTeamVideoIds(teamId);
|
||||
await this.enqueueRerun(videoIds);
|
||||
return { queued: videoIds.length };
|
||||
}
|
||||
|
||||
@Post('team/recompute-status')
|
||||
@ApiOperation({ summary: 'Recompute lintStatus for all team videos from actual open results (heals stale status)' })
|
||||
async recomputeTeamStatus(@Req() req: any) {
|
||||
const teamId = await this.resolveTeamId(req.user.id);
|
||||
|
||||
// Aggregate open result severities per video in one query
|
||||
const openResults = await this.prisma.lintResult.groupBy({
|
||||
by: ['videoId'],
|
||||
where: { resolvedAt: null, video: { channel: { teamId } } },
|
||||
_max: { severity: true },
|
||||
});
|
||||
|
||||
// Build a map: videoId → worst severity
|
||||
const severityMap = new Map(openResults.map((r) => [r.videoId, r._max.severity]));
|
||||
|
||||
// All video IDs for the team
|
||||
const allVideos = await this.prisma.video.findMany({
|
||||
where: { channel: { teamId } },
|
||||
select: { id: true, lintStatus: true },
|
||||
});
|
||||
|
||||
const updates = allVideos
|
||||
.map((v) => {
|
||||
const worst = severityMap.get(v.id) ?? null;
|
||||
const status = worst === 'ERROR' ? 'ERROR' : worst === 'WARNING' ? 'WARNING' : 'OK';
|
||||
return status !== v.lintStatus ? { id: v.id, status } : null;
|
||||
})
|
||||
.filter(Boolean) as { id: string; status: string }[];
|
||||
|
||||
await Promise.all(
|
||||
updates.map((u) =>
|
||||
this.prisma.video.update({ where: { id: u.id }, data: { lintStatus: u.status as any } }),
|
||||
),
|
||||
);
|
||||
|
||||
return { checked: allVideos.length, updated: updates.length };
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Enqueues re-run jobs with unique IDs so BullMQ never deduplicates them. */
|
||||
private enqueueRerun(videoIds: string[]) {
|
||||
const ts = Date.now();
|
||||
return Promise.all(
|
||||
videoIds.map((videoId) =>
|
||||
this.lintQueue.add('lint', { videoId }, { jobId: `lint-${videoId}-${ts}` }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveTeamId(userId: string): Promise<string> {
|
||||
const membership = await this.prisma.teamMember.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return membership?.teamId ?? '';
|
||||
}
|
||||
|
||||
private async recomputeVideoStatus(videoId: string) {
|
||||
const open = await this.prisma.lintResult.findMany({
|
||||
where: { videoId, resolvedAt: null },
|
||||
select: { severity: true },
|
||||
});
|
||||
const severities = open.map((r) => r.severity);
|
||||
const status = severities.includes(LintSeverity.ERROR)
|
||||
? 'ERROR'
|
||||
: severities.includes(LintSeverity.WARNING)
|
||||
? 'WARNING'
|
||||
: 'OK';
|
||||
await this.prisma.video.update({ where: { id: videoId }, data: { lintStatus: status as any } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { LintingService } from './linting.service';
|
||||
import { LintingController } from './linting.controller';
|
||||
import { QUEUES } from '../../queues/queues.constants';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: QUEUES.LINT })],
|
||||
providers: [LintingService],
|
||||
controllers: [LintingController],
|
||||
exports: [LintingService],
|
||||
})
|
||||
export class LintingModule {}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||
import { LintSeverity, LintStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { LintRule } from './rules/base.rule';
|
||||
import { TitleWeakRule } from './rules/title-weak.rule';
|
||||
import { TitleTooLongRule } from './rules/title-too-long.rule';
|
||||
import { DescMissingCtaRule } from './rules/desc-missing-cta.rule';
|
||||
import { DescMissingChaptersRule } from './rules/desc-missing-chapters.rule';
|
||||
import { DescEmptyPlaceholderRule } from './rules/desc-empty-placeholder.rule';
|
||||
import { DescDuplicateHashtagRule } from './rules/desc-duplicate-hashtag.rule';
|
||||
import { DescRequiredLinkMissingRule } from './rules/desc-required-link-missing.rule';
|
||||
import { DescOutdatedSponsorRule } from './rules/desc-outdated-sponsor.rule';
|
||||
import { RemoteConflictRule } from './rules/remote-conflict.rule';
|
||||
|
||||
@Injectable()
|
||||
export class LintingService {
|
||||
private readonly rules: LintRule[] = [
|
||||
new TitleWeakRule(),
|
||||
new TitleTooLongRule(),
|
||||
new DescMissingCtaRule(),
|
||||
new DescMissingChaptersRule(),
|
||||
new DescEmptyPlaceholderRule(),
|
||||
new DescDuplicateHashtagRule(),
|
||||
new DescRequiredLinkMissingRule(),
|
||||
new DescOutdatedSponsorRule(),
|
||||
new RemoteConflictRule(),
|
||||
];
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async lintVideo(videoId: string) {
|
||||
const video = await this.prisma.video.findUniqueOrThrow({
|
||||
where: { id: videoId },
|
||||
include: {
|
||||
config: true,
|
||||
template: true,
|
||||
lintResults: { where: { resolvedAt: null } },
|
||||
channel: { include: { team: { select: { disabledLintRules: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const disabledRules: string[] = (video as any).channel?.team?.disabledLintRules ?? [];
|
||||
|
||||
// Attach campaign blocks for the outdated sponsor check
|
||||
const blockIds: string[] = (video.config?.blockOrder as string[]) ?? [];
|
||||
const campaignBlocks = await this.prisma.descriptionBlock.findMany({
|
||||
where: { id: { in: blockIds }, campaignId: { not: null } },
|
||||
include: { campaign: true },
|
||||
});
|
||||
(video as any)._campaignBlocks = campaignBlocks;
|
||||
|
||||
const activeRules = disabledRules.length > 0
|
||||
? this.rules.filter((r) => !disabledRules.includes(r.code))
|
||||
: this.rules;
|
||||
|
||||
const issues = activeRules
|
||||
.map((rule) => {
|
||||
const issue = rule.check(video);
|
||||
if (!issue) return null;
|
||||
return {
|
||||
videoId,
|
||||
ruleCode: rule.code,
|
||||
severity: rule.severity,
|
||||
targetField: issue.targetField,
|
||||
message: issue.message,
|
||||
fixSuggestion: issue.fixSuggestion,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
|
||||
// Replace all unresolved lint results for this video
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.lintResult.deleteMany({ where: { videoId, resolvedAt: null } }),
|
||||
...(issues.length > 0
|
||||
? [this.prisma.lintResult.createMany({ data: issues })]
|
||||
: []),
|
||||
this.prisma.video.update({
|
||||
where: { id: videoId },
|
||||
data: { lintStatus: this.computeStatus(issues.map((i) => i.severity)) },
|
||||
}),
|
||||
]);
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** Returns all video IDs for a channel so the controller can enqueue them. */
|
||||
async getChannelVideoIds(channelId: string, teamId: string): Promise<string[]> {
|
||||
const channel = await this.prisma.channel.findFirst({ where: { id: channelId, teamId } });
|
||||
if (!channel) throw new ForbiddenException('Channel not found in this team');
|
||||
|
||||
const videos = await this.prisma.video.findMany({
|
||||
where: { channelId },
|
||||
select: { id: true },
|
||||
});
|
||||
return videos.map((v) => v.id);
|
||||
}
|
||||
|
||||
/** Returns all video IDs across all channels in a team. */
|
||||
async getTeamVideoIds(teamId: string): Promise<string[]> {
|
||||
const videos = await this.prisma.video.findMany({
|
||||
where: { channel: { teamId } },
|
||||
select: { id: true },
|
||||
});
|
||||
return videos.map((v) => v.id);
|
||||
}
|
||||
|
||||
private computeStatus(severities: LintSeverity[]): LintStatus {
|
||||
if (severities.includes(LintSeverity.ERROR)) return LintStatus.ERROR;
|
||||
if (severities.includes(LintSeverity.WARNING)) return LintStatus.WARNING;
|
||||
return LintStatus.OK;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LintSeverity } from '@prisma/client';
|
||||
|
||||
export interface LintIssue {
|
||||
message: string;
|
||||
targetField?: string;
|
||||
fixSuggestion?: string;
|
||||
}
|
||||
|
||||
export interface LintRule {
|
||||
code: string;
|
||||
severity: LintSeverity;
|
||||
check(video: any): LintIssue | null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { LintSeverity } from '@prisma/client';
|
||||
import { LintRule, LintIssue } from './base.rule';
|
||||
|
||||
export class DescDuplicateHashtagRule implements LintRule {
|
||||
code = 'DESC_DUPLICATE_HASHTAG';
|
||||
severity = LintSeverity.WARNING;
|
||||
|
||||
check(video: any): LintIssue | null {
|
||||
const desc: string = video.renderedDescription ?? '';
|
||||
const hashtags = desc.match(/#\w+/g)?.map((h) => h.toLowerCase()) ?? [];
|
||||
const seen = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
for (const tag of hashtags) {
|
||||
if (seen.has(tag)) duplicates.push(tag);
|
||||
else seen.add(tag);
|
||||
}
|
||||
if (duplicates.length > 0) {
|
||||
return {
|
||||
message: `Duplicate hashtags found: ${[...new Set(duplicates)].join(', ')}`,
|
||||
targetField: 'description',
|
||||
fixSuggestion: 'Remove duplicate hashtags from the description.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { LintSeverity } from '@prisma/client';
|
||||
import { LintRule, LintIssue } from './base.rule';
|
||||
|
||||
const PLACEHOLDER_REGEX = /\{[a-z_][a-z0-9_]*\}/g;
|
||||
|
||||
export class DescEmptyPlaceholderRule implements LintRule {
|
||||
code = 'DESC_EMPTY_PLACEHOLDER';
|
||||
severity = LintSeverity.ERROR;
|
||||
|
||||
check(video: any): LintIssue | null {
|
||||
const desc: string = video.renderedDescription ?? '';
|
||||
const unresolved = desc.match(PLACEHOLDER_REGEX);
|
||||
if (unresolved?.length) {
|
||||
return {
|
||||
message: `Unresolved placeholders in description: ${unresolved.join(', ')}`,
|
||||
targetField: 'description',
|
||||
fixSuggestion: 'Provide values for all variable placeholders before publishing.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { LintSeverity } from '@prisma/client';
|
||||
import { LintRule, LintIssue } from './base.rule';
|
||||
|
||||
// Timestamps follow pattern 0:00 or 00:00 or 0:00:00
|
||||
const TIMESTAMP_REGEX = /\d{1,2}:\d{2}(:\d{2})?/g;
|
||||
|
||||
export class DescMissingChaptersRule implements LintRule {
|
||||
code = 'DESC_MISSING_CHAPTERS';
|
||||
severity = LintSeverity.WARNING;
|
||||
|
||||
check(video: any): LintIssue | null {
|
||||
const desc: string = video.renderedDescription ?? '';
|
||||
const matches = desc.match(TIMESTAMP_REGEX);
|
||||
if (!matches || matches.length < 2) {
|
||||
return {
|
||||
message: 'No chapter timestamps found in description',
|
||||
targetField: 'description',
|
||||
fixSuggestion: 'Add a chapters block with at least two timestamps (e.g., 0:00 Intro).',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { LintSeverity } from '@prisma/client';
|
||||
import { LintRule, LintIssue } from './base.rule';
|
||||
|
||||
const CTA_KEYWORDS = ['subscribe', 'abonnieren', 'follow', 'like', 'comment', 'cta'];
|
||||
|
||||
export class DescMissingCtaRule implements LintRule {
|
||||
code = 'DESC_MISSING_CTA';
|
||||
severity = LintSeverity.WARNING;
|
||||
|
||||
check(video: any): LintIssue | null {
|
||||
const desc: string = (video.renderedDescription ?? '').toLowerCase();
|
||||
const hasCta = CTA_KEYWORDS.some((k) => desc.includes(k));
|
||||
if (!hasCta) {
|
||||
return {
|
||||
message: 'No call-to-action found in description',
|
||||
targetField: 'description',
|
||||
fixSuggestion: 'Add a CTA block (subscribe, follow, etc.)',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { LintSeverity } from '@prisma/client';
|
||||
import { LintRule, LintIssue } from './base.rule';
|
||||
|
||||
export class DescOutdatedSponsorRule implements LintRule {
|
||||
code = 'DESC_OUTDATED_SPONSOR_COPY';
|
||||
severity = LintSeverity.ERROR;
|
||||
|
||||
check(video: any): LintIssue | null {
|
||||
const config = video.config;
|
||||
if (!config) return null;
|
||||
|
||||
const blockOrder: string[] = (config.blockOrder as string[]) ?? [];
|
||||
// blocks that come with a campaign reference
|
||||
const campaignBlocks = (video._campaignBlocks ?? []) as Array<{ campaignId?: string; campaign?: { endAt?: Date; status: string } }>;
|
||||
|
||||
for (const block of campaignBlocks) {
|
||||
if (!blockOrder.includes((block as any).id)) continue;
|
||||
if (!block.campaign) continue;
|
||||
const { endAt, status } = block.campaign;
|
||||
if (status !== 'active' || (endAt && new Date(endAt) < new Date())) {
|
||||
return {
|
||||
message: 'Sponsor/campaign block references an expired or inactive campaign',
|
||||
targetField: 'description',
|
||||
fixSuggestion: 'Update or remove the outdated sponsor block.',
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { LintSeverity } from '@prisma/client';
|
||||
import { LintRule, LintIssue } from './base.rule';
|
||||
|
||||
export class DescRequiredLinkMissingRule implements LintRule {
|
||||
code = 'DESC_REQUIRED_LINK_MISSING';
|
||||
severity = LintSeverity.ERROR;
|
||||
|
||||
check(video: any): LintIssue | null {
|
||||
const template = video.template;
|
||||
if (!template) return null;
|
||||
|
||||
const rules = (template.rules as any) ?? {};
|
||||
const requiredLinks: string[] = rules.requiredLinks ?? [];
|
||||
if (requiredLinks.length === 0) return null;
|
||||
|
||||
const desc: string = video.renderedDescription ?? '';
|
||||
const missing = requiredLinks.filter((link: string) => !desc.includes(link));
|
||||
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
message: `Required links missing from description: ${missing.join(', ')}`,
|
||||
targetField: 'description',
|
||||
fixSuggestion: 'Add the required links defined in the template rules.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { LintSeverity } from '@prisma/client';
|
||||
import { LintRule, LintIssue } from './base.rule';
|
||||
|
||||
export class RemoteConflictRule implements LintRule {
|
||||
code = 'REMOTE_CONFLICT';
|
||||
severity = LintSeverity.ERROR;
|
||||
|
||||
check(video: any): LintIssue | null {
|
||||
if (video.remoteConflict) {
|
||||
return {
|
||||
message: 'Remote YouTube metadata has changed since last sync — local and remote are out of sync',
|
||||
targetField: 'sync',
|
||||
fixSuggestion: 'Review the remote changes and re-sync to resolve the conflict.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { LintSeverity } from '@prisma/client';
|
||||
import { LintRule, LintIssue } from './base.rule';
|
||||
|
||||
export class TitleTooLongRule implements LintRule {
|
||||
code = 'TITLE_TOO_LONG';
|
||||
severity = LintSeverity.WARNING;
|
||||
|
||||
check(video: any): LintIssue | null {
|
||||
const title: string = video.title ?? '';
|
||||
if (title.length > 100) {
|
||||
return {
|
||||
message: `Title exceeds 100 characters (${title.length})`,
|
||||
targetField: 'title',
|
||||
fixSuggestion: 'Shorten the title to under 100 characters.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { LintSeverity } from '@prisma/client';
|
||||
import { LintRule, LintIssue } from './base.rule';
|
||||
|
||||
const GENERIC_WORDS = ['video', 'test', 'untitled', 'new video', 'upload'];
|
||||
|
||||
export class TitleWeakRule implements LintRule {
|
||||
code = 'TITLE_WEAK';
|
||||
severity = LintSeverity.WARNING;
|
||||
|
||||
check(video: any): LintIssue | null {
|
||||
const title: string = video.title ?? '';
|
||||
if (title.length < 20 || GENERIC_WORDS.some((w) => title.toLowerCase().includes(w))) {
|
||||
return {
|
||||
message: `Title is too short or uses generic words: "${title}"`,
|
||||
targetField: 'title',
|
||||
fixSuggestion: 'Use a descriptive, specific title with at least 20 characters.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Controller, Get, Post, Delete, Param, Query, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { PlaylistsService } from './playlists.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { TeamRole } from '@prisma/client';
|
||||
|
||||
@ApiTags('playlists')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Controller('playlists')
|
||||
export class PlaylistsController {
|
||||
constructor(private readonly service: PlaylistsService) {}
|
||||
|
||||
@Get('channel/:channelId')
|
||||
@ApiOperation({ summary: 'List playlists for a channel (from DB)' })
|
||||
listForChannel(@Param('channelId') channelId: string, @Req() req: any) {
|
||||
return this.service.listForChannel(channelId, req.user.teamId);
|
||||
}
|
||||
|
||||
@Post('channel/:channelId/sync')
|
||||
@ApiOperation({ summary: 'Sync playlists from YouTube for a channel' })
|
||||
@Roles(TeamRole.EDITOR)
|
||||
sync(@Param('channelId') channelId: string, @Req() req: any) {
|
||||
return this.service.syncChannelPlaylists(channelId, req.user.teamId);
|
||||
}
|
||||
|
||||
@Get('video/:videoId')
|
||||
@ApiOperation({ summary: 'List playlists a video belongs to' })
|
||||
listForVideo(@Param('videoId') videoId: string, @Req() req: any) {
|
||||
return this.service.listForVideo(videoId, req.user.teamId);
|
||||
}
|
||||
|
||||
@Post('video/:videoId/add/:playlistId')
|
||||
@ApiOperation({ summary: 'Add video to playlist' })
|
||||
@Roles(TeamRole.EDITOR)
|
||||
add(@Param('videoId') videoId: string, @Param('playlistId') playlistId: string, @Req() req: any) {
|
||||
return this.service.addVideoToPlaylist(videoId, playlistId, req.user.teamId);
|
||||
}
|
||||
|
||||
@Delete('video/:videoId/remove/:playlistId')
|
||||
@ApiOperation({ summary: 'Remove video from playlist' })
|
||||
@Roles(TeamRole.EDITOR)
|
||||
remove(@Param('videoId') videoId: string, @Param('playlistId') playlistId: string, @Req() req: any) {
|
||||
return this.service.removeVideoFromPlaylist(videoId, playlistId, req.user.teamId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlaylistsService } from './playlists.service';
|
||||
import { PlaylistsController } from './playlists.controller';
|
||||
import { YouTubeSyncModule } from '../youtube-sync/youtube-sync.module';
|
||||
|
||||
@Module({
|
||||
imports: [YouTubeSyncModule],
|
||||
controllers: [PlaylistsController],
|
||||
providers: [PlaylistsService],
|
||||
})
|
||||
export class PlaylistsModule {}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { YouTubeApiClient } from '../youtube-sync/youtube-api.client';
|
||||
|
||||
@Injectable()
|
||||
export class PlaylistsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ytApi: YouTubeApiClient,
|
||||
) {}
|
||||
|
||||
async syncChannelPlaylists(channelId: string, teamId: string) {
|
||||
const channel = await this.prisma.channel.findFirst({ where: { id: channelId, teamId } });
|
||||
if (!channel) throw new NotFoundException('Channel not found');
|
||||
|
||||
const ctx = { actionId: randomUUID(), actionType: 'playlist_sync' };
|
||||
|
||||
const existingPlaylists = await this.prisma.playlist.findMany({
|
||||
where: { channelId },
|
||||
select: { id: true, youtubePlaylistId: true, itemCount: true },
|
||||
});
|
||||
const existingMap = new Map(existingPlaylists.map((pl) => [pl.youtubePlaylistId, pl]));
|
||||
|
||||
const ytPlaylists = await this.ytApi.listChannelPlaylists(channelId, ctx);
|
||||
|
||||
// Upsert playlist metadata
|
||||
for (const pl of ytPlaylists) {
|
||||
await this.prisma.playlist.upsert({
|
||||
where: { youtubePlaylistId: pl.youtubePlaylistId },
|
||||
create: { channelId, ...pl },
|
||||
update: { title: pl.title, description: pl.description, itemCount: pl.itemCount, privacyStatus: pl.privacyStatus },
|
||||
});
|
||||
}
|
||||
|
||||
const dbPlaylists = await this.prisma.playlist.findMany({
|
||||
where: { channelId },
|
||||
select: { id: true, youtubePlaylistId: true },
|
||||
});
|
||||
const playlistIdMap = new Map(dbPlaylists.map((pl) => [pl.youtubePlaylistId, pl.id]));
|
||||
|
||||
for (const pl of ytPlaylists) {
|
||||
const dbPlaylistId = playlistIdMap.get(pl.youtubePlaylistId);
|
||||
if (!dbPlaylistId) continue;
|
||||
|
||||
const existing = existingMap.get(pl.youtubePlaylistId);
|
||||
if (existing && existing.itemCount === pl.itemCount) continue;
|
||||
|
||||
const { ids: ytVideoIds } = await this.ytApi.listPlaylistVideoIds(channelId, pl.youtubePlaylistId, ctx);
|
||||
|
||||
const matchingVideos = await this.prisma.video.findMany({
|
||||
where: { youtubeVideoId: { in: ytVideoIds }, channelId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await this.prisma.videoPlaylist.deleteMany({ where: { playlistId: dbPlaylistId } });
|
||||
if (matchingVideos.length > 0) {
|
||||
await this.prisma.videoPlaylist.createMany({
|
||||
data: matchingVideos.map((v) => ({ videoId: v.id, playlistId: dbPlaylistId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.playlist.findMany({ where: { channelId } });
|
||||
}
|
||||
|
||||
async listForChannel(channelId: string, teamId: string) {
|
||||
const channel = await this.prisma.channel.findFirst({ where: { id: channelId, teamId } });
|
||||
if (!channel) throw new NotFoundException('Channel not found');
|
||||
return this.prisma.playlist.findMany({ where: { channelId }, orderBy: { title: 'asc' } });
|
||||
}
|
||||
|
||||
async listForVideo(videoId: string, teamId: string) {
|
||||
const video = await this.prisma.video.findFirst({
|
||||
where: { id: videoId, channel: { teamId } },
|
||||
include: { playlists: { include: { playlist: true } } },
|
||||
});
|
||||
if (!video) throw new NotFoundException('Video not found');
|
||||
return video.playlists.map((vp) => vp.playlist);
|
||||
}
|
||||
|
||||
async addVideoToPlaylist(videoId: string, playlistId: string, teamId: string) {
|
||||
const video = await this.prisma.video.findFirst({
|
||||
where: { id: videoId, channel: { teamId } },
|
||||
include: { channel: true },
|
||||
});
|
||||
if (!video) throw new NotFoundException('Video not found');
|
||||
|
||||
const playlist = await this.prisma.playlist.findFirst({ where: { id: playlistId, channel: { teamId } } });
|
||||
if (!playlist) throw new NotFoundException('Playlist not found');
|
||||
|
||||
await this.ytApi.addVideoToPlaylist(video.youtubeVideoId, playlist.youtubePlaylistId, video.channelId, { actionId: randomUUID(), actionType: 'playlist_add' });
|
||||
|
||||
await this.prisma.videoPlaylist.upsert({
|
||||
where: { videoId_playlistId: { videoId, playlistId } },
|
||||
create: { videoId, playlistId },
|
||||
update: {},
|
||||
});
|
||||
|
||||
return { videoId, playlistId };
|
||||
}
|
||||
|
||||
async removeVideoFromPlaylist(videoId: string, playlistId: string, teamId: string) {
|
||||
const video = await this.prisma.video.findFirst({
|
||||
where: { id: videoId, channel: { teamId } },
|
||||
include: { channel: true },
|
||||
});
|
||||
if (!video) throw new NotFoundException('Video not found');
|
||||
|
||||
const playlist = await this.prisma.playlist.findFirst({ where: { id: playlistId, channel: { teamId } } });
|
||||
if (!playlist) throw new NotFoundException('Playlist not found');
|
||||
|
||||
await this.ytApi.removeVideoFromPlaylist(video.youtubeVideoId, playlist.youtubePlaylistId, video.channelId, { actionId: randomUUID(), actionType: 'playlist_remove' });
|
||||
|
||||
await this.prisma.videoPlaylist.deleteMany({ where: { videoId, playlistId } });
|
||||
|
||||
return { videoId, playlistId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Controller, Get, Query, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { QuotaService } from '../../shared/quota/quota.service';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('quota')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('quota')
|
||||
export class QuotaController {
|
||||
constructor(
|
||||
private readonly quota: QuotaService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get('today')
|
||||
@ApiOperation({ summary: 'YouTube API quota status for today (scoped to active team)' })
|
||||
async today(@Req() req: any) {
|
||||
const teamId: string = req.user.teamId;
|
||||
const used = await this.quota.getTodayUsageForTeam(teamId);
|
||||
const limit = this.quota.getLimit();
|
||||
const remaining = Math.max(0, limit - used);
|
||||
const resetMs = this.quota.msUntilQuotaReset();
|
||||
const resetAt = new Date(Date.now() + resetMs).toISOString();
|
||||
|
||||
return { used, remaining, limit, resetAt, percentUsed: Math.round((used / limit) * 100) };
|
||||
}
|
||||
|
||||
@Get('history')
|
||||
@ApiOperation({ summary: 'Quota log entries scoped to the active team, newest first' })
|
||||
@ApiQuery({ name: 'days', required: false, description: 'How many days back to fetch (default 7)' })
|
||||
async history(@Req() req: any, @Query('days') days?: string) {
|
||||
const teamId: string = req.user.teamId;
|
||||
const daysBack = Math.min(parseInt(days ?? '7', 10) || 7, 90);
|
||||
|
||||
const channels = await this.prisma.channel.findMany({
|
||||
where: { teamId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const channelIds = channels.map((c) => c.id);
|
||||
const channelNameMap = Object.fromEntries(channels.map((c) => [c.id, c.name]));
|
||||
|
||||
if (channelIds.length === 0) return { items: [], totalUnits: 0 };
|
||||
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - daysBack);
|
||||
|
||||
const logs = await this.prisma.quotaLog.findMany({
|
||||
where: {
|
||||
channelId: { in: channelIds },
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
include: {
|
||||
video: { select: { id: true, title: true, youtubeVideoId: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
|
||||
// Collect YouTube playlist IDs stored in entityId so we can resolve their names
|
||||
const playlistEntityIds = logs
|
||||
.map((l) => l.entityId)
|
||||
.filter((id): id is string => !!id && !id.startsWith('batch:'));
|
||||
|
||||
const playlists = playlistEntityIds.length
|
||||
? await this.prisma.playlist.findMany({
|
||||
where: { youtubePlaylistId: { in: playlistEntityIds } },
|
||||
select: { youtubePlaylistId: true, title: true },
|
||||
})
|
||||
: [];
|
||||
const playlistNameMap = Object.fromEntries(playlists.map((p) => [p.youtubePlaylistId, p.title]));
|
||||
|
||||
const totalUnits = logs.reduce((sum, l) => sum + l.units, 0);
|
||||
|
||||
const items = logs.map((l) => {
|
||||
let entityLabel: string | null = null;
|
||||
if (l.entityId) {
|
||||
if (l.entityId.startsWith('batch:')) {
|
||||
entityLabel = `Import batch of ${l.entityId.slice(6)} videos`;
|
||||
} else if (l.entityId.startsWith('UU')) {
|
||||
// YouTube auto-generated uploads playlist — resolve channel name for context
|
||||
const chName = l.channelId ? (channelNameMap[l.channelId] ?? null) : null;
|
||||
entityLabel = chName ? `Uploads playlist (${chName})` : 'Uploads playlist (channel import)';
|
||||
} else {
|
||||
entityLabel = playlistNameMap[l.entityId] ?? l.entityId;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: l.id,
|
||||
operation: l.operation,
|
||||
units: l.units,
|
||||
channelId: l.channelId,
|
||||
channelName: l.channelId ? (channelNameMap[l.channelId] ?? null) : null,
|
||||
videoId: l.videoId,
|
||||
videoTitle: l.video?.title ?? null,
|
||||
youtubeVideoId: l.video?.youtubeVideoId ?? null,
|
||||
entityId: l.entityId,
|
||||
entityLabel,
|
||||
actionId: (l as any).actionId ?? null,
|
||||
actionType: (l as any).actionType ?? null,
|
||||
createdAt: l.createdAt.toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
return { items, totalUnits };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { TeamRole } from '@prisma/client';
|
||||
import { SavedViewsService } from './saved-views.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('saved-views')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Controller('saved-views')
|
||||
export class SavedViewsController {
|
||||
constructor(private readonly service: SavedViewsService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); }
|
||||
|
||||
@Get('tabs')
|
||||
findTabs(@Req() req: any) { return this.service.findTabs(req.user.teamId); }
|
||||
|
||||
@Post() @Roles(TeamRole.EDITOR)
|
||||
create(@Body() body: any, @Req() req: any) {
|
||||
return this.service.create({ ...body, ownerId: req.user.id, teamId: req.user.teamId }, req.user.id);
|
||||
}
|
||||
|
||||
@Patch(':id') @Roles(TeamRole.EDITOR)
|
||||
update(@Param('id') id: string, @Body() body: any, @Req() req: any) {
|
||||
return this.service.update(id, body, req.user.teamId, req.user.id);
|
||||
}
|
||||
|
||||
@Delete(':id') @Roles(TeamRole.ADMIN)
|
||||
remove(@Param('id') id: string, @Req() req: any) { return this.service.delete(id, req.user.teamId, req.user.id); }
|
||||
|
||||
@Post(':id/execute') @ApiOperation({ summary: 'Execute saved view and return matching video IDs' })
|
||||
execute(@Param('id') id: string, @Req() req: any) { return this.service.execute(id, req.user.teamId); }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user