StudioFlow Backend
Developer Guide — Part 2 of 3
Database with Prisma · Authentication with Google OAuth & JWT
What Is Prisma
Prisma is an ORM (Object-Relational Mapper) — a tool that lets you talk to a SQL database using TypeScript instead of raw SQL. You define your database schema in a schema.prisma file, and Prisma generates a fully type-safe client that knows the exact shape of every table.
The workflow is:
schema.prismanpx prisma migrate devnpx prisma generatenode_modules/@prisma/clientThe generated client knows your exact schema. If you write prisma.video.findUnique({ where: { id } }), TypeScript knows the return type includes title, tags, lintStatus, and every other field you defined.
Schema Overview
The database has 17 tables (called models in Prisma). Here is the full picture:
| Model | Purpose |
|---|---|
| Video | One row per YouTube video. Stores title, tags, privacy status, sync state, lint status. |
| VideoConfig | The rendering configuration for a video — which blocks to use, in what order, with what variable values. |
| DescriptionBlock | A reusable chunk of description text. Has a type (STATIC, VARIABLE, CONDITIONAL, etc.). |
| BlockVersion | Snapshot of a block at the moment it was edited — full history. |
| Template | A named set of default blocks and rules applied to a category of videos. |
| TemplateVersion | Snapshot of a template at the moment it was edited. |
| Collaborator | A person who appears in videos — stores name, YouTube handle, Twitch link. |
| VideoCollaborator | Junction table linking a Video to a Collaborator (many-to-many). |
| SavedView | A named filter preset — stores a Prisma where clause as JSON. |
| LintResult | One row per quality issue found by a lint rule. |
| BulkJob | Tracks a batch operation (e.g. "change privacy on 200 videos"). |
| BulkJobItem | One row per video in a bulk job — stores before/after snapshots. |
| Campaign | A sponsor campaign with a date range. Blocks can belong to a campaign. |
| ImportJob | Tracks a CSV or JSON import — validation report and commit status. |
| ExportJob | Tracks an export operation. |
| QuotaLog | Every YouTube API call is logged here — used to enforce the 9,000 unit/day limit. |
| AuditLog | Every write operation is logged here — who did what, before and after state. |
| User | A logged-in user — stores Google OAuth data and encrypted YouTube tokens. |
Key Models in Detail
Video — the central entity
id String @id @default(cuid()) // auto-generated unique ID
youtubeVideoId String @unique // e.g. "dQw4w9WgXcQ"
channelId String
title String
renderedDescription String? // null until first render
tags String[] // PostgreSQL array
privacyStatus PrivacyStatus @default(PRIVATE)
lintStatus LintStatus @default(OK)
lastSyncedHash String? // SHA-256 of last synced state
remoteConflict Boolean @default(false)
template Template? @relation(...) // optional foreign key
config VideoConfig? // one-to-one
collaborators VideoCollaborator[] // many-to-many via junction table
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt // auto-updated on every write
}
VideoConfig — the rendering recipe
videoId String @unique // one config per video
blockOrder Json // String[] — ordered block IDs e.g. ["abc", "def"]
blockOverrides Json // { blockId: { content?: string, active?: bool } }
variableValues Json // { sponsorName: "Squarespace", link: "..." }
collaboratorIds Json // String[] — which collaborators appear in this video
version Int @default(1) // incremented on every update
renderHash String? // SHA-256 of last render output
}
The Json type stores arbitrary JSON in a PostgreSQL JSONB column. Prisma returns it as unknown, so the code casts it with as string[] or as Record<string, any> where needed.
DescriptionBlock — reusable text chunks
id String @id @default(cuid())
name String // human-readable name
type BlockType // STATIC | VARIABLE | CONDITIONAL | ...
content String // the text, may contain {variables}
campaignId String? // optional link to a Campaign
version Int @default(1) // incremented on every edit
versions BlockVersion[] // full edit history
}
Block types and what they do
| Type | Behaviour in the render engine |
|---|---|
STATIC | Plain text. Always included if active. Variables still resolved. |
VARIABLE | Text with {variable} placeholders filled from variableValues. |
CONDITIONAL | Starts with [if:variableName]. Skipped entirely if that variable is falsy. |
REPEATABLE | Rendered once per item in an array variable. |
GLOBAL | Shared across all videos — e.g. channel-wide footer. |
CAMPAIGN | Linked to a Campaign. The outdated-sponsor lint rule checks its end date. |
COLLABORATOR | Expanded once per assigned collaborator with their name/handle injected. |
Relations in Prisma
Prisma relations mirror SQL foreign keys but add TypeScript types so you can navigate them in queries.
One-to-one: Video ↔ VideoConfig
// In schema.prisma — each Video has at most one VideoConfig
model Video {
config VideoConfig? // the ? means it might not exist yet
}
model VideoConfig {
videoId String @unique // foreign key
video Video @relation(fields: [videoId], references: [id])
}
// In TypeScript — include loads the related record in one query
const video = await prisma.video.findUnique({
where: { id },
include: { config: true }, // video.config is now a VideoConfig object (or null)
});
One-to-many: DescriptionBlock → BlockVersion
// One block has many versions (one per edit)
model DescriptionBlock {
versions BlockVersion[] // array relation
}
model BlockVersion {
blockId String
block DescriptionBlock @relation(fields: [blockId], references: [id])
}
// Querying: get the block with all its versions
prisma.descriptionBlock.findUnique({
where: { id },
include: { versions: { orderBy: { version: 'desc' } } }
});
Many-to-many: Video ↔ Collaborator (via junction table)
// A video has many collaborators; a collaborator appears in many videos
// The junction table VideoCollaborator stores the link + extra data (role, sortOrder)
model VideoCollaborator {
videoId String
collaboratorId String
role String? // e.g. "guest", "editor"
sortOrder Int
@@id([videoId, collaboratorId]) // composite primary key
}
// Loading collaborators for a video
prisma.video.findUnique({
where: { id },
include: {
collaborators: {
include: { collaborator: true } // two levels of include
}
}
});
Prisma Client Query API
The generated Prisma client exposes a consistent API for every model. Here are the methods used throughout this project:
| Method | Returns | Use case |
|---|---|---|
findUnique({ where }) | Record or null | Find by ID or unique field |
findUniqueOrThrow({ where }) | Record (throws if not found) | When absence is an error |
findFirst({ where }) | Record or null | Find first matching record |
findMany({ where, orderBy, skip, take, include }) | Array | Paginated list queries |
create({ data }) | New record | Insert a new row |
update({ where, data }) | Updated record | Update a specific row |
upsert({ where, create, update }) | Created or updated record | Insert or update atomically |
delete({ where }) | Deleted record | Delete a specific row |
count({ where }) | Number | Count matching rows |
aggregate({ _sum, where }) | Aggregation result | Sum, avg, min, max |
createMany({ data }) | { count: number } | Bulk insert |
deleteMany({ where }) | { count: number } | Bulk delete |
Filtering with where
// Simple equality
where: { id: 'abc', lintStatus: LintStatus.ERROR }
// String operators
where: { title: { contains: 'tutorial', mode: 'insensitive' } }
// Array operators
where: { tags: { has: 'sponsor' } } // array contains value
where: { blockOrder: { array_contains: id } } // JSON array contains value
// Date range
where: { publishedAt: { gte: new Date('2026-01-01'), lte: new Date('2026-12-31') } }
// OR — fulltext search across multiple fields
where: {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ tags: { has: search } },
],
}
// Nested relation filter — videos that have this collaborator
where: { collaborators: { some: { collaboratorId: id } } }
Pagination pattern
// Skip/take is SQL OFFSET/LIMIT
prisma.video.findMany({
where,
orderBy: { [sort]: order }, // dynamic sort column
skip: (page - 1) * limit, // skip the first N-1 pages
take: limit, // return at most `limit` rows
});
// Always fetch count and items in parallel for efficiency
const [total, items] = await Promise.all([
prisma.video.count({ where }),
prisma.video.findMany({ where, skip, take }),
]);
return { total, page, limit, items };
PrismaService
Rather than using the generated PrismaClient directly, the project wraps it in a NestJS service. This gives NestJS control over the lifecycle — connecting when the app starts, disconnecting when it shuts down.
// src/shared/prisma/prisma.service.ts
@Injectable()
export class PrismaService
extends PrismaClient // IS a PrismaClient — inherits all query methods
implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
// From this moment, this.prisma.video.findMany() works
}
async onModuleDestroy() {
await this.$disconnect();
// Closes all database connections cleanly
}
}
// Because PrismaModule is @Global(), any service can inject PrismaService
// just by adding it to its constructor — no need to import PrismaModule everywhere:
@Injectable()
export class AnyService {
constructor(private readonly prisma: PrismaService) {}
// this.prisma.video.findMany() — works immediately
}
Transactions
A database transaction groups multiple writes into an atomic unit — either all succeed or none are applied. This prevents partial states (e.g. a lint result written but the video's lintStatus not updated).
// src/modules/linting/linting.service.ts — atomic lint result replacement
await this.prisma.$transaction([
// Step 1: delete all old unresolved results
this.prisma.lintResult.deleteMany({
where: { videoId, resolvedAt: null }
}),
// Step 2: insert new results (if any)
...(issues.length > 0
? [this.prisma.lintResult.createMany({ data: issues })]
: []),
// Step 3: update video's overall lint status
this.prisma.video.update({
where: { id: videoId },
data: { lintStatus: this.computeStatus(severities) },
}),
]);
// All three run in a single SQL transaction — atomic and consistent
$transaction(async (tx) => { const x = await tx.foo.create(...); ... }) which lets you use results from one query in the next — at the cost of a longer-held lock.
Authentication Overview
The project uses a two-layer authentication strategy:
| Layer | Technology | Lifetime | How used |
|---|---|---|---|
| Access Token | JWT (HS256) | 15 minutes | Sent as Bearer token in Authorization header for every API request |
| Refresh Token | JWT (HS256, different secret) | 7 days | Stored in httpOnly cookie; used to issue new access tokens silently |
For the initial login, the project uses Google OAuth 2.0 — users click "Login with Google", are redirected to Google's consent page, and come back with a code that the backend exchanges for tokens.
Google OAuth Flow — Step by Step
GET /api/v1/auth/googleGET /api/v1/auth/google/callback?code=...http://localhost:3000/auth/callback?token=eyJ...?token= from URL and stores in memory/localStorage for API requests// src/modules/auth/auth.controller.ts — the callback handler
@Get('google/callback')
@UseGuards(AuthGuard('google')) // Passport exchanges the code for tokens
async googleCallback(@Req() req: Request, @Res() res: Response) {
const user = req.user as any; // set by GoogleStrategy.validate()
const accessToken = this.authService.issueJwt(user);
const refreshToken = this.authService.issueRefreshToken(user);
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // JavaScript in the browser CANNOT read this cookie
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'lax', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in milliseconds
});
res.redirect(`${frontendUrl}/auth/callback?token=${accessToken}`);
}
JWT Tokens
A JWT (JSON Web Token) is a self-contained credential. It has three parts separated by dots: header.payload.signature. The payload contains claims (data); the signature proves the token was issued by the server.
// The JWT payload for this project looks like:
{
"sub": "clx8abc123", // subject = user.id
"email": "alice@example.com",
"role": "EDITOR",
"iat": 1714300000, // issued at (Unix timestamp)
"exp": 1714300900 // expires at (15 minutes later)
}
// Issuing the JWT — signed with JWT_SECRET from .env
issueJwt(user: User): string {
return this.jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
{ expiresIn: '15m' }
);
}
// Verifying — JwtStrategy reads the token from Authorization: Bearer <token>
async validate(payload: JwtPayload) {
// The token's signature is already verified by passport-jwt
// We additionally load the user from DB to ensure they still exist
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
if (!user) throw new UnauthorizedException();
return user; // attached to req.user
}
YouTube Token Encryption
Google issues OAuth tokens that grant access to the user's YouTube account. These are extremely sensitive — storing them in plaintext in the database would be a serious security vulnerability.
The project encrypts them with AES-256-CBC before storage and decrypts on demand.
// src/modules/auth/auth.service.ts
// At startup — derive a 256-bit (32-byte) encryption key from the env variable
// scryptSync is a key derivation function — it's slow on purpose to resist brute force
private readonly encKey: Buffer;
constructor(...) {
const raw = config.get<string>('TOKEN_ENCRYPTION_KEY');
this.encKey = scryptSync(raw, 'studioflow-salt', 32);
// salt is a fixed string here — in production use a random per-key salt
}
private encrypt(text: string): string {
const iv = randomBytes(16); // 16-byte random IV (Initialization Vector)
const cipher = createCipheriv('aes-256-cbc', this.encKey, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex');
// Stored as: "a1b2c3d4...:e5f6a7b8..." (iv:ciphertext, both hex-encoded)
}
private decrypt(text: string): string {
const [ivHex, encHex] = text.split(':');
const iv = Buffer.from(ivHex, 'hex');
const encrypted = Buffer.from(encHex, 'hex');
const decipher = createDecipheriv('aes-256-cbc', this.encKey, iv);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
Passport Strategies
Passport.js is an authentication middleware library with a plugin model called strategies. Each strategy knows how to authenticate a specific way — Google OAuth, JWT, local username/password, etc.
GoogleStrategy — handles OAuth dance
// src/modules/auth/strategies/google.strategy.ts
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(config: ConfigService, private readonly authService: AuthService) {
super({
clientID: config.getOrThrow('GOOGLE_CLIENT_ID'),
clientSecret: config.getOrThrow('GOOGLE_CLIENT_SECRET'),
callbackURL: config.get('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile', 'https://www.googleapis.com/auth/youtube'],
// The youtube scope lets us call YouTube Data API on the user's behalf
});
}
async validate(accessToken: string, refreshToken: string, profile: any) {
// Called after Google confirms the user authenticated successfully
// accessToken — short-lived token for YouTube API calls
// refreshToken — long-lived token to get new access tokens (only sent once!)
// profile — { id, displayName, emails, photos, ... }
const user = await this.authService.upsertGoogleUser(profile, accessToken, refreshToken);
return user; // attached to req.user by Passport
}
}
JwtStrategy — validates every API request
// src/modules/auth/strategies/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(config: ConfigService, private readonly prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
// Reads: Authorization: Bearer eyJhbGci...
ignoreExpiration: false, // reject expired tokens
secretOrKey: config.getOrThrow('JWT_SECRET'),
});
}
async validate(payload: JwtPayload) {
// At this point, passport-jwt has already verified the signature and expiry
// We do a final DB lookup to ensure the user still exists
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
if (!user) throw new UnauthorizedException();
return user; // becomes req.user
}
}
AuthService — upsertGoogleUser
The upsertGoogleUser method is called every time a user logs in via Google. "Upsert" means: create if new, update if exists. This handles both first-time signups and returning users transparently.
async upsertGoogleUser(profile: any, accessToken: string, refreshToken: string) {
const email = profile.emails?.[0]?.value; // primary email
const googleId = profile.id; // stable Google account ID
const name = profile.displayName;
return this.prisma.user.upsert({
where: { googleId }, // find by googleId (unique)
create: { // first login — create the row
email, name, googleId,
youtubeAccessToken: this.encrypt(accessToken),
youtubeRefreshToken: refreshToken ? this.encrypt(refreshToken) : undefined,
youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000),
},
update: { // returning user — refresh their tokens
email, name,
youtubeAccessToken: this.encrypt(accessToken),
youtubeRefreshToken: refreshToken ? this.encrypt(refreshToken) : undefined,
youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000),
},
});
}
Refresh Token Flow
When the frontend's 15-minute access token expires, it calls POST /api/v1/auth/refresh with the httpOnly cookie. The server verifies the refresh token and issues a new access token — the user doesn't need to log in again.
// POST /auth/refresh — no body needed, cookie is sent automatically
@Post('refresh')
async refresh(@Req() req: Request) {
const token = req.cookies?.['refresh_token'];
return this.authService.refreshAccessToken(token);
}
// AuthService.refreshAccessToken
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string }> {
// Verify the refresh token using the REFRESH secret (different from JWT_SECRET)
const payload = this.jwt.verify<{ sub: string }>(refreshToken, {
secret: this.config.get('JWT_REFRESH_SECRET'),
});
// Load the user and issue a fresh access token
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: payload.sub } });
return { accessToken: this.issueJwt(user) };
}
JWT_SECRET signs access tokens (15 min). JWT_REFRESH_SECRET signs refresh tokens (7 days). Using separate secrets means a compromised access token cannot be upgraded to a long-lived refresh token — the two are cryptographically independent.
Complete Auth API endpoints
| Method | Path | Auth required | What it does |
|---|---|---|---|
| GET | /auth/google | No | Redirects browser to Google's consent screen |
| GET | /auth/google/callback | No (Google callback) | Exchanges code for tokens, issues JWT, sets cookie, redirects |
| GET | /auth/me | JWT | Returns the current user object from the database |
| POST | /auth/refresh | Cookie | Issues a new access token using the refresh cookie |