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

LayerLibraryWhy
FrameworkNestJS 10Decorator-driven DI, modular architecture, TypeScript-first
ORMPrisma 6Type-safe queries, migrations, JSONB support
DatabasePostgreSQL 16JSONB for flexible config fields, GIN indexes for query performance
QueueBullMQ + Redis 7Rate limiting, delayed jobs (quota overflow), job retries
AuthPassport + JWT + Google OAuth2YouTube OAuth flow, JWT sessions (15 min), refresh tokens (7 d)
Validationclass-validator + zodRequest DTOs via decorators; zod for CSV row schema validation
Docs@nestjs/swaggerAuto-generated OpenAPI at /api/docs
CSVcsv-parse / csv-stringifySync parsing for import preview; streaming stringify for export
YouTube APIgoogleapisOfficial Google client for videos.list and videos.update

📁 File Structure

Every folder under src/ has a single, clearly scoped responsibility.

backend/ ├── package.json all dependencies + npm scripts ├── tsconfig.json ├── nest-cli.json dual entry: main + worker ├── .env.example ├── prisma/ │ └── schema.prisma 17 models, enums, GIN indexes └── src/ ├── main.ts HTTP server bootstrap ├── worker.ts BullMQ worker bootstrap ├── app.module.ts root module for HTTP ├── worker.module.ts root module for worker ├── health.controller.ts GET /health → Docker healthcheck ├── shared/ cross-cutting, no HTTP endpoints │ ├── prisma/ PrismaService (global) │ ├── render-engine/ RenderEngineService + hash.ts │ ├── quota/ QuotaService — daily unit tracking │ └── audit/ AuditService — write AuditLog rows ├── queues/ │ ├── queues.constants.ts queue name constants │ └── processors/ │ ├── youtube-sync.processor.ts │ ├── bulk-metadata.processor.ts │ ├── render.processor.ts │ ├── lint.processor.ts │ └── import.processor.ts └── modules/ one folder per domain ├── auth/ ├── videos/ ├── blocks/ ├── templates/ ├── video-configs/ ├── collaborators/ ├── bulk-jobs/ ├── saved-views/ ├── linting/ service + 10 rule files ├── calendar/ ├── imports/ ├── exports/ ├── youtube-sync/ ├── quota/ HTTP controller only └── audit-logs/ HTTP controller only

🚀 Entry Points

src/main.ts — HTTP Server

Bootstraps the full NestJS application with all feature modules registered.

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

ModuleImports
AppModule All 13 feature modules + Shared layer + BullModule (Redis) + ConfigModule
WorkerModule YouTubeSyncModule, BulkJobsModule, LintingModule, ImportsModule + Shared layer + 5 queue registrations + 5 processor providers

Shared Layer

These modules have no HTTP controllers. They are pure service classes used by feature modules and worker processors alike.

PrismaService (shared/prisma/)

Extends PrismaClient directly. Implements OnModuleInit and OnModuleDestroy to call $connect() and $disconnect(). Registered as a @Global() module so it never needs to be re-imported.

@Injectable()
export class PrismaService extends PrismaClient
  implements OnModuleInit, OnModuleDestroy {

  async onModuleInit()    { await this.$connect(); }
  async onModuleDestroy() { await this.$disconnect(); }
}

AuditService (shared/audit/)

Single method log(actorId, entityType, entityId, action, before?, after?). Called by every service that mutates data. Writes to the AuditLog table with full before/after JSON snapshots.

QuotaService (shared/quota/) — see next section

RenderEngineService (shared/render-engine/) — see next section

🖨 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

Load blockOrder
Apply blockOverrides
Resolve {variables}
Inject collaborators
Eval conditionals
Expand repeatables
Join + SHA-256 hash

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

BlockTypeHow it's rendered
STATICContent is used as-is after variable substitution
VARIABLE{placeholder} tokens replaced from variableValues
CONDITIONALSkipped entirely if its condition variable is falsy ([if:var_name] prefix)
REPEATABLERendered once per item in the relevant array
GLOBALSame as STATIC — content fetched from block library
CAMPAIGNLinked to a Campaign entity; linting checks expiry
COLLABORATORExpanded 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).

MethodWhat 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

GET /auth/google
Google consent
Callback with code
Upsert User
Issue JWT (15 min)
Set refresh cookie (7 d)

Key files

FileResponsibility
strategies/google.strategy.tsPassport strategy — calls authService.upsertGoogleUser() on callback
strategies/jwt.strategy.tsExtracts JWT from Bearer header, validates against DB
guards/jwt-auth.guard.tsApplied globally to all routes that need auth
guards/roles.guard.tsReads @Roles() decorator, compares user role priority
auth.service.tsToken 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.

RolePriorityCan
ADMIN4Everything including role management and YouTube sync push
EDITOR3CRUD on all entities, bulk jobs, import/export, render
REVIEWER2Read all data, see lint results
READONLY1GET endpoints only

Sensitive endpoints requiring EDITOR+

ADMIN-only

Videos Module

Central module. All video list/filter/bulk operations live here.

MethodPathDescription
GET/videosPaginated list with filters: search, lintStatus, privacyStatus, channelId, templateId, collaboratorId, date range
GET/videos/:idSingle video with config, collaborators, lint results
PATCH/videos/:idUpdate title, tags, privacyStatus, scheduledAt, categoryId, templateId
POST/videos/bulk-previewDry-run — returns before/after diff for each affected video
POST/videos/bulk-applyCreates BulkJob + BulkJobItems, enqueues to bulk-metadata queue
POST/videos/:id/renderSynchronous render — returns rendered text immediately
POST/videos/:id/syncEnqueues 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

Blocks Module

Description blocks are the atomic content units. They are versioned — every update creates an immutable BlockVersion snapshot before applying changes.

MethodPathDescription
GET/blocksAll blocks sorted by name
POST/blocksCreate new block
PATCH/blocks/:idUpdate block → auto-creates BlockVersion → increments version
GET/blocks/:id/versionsFull version history (newest first)
GET/blocks/:id/usageWhich 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.

MethodPathDescription
GET/templatesAll active templates
POST/templatesCreate template
PATCH/templates/:idUpdate + TemplateVersion snapshot
POST/templates/:id/render-previewRender with sample variable values (no DB write)

Template schema fields (JSONB)

🎛 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.

MethodPathDescription
GET/video-configs/:videoIdGet current config for a video
PUT/video-configs/:videoIdUpsert config (create or replace), increments version
POST/video-configs/:videoId/render-previewPreview render without persisting

Config fields

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.

MethodPathDescription
GET/collaboratorsAll collaborators
POST/collaboratorsCreate collaborator
PATCH/collaborators/:idUpdate (archive via active: false)
GET/collaborators/:id/videosAll videos linked to this collaborator
POST/videos/:videoId/collaboratorsLink collaborator to video (upsert)
DELETE/videos/:videoId/collaborators/:cIdRemove link

Placeholder tokens in blocks

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

CodeSeverityWhat it checks
TITLE_WEAKWARNINGTitle shorter than 20 chars or contains generic words (video, test, untitled)
TITLE_TOO_LONGWARNINGTitle exceeds 100 characters
DESC_MISSING_CTAWARNINGNo subscribe/follow/like keywords in rendered description
DESC_MISSING_CHAPTERSWARNINGFewer than 2 timestamp patterns (e.g. 0:00) in description
DESC_EMPTY_PLACEHOLDERERRORUnresolved {variable} tokens remain in the rendered text
DESC_DUPLICATE_HASHTAGWARNINGDuplicate #hashtag tokens in description
DESC_REQUIRED_LINK_MISSINGERRORTemplate rules.requiredLinks contains a URL not present in description
DESC_OUTDATED_SPONSOR_COPYERRORA campaign block in the config references a campaign that has expired
COLLAB_REFERENCE_INVALIDERRORA collaborator ID in videoConfig.collaboratorIds has no corresponding DB record
REMOTE_CONFLICTERRORvideo.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
MethodPathDescription
POST/lint/videos/:idLint one video synchronously
POST/lint/bulkEnqueue lint jobs for array of video IDs
GET/lint/resultsAll open lint results, filterable by severity/ruleCode/videoId

Bulk Jobs Module

Bulk jobs follow a dry-run → confirm → execute → rollback lifecycle.

Lifecycle

PENDING (dry-run)
DRY_RUN (diff shown)
CONFIRMED
RUNNING
DONE / FAILED

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.

MethodPathDescription
GET/bulk-jobsList all bulk jobs (filter by status)
GET/bulk-jobs/:idSingle job with all items and video titles
POST/bulk-jobs/:id/rollbackRestore 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)

MethodPathDescription
GET/saved-viewsAll views
POST/saved-viewsCreate (sets ownerId from JWT)
PATCH/saved-views/:idUpdate
DELETE/saved-views/:idDelete (ADMIN only)
POST/saved-views/:id/executeRun 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 paramDate range
monthFull calendar month from the given YYYY-MM
week7-day week starting from the Sunday of the given date
agenda30 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.

Upload file (multipart)
POST /imports/csv/preview
Validation report per row
POST /imports/csv/commit
Worker executes

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

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

QueueProcessorJob dataWhat 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

MethodPathResponse
GET/quota/today{ used, remaining, limit: 9000, resetAt, percentUsed }

Audit log endpoints

MethodPathDescription
GET/audit-logsPaginated full audit trail (newest first)
GET/audit-logs/:entityType/:entityIdAll 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

VariableRequiredDescription
DATABASE_URLPostgreSQL connection string
REDIS_URLRedis connection string (with password)
JWT_SECRETSecret for access JWTs (min 32 chars)
JWT_REFRESH_SECRETSeparate secret for refresh tokens
GOOGLE_CLIENT_IDGoogle OAuth app client ID
GOOGLE_CLIENT_SECRETGoogle OAuth app client secret
GOOGLE_CALLBACK_URLFull callback URL e.g. http://localhost:3001/api/v1/auth/google/callback
YOUTUBE_API_KEYYouTube Data API v3 key (for public read ops)
TOKEN_ENCRYPTION_KEY32-char key for AES-256-CBC encryption of stored OAuth tokens
PORTAPI port, default 3001
FRONTEND_URLCORS origin, default http://localhost:3000
NODE_ENVdevelopment / 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

PhaseScopeKey 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