StudioFlow Backend
NestJS 10 · Prisma 6 · BullMQ · PostgreSQL 16 · Redis 7
🗺 What is this?
StudioFlow is an internal YouTube metadata management platform. The backend handles all business logic: structured description rendering, bulk metadata updates, YouTube API sync with quota management, metadata linting, and import/export.
The codebase is a single NestJS monorepo with two independent entry points — an HTTP API server and an asynchronous BullMQ worker — sharing all modules and services.
HTTP API (main.ts)
REST endpoints, Swagger docs, auth, all CRUD operations. Runs on port 3001.
Worker (worker.ts)
BullMQ consumer for async jobs: sync, render, lint, bulk edits, CSV imports.
Render Engine
Pure TypeScript — no I/O. Turns a VideoConfig into a final description string.
Quota Guard
Tracks YouTube API unit consumption per Pacific-Time day. Enforces 9,000-unit cap.
🔧 Tech Stack
| Layer | Library | Why |
|---|---|---|
| Framework | NestJS 10 | Decorator-driven DI, modular architecture, TypeScript-first |
| ORM | Prisma 6 | Type-safe queries, migrations, JSONB support |
| Database | PostgreSQL 16 | JSONB for flexible config fields, GIN indexes for query performance |
| Queue | BullMQ + Redis 7 | Rate limiting, delayed jobs (quota overflow), job retries |
| Auth | Passport + JWT + Google OAuth2 | YouTube OAuth flow, JWT sessions (15 min), refresh tokens (7 d) |
| Validation | class-validator + zod | Request DTOs via decorators; zod for CSV row schema validation |
| Docs | @nestjs/swagger | Auto-generated OpenAPI at /api/docs |
| CSV | csv-parse / csv-stringify | Sync parsing for import preview; streaming stringify for export |
| YouTube API | googleapis | Official Google client for videos.list and videos.update |
📁 File Structure
Every folder under src/ has a single, clearly scoped responsibility.
🚀 Entry Points
src/main.ts — HTTP Server
Bootstraps the full NestJS application with all feature modules registered.
- Global
ValidationPipewithwhitelist: trueandtransform: true— strips unknown fields, auto-converts types - Global prefix
/api/v1on all routes - Swagger UI available at
/api/docs(OpenAPI 3) - CORS configured for
FRONTEND_URL(default:http://localhost:3000) - Cookie parser for refresh token httpOnly cookies
src/worker.ts — BullMQ Worker
Creates an ApplicationContext (no HTTP server) using WorkerModule.
Only loads the modules that contain job processors — no controllers are registered.
src/app.module.ts vs worker.module.ts
| Module | Imports |
|---|---|
| AppModule | All 13 feature modules + Shared layer + BullModule (Redis) + ConfigModule |
| WorkerModule | YouTubeSyncModule, BulkJobsModule, LintingModule, ImportsModule + Shared layer + 5 queue registrations + 5 processor providers |
🖨 Render Engine
The render engine is pure TypeScript with zero I/O. It takes all data as input and returns a string. It lives in shared/render-engine/ so both the API (for previews) and the worker (for bulk renders) can use it identically.
render-engine.service.ts — Pipeline
Input: RenderInput containing the VideoConfig fields, an array of resolved DescriptionBlock objects, and an array of Collaborator objects.
Output: { rendered: string, hash: string }
Block type handling
| BlockType | How it's rendered |
|---|---|
STATIC | Content is used as-is after variable substitution |
VARIABLE | {placeholder} tokens replaced from variableValues |
CONDITIONAL | Skipped entirely if its condition variable is falsy ([if:var_name] prefix) |
REPEATABLE | Rendered once per item in the relevant array |
GLOBAL | Same as STATIC — content fetched from block library |
CAMPAIGN | Linked to a Campaign entity; linting checks expiry |
COLLABORATOR | Expanded once per assigned collaborator; injects {collab_name}, {@youtube_handle}, {twitch_link} |
hash.ts — SHA-256 metadata hash
hashMetadata({ title, description, tags, categoryId }) produces a SHA-256 hex string over
JSON.stringify(data). This hash is stored as lastSyncedHash on the Video and
renderHash on VideoConfig. Before any YouTube sync the worker compares the current hash
against the stored one — if identical, zero API units are consumed.
export function hashMetadata(data: {
title: string; description: string;
tags: string[]; categoryId?: string | null;
}): string {
return createHash('sha256')
.update(JSON.stringify(data))
.digest('hex');
}
📊 Quota Service
YouTube Data API v3 resets at midnight Pacific Time. The quota service enforces a 9,000-unit daily cap (10% reserve below the 10,000 limit).
| Method | What it does |
|---|---|
canSpend(units) | Returns true if today's usage + units ≤ 9,000 |
spend(units, op, videoId?) | Inserts a QuotaLog row with Pacific Time day key |
getTodayUsage() | Aggregates SUM(units) for today's PT date |
getRemainingToday() | 9,000 − today's usage |
msUntilQuotaReset() | Milliseconds until midnight PT — used by worker to delay jobs |
Unit costs: videos.list costs 1 unit (up to 50 IDs per call).
videos.update costs 50 units regardless of how many fields change.
The hash diff check prevents all writes when nothing has changed.
Tranche scheduling
When a Bulk Job would exceed the day's remaining quota, the YouTube Sync processor calls
job.moveToDelayed(msUntilQuotaReset()) to defer those jobs to the next Pacific midnight
automatically — no manual intervention needed.
🔐 Auth Module
Authentication uses Google OAuth 2.0 for identity + YouTube token acquisition, then issues short-lived JWTs for API access.
OAuth Flow
Key files
| File | Responsibility |
|---|---|
strategies/google.strategy.ts | Passport strategy — calls authService.upsertGoogleUser() on callback |
strategies/jwt.strategy.ts | Extracts JWT from Bearer header, validates against DB |
guards/jwt-auth.guard.ts | Applied globally to all routes that need auth |
guards/roles.guard.ts | Reads @Roles() decorator, compares user role priority |
auth.service.ts | Token encryption/decryption (AES-256-CBC), JWT issuance, refresh |
Token storage security
YouTube access and refresh tokens are encrypted with AES-256-CBC before being written to the database. The encryption key comes from TOKEN_ENCRYPTION_KEY env var, derived via scryptSync.
🛡 Roles & Guards
Four roles with a priority hierarchy. The RolesGuard checks that the user's priority meets the minimum required for the endpoint.
| Role | Priority | Can |
|---|---|---|
ADMIN | 4 | Everything including role management and YouTube sync push |
EDITOR | 3 | CRUD on all entities, bulk jobs, import/export, render |
REVIEWER | 2 | Read all data, see lint results |
READONLY | 1 | GET endpoints only |
Sensitive endpoints requiring EDITOR+
POST /videos/bulk-applyPOST /videos/:id/syncPOST /imports/csv/commitand/imports/json/commitPOST /bulk-jobs/:id/rollback
ADMIN-only
DELETE /saved-views/:id(global views)
▶ Videos Module
Central module. All video list/filter/bulk operations live here.
| Method | Path | Description |
|---|---|---|
| GET | /videos | Paginated list with filters: search, lintStatus, privacyStatus, channelId, templateId, collaboratorId, date range |
| GET | /videos/:id | Single video with config, collaborators, lint results |
| PATCH | /videos/:id | Update title, tags, privacyStatus, scheduledAt, categoryId, templateId |
| POST | /videos/bulk-preview | Dry-run — returns before/after diff for each affected video |
| POST | /videos/bulk-apply | Creates BulkJob + BulkJobItems, enqueues to bulk-metadata queue |
| POST | /videos/:id/render | Synchronous render — returns rendered text immediately |
| POST | /videos/:id/sync | Enqueues a youtube-sync job (async) |
Full-text search
The search query param builds a Prisma OR condition across title (case-insensitive LIKE) and tags (array contains). No full-text index is required for moderate data volumes.
Supported bulk action types
SET_PRIVACY— change privacyStatus for all matched videosSET_TEMPLATE— assign a templateADD_TAGS/REMOVE_TAGS— array manipulationSEARCH_REPLACE_TITLE— string replace in title
☰ Blocks Module
Description blocks are the atomic content units. They are versioned — every update creates an immutable BlockVersion snapshot before applying changes.
| Method | Path | Description |
|---|---|---|
| GET | /blocks | All blocks sorted by name |
| POST | /blocks | Create new block |
| PATCH | /blocks/:id | Update block → auto-creates BlockVersion → increments version |
| GET | /blocks/:id/versions | Full version history (newest first) |
| GET | /blocks/:id/usage | Which VideoConfigs and Templates reference this block |
Version snapshot logic
// On every PATCH:
1. Load current block
2. INSERT BlockVersion { contentSnapshot: { ...currentBlock }, version: block.version }
3. UPDATE block SET { ...dto, version: version + 1 }
4. AuditService.log()
◫ Templates Module
Templates define a named, reusable block sequence. They also carry default variable values and rule definitions (e.g., required links). Like blocks, templates are versioned.
| Method | Path | Description |
|---|---|---|
| GET | /templates | All active templates |
| POST | /templates | Create template |
| PATCH | /templates/:id | Update + TemplateVersion snapshot |
| POST | /templates/:id/render-preview | Render with sample variable values (no DB write) |
Template schema fields (JSONB)
defaultBlocks— ordered array of block IDsrules— e.g.{ "requiredLinks": ["https://..."] }— used by lint rulesvariables— default values for{placeholder}tokens
🎛 Video Configs Module
A VideoConfig is the reconstruction key for a video's description.
It stores which blocks are active, in what order, with what variable values and which collaborators —
not the final text. The final text is always reconstructable from this config.
| Method | Path | Description |
|---|---|---|
| GET | /video-configs/:videoId | Get current config for a video |
| PUT | /video-configs/:videoId | Upsert config (create or replace), increments version |
| POST | /video-configs/:videoId/render-preview | Preview render without persisting |
Config fields
blockOrder—string[]— ordered block IDsblockOverrides—{ [blockId]: { content?, active? } }— per-video overridesvariableValues—{ [key]: value }— fills{placeholder}tokenscollaboratorIds—string[]— IDs used for collaborator block expansionautoRender?: boolean— if true, queues a render job immediately on save
◉ Collaborators Module
Collaborators are entities, not free text. They are referenced by ID in the VideoConfig and resolved at render time into their actual handle, name, and links.
| Method | Path | Description |
|---|---|---|
| GET | /collaborators | All collaborators |
| POST | /collaborators | Create collaborator |
| PATCH | /collaborators/:id | Update (archive via active: false) |
| GET | /collaborators/:id/videos | All videos linked to this collaborator |
| POST | /videos/:videoId/collaborators | Link collaborator to video (upsert) |
| DELETE | /videos/:videoId/collaborators/:cId | Remove link |
Placeholder tokens in blocks
{collab_name}→Rhea Vale{@youtube_handle}→@RheaPlays{twitch_link}→https://twitch.tv/rheavale
✦ Linting Module
Ten pluggable lint rules. Each is a class implementing LintRule. New rules can be added without touching existing code.
LintRule interface
interface LintRule {
code: string;
severity: LintSeverity; // INFO | WARNING | ERROR
check(video: any): LintIssue | null;
}
All 10 rules
| Code | Severity | What it checks |
|---|---|---|
TITLE_WEAK | WARNING | Title shorter than 20 chars or contains generic words (video, test, untitled) |
TITLE_TOO_LONG | WARNING | Title exceeds 100 characters |
DESC_MISSING_CTA | WARNING | No subscribe/follow/like keywords in rendered description |
DESC_MISSING_CHAPTERS | WARNING | Fewer than 2 timestamp patterns (e.g. 0:00) in description |
DESC_EMPTY_PLACEHOLDER | ERROR | Unresolved {variable} tokens remain in the rendered text |
DESC_DUPLICATE_HASHTAG | WARNING | Duplicate #hashtag tokens in description |
DESC_REQUIRED_LINK_MISSING | ERROR | Template rules.requiredLinks contains a URL not present in description |
DESC_OUTDATED_SPONSOR_COPY | ERROR | A campaign block in the config references a campaign that has expired |
COLLAB_REFERENCE_INVALID | ERROR | A collaborator ID in videoConfig.collaboratorIds has no corresponding DB record |
REMOTE_CONFLICT | ERROR | video.remoteConflict === true — YouTube was edited outside StudioFlow |
LintingService.lintVideo(videoId)
1. Load video with config + template + collaborators
2. Load campaign blocks for expiry check
3. Run all 10 rules → collect non-null LintIssue results
4. DELETE existing unresolved LintResults for this video
5. INSERT new LintResult rows
6. Compute overall lintStatus: ERROR > WARNING > OK
7. UPDATE video.lintStatus
| Method | Path | Description |
|---|---|---|
| POST | /lint/videos/:id | Lint one video synchronously |
| POST | /lint/bulk | Enqueue lint jobs for array of video IDs |
| GET | /lint/results | All open lint results, filterable by severity/ruleCode/videoId |
⇅ Bulk Jobs Module
Bulk jobs follow a dry-run → confirm → execute → rollback lifecycle.
Lifecycle
Each BulkJob has one BulkJobItem per affected video. The item stores the before snapshot and after snapshot so rollback is a simple record-by-record restore.
| Method | Path | Description |
|---|---|---|
| GET | /bulk-jobs | List all bulk jobs (filter by status) |
| GET | /bulk-jobs/:id | Single job with all items and video titles |
| POST | /bulk-jobs/:id/rollback | Restore all beforeSnapshot values; sets status to ROLLED_BACK |
Rollback safety: Only items with status: 'done' are restored. Items that failed are skipped. The rollback itself is audited via AuditService.
⊿ Saved Views Module
Saved views are reusable filter presets stored as JSON. They can be applied to the video table, used as a scope for exports, or fed into bulk jobs as a video selector.
4 default views (seeded on startup)
- All sponsor videos — tags contain "sponsor"
- Needs chapters — lintStatus WARNING
- Missing CTA — lintStatus WARNING
- Published this month — publishedAt ≥ first day of current month (dynamic placeholder resolved at execute time)
| Method | Path | Description |
|---|---|---|
| GET | /saved-views | All views |
| POST | /saved-views | Create (sets ownerId from JWT) |
| PATCH | /saved-views/:id | Update |
| DELETE | /saved-views/:id | Delete (ADMIN only) |
| POST | /saved-views/:id/execute | Run queryJson against DB, return matching video IDs + count |
📅 Calendar Module
Returns calendar-shaped entries for scheduled and published videos. Frontend can use these to render month/week/agenda views.
| View param | Date range |
|---|---|
month | Full calendar month from the given YYYY-MM |
week | 7-day week starting from the Sunday of the given date |
agenda | 30 days forward from the given date |
Each calendar entry shape: { videoId, title, date, templateName, collaborators[], lintStatus, channelId, privacyStatus }
↑↓ Import / Export
CSV Import
Supports these columns: youtube_video_id, title, tags, category_id, privacy_status, scheduled_at, template_name, collaborators.
Each row is validated with a Zod schema. Errors are returned as [{ row, field, message }]. An ImportJob record is created at preview time to track the state.
JSON Import/Export
Full workspace format covers all entity types:
{
"version": "1.0",
"exportedAt": "2026-04-27T...",
"videos": [...],
"videoConfigs": [...],
"blocks": [...],
"templates": [...],
"collaborators": [...],
"savedViews": [...]
}
Import upserts all entities by ID, so re-importing a backup is idempotent.
CSV Export
Accepts { videoIds?, savedViewId? }. If neither is provided, exports all videos. Returns a streamed text/csv response with a Content-Disposition: attachment header.
🔴 YouTube Sync Module
Wraps the googleapis YouTube Data API v3 client. The YouTubeApiClient handles OAuth token decryption and the YouTubeSyncService adds quota accounting and conflict detection.
YouTubeApiClient
getVideoMetadata(youtubeVideoId)—videos.list, part: snippet + status, cost: 1 unitupdateVideoMetadata(id, meta)—videos.update, part: snippet + status, cost: 50 units
YouTubeSyncService.detectConflict(videoId)
Fetches the current YouTube snippet, hashes it, and compares with lastSyncedHash. If they differ, sets video.remoteConflict = true — which will be caught by the REMOTE_CONFLICT lint rule on the next lint run.
YouTube Sync Worker (processor)
1. Load video + config from DB
2. Render description with RenderEngineService
3. Hash new metadata → compare with lastSyncedHash
→ identical? SKIP (0 units spent)
4. quota.canSpend(50)?
→ no? moveToDelayed(msUntilQuotaReset())
5. ytSync.pushUpdate(video, meta)
6. quota.spend(50, 'videos.update', videoId)
7. UPDATE video.lastSyncedHash + lastSyncedAt
Rate limiter: The processor is configured with limiter: { max: 5, duration: 10_000 } — maximum 5 YouTube API calls per 10 seconds — and concurrency: 1 to prevent race conditions on quota tracking.
⟳ BullMQ Queues & Processors
| Queue | Processor | Job data | What it does |
|---|---|---|---|
youtube-sync |
YouTubeSyncProcessor |
{ videoId } |
Render → hash diff → quota check → YouTube update |
bulk-metadata |
BulkMetadataProcessor |
{ bulkJobId, itemId } |
Apply one BulkJobItem action, update success/error counts, mark job DONE when all items complete |
render |
RenderProcessor |
{ videoId } |
Render description, save to DB, then enqueue a lint job |
lint |
LintProcessor |
{ videoId } |
Run all 10 lint rules, persist results |
import |
ImportProcessor |
{ importJobId } |
Execute committed ImportJob (upsert rows to DB) |
Default job options (bulk-metadata)
{
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { age: 7 * 24 * 60 * 60 }, // keep 7 days
removeOnFail: false // keep failed jobs for analysis
}
Chained jobs: Render → Lint
When the render processor finishes, it immediately enqueues a lint job so the lint status is always fresh after any render. This means a PUT /video-configs/:videoId with autoRender: true triggers: save config → render job → lint job.
💹 Quota & Audit Log API
Quota endpoint
| Method | Path | Response |
|---|---|---|
| GET | /quota/today | { used, remaining, limit: 9000, resetAt, percentUsed } |
Audit log endpoints
| Method | Path | Description |
|---|---|---|
| GET | /audit-logs | Paginated full audit trail (newest first) |
| GET | /audit-logs/:entityType/:entityId | All audit events for a specific entity (e.g. /audit-logs/Video/clxyz123) |
Every mutation that goes through a service method calls AuditService.log() with a full before/after JSON snapshot. This gives a complete change history for any entity.
🔑 Environment Variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | ✅ | PostgreSQL connection string |
REDIS_URL | ✅ | Redis connection string (with password) |
JWT_SECRET | ✅ | Secret for access JWTs (min 32 chars) |
JWT_REFRESH_SECRET | ✅ | Separate secret for refresh tokens |
GOOGLE_CLIENT_ID | ✅ | Google OAuth app client ID |
GOOGLE_CLIENT_SECRET | ✅ | Google OAuth app client secret |
GOOGLE_CALLBACK_URL | ✅ | Full callback URL e.g. http://localhost:3001/api/v1/auth/google/callback |
YOUTUBE_API_KEY | ✅ | YouTube Data API v3 key (for public read ops) |
TOKEN_ENCRYPTION_KEY | ✅ | 32-char key for AES-256-CBC encryption of stored OAuth tokens |
PORT | — | API port, default 3001 |
FRONTEND_URL | — | CORS origin, default http://localhost:3000 |
NODE_ENV | — | development / production |
⚡ Getting Started
1. Install dependencies
cd backend
npm install
2. Set up environment
cp .env.example .env
# Fill in all required values in .env
3. Generate Prisma client
npx prisma generate
4. Run migrations (requires running PostgreSQL)
npx prisma migrate dev --name init
5. Start the API server
npm run start:dev
# → http://localhost:3001/api/v1
# → http://localhost:3001/api/docs (Swagger UI)
# → http://localhost:3001/health (Docker healthcheck)
6. Start the worker (separate terminal)
npm run start:worker
Via Docker Compose
# First-time migration:
docker compose --profile tools run --rm migrate
# Start all services:
docker compose up -d
Note: The worker and API use the same Docker image but different start commands.
The worker runs node dist/worker.js instead of the default node dist/main.js.
MVP Phase summary
| Phase | Scope | Key deliverable |
|---|---|---|
| Phase 1 | Structured core | Video config editor, block library, templates, collaborators, render + preview |
| Phase 2 | Operations | Bulk jobs with dry-run/rollback, linting, YouTube sync, quota management |
| Phase 3 | Extended features | Content calendar, CSV/JSON import-export, campaigns, quota history |