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!
}
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 shorter than 20 characters or contains generic words like "video", "test", "untitled".
Title exceeds 100 characters. YouTube truncates long titles in search results.
The rendered description contains none of: subscribe, like, follow, comment, cta.
Fewer than 2 timestamp patterns (e.g. "0:00", "1:23") found in the description.
The rendered description still contains unresolved {variable} placeholders.
The same #hashtag appears more than once in the description.
The video's template defines required links (e.g. merch URL) that are absent from the description.
A CAMPAIGN block is present and the linked campaign's endAt date has passed.
A collaborator ID listed in the video config doesn't match any VideoCollaborator row in the DB.
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:
| Concept | Description |
|---|---|
| Queue | A named list of jobs stored in Redis. Producers add to it. |
| Job | A unit of work with a JSON payload. Has a status: waiting → active → completed/failed. |
| Processor | A class decorated with @Processor(queueName) that implements process(job). |
| Worker | The running instance that pulls jobs from the queue and calls the processor. |
| Concurrency | How many jobs a processor handles simultaneously. Default is unlimited. |
| Delayed jobs | job.moveToDelayed(timestamp) puts a job on hold until a future time. |
| Rate limiter | Restricts 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.
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:
| Operation | Cost | Method called |
|---|---|---|
videos.list (read metadata) | 1 unit | YouTubeSyncService.fetchRemote() |
videos.update (write metadata) | 50 units | YouTubeSyncService.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
| Method | Path | Role | Description |
|---|---|---|---|
| GET | /videos | READONLY+ | Paginated video list with filters: search, lintStatus, privacyStatus, channelId, templateId, collaboratorId, from, to, sort, order |
| GET | /videos/:id | READONLY+ | Single video with full includes: config, collaborators, lintResults |
| PATCH | /videos/:id | EDITOR+ | Partial update: title, tags, privacyStatus, scheduledAt, categoryId, templateId |
| POST | /videos/bulk-preview | EDITOR+ | Dry-run a bulk action — returns before/after diffs per video, no writes |
| POST | /videos/bulk-apply | EDITOR+ | Creates a BulkJob and enqueues one job per video |
| POST | /videos/:id/render | EDITOR+ | Synchronously renders description and saves to DB |
| POST | /videos/:id/sync | EDITOR+ | Enqueues a youtube-sync job for this video |
Bulk action types
| Type | Payload | What 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
| Method | Path | Response |
|---|---|---|
| GET | /quota/today | { used: 1250, remaining: 7750, limit: 9000, resetAt: "...", percentUsed: 14 } |
| GET | /audit-logs?page=1&limit=50 | Paginated list of all audit log entries, newest first |
| GET | /audit-logs/:entityType/:entityId | All 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:
| Variable | Example value | Purpose |
|---|---|---|
| DATABASE_URL | postgresql://user:pass@localhost:5432/studioflow | PostgreSQL connection string |
| REDIS_URL | redis://:password@localhost:6379 | Redis connection for BullMQ |
| JWT_SECRET | at-least-32-random-chars | Signs access tokens (15 min) |
| JWT_REFRESH_SECRET | different-32-random-chars | Signs refresh tokens (7 days) |
| GOOGLE_CLIENT_ID | 123456.apps.googleusercontent.com | From Google Cloud Console |
| GOOGLE_CLIENT_SECRET | GOCSPX-... | From Google Cloud Console |
| GOOGLE_CALLBACK_URL | http://localhost:3001/api/v1/auth/google/callback | Must match Google OAuth config |
| TOKEN_ENCRYPTION_KEY | exactly-32-random-chars-here-xx | AES-256 key for YouTube token encryption |
| PORT | 3001 | HTTP server port |
| FRONTEND_URL | http://localhost:3000 | CORS origin + OAuth redirect target |
| NODE_ENV | development | Set to production to enable secure cookies |
Getting Started
Prerequisites
- Node.js ≥ 20
- PostgreSQL 16 running locally
- Redis 7 running locally
- A Google Cloud project with OAuth 2.0 credentials and YouTube Data API v3 enabled — no API key needed, all YouTube calls use the logged-in user's OAuth token
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
| Method | Path | Module | Min Role |
|---|---|---|---|
| GET | /health | Health | Public |
| GET | /auth/google | Auth | Public |
| GET | /auth/google/callback | Auth | Public |
| GET | /auth/me | Auth | Any JWT |
| POST | /auth/refresh | Auth | Cookie |
| GET | /videos | Videos | READONLY |
| GET | /videos/:id | Videos | READONLY |
| PATCH | /videos/:id | Videos | EDITOR |
| POST | /videos/bulk-preview | Videos | EDITOR |
| POST | /videos/bulk-apply | Videos | EDITOR |
| POST | /videos/:id/render | Videos | EDITOR |
| POST | /videos/:id/sync | Videos | EDITOR |
| GET | /blocks | Blocks | READONLY |
| POST | /blocks | Blocks | EDITOR |
| PATCH | /blocks/:id | Blocks | EDITOR |
| GET | /blocks/:id/versions | Blocks | READONLY |
| GET | /blocks/:id/usage | Blocks | READONLY |
| GET | /templates | Templates | READONLY |
| POST | /templates | Templates | EDITOR |
| PATCH | /templates/:id | Templates | EDITOR |
| POST | /templates/:id/render-preview | Templates | EDITOR |
| GET | /video-configs/:videoId | VideoConfigs | READONLY |
| PUT | /video-configs/:videoId | VideoConfigs | EDITOR |
| POST | /video-configs/:videoId/render-preview | VideoConfigs | READONLY |
| GET | /collaborators | Collaborators | READONLY |
| POST | /collaborators | Collaborators | EDITOR |
| PATCH | /collaborators/:id | Collaborators | EDITOR |
| GET | /collaborators/:id/videos | Collaborators | READONLY |
| POST | /videos/:id/collaborators | Collaborators | EDITOR |
| DELETE | /videos/:id/collaborators/:cId | Collaborators | EDITOR |
| GET | /bulk-jobs | BulkJobs | READONLY |
| GET | /bulk-jobs/:id | BulkJobs | READONLY |
| POST | /bulk-jobs/:id/rollback | BulkJobs | EDITOR |
| GET | /saved-views | SavedViews | READONLY |
| POST | /saved-views | SavedViews | EDITOR |
| PATCH | /saved-views/:id | SavedViews | EDITOR |
| DELETE | /saved-views/:id | SavedViews | ADMIN |
| POST | /saved-views/:id/execute | SavedViews | READONLY |
| POST | /lint/videos/:id | Linting | EDITOR |
| POST | /lint/bulk | Linting | EDITOR |
| GET | /lint/results | Linting | READONLY |
| GET | /calendar | Calendar | READONLY |
| POST | /imports/csv/preview | Imports | EDITOR |
| POST | /imports/csv/commit | Imports | EDITOR |
| POST | /imports/json/preview | Imports | EDITOR |
| POST | /imports/json/commit | Imports | EDITOR |
| POST | /exports/csv | Exports | READONLY |
| POST | /exports/json | Exports | READONLY |
| GET | /quota/today | Quota | READONLY |
| GET | /audit-logs | AuditLogs | READONLY |
| GET | /audit-logs/:entityType/:entityId | AuditLogs | READONLY |
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.