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:

Edit schema.prisma
npx prisma migrate dev
SQL migration created & applied
npx prisma generate
Type-safe client in node_modules/@prisma/client

The 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:

ModelPurpose
VideoOne row per YouTube video. Stores title, tags, privacy status, sync state, lint status.
VideoConfigThe rendering configuration for a video — which blocks to use, in what order, with what variable values.
DescriptionBlockA reusable chunk of description text. Has a type (STATIC, VARIABLE, CONDITIONAL, etc.).
BlockVersionSnapshot of a block at the moment it was edited — full history.
TemplateA named set of default blocks and rules applied to a category of videos.
TemplateVersionSnapshot of a template at the moment it was edited.
CollaboratorA person who appears in videos — stores name, YouTube handle, Twitch link.
VideoCollaboratorJunction table linking a Video to a Collaborator (many-to-many).
SavedViewA named filter preset — stores a Prisma where clause as JSON.
LintResultOne row per quality issue found by a lint rule.
BulkJobTracks a batch operation (e.g. "change privacy on 200 videos").
BulkJobItemOne row per video in a bulk job — stores before/after snapshots.
CampaignA sponsor campaign with a date range. Blocks can belong to a campaign.
ImportJobTracks a CSV or JSON import — validation report and commit status.
ExportJobTracks an export operation.
QuotaLogEvery YouTube API call is logged here — used to enforce the 9,000 unit/day limit.
AuditLogEvery write operation is logged here — who did what, before and after state.
UserA logged-in user — stores Google OAuth data and encrypted YouTube tokens.

Key Models in Detail

Video — the central entity

model Video {
  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

model VideoConfig {
  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

model DescriptionBlock {
  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

TypeBehaviour in the render engine
STATICPlain text. Always included if active. Variables still resolved.
VARIABLEText with {variable} placeholders filled from variableValues.
CONDITIONALStarts with [if:variableName]. Skipped entirely if that variable is falsy.
REPEATABLERendered once per item in an array variable.
GLOBALShared across all videos — e.g. channel-wide footer.
CAMPAIGNLinked to a Campaign. The outdated-sponsor lint rule checks its end date.
COLLABORATORExpanded 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:

MethodReturnsUse case
findUnique({ where })Record or nullFind by ID or unique field
findUniqueOrThrow({ where })Record (throws if not found)When absence is an error
findFirst({ where })Record or nullFind first matching record
findMany({ where, orderBy, skip, take, include })ArrayPaginated list queries
create({ data })New recordInsert a new row
update({ where, data })Updated recordUpdate a specific row
upsert({ where, create, update })Created or updated recordInsert or update atomically
delete({ where })Deleted recordDelete a specific row
count({ where })NumberCount matching rows
aggregate({ _sum, where })Aggregation resultSum, 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 array vs callback Passing an array of Prisma promises (as above) runs them in a single transaction. There's also an interactive transaction using a callback: $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:

LayerTechnologyLifetimeHow used
Access TokenJWT (HS256)15 minutesSent as Bearer token in Authorization header for every API request
Refresh TokenJWT (HS256, different secret)7 daysStored 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

1. User clicks Login
Frontend redirects to GET /api/v1/auth/google
2. NestJS redirects
Passport's GoogleStrategy builds the Google OAuth URL and redirects the browser
3. User consents
User sees Google's permission screen and clicks Allow
4. Google callback
Google sends the user to GET /api/v1/auth/google/callback?code=...
5. Upsert user
GoogleStrategy.validate() calls AuthService.upsertGoogleUser() — creates or updates the DB row
6. Issue tokens
Controller issues JWT access token (15m) + sets refresh token in httpOnly cookie (7d)
7. Redirect to frontend
Browser sent to http://localhost:3000/auth/callback?token=eyJ...
8. Frontend stores token
Frontend reads ?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
}
Why 15 minutes? Short-lived access tokens limit the damage if one is stolen — it becomes worthless quickly. The refresh token (7 days) lives in an httpOnly cookie which JavaScript cannot read, making it much harder to steal via XSS.

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');
}
Why a random IV each time? AES-CBC without a random IV would produce the same ciphertext for the same plaintext. That leaks information — an attacker who sees two identical ciphertexts knows the underlying tokens match. A fresh random IV for every encryption ensures ciphertexts are always different, even for identical inputs.

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) };
}
Two secrets, two purposes 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

MethodPathAuth requiredWhat it does
GET/auth/googleNoRedirects browser to Google's consent screen
GET/auth/google/callbackNo (Google callback)Exchanges code for tokens, issues JWT, sets cookie, redirects
GET/auth/meJWTReturns the current user object from the database
POST/auth/refreshCookieIssues a new access token using the refresh cookie
← Part 1: TypeScript & NestJS → Part 3: Business Logic, Queues & APIs