StudioFlow Backend
Developer Guide — Part 1 of 3
Project Overview · TypeScript · NestJS Core Concepts
What Is This Project
StudioFlow is a YouTube content management system for creators who manage large video libraries. Instead of editing every video description by hand on YouTube's website, StudioFlow lets you build reusable description blocks, combine them into templates, attach them to individual videos, and push updates to YouTube's API in bulk — all while enforcing quality rules through an automated linting system.
The backend you are reading about is the server-side application — it handles:
| Responsibility | How it works |
|---|---|
| User authentication | Google OAuth 2.0 — users log in with their Google/YouTube account |
| Data storage | PostgreSQL database accessed through the Prisma ORM |
| Description rendering | A pure TypeScript engine that assembles blocks into final text |
| Quality checks | 10 pluggable lint rules run after every render |
| YouTube sync | Pushes rendered descriptions to YouTube via their Data API v3 |
| Background jobs | BullMQ queues on Redis handle long-running work asynchronously |
| Bulk operations | Apply changes to hundreds of videos at once, with rollback support |
| Import / export | CSV and JSON workspace snapshots |
Technology Stack
| Technology | Version | Role |
|---|---|---|
| Node.js | ≥ 20 | JavaScript runtime — executes the compiled TypeScript |
| TypeScript | 5.x | Adds static types to JavaScript; compiled to plain JS for Node.js to run |
| NestJS | 11.x | Application framework — organises the code into modules, controllers, services |
| PostgreSQL | 16 | Relational database — stores all persistent data |
| Prisma | 6.x | ORM (Object-Relational Mapper) — TypeScript-first database client |
| Redis | 7 | In-memory data store — used as the BullMQ queue backend |
| BullMQ | 5.x | Queue library — runs background jobs (sync, render, lint, import) |
| Passport.js | 0.7 | Authentication middleware — handles Google OAuth and JWT strategies |
| googleapis | 171.x | Google's official Node.js SDK — calls the YouTube Data API v3 |
| Zod | 3.x | Schema validation — validates CSV import rows at runtime |
| class-validator | 0.14 | Decorator-based validation — validates HTTP request bodies via NestJS pipes |
File Structure
├── prisma/
│ └── schema.prisma ← database schema (tables, enums, relations)
├── src/
│ ├── main.ts ← HTTP server entry point
│ ├── worker.ts ← background worker entry point
│ ├── app.module.ts ← root module (wires everything together)
│ ├── worker.module.ts ← root module for the worker process
│ ├── health.controller.ts ← GET /health endpoint
│ │
│ ├── shared/ ← cross-cutting services used by many modules
│ │ ├── prisma/ ← database connection (PrismaService)
│ │ ├── render-engine/ ← description assembly engine
│ │ ├── quota/ ← YouTube API quota tracker
│ │ └── audit/ ← audit log writer
│ │
│ ├── queues/
│ │ ├── queues.constants.ts ← queue name strings
│ │ └── processors/ ← background job handlers (one file per queue)
│ │
│ └── modules/ ← feature modules (one folder per domain)
│ ├── auth/ ← Google OAuth + JWT
│ ├── videos/ ← video CRUD, render, sync
│ ├── blocks/ ← description block management
│ ├── templates/ ← template management
│ ├── video-configs/ ← per-video configuration
│ ├── collaborators/ ← collaborator management
│ ├── linting/ ← 10 lint rules + service
│ ├── bulk-jobs/ ← bulk operation tracking
│ ├── saved-views/ ← saved filter presets
│ ├── calendar/ ← calendar view endpoint
│ ├── imports/ ← CSV + JSON import
│ ├── exports/ ← CSV + JSON export
│ ├── youtube-sync/ ← YouTube API wrapper
│ ├── quota/ ← HTTP quota status endpoint
│ └── audit-logs/ ← HTTP audit log endpoints
Two Entry Points
One of the most important architectural decisions in this project: the backend runs as two separate processes.
| Process | File | Port | Does |
|---|---|---|---|
| API server | src/main.ts | 3001 | Handles HTTP requests from the frontend. Returns JSON. |
| Worker | src/worker.ts | none | Listens to Redis queues and processes background jobs. |
Why split them? Background jobs (rendering hundreds of descriptions, syncing to YouTube) can take seconds or minutes. If they ran in the same process as the HTTP server, they would block responses. By separating them, the API server stays fast and responsive while the worker does the heavy work in the background.
// src/main.ts — HTTP API server
async function bootstrap() {
const app = await NestFactory.create(AppModule); // creates a full HTTP server
app.use(cookieParser()); // parse cookies (for refresh tokens)
app.enableCors({ origin: 'http://localhost:3000', credentials: true });
app.setGlobalPrefix('api/v1'); // all routes become /api/v1/...
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.listen(3001);
}
// src/worker.ts — background worker (no HTTP)
async function bootstrap() {
const app = await NestFactory.createApplicationContext(WorkerModule);
app.enableShutdownHooks(); // graceful shutdown on SIGTERM
}
NestFactory.create() starts an HTTP server. NestFactory.createApplicationContext() starts the NestJS dependency injection container without any HTTP server — just the services and queue processors.
Why TypeScript
JavaScript is dynamically typed — you can write let x = 5; x = "hello"; and it will just work. This is convenient but causes bugs that only appear at runtime.
TypeScript adds a type system on top of JavaScript. The TypeScript compiler checks your code before it runs and catches entire categories of bugs at compile time. When you run npm run build, the TypeScript compiler (tsc) reads all .ts files, checks the types, and outputs plain .js files that Node.js actually executes.
// JavaScript: this fails silently at runtime
function greet(user) {
return `Hello, ${user.name}`; // crashes if user is undefined
}
// TypeScript: the compiler warns you before it runs
function greet(user: { name: string }): string {
return `Hello, ${user.name}`; // ✓ safe — TypeScript knows name is a string
}
greet(undefined); // ✗ ERROR at compile time: Argument of type 'undefined' is
// not assignable to parameter of type '{ name: string }'
Types & Interfaces
TypeScript gives you several ways to describe the shape of data.
Primitive types
let name: string = "Alice";
let count: number = 42;
let active: boolean = true;
let data: unknown = fetchSomething(); // type unknown until you narrow it
let anything: any = legacyFunction(); // escapes type checking — use sparingly
Interfaces — describing object shapes
An interface is a contract. It says "any object that claims to be this type must have these properties." Interfaces exist only in TypeScript — they disappear completely in the compiled JavaScript.
// From src/shared/render-engine/render-engine.service.ts
interface RenderBlock {
id: string;
type: BlockType; // BlockType is a Prisma-generated enum
content: string;
active: boolean;
campaignId?: string | null; // ? = optional property
}
interface RenderResult {
rendered: string;
hash: string;
}
strictNullChecks: true (which this project uses), string means the value is definitely a string — it can never be null or undefined. If null is possible, you must explicitly write string | null or string? (shorthand for string | undefined).
Union types
// A value can be one of several specific types
type Order = 'asc' | 'desc'; // only these two strings
type MaybeString = string | null | undefined; // string or absent
// Used in query-videos.dto.ts:
order?: 'asc' | 'desc' = 'desc'; // optional, defaults to 'desc'
Type aliases
// Type aliases give a name to any type expression
type VideoId = string; // just a named string
type BulkAction = 'SET_PRIVACY' | 'ADD_TAGS' | 'SET_TEMPLATE';
// Record<K, V> — an object whose keys are K and values are V
const overrides: Record<string, { content?: string; active?: boolean }> = {};
Classes & OOP
TypeScript classes are the backbone of NestJS. They combine data (properties) and behaviour (methods) in one place, and the type system understands them fully.
Class fundamentals
// A class defines a blueprint; instances are created with `new`
class QuotaService {
// private = only accessible inside this class
private readonly DAILY_LIMIT = 9_000;
// constructor = runs when you do `new QuotaService(prisma)`
constructor(private readonly prisma: PrismaService) {}
// ↑ TypeScript shorthand: declares AND assigns this.prisma in one step
// async method — returns a Promise
async canSpend(units: number): Promise<boolean> {
const used = await this.getTodayUsage();
return used + units <= this.DAILY_LIMIT;
}
}
extends — inheritance
extends means "this class IS a kind of that class — it inherits all its methods and properties."
// src/shared/prisma/prisma.service.ts
class PrismaService extends PrismaClient
implements OnModuleInit, OnModuleDestroy {
// PrismaService IS a PrismaClient — it has all its database methods
// PLUS it adds NestJS lifecycle hooks
async onModuleInit() {
await this.$connect(); // inherited from PrismaClient
}
async onModuleDestroy() {
await this.$disconnect();
}
}
implements — contracts
implements says "this class promises to have all the methods that this interface requires." The TypeScript compiler will error if any method is missing.
// src/modules/linting/rules/base.rule.ts
interface LintRule {
code: string;
severity: LintSeverity;
check(video: any): LintIssue | null; // return null if no issue
}
// Every rule class must implement this interface
class TitleWeakRule implements LintRule {
code = 'TITLE_WEAK';
severity = LintSeverity.WARNING;
check(video: any): LintIssue | null {
if (video.title.length < 20) {
return { message: 'Title too short', targetField: 'title' };
}
return null;
}
}
Generics
Generics let you write code that works with any type while still being type-safe. Think of <T> as a type variable — a placeholder you fill in when you actually use the function or class.
// Without generics — not type-safe, returns `any`
function first(arr: any[]): any {
return arr[0];
}
// With generics — type-safe, TypeScript knows the return type
function first<T>(arr: T[]): T {
return arr[0];
}
const num = first([1, 2, 3]); // TypeScript infers: num is number
const str = first(['a', 'b']); // TypeScript infers: str is string
Generics in this project
// Promise<T> — a future value of type T
async canSpend(units: number): Promise<boolean> // will resolve to a boolean
async findOne(id: string): Promise<Video> // will resolve to a Video
// Record<K, V> — object with keys of type K and values of type V
blockOverrides: Record<string, { content?: string; active?: boolean }>
// Map<K, V> — JavaScript Map with typed keys and values
const blockMap = new Map<string, RenderBlock>(
blocks.map((b) => [b.id, b])
);
// BullMQ Job<T> — a queue job whose data payload is of type T
async process(job: Job<{ videoId: string }>) {
const { videoId } = job.data; // TypeScript knows this is a string
}
Decorators
Decorators are the most important TypeScript feature to understand for NestJS. They are functions that annotate classes, methods, or properties — they run at startup and attach metadata or modify behaviour.
In TypeScript, a decorator is written with an @ prefix directly above what it decorates.
// A decorator is just a function that receives the target it decorates
function Injectable() {
return function(target: any) {
// Reflect.metadata stores information about the class
Reflect.defineMetadata('injectable', true, target);
};
}
// Usage — NestJS reads this metadata to know it can inject this class
@Injectable()
class QuotaService { ... }
How NestJS uses decorators
// CONTROLLER DECORATOR — marks this class as an HTTP controller
// and sets the route prefix to /videos
@Controller('videos')
export class VideosController {
// METHOD DECORATOR — this method handles GET /videos
@Get()
findAll() { ... }
// PARAM DECORATOR — extracts :id from the URL path
@Get(':id')
findOne(@Param('id') id: string) { ... }
// QUERY DECORATOR — extracts ?search=... from the URL
@Get()
search(@Query() query: QueryVideosDto) { ... }
// BODY DECORATOR — extracts the JSON request body
@Post()
create(@Body() dto: CreateBlockDto) { ... }
// REQ DECORATOR — injects the full Express request object
@Get('me')
me(@Req() req: Request) {
return req.user; // attached by JwtStrategy
}
}
Stacking decorators
// Multiple decorators are applied bottom-up (closest to the function first)
@UseGuards(JwtAuthGuard, RolesGuard) // applied second
@Controller('videos') // applied first
export class VideosController { ... }
// On a method, decorators describe what middleware runs and what Swagger shows
@Patch(':id')
@Roles(UserRole.EDITOR) // custom decorator — attaches metadata
@ApiOperation({ summary: 'Update video fields' })
update(@Param('id') id: string) { ... }
"experimentalDecorators": true and "emitDecoratorMetadata": true in tsconfig.json. The project already has both set. Without them, NestJS's dependency injection system cannot function.
async / await
Almost every database call and HTTP request in this project is asynchronous — it takes time and the Node.js runtime should not sit idle waiting. JavaScript handles this with Promises and the async/await syntax.
// A Promise is a value that will be available in the future
const p: Promise<Video> = prisma.video.findFirst(...);
// p.then(video => ...) — the old way to get the value
// async/await — the modern, readable way
async function getVideo(id: string): Promise<Video> {
// await pauses this function until the Promise resolves
// but does NOT block other requests — Node.js handles other work meanwhile
const video = await prisma.video.findUnique({ where: { id } });
if (!video) throw new NotFoundException();
return video;
}
// Run two queries in parallel — much faster than sequential awaits
const [total, items] = await Promise.all([
prisma.video.count({ where }),
prisma.video.findMany({ where, skip, take }),
]);
// Both queries execute simultaneously; we wait for BOTH to finish
VideosService.findAll()). When two operations don't depend on each other, running them in parallel roughly halves the wait time.
Enums
An enum is a set of named constants. Prisma generates TypeScript enums from schema.prisma automatically — you import them and use them instead of raw strings.
// In schema.prisma:
// enum UserRole { ADMIN EDITOR REVIEWER READONLY }
// Prisma generates this TypeScript enum:
enum UserRole {
ADMIN = 'ADMIN',
EDITOR = 'EDITOR',
REVIEWER = 'REVIEWER',
READONLY = 'READONLY',
}
// Usage — much safer than raw strings
@Roles(UserRole.EDITOR) // ✓ compiler checks this is a valid role
@Roles('ediotr') // ✗ typo would cause a runtime bug, not a compile error
// The RolesGuard maps roles to priority numbers
const ROLE_PRIORITY: Record<UserRole, number> = {
[UserRole.ADMIN]: 4,
[UserRole.EDITOR]: 3,
[UserRole.REVIEWER]: 2,
[UserRole.READONLY]: 1,
};
Utility Types
TypeScript ships with built-in generic "utility types" that transform existing types into new ones. This project uses several of them via Prisma's generated types.
// Partial<T> — makes all properties of T optional
// Used in UpdateVideoDto: you only need to provide fields you want to change
type UpdateVideoDto = Partial<{ title: string; privacyStatus: PrivacyStatus }>;
// Omit<T, K> — removes keys K from type T
// Prisma uses this to create "CreateInput" vs "UpdateInput" types
type CreateInput = Omit<Video, 'id' | 'createdAt' | 'updatedAt'>;
// Pick<T, K> — keeps only keys K from type T
prisma.video.findMany({
select: { id: true, title: true } // returns Pick<Video, 'id' | 'title'>
});
// ReturnType<T> — the type that function T returns
type ServiceResult = ReturnType<typeof videosService.findAll>;
What Is NestJS
NestJS is an opinionated framework for building server-side applications with TypeScript. "Opinionated" means it makes strong decisions about how to structure your code — where files go, how services talk to each other, how requests get validated — so you don't have to reinvent that every project.
NestJS is built on top of Express.js (the traditional Node.js HTTP library) and adds:
- A module system for organising code into cohesive features
- Dependency injection so services can share each other without manual wiring
- Decorators that describe routes, guards, and validation declaratively
- A standard pattern for middleware, guards, interceptors, and pipes
Modules
A module is a cohesive unit of functionality. Every NestJS application has at least one module (the root module). Modules declare which controllers and services they contain, and which other modules they depend on.
// A minimal module — declares its own parts and exports PrismaService
// so other modules can use it without re-declaring it
@Global() // makes this module's exports available everywhere without importing
@Module({
providers: [PrismaService], // services this module creates and manages
exports: [PrismaService], // services this module shares with others
})
export class PrismaModule {}
// A feature module — imports what it needs, declares its own parts
@Module({
imports: [QuotaModule, AuthModule], // other modules whose exports we need
providers: [YouTubeSyncService, YouTubeApiClient],
exports: [YouTubeSyncService, YouTubeApiClient], // share with other modules
})
export class YouTubeSyncModule {}
providers or that are exported by modules it listed in imports. This is what prevents spaghetti dependencies.
The root AppModule
The AppModule imports every feature module. It is the entry point of the module graph — NestJS walks it to discover everything the application needs.
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }), // reads .env file
BullModule.forRootAsync({ ... }), // Redis connection
PrismaModule, RenderEngineModule, QuotaModule, // shared layer
AuthModule, VideosModule, BlocksModule, // feature modules
// ... all other modules
],
controllers: [HealthController, QuotaController, AuditLogsController],
})
export class AppModule {}
Dependency Injection
Dependency Injection (DI) is the mechanism that connects services together. Instead of a service creating its own dependencies with new, NestJS creates them and injects them through the constructor.
// WITHOUT dependency injection — tightly coupled, hard to test
class VideosService {
private prisma = new PrismaService(); // creates its own instance
private audit = new AuditService(); // creates its own instance
}
// WITH dependency injection — NestJS handles creation and sharing
@Injectable()
class VideosService {
constructor(
private readonly prisma: PrismaService, // NestJS provides the singleton
private readonly audit: AuditService, // same instance used everywhere
private readonly renderEngine: RenderEngineService,
@InjectQueue(QUEUES.YOUTUBE_SYNC) private readonly syncQueue: Queue,
// ↑ special injection for BullMQ queues — uses a token, not a class name
) {}
}
NestJS reads the type annotations on the constructor parameters (thanks to emitDecoratorMetadata in tsconfig) and knows exactly which singleton to inject. The @Injectable() decorator marks a class as something NestJS can manage.
Controllers
A controller maps HTTP routes to service methods. It handles request parsing and response formatting, but contains no business logic — it delegates to services for that.
@ApiTags('videos') // Swagger grouping
@ApiBearerAuth() // Swagger shows lock icon on these endpoints
@UseGuards(JwtAuthGuard, RolesGuard) // ALL routes require JWT + role check
@Controller('videos') // prefix: /api/v1/videos
export class VideosController {
constructor(private readonly service: VideosService) {}
@Get()
findAll(@Query() query: QueryVideosDto) {
return this.service.findAll(query);
// NestJS automatically serializes the returned object to JSON
}
@Patch(':id')
@Roles(UserRole.EDITOR) // additionally require EDITOR role
update(
@Param('id') id: string, // from URL path
@Body() dto: UpdateVideoDto, // from request body (validated by pipe)
@Req() req: any, // full request — we need req.user.id
) {
return this.service.update(id, dto, req.user.id);
}
}
Providers & Services
Any class decorated with @Injectable() is a provider. Services are the most common kind of provider — they contain the business logic.
By default, NestJS creates providers as singletons — one instance per module. The same PrismaService instance is shared by every service that injects it. This is efficient and means database connection pooling works correctly.
@Injectable() // ← this is what makes it a provider
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async log(
actorId: string,
entityType: string,
entityId: string,
action: string,
before?: object | null,
after?: object | null,
): Promise<void> {
await this.prisma.auditLog.create({
data: { actorId, entityType, entityId, action, beforeJson: before, afterJson: after },
});
}
}
Guards
A guard is a class that implements CanActivate. It runs before a controller method and decides whether the request should proceed. If it returns false (or throws), NestJS returns 403 Forbidden.
JwtAuthGuard — verifies the JWT token
// src/modules/auth/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// That's it — Passport's AuthGuard does the heavy lifting:
// 1. Extracts Bearer token from the Authorization header
// 2. Verifies signature using JWT_SECRET
// 3. Calls JwtStrategy.validate() to load the user from DB
// 4. Attaches user to req.user for controllers to access
RolesGuard — checks user permissions
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
// Reflector reads metadata attached by decorators
canActivate(context: ExecutionContext): boolean {
// 1. Read the @Roles(...) metadata from the route handler
const required = this.reflector.getAllAndOverride<UserRole[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()]
);
if (!required?.length) return true; // no @Roles = open to all authenticated users
// 2. Get the user from req.user (attached by JwtAuthGuard)
const { user } = context.switchToHttp().getRequest();
// 3. Compare priorities: ADMIN=4, EDITOR=3, REVIEWER=2, READONLY=1
const userPriority = ROLE_PRIORITY[user.role] ?? 0;
const minRequired = Math.min(...required.map((r) => ROLE_PRIORITY[r]));
if (userPriority < minRequired) throw new ForbiddenException('Insufficient role');
return true;
}
}
Custom @Roles decorator
// src/modules/auth/decorators/roles.decorator.ts
export const ROLES_KEY = 'roles';
// SetMetadata attaches data to a route so guards can read it with Reflector
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
// Usage:
@Roles(UserRole.EDITOR) // stores ['EDITOR'] in metadata under the key 'roles'
update() { ... }
// RolesGuard then reads this metadata and checks the user's role against it
Pipes & Validation
A pipe runs before the controller method and transforms or validates the incoming data. The global ValidationPipe set up in main.ts automatically validates every @Body(), @Query(), and @Param() argument that uses a DTO class.
Data Transfer Objects (DTOs)
A DTO (Data Transfer Object) is a plain class decorated with class-validator decorators. The ValidationPipe reads these decorators at runtime and rejects requests that don't match.
// src/modules/videos/dto/query-videos.dto.ts
export class QueryVideosDto {
@IsOptional() // field may be absent — but if present, must pass other validators
@IsString() // must be a string
search?: string;
@IsOptional()
@IsEnum(LintStatus) // must be one of the LintStatus enum values
lintStatus?: LintStatus;
@IsOptional()
@Type(() => Number) // transforms the string "10" from the URL into the number 10
@IsInt() // must be an integer
@Min(1) // must be >= 1
page?: number = 1; // defaults to 1 if not provided
}
If someone sends GET /api/v1/videos?page=abc, the ValidationPipe returns 400 Bad Request with a clear error message — the controller method never runs.
whitelist: true. This strips any properties from the request body that are NOT declared in the DTO class. This prevents attackers from injecting unexpected fields.
Built-in Decorators Reference
| Decorator | Where used | What it does |
|---|---|---|
@Module() | Class | Marks a class as a NestJS module |
@Injectable() | Class | Marks a class as a provider (can be injected) |
@Controller('path') | Class | Marks a class as an HTTP controller with a route prefix |
@Global() | Module class | Module's exports available everywhere without importing |
@Get() @Post() @Patch() @Put() @Delete() | Method | Maps method to an HTTP route |
@Param('name') | Parameter | Extracts a URL path parameter |
@Query() | Parameter | Extracts query string parameters as an object |
@Body() | Parameter | Extracts and validates the request body |
@Req() | Parameter | Injects the full Express Request object |
@Res() | Parameter | Injects the full Express Response object |
@UseGuards(...) | Class/Method | Attaches guards to a controller or method |
@InjectQueue('name') | Parameter | Injects a BullMQ Queue by name |
@Processor('name') | Class | Marks a class as a BullMQ queue processor |
Lifecycle Hooks
NestJS calls special methods at specific points in the application's life. You implement them by adding the interface to your class.
// OnModuleInit — runs once after the module's dependencies are resolved
export class PrismaService extends PrismaClient
implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect(); // connect to database when app starts
}
async onModuleDestroy() {
await this.$disconnect(); // close connection when app shuts down
}
}
// OnModuleInit used to seed default data
export class SavedViewsService implements OnModuleInit {
async onModuleInit() {
// Create the 4 default saved views if they don't already exist
for (const view of DEFAULT_VIEWS) {
const existing = await this.prisma.savedView.findFirst({ where: { name: view.name } });
if (!existing) await this.prisma.savedView.create({ data: view });
}
}
}