StudioFlow Backend

Developer Guide — Part 3 of 3

Render Engine · Linting · BullMQ Queues · API Reference · Setup

How Rendering Works

The render engine is the heart of StudioFlow. It takes a video's configuration and assembles the final description text that gets pushed to YouTube. It is a pure TypeScript function — no database calls, no I/O, just data in and text out. This makes it fast, testable, and deterministic.

A rendered description is built from blocks — reusable text chunks that are assembled in a specific order. Each block can have its content overridden per video, variables filled in, and conditional logic applied.

Input and output types

// src/shared/render-engine/render-engine.service.ts

// INPUT: everything the engine needs
export interface RenderInput {
  videoTitle: string;
  videoTags: string[];
  videoCategoryId?: string | null;
  config: {
    blockOrder: string[];                // ordered list of block IDs
    blockOverrides: Record<string, {     // per-video overrides
      content?: string;
      active?: boolean;
    }>;
    variableValues: Record<string, string>;  // { sponsorName: "Squarespace" }
    collaboratorIds: string[];
  };
  blocks: RenderBlock[];        // fetched from DB before calling render()
  collaborators: RenderCollaborator[];
}

// OUTPUT: the rendered text + a hash of the result
export interface RenderResult {
  rendered: string;   // the final description ready to send to YouTube
  hash: string;       // SHA-256 fingerprint of { title, description, tags, categoryId }
}

Render Pipeline — Step by Step

The render(input) method processes each block ID in blockOrder from top to bottom:

render(input: RenderInput): RenderResult {
  const blockMap = new Map(blocks.map((b) => [b.id, b]));
  const lines: string[] = [];

  for (const blockId of config.blockOrder) {
    const block = blockMap.get(blockId);
    if (!block) continue;    // block deleted from DB but still in config

    // ① Apply override: per-video active flag and content
    const override = config.blockOverrides[blockId] ?? {};
    const isActive = override.active !== undefined ? override.active : block.active;
    if (!isActive) continue;   // skip disabled blocks

    let content = override.content !== undefined ? override.content : block.content;

    // ② Resolve {variable} placeholders from variableValues
    content = this.resolveVariables(content, config.variableValues);

    // ③ Inject collaborator data ({collab_name}, {@youtube_handle})
    content = this.resolveCollaborators(content, collaborators, config.collaboratorIds);

    // ④ CONDITIONAL blocks — skip if the condition variable is falsy
    if (block.type === BlockType.CONDITIONAL) {
      const condVar = this.extractConditionVar(content);  // parses [if:varName]
      if (condVar && !config.variableValues[condVar]) continue;
    }

    // ⑤ COLLABORATOR blocks — expand once per collaborator assigned to this video
    if (block.type === BlockType.COLLABORATOR) {
      lines.push(this.expandCollaboratorBlock(content, collaborators, config.collaboratorIds));
      continue;
    }

    lines.push(content);
  }

  const rendered = lines.join('\n\n');   // blocks separated by blank lines

  // ⑥ Compute hash of the entire metadata set
  const hash = hashMetadata({ title: videoTitle, description: rendered, tags, categoryId });

  return { rendered, hash };
}

Variable resolution

// Replaces {variableName} with the corresponding value
// Unresolved placeholders remain in the text (caught by the DESC_EMPTY_PLACEHOLDER lint rule)

// Regex: { followed by a letter/underscore, then letters/digits/underscores, then }
// The g flag replaces ALL occurrences in the string
content.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (match, key) => {
  return values[key] !== undefined ? values[key] : match;
});

// Example:
// content: "Sponsored by {sponsorName} — use code {promoCode} for 20% off!"
// values:  { sponsorName: "Squarespace", promoCode: "STUDIO20" }
// result:  "Sponsored by Squarespace — use code STUDIO20 for 20% off!"

Collaborator expansion

// COLLABORATOR blocks are rendered once per collaborator assigned to the video
private expandCollaboratorBlock(template, collaborators, collaboratorIds) {
  const active = collaborators.filter((c) => collaboratorIds.includes(c.id));

  // Map each collaborator to a copy of the block with their data substituted
  return active.map((c) =>
    template
      .replace(/\{collab_name\}/g, c.name)
      .replace(/\{@youtube_handle\}/g, c.youtubeHandle)
      .replace(/\{twitch_link\}/g, c.twitchLink ?? '')
  ).join('\n\n');
}

// Block template: "Big thanks to {collab_name} — subscribe: {#youtube_handle}"
// With 2 collaborators: produces two paragraphs, one per collaborator

Change Detection with Hashing

Before pushing a video to YouTube (which costs 50 API quota units), the sync processor checks whether anything actually changed. If the rendered text, title, tags, and category are identical to the last sync, the update is skipped entirely.

// src/shared/render-engine/hash.ts

// SHA-256 hash of the stringified metadata object
// JSON.stringify produces the same string for the same object
// (as long as key order is consistent — it is here because it's hardcoded)
export function hashMetadata(data: {
  title: string;
  description: string;
  tags: string[];
  categoryId?: string | null;
}): string {
  return createHash('sha256')
    .update(JSON.stringify(data))
    .digest('hex');  // 64-character hex string
}

export function hasChanged(lastSyncedHash: string | null | undefined, newHash: string): boolean {
  return lastSyncedHash !== newHash;
}

// In the YouTube sync processor:
if (!hasChanged(video.lastSyncedHash, newHash)) {
  return { skipped: true, reason: 'no-change' };   // 50 quota units saved!
}
Why SHA-256? SHA-256 produces a fixed 64-character string regardless of input length. Any change — even a single character in the description — produces a completely different hash. Comparing two 64-character strings is O(1), no matter how long the description is.

Linting Overview

The linting system automatically checks the quality of every video's description, title, and configuration. It runs after every render and reports issues that editors need to fix before publishing.

The design uses the strategy pattern: a common interface (LintRule) that all rules implement. The LintingService holds an array of all rule instances and iterates over them — adding a new rule requires only creating a new file and adding it to the array.

// src/modules/linting/rules/base.rule.ts
export interface LintIssue {
  message: string;
  targetField?: string;       // 'title', 'description', 'sync'
  fixSuggestion?: string;    // human-readable hint
}

export interface LintRule {
  code: string;               // e.g. 'TITLE_WEAK'
  severity: LintSeverity;    // INFO | WARNING | ERROR
  check(video: any): LintIssue | null;
}

All 10 Lint Rules

TITLE_WEAK
WARNING

Title shorter than 20 characters or contains generic words like "video", "test", "untitled".

TITLE_TOO_LONG
WARNING

Title exceeds 100 characters. YouTube truncates long titles in search results.

DESC_MISSING_CTA
WARNING

The rendered description contains none of: subscribe, like, follow, comment, cta.

DESC_MISSING_CHAPTERS
WARNING

Fewer than 2 timestamp patterns (e.g. "0:00", "1:23") found in the description.

DESC_EMPTY_PLACEHOLDER
ERROR

The rendered description still contains unresolved {variable} placeholders.

DESC_DUPLICATE_HASHTAG
WARNING

The same #hashtag appears more than once in the description.

DESC_REQUIRED_LINK_MISSING
ERROR

The video's template defines required links (e.g. merch URL) that are absent from the description.

DESC_OUTDATED_SPONSOR_COPY
ERROR

A CAMPAIGN block is present and the linked campaign's endAt date has passed.

COLLAB_REFERENCE_INVALID
ERROR

A collaborator ID listed in the video config doesn't match any VideoCollaborator row in the DB.

REMOTE_CONFLICT
ERROR

The video's remoteConflict flag is true — YouTube's metadata differs from our last known sync.

Example rule implementation

// src/modules/linting/rules/desc-empty-placeholder.rule.ts

// This regex matches any {variable} that remains in the description after rendering
const PLACEHOLDER_REGEX = /\{[a-z_][a-z0-9_]*\}/g;

export class DescEmptyPlaceholderRule implements LintRule {
  code = 'DESC_EMPTY_PLACEHOLDER';
  severity = LintSeverity.ERROR;    // ERROR = blocks publishing

  check(video: any): LintIssue | null {
    const desc: string = video.renderedDescription ?? '';

    // String.match() with a /g regex returns ALL matches as an array
    const unresolved = desc.match(PLACEHOLDER_REGEX);

    if (unresolved?.length) {
      return {
        message: `Unresolved placeholders: ${unresolved.join(', ')}`,
        targetField: 'description',
        fixSuggestion: 'Fill in all variable values before publishing.',
      };
    }
    return null;   // null = no issue
  }
}

LintingService

The service runs all rules against a video and replaces the previous lint results atomically. The overall lintStatus on the Video record is then updated to reflect the worst severity found.

@Injectable()
export class LintingService {
  // All rules as singleton instances — no DI needed, they have no dependencies
  private readonly rules: LintRule[] = [
    new TitleWeakRule(), new TitleTooLongRule(),
    new DescMissingCtaRule(), new DescMissingChaptersRule(),
    new DescEmptyPlaceholderRule(), new DescDuplicateHashtagRule(),
    new DescRequiredLinkMissingRule(), new DescOutdatedSponsorRule(),
    new CollabReferenceInvalidRule(), new RemoteConflictRule(),
  ];

  async lintVideo(videoId: string) {
    // Load video with everything the rules might need
    const video = await this.prisma.video.findUniqueOrThrow({
      where: { id: videoId },
      include: { config: true, template: true, collaborators: { include: { collaborator: true } } },
    });

    // Run ALL rules and collect non-null results
    const issues = this.rules
      .map((rule) => rule.check(video))  // [null, issue, null, issue, ...]
      .filter(Boolean)                     // remove nulls
      .map((issue, i) => ({              // shape for createMany
        videoId,
        ruleCode: this.rules[i].code,
        severity: this.rules[i].severity,
        ...issue,
      }));

    // Atomically: delete old results + insert new ones + update video status
    await this.prisma.$transaction([
      this.prisma.lintResult.deleteMany({ where: { videoId, resolvedAt: null } }),
      ...(issues.length ? [this.prisma.lintResult.createMany({ data: issues })] : []),
      this.prisma.video.update({
        where: { id: videoId },
        data: { lintStatus: this.computeStatus(issues.map((i) => i.severity)) },
      }),
    ]);

    return issues;
  }

  // Escalation: ERROR > WARNING > OK
  private computeStatus(severities: LintSeverity[]): LintStatus {
    if (severities.includes(LintSeverity.ERROR)) return LintStatus.ERROR;
    if (severities.includes(LintSeverity.WARNING)) return LintStatus.WARNING;
    return LintStatus.OK;
  }
}

BullMQ Overview

BullMQ is a queue library that uses Redis as its backing store. A queue is like a to-do list — producers add jobs to it, consumers (processors) pick them up and execute them. This separates the HTTP request cycle (fast) from the actual work (slow).

Key concepts:

ConceptDescription
QueueA named list of jobs stored in Redis. Producers add to it.
JobA unit of work with a JSON payload. Has a status: waiting → active → completed/failed.
ProcessorA class decorated with @Processor(queueName) that implements process(job).
WorkerThe running instance that pulls jobs from the queue and calls the processor.
ConcurrencyHow many jobs a processor handles simultaneously. Default is unlimited.
Delayed jobsjob.moveToDelayed(timestamp) puts a job on hold until a future time.
Rate limiterRestricts how many jobs start in a time window (e.g. max 5 per 10 seconds).

The 5 Queues

youtube-sync

Producer: VideosService.enqueueSyncJob() — called when a user clicks "Sync to YouTube"
Processor: YouTubeSyncProcessor
Payload: { videoId: string; userId: string }
Special: concurrency=1 (one sync at a time), rate-limited to 5 per 10 seconds. userId identifies which user's stored OAuth token to use for the YouTube API call.

bulk-metadata

Producer: BulkJobsService.enqueueItems() — one job per video in the bulk operation
Processor: BulkMetadataProcessor
Payload: { bulkJobId: string, itemId: string }

render

Producer: VideoConfigsService when autoRender: true
Processor: RenderProcessor
Payload: { videoId: string }
Special: automatically enqueues a lint job when finished

lint

Producer: RenderProcessor (chained) or LintingController
Processor: LintProcessor
Payload: { videoId: string }

import

Producer: ImportsService.commitCsv()
Processor: ImportProcessor
Payload: { importJobId: string }

YouTube Sync Processor — Full Flow

This is the most complex processor. It renders the description, checks if anything changed, checks quota availability, and only then calls YouTube's API.

@Processor(QUEUES.YOUTUBE_SYNC, {
  concurrency: 1,                          // one sync at a time per worker
  limiter: { max: 5, duration: 10_000 },   // max 5 jobs per 10 seconds
})
export class YouTubeSyncProcessor extends WorkerHost {

  async process(job: Job<{ videoId: string; userId: string }>) {
    const { videoId, userId } = job.data;  // userId identifies whose OAuth token to use

    // Step 1: Load everything needed for rendering
    const video = await prisma.video.findUniqueOrThrow({
      where: { id: videoId },
      include: { config: true, collaborators: { include: { collaborator: true } } },
    });

    // Step 2: Render the description
    const renderResult = renderEngine.render({ ... });

    // Step 3: Hash the metadata and compare with last synced state
    const newHash = hashMetadata({ title: video.title, description: renderResult.rendered, ... });
    if (!hasChanged(video.lastSyncedHash, newHash)) {
      return { skipped: true, reason: 'no-change' };   // ✓ saves 50 quota units
    }

    // Step 4: Check YouTube API quota
    if (!(await quota.canSpend(50))) {
      // Delay the job until quota resets (midnight Pacific Time)
      await job.moveToDelayed(Date.now() + quota.msUntilQuotaReset());
      return { deferred: true };
    }

    // Step 5: Push to YouTube using the triggering user's OAuth token (costs 50 units)
    await ytSync.pushUpdate(video, { title, description: renderResult.rendered, ... }, userId);

    // Step 6: Record the quota spend
    await quota.spend(50, 'videos.update', videoId);

    // Step 7: Save the hash so we can skip next time if nothing changed
    await prisma.video.update({
      where: { id: videoId },
      data: { lastSyncedHash: newHash, lastSyncedAt: new Date(), remoteConflict: false },
    });
  }
}

Bulk Metadata Processor

Each bulk job item (one video in a batch operation) becomes its own queue job. The processor applies the action, records before/after snapshots, and updates the parent BulkJob's counters.

async process(job: Job<{ bulkJobId: string; itemId: string }>) {
  const item = await prisma.bulkJobItem.findUniqueOrThrow({
    where: { id: itemId },
    include: { bulkJob: true, video: true },
  });

  const before = { title: video.title, tags: video.tags, privacyStatus: video.privacyStatus };

  try {
    const after = this.applyAction(before, bulkJob.type, bulkJob.filterSnapshot);

    await prisma.$transaction([
      prisma.video.update({ where: { id: video.id }, data: after }),
      prisma.bulkJobItem.update({
        where: { id: itemId },
        data: { status: 'done', beforeSnapshot: before, afterSnapshot: after },
      }),
      prisma.bulkJob.update({
        where: { id: bulkJobId },
        data: { successCount: { increment: 1 } },  // atomic increment
      }),
    ]);
  } catch (err) {
    // Record the failure without crashing the whole batch
    await prisma.$transaction([
      prisma.bulkJobItem.update({ where: { id: itemId }, data: { status: 'error', errorMessage: err.message } }),
      prisma.bulkJob.update({ where: { id: bulkJobId }, data: { errorCount: { increment: 1 } } }),
    ]);
  }

  // Check if all items are done — if so, mark the parent job complete
  const pending = await prisma.bulkJobItem.count({ where: { bulkJobId, status: 'pending' } });
  if (pending === 0) {
    const errors = await prisma.bulkJobItem.count({ where: { bulkJobId, status: 'error' } });
    await prisma.bulkJob.update({
      where: { id: bulkJobId },
      data: { status: errors > 0 ? 'FAILED' : 'DONE', completedAt: new Date() },
    });
  }
}

Render → Lint Job Chaining

The RenderProcessor automatically triggers a lint job when rendering finishes. This chaining keeps the two concerns (rendering and quality-checking) separate while ensuring lint always runs after a render.

@Processor(QUEUES.RENDER)
export class RenderProcessor extends WorkerHost {
  constructor(
    private readonly prisma: PrismaService,
    private readonly renderEngine: RenderEngineService,
    @InjectQueue(QUEUES.LINT) private readonly lintQueue: Queue,
    // ↑ This processor is ALSO a producer of lint jobs
  ) { super(); }

  async process(job: Job<{ videoId: string }>) {
    // ... render and save to DB ...

    // Chain: enqueue a lint job immediately after render completes
    await this.lintQueue.add('lint', { videoId }, {
      jobId: `lint-${videoId}-after-render`
      // Named jobId prevents duplicate lint jobs if render fires multiple times quickly
    });

    return { rendered: true };
  }
}

YouTubeApiClient — OAuth-based YouTube Access

All YouTube API calls use the logged-in user's OAuth token, stored encrypted in the database after the Google login flow. There is no separate YouTube API key — the YouTube Data API v3 scope is requested at login time, so the user's access token already grants full read/write access to their channel.

Why no API key? A standalone API key only allows unauthenticated read access to public data. Since StudioFlow writes video metadata (titles, descriptions, tags), it needs OAuth — and once you have OAuth, the API key is redundant. Removing it simplifies config and reduces secrets to manage.
private async getYouTubeClient(userId: string) {
  const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });

  // OAuth2 client needs client ID + secret so it can auto-refresh expired tokens
  const oauth2 = new google.auth.OAuth2(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);

  oauth2.setCredentials({
    access_token: authService.decryptToken(user.youtubeAccessToken),
    refresh_token: authService.decryptToken(user.youtubeRefreshToken),
  });

  // When Google silently rotates the tokens, persist the new ones back to DB
  oauth2.on('tokens', async (tokens) => {
    await prisma.user.update({
      where: { id: userId },
      data: {
        youtubeAccessToken: authService.encryptToken(tokens.access_token),
        youtubeTokenExpiry: new Date(tokens.expiry_date),
        // refresh_token only included when Google issues a new one
        ...(tokens.refresh_token && {
          youtubeRefreshToken: authService.encryptToken(tokens.refresh_token),
        }),
      },
    });
  });

  return google.youtube({ version: 'v3', auth: oauth2 });
}

The 'tokens' event fires automatically whenever the Google OAuth2 client performs a token refresh. By persisting the new tokens back to the database, the user never needs to log in again unless they explicitly revoke access.

Every public method on YouTubeApiClient now requires a userId parameter, which flows from the HTTP request through the queue job payload all the way to this method:

// Controller — user triggers sync via HTTP
sync(@Param('id') id: string, @Req() req: any) {
  return this.service.enqueueSyncJob(id, req.user.sub);   // sub = user's DB id from JWT
}

// Service — puts userId into the queue job payload
async enqueueSyncJob(videoId: string, userId: string) {
  await syncQueue.add('sync', { videoId, userId });
}

// Processor — reads userId from job and passes it to the API client
async process(job: Job<{ videoId: string; userId: string }>) {
  const { videoId, userId } = job.data;
  // ... render, hash check, quota check ...
  await ytSync.pushUpdate(video, meta, userId);   // userId carried through
}

YouTube Quota Management

YouTube's Data API v3 has a daily quota of 10,000 units (this project uses 9,000 to leave a buffer). Different API operations cost different amounts:

OperationCostMethod called
videos.list (read metadata)1 unitYouTubeSyncService.fetchRemote()
videos.update (write metadata)50 unitsYouTubeSyncService.pushUpdate()

QuotaService design

@Injectable()
export class QuotaService {
  private readonly DAILY_LIMIT = 9_000;

  // Checks if we can afford to spend `units` without exceeding the daily limit
  async canSpend(units: number): Promise<boolean> {
    return (await this.getTodayUsage()) + units <= this.DAILY_LIMIT;
  }

  // Records a quota spend in the database
  async spend(units: number, operation: string, videoId?: string): Promise<void> {
    await this.prisma.quotaLog.create({
      data: { units, operation, videoId, datePt: this.getTodayPT() }
    });
  }

  // Sums all quota used today (Pacific Time day)
  async getTodayUsage(): Promise<number> {
    const result = await this.prisma.quotaLog.aggregate({
      _sum: { units: true },
      where: { datePt: this.getTodayPT() },   // only today's rows
    });
    return result._sum.units ?? 0;
  }

  // Returns ms until midnight Pacific Time (YouTube's reset hour)
  msUntilQuotaReset(): number {
    const PT_OFFSET_MS = 8 * 60 * 60 * 1000;   // UTC-8 (PST)
    const nowPT = new Date(Date.now() - PT_OFFSET_MS);
    const tomorrowMidnightPT = new Date(nowPT);
    tomorrowMidnightPT.setUTCHours(24, 0, 0, 0);
    return tomorrowMidnightPT.getTime() - nowPT.getTime();
  }
}

Videos Module — Full API

MethodPathRoleDescription
GET/videosREADONLY+Paginated video list with filters: search, lintStatus, privacyStatus, channelId, templateId, collaboratorId, from, to, sort, order
GET/videos/:idREADONLY+Single video with full includes: config, collaborators, lintResults
PATCH/videos/:idEDITOR+Partial update: title, tags, privacyStatus, scheduledAt, categoryId, templateId
POST/videos/bulk-previewEDITOR+Dry-run a bulk action — returns before/after diffs per video, no writes
POST/videos/bulk-applyEDITOR+Creates a BulkJob and enqueues one job per video
POST/videos/:id/renderEDITOR+Synchronously renders description and saves to DB
POST/videos/:id/syncEDITOR+Enqueues a youtube-sync job for this video

Bulk action types

TypePayloadWhat it does
SET_PRIVACY{ privacyStatus: "PUBLIC" }Changes privacy status on all target videos
SET_TEMPLATE{ templateId: "abc" }Assigns a template to all target videos
ADD_TAGS{ tags: ["tutorial"] }Appends tags to each video's tag array
REMOVE_TAGS{ tags: ["old-tag"] }Removes specified tags from each video
SEARCH_REPLACE_TITLE{ search: "2024", replace: "2025" }String replace in each video's title

Blocks & Automatic Versioning

Every time a block is edited, the system automatically creates a BlockVersion snapshot of the block's state before the change. This gives you a full edit history you can browse and roll back to.

// src/modules/blocks/blocks.service.ts — update() method
async update(id: string, dto: UpdateBlockDto, actorId: string) {
  // 1. Load the CURRENT state before changing anything
  const before = await this.prisma.descriptionBlock.findUnique({ where: { id } });

  // 2. Snapshot the current state into BlockVersion
  await this.prisma.blockVersion.create({
    data: {
      blockId: id,
      version: before.version,            // version number at time of snapshot
      contentSnapshot: before as any,    // full block object stored as JSON
      createdBy: actorId,
    },
  });

  // 3. Apply the changes and increment the version counter
  const updated = await this.prisma.descriptionBlock.update({
    where: { id },
    data: { ...dto, version: { increment: 1 } },  // atomic increment in DB
  });

  // 4. Write an audit log entry
  await this.audit.log(actorId, 'DescriptionBlock', id, 'update', before, updated);
  return updated;
}

Block usage lookup

// "Which videos and templates use this block?"
// Uses Prisma's JSON array_contains filter on JSONB columns
async getUsage(id: string) {
  const [videoConfigs, templates] = await Promise.all([
    prisma.videoConfig.findMany({
      where: { blockOrder: { array_contains: id } },   // JSON array contains this ID
      select: { videoId: true },
    }),
    prisma.template.findMany({
      where: { defaultBlocks: { array_contains: id } },
      select: { id: true, name: true },
    }),
  ]);
  return { videoIds: videoConfigs.map((c) => c.videoId), templates };
}

Bulk Jobs — Rollback

Every bulk job item stores a beforeSnapshot of the video's state before the change. The rollback endpoint uses these snapshots to restore each video to its pre-operation state.

// src/modules/bulk-jobs/bulk-jobs.service.ts
async rollback(id: string, actorId: string) {
  const job = await prisma.bulkJob.findUniqueOrThrow({
    where: { id },
    include: { items: { where: { status: 'done' } } },   // only items that succeeded
  });

  for (const item of job.items) {
    if (!item.beforeSnapshot) continue;

    // Restore the video to its state before the bulk action
    await prisma.video.update({
      where: { id: item.videoId },
      data: item.beforeSnapshot as any,   // { title, tags, privacyStatus, ... }
    });
    await prisma.bulkJobItem.update({
      where: { id: item.id },
      data: { status: 'rolled_back' },
    });
  }

  return prisma.bulkJob.update({ where: { id }, data: { status: 'ROLLED_BACK' } });
}

Import & Export

CSV Import — two-step process

Imports use a preview-then-commit pattern. The user uploads a CSV first, the server validates it and stores a report, then the user explicitly commits. This prevents partial imports from bad data.

// Step 1: POST /imports/csv/preview — validates rows with Zod
const CsvRowSchema = z.object({
  youtube_video_id: z.string().min(1),
  title: z.string().optional(),
  privacy_status: z.enum(['PUBLIC', 'PRIVATE', 'UNLISTED']).optional(),
  // ... other fields
});

// csv-parse/sync parses the uploaded file buffer into row objects
const rows = parse(fileBuffer, { columns: true, skip_empty_lines: true, trim: true });

rows.forEach((row, i) => {
  const result = CsvRowSchema.safeParse(row);
  if (result.success) {
    validRows.push(result.data);
  } else {
    // Zod's error contains field path + message
    result.error.errors.forEach((e) =>
      errors.push({ row: i + 1, field: e.path.join('.'), message: e.message })
    );
  }
});

// Returns: { importJobId, validRows: 47, errors: [{ row: 3, field: 'privacy_status', message: ... }] }

// Step 2: POST /imports/csv/commit — enqueues the actual DB upserts
await importQueue.add('import', { importJobId });

JSON Export

// POST /exports/json — full workspace snapshot
async exportJson() {
  // All 6 resource types fetched in parallel
  const [videos, videoConfigs, blocks, templates, collaborators, savedViews] =
    await Promise.all([
      prisma.video.findMany(),
      prisma.videoConfig.findMany(),
      prisma.descriptionBlock.findMany(),
      prisma.template.findMany(),
      prisma.collaborator.findMany(),
      prisma.savedView.findMany(),
    ]);

  return {
    version: '1.0',
    exportedAt: new Date().toISOString(),
    videos, videoConfigs, blocks, templates, collaborators, savedViews,
  };
}

Calendar Endpoint

The calendar endpoint returns videos that fall within a date range, shaped as calendar entries the frontend can display.

// GET /calendar?view=month&date=2026-04
// view: 'month' | 'week' | 'agenda'
// date: 'YYYY-MM' for month/week, 'YYYY-MM-DD' for agenda

private parseRange(view: string, date: string): { start: Date; end: Date } {
  const [year, month] = date.split('-').map(Number);

  if (view === 'month') {
    return {
      start: new Date(year, month - 1, 1),         // first of month
      end: new Date(year, month, 0, 23, 59, 59),  // last day (day 0 of next month)
    };
  }
  // 'agenda': next 30 days from the given date
  const start = new Date(date);
  const end = new Date(start);
  end.setDate(start.getDate() + 30);
  return { start, end };
}

// Returns videos scheduled or published in the range
where: {
  OR: [
    { scheduledAt: { gte: start, lte: end } },
    { publishedAt: { gte: start, lte: end } },
  ],
}

Audit & Quota HTTP Endpoints

MethodPathResponse
GET/quota/today{ used: 1250, remaining: 7750, limit: 9000, resetAt: "...", percentUsed: 14 }
GET/audit-logs?page=1&limit=50Paginated list of all audit log entries, newest first
GET/audit-logs/:entityType/:entityIdAll audit entries for a specific entity (e.g. /audit-logs/Video/abc123)

The AuditService is called by every write operation in the codebase to create a tamper-evident log of who changed what:

// Every update operation follows this pattern:
const before = await this.findOne(id);                    // snapshot before
const after = await prisma.video.update({ where: { id }, data });  // apply change
await this.audit.log(actorId, 'Video', id, 'update', before, after);

Environment Variables

Copy .env.example to .env and fill in these values before running anything:

VariableExample valuePurpose
DATABASE_URLpostgresql://user:pass@localhost:5432/studioflowPostgreSQL connection string
REDIS_URLredis://:password@localhost:6379Redis connection for BullMQ
JWT_SECRETat-least-32-random-charsSigns access tokens (15 min)
JWT_REFRESH_SECRETdifferent-32-random-charsSigns refresh tokens (7 days)
GOOGLE_CLIENT_ID123456.apps.googleusercontent.comFrom Google Cloud Console
GOOGLE_CLIENT_SECRETGOCSPX-...From Google Cloud Console
GOOGLE_CALLBACK_URLhttp://localhost:3001/api/v1/auth/google/callbackMust match Google OAuth config
TOKEN_ENCRYPTION_KEYexactly-32-random-chars-here-xxAES-256 key for YouTube token encryption
PORT3001HTTP server port
FRONTEND_URLhttp://localhost:3000CORS origin + OAuth redirect target
NODE_ENVdevelopmentSet to production to enable secure cookies

Getting Started

Prerequisites

First-time setup

# 1. Install dependencies
npm install

# 2. Copy environment variables
cp .env.example .env
# Edit .env and fill in all values

# 3. Generate Prisma client (must run after every schema.prisma change)
npx prisma generate

# 4. Create database tables
npx prisma migrate dev --name init

# 5. Verify the TypeScript compiles with no errors
npm run build

Development (two terminals)

# Terminal 1 — HTTP API server with hot reload
npm run start:dev

# Terminal 2 — Background worker with hot reload
npm run start:worker

Verify it works

# Health check — should return {"status":"ok"}
curl http://localhost:3001/api/v1/health

# Swagger UI — interactive API documentation
# Open in browser:
http://localhost:3001/api/docs

Useful Prisma commands

# Open Prisma Studio — GUI to browse and edit the database
npx prisma studio

# Create a new migration after changing schema.prisma
npx prisma migrate dev --name describe-your-change

# Apply pending migrations in production (no interactive prompt)
npx prisma migrate deploy

# Reset the database (drops all tables and recreates)
npx prisma migrate reset

All API Endpoints

MethodPathModuleMin Role
GET/healthHealthPublic
GET/auth/googleAuthPublic
GET/auth/google/callbackAuthPublic
GET/auth/meAuthAny JWT
POST/auth/refreshAuthCookie
GET/videosVideosREADONLY
GET/videos/:idVideosREADONLY
PATCH/videos/:idVideosEDITOR
POST/videos/bulk-previewVideosEDITOR
POST/videos/bulk-applyVideosEDITOR
POST/videos/:id/renderVideosEDITOR
POST/videos/:id/syncVideosEDITOR
GET/blocksBlocksREADONLY
POST/blocksBlocksEDITOR
PATCH/blocks/:idBlocksEDITOR
GET/blocks/:id/versionsBlocksREADONLY
GET/blocks/:id/usageBlocksREADONLY
GET/templatesTemplatesREADONLY
POST/templatesTemplatesEDITOR
PATCH/templates/:idTemplatesEDITOR
POST/templates/:id/render-previewTemplatesEDITOR
GET/video-configs/:videoIdVideoConfigsREADONLY
PUT/video-configs/:videoIdVideoConfigsEDITOR
POST/video-configs/:videoId/render-previewVideoConfigsREADONLY
GET/collaboratorsCollaboratorsREADONLY
POST/collaboratorsCollaboratorsEDITOR
PATCH/collaborators/:idCollaboratorsEDITOR
GET/collaborators/:id/videosCollaboratorsREADONLY
POST/videos/:id/collaboratorsCollaboratorsEDITOR
DELETE/videos/:id/collaborators/:cIdCollaboratorsEDITOR
GET/bulk-jobsBulkJobsREADONLY
GET/bulk-jobs/:idBulkJobsREADONLY
POST/bulk-jobs/:id/rollbackBulkJobsEDITOR
GET/saved-viewsSavedViewsREADONLY
POST/saved-viewsSavedViewsEDITOR
PATCH/saved-views/:idSavedViewsEDITOR
DELETE/saved-views/:idSavedViewsADMIN
POST/saved-views/:id/executeSavedViewsREADONLY
POST/lint/videos/:idLintingEDITOR
POST/lint/bulkLintingEDITOR
GET/lint/resultsLintingREADONLY
GET/calendarCalendarREADONLY
POST/imports/csv/previewImportsEDITOR
POST/imports/csv/commitImportsEDITOR
POST/imports/json/previewImportsEDITOR
POST/imports/json/commitImportsEDITOR
POST/exports/csvExportsREADONLY
POST/exports/jsonExportsREADONLY
GET/quota/todayQuotaREADONLY
GET/audit-logsAuditLogsREADONLY
GET/audit-logs/:entityType/:entityIdAuditLogsREADONLY

All routes (except /health, /auth/google, /auth/google/callback, and /auth/refresh) require a valid JWT in the Authorization: Bearer <token> header. The interactive Swagger UI at /api/docs includes an "Authorize" button to paste your token and test all endpoints directly.

← Part 1: TypeScript & NestJS ← Part 2: Database & Authentication