Files
youtube-studio-flow/documentation/YouTube Studio Flow/01 - Architecture/04 - Database Schema.md
T

17 KiB
Raw Blame History

Database Schema

PostgreSQL via Prisma. Schema at backend/prisma/schema.prisma.

Model Overview

User
  └── TeamMember[] (many-to-many with Team)

Team
  ├── TeamMember[]
  ├── Channel[]
  ├── DescriptionBlock[]
  ├── Template[]
  ├── Collaborator[]
  ├── BulkJob[]
  ├── SavedView[]
  ├── ImportJob[]
  ├── ExportJob[]
  ├── Campaign[]
  └── TeamVariable[]

Channel
  ├── Video[]
  ├── Playlist[]
  └── QuotaLog[]

Video
  ├── VideoConfig (1:1)
  ├── VideoPlaylist[] (many-to-many with Playlist)
  ├── LintResult[]
  ├── BulkJobItem[]
  └── QuotaLog[]

DescriptionBlock
  └── BlockVersion[]

Template
  └── TemplateVersion[]

BulkJob
  └── BulkJobItem[]

Playlist
  └── VideoPlaylist[] (many-to-many with Video)

Key Models

User

id, email, name, googleId, isAppAdmin, preferences (Json), createdAt, updatedAt

preferences is a freeform JSON field used to persist per-user UI state (column visibility, column order, video editor section layout).

Team

id, name, slug,
dateFormat (String?),
timezone (String, default "UTC"),
publishingSchedule (Json?),
showCanvaLink (Boolean),
disabledLintRules (String[]),
showDeletedVideos (Boolean),
conflictDetectionEnabled (Boolean, default false),
conflictDetectionBatchSize (Int, default 50),
conflictDetectionMinAgeDays (Int, default 7)

The three conflictDetection* fields configure the scheduled remote-conflict sweep. Enabled is per-team opt-in; batchSize caps how many videos are scanned per run (1500); minAgeDays skips videos synced more recently than N days. Global on/off and cron pattern live in env vars (CONFLICT_DETECTION_ENABLED, CONFLICT_DETECTION_CRON).

Channel

id, teamId, youtubeChannelId (unique), name, uploadsPlaylistId,
supplementalVideoIds (String[]),
youtubeAccessToken (encrypted), youtubeRefreshToken (encrypted), youtubeTokenExpiry,
connectedBy

OAuth tokens are AES-256 encrypted using TOKEN_ENCRYPTION_KEY. Changing this key breaks all existing channel connections.

supplementalVideoIds is an array of YouTube video IDs that are always imported during channel-import and channel-full-refresh runs, regardless of whether they appear in the uploads playlist. This exists for unlisted or otherwise playlist-excluded videos that the team still wants to manage. On each import the list is filtered against the playlist results so only IDs not already discovered via the playlist are fetched as extras. New IDs are added via POST /youtube-sync/channels/:channelId/supplemental-ids.

Video

id, youtubeVideoId (unique), channelId, title,
youtubeDescription, renderedDescription, thumbnailUrl,
tags (String[]), categoryId, privacyStatus (PUBLIC/PRIVATE/UNLISTED),
publishedAt, scheduledAt, templateId,
selfDeclaredMadeForKids, embeddable, license, defaultLanguage, defaultAudioLanguage,
recordingDate, gameTitle,
collaboratorIds (Json, default "[]"),
youtubeSnapshot (Json?),
lintStatus (OK/WARNING/ERROR),
lastSyncedAt, lastSyncedHash,
remoteConflict (Boolean),
pendingRemoteSnapshot (Json?), pendingRemoteDescription (String?),
youtubeDeletedAt

Important fields:

  • lintStatus — denormalized cache. Must be recomputed by any code that deletes LintResult rows
  • lastSyncedHash — hash of YouTube-side state. Compared against current state to compute hasPendingChanges
  • youtubeSnapshot — the last intentionally-synced YouTube metadata (written on push and on refresh). Used for field-level diff
  • collaboratorIds — JSON array of Collaborator IDs used in description rendering
  • remoteConflict — set to true when YouTube-side metadata diverges from lastSyncedHash, meaning something changed on YouTube outside of this tool since the last push. Detected via two paths: (1) manual POST /videos/:id/refresh, or (2) the scheduled CONFLICT_DETECTION queue when the team opts in (Team.conflictDetectionEnabled). Cleared on a successful YouTube push, on POST /videos/:id/accept-remote, or on a self-heal pass where the remote hash matches lastSyncedHash again.
  • pendingRemoteSnapshot / pendingRemoteDescription — written by detectConflict() when a mismatch is found. Snapshot mirrors the youtubeSnapshot shape and holds the freshly fetched remote state; description holds the raw remote description. Consumed by POST /videos/:id/accept-remote (no extra YouTube call). Cleared when the conflict is resolved.

VideoConfig

id, videoId (unique), templateId?,
blockOrder (Json, String[]),
blockOverrides (Json),
variableValues (Json),
version, renderHash, renderedAt

blockOrder may contain both real block IDs and freetext:{uuid} IDs. Freetext content is stored in blockOverrides[id].content.

DescriptionBlock

id, teamId, name, type (BlockType), content, language,
campaignId?, version, active, compact,
tags (String[]), variableDefinitions (Json), condition (Json?)

BlockType enum values:

  • STATIC — raw output, no token substitution
  • VARIABLE — resolves {custom_var}, {video.*}, {collab.*} tokens
  • CAMPAIGN — auto-included at end of render when linked campaign is active
  • CONDITIONAL, COLLABORATOR — contextual blocks
  • GLOBAL, REPEATABLE — deprecated (kept in enum, hidden in UI)

Template

id, teamId, name, description?,
defaultBlocks (Json, String[]),
defaultOverrides (Json),
rules (Json),
variables (Json),
videoFields (Json?),
version, active

rules.requiredLinks is used by the DESC_REQUIRED_LINK_MISSING lint rule. videoFields contains default values for video metadata fields applied when the template is assigned.

TeamMember

userId (PK), teamId (PK), role (TeamRole, default EDITOR), createdAt

Composite primary key (userId, teamId). Represents a user's membership in a team.

TeamRole enum: OWNER, ADMIN, EDITOR, REVIEWER, READONLY

OWNER is set at team creation and cannot be assigned via the invite API. See 06 - Authentication for role hierarchy and permission levels.

TeamVariable

id, teamId, name, value, createdAt, updatedAt

Unique constraint on (teamId, name) — variable names must be unique per team. The name is used as the token key: {name} in VARIABLE-type description blocks. Team variables form the baseline layer; video-level variableValues override them per-video.

Any mutation (create / update / delete) triggers a background re-render of all team videos.

Collaborator

id, teamId, name,
youtubeLink?, twitchLink?, instagramLink?, tiktokLink?,
twitterLink?, blueskyLink?, discordHandle?,
aliases (String[]),
active (Boolean, default true),
notes?,
createdAt, updatedAt

Platform link fields are full URLs except discordHandle which is a handle string. All are optional. aliases is an array of alternative names used in search and token display.

Collaborators are assigned to a video via Video.collaboratorIds (JSON string array). This is the single source used by the render engine to resolve {collab.*} tokens and by the calendar and filter queries to check collaborator assignment. There is no separate join table.

Campaign

id, teamId, name, startAt, endAt?, status (String, default "active"), notes?

endAt is optional — null means the campaign is open-ended. status is a free-form string (not an enum), default "active".

At render time, any CAMPAIGN-type DescriptionBlock linked to this campaign (via campaignId) is automatically appended to the description if startAt ≤ now ≤ endAt. Campaign CRUD is not exposed via the REST API — managed directly in the database.

BlockVersion

id, blockId, version (Int), contentSnapshot (Json), createdAt, createdBy?

Append-only history table. A new row is written each time a DescriptionBlock is saved. contentSnapshot stores the full block content at that version. createdBy is the user ID of the actor who saved it (nullable for system/migration writes).

version mirrors DescriptionBlock.version at the time of the snapshot. Versions are used to show change history in the block editor.

TemplateVersion

id, templateId, version (Int), snapshot (Json), createdAt

Same append-only pattern as BlockVersion. snapshot stores the full template state (including defaultBlocks, defaultOverrides, rules, variables, videoFields) at the time of save.

LintResult

id, videoId, ruleCode, severity (INFO/WARNING/ERROR),
targetField?, message, fixSuggestion?, resolvedAt?

Unresolved results have resolvedAt: null. Resolving marks resolvedAt = now() and triggers lintStatus recomputation on the video.

SavedView

id, teamId, name, description?,
isGlobal, ownerId?,
queryJson, columnsJson, sortJson?,
pinnedAsTab (Boolean), tabOrder (Int?)

BulkJob

id, teamId, type, initiatedBy, filterSnapshot, targetIds,
status (PENDING/DRY_RUN/CONFIRMED/RUNNING/DONE/FAILED/ROLLED_BACK),
dryRunResult?, rollbackData?, totalCount, successCount, errorCount

BulkJobItem

id, bulkJobId, videoId, beforeSnapshot (Json?), afterSnapshot (Json?),
status (String, default "pending"), errorMessage?
createdAt

One row per video in a bulk job. status values: "pending", "done", "error". beforeSnapshot and afterSnapshot store the YouTube metadata before and after the operation — used for rollback and the change diff display in the UI.

Playlist

id, channelId, youtubePlaylistId (unique), title, description?,
itemCount (Int, default 0), privacyStatus (String, default "public"),
createdAt, updatedAt

Synced from YouTube. itemCount is cached and used to detect playlist changes without fetching all items on every import.

VideoPlaylist

videoId (PK), playlistId (PK), position (Int?)

Composite primary key join table. position is the video's position within the playlist as returned by YouTube, if available.

ImportJob

id, teamId, type (String), sourceName, mappingJson (Json?),
validationReport (Json?), commitStatus (String, default "pending"),
createdBy, createdAt, committedAt?

Tracks a CSV import session. type identifies the import variant (e.g. "csv-videos"). mappingJson stores the column-to-field mapping chosen during the import wizard. validationReport stores per-row validation results. commitStatus transitions: "pending""committed".

ExportJob

id, teamId, type (String), scopeJson (Json),
fileReference?, createdBy, createdAt

Records each export operation. scopeJson captures the filter/selection that was exported. fileReference stores the path or identifier of the generated export file.

QuotaLog

id, datePt, units, operation, entityId?, channelId?, videoId?, bulkJobId?, actionId?, actionType?

actionId groups related quota entries (e.g. all API calls for a single video sync). Used for quota history grouping in the frontend.

AuditLog

id, actorId, entityType, entityId, action, beforeJson?, afterJson?, requestId?

JSON Field Schemas

Team.publishingSchedule

type PublishingSlot = {
  days: number[];   // weekdays: 0 = Sunday … 6 = Saturday. Empty array = every day.
  time: string;     // "HH:MM" in 24h format, interpreted in the team's IANA timezone
};

// Stored as: PublishingSlot[] | null

Used by GET /teams/:teamId/next-publish-slot. The algorithm walks up to 90 days forward, checks each slot against already-scheduled videos (±30 min collision window), and returns the first free slot.


DescriptionBlock.condition

Only present on CONDITIONAL-type blocks. If absent or rules is empty, the block always renders.

type Condition = {
  combinator?: 'and' | 'or';   // default: 'and'
  rules: ConditionRule[];
};

type ConditionRule =
  | { type: 'variable_filled'; variable: string }   // true if variable has a non-empty value
  | { type: 'variable_empty';  variable: string }   // true if variable is absent or empty
  | { type: 'collab_count';
      operator: 'eq' | 'gt' | 'lt' | 'gte' | 'lte';
      value: number;                                 // compared against number of assigned collaborators
    };

variable refers to the resolved variable name (after team + video-level override merge). An unknown variable evaluates as empty. Unknown rule type values default to true.


DescriptionBlock.variableDefinitions

Declares which custom variable tokens this block expects. Used by the UI to render per-block variable inputs.

type BlockVariableDefinition = {
  name: string;           // token name (without braces), e.g. "sponsor_name"
  label: string;          // human-readable label shown in the UI
  description?: string;   // optional tooltip text
  defaultValue?: string;  // pre-filled default
};

// Stored as: BlockVariableDefinition[]

BulkJob.filterSnapshot

Stores the original request that created the job. Shape varies by job type:

Metadata bulk jobs (SET_PRIVACY, SET_TEMPLATE, ADD_TAGS, REMOVE_TAGS, SEARCH_REPLACE_TITLE):

{
  type: string;            // job type, e.g. "SET_PRIVACY"
  videoIds?: string[];     // explicit video IDs, or...
  savedViewId?: string;    // ...a saved view to resolve IDs from
  payload: {
    // SET_PRIVACY:
    privacyStatus?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
    // SET_TEMPLATE:
    templateId?: string;
    // ADD_TAGS / REMOVE_TAGS:
    tags?: string[];
    // SEARCH_REPLACE_TITLE:
    search?: string;
    replace?: string;
  };
}

Sync-push bulk jobs (SYNC_PUSH):

{
  type: 'SYNC_PUSH';
  videoIds: string[];   // already-resolved and ownership-verified video IDs
}

BulkJob.rollbackData

Unused. This field exists in the schema but is never written or read by the application. Rollback is implemented entirely through BulkJobItem.beforeSnapshot — each item stores the pre-operation video snapshot, and the rollback endpoint restores it per-item. rollbackData is a vestigial field.


BulkJobItem.beforeSnapshot / afterSnapshot

Stores a snapshot of the video's mutable fields before and after the bulk operation. Shape matches the fields relevant to the job type:

{
  title: string;
  tags: string[];
  privacyStatus: string;
  templateId: string | null;
}

Used by the rollback endpoint: beforeSnapshot is written back to Video directly via prisma.video.update({ data: item.beforeSnapshot }).

For SYNC_PUSH jobs, these hold the youtubeSnapshot before and after the push.


ImportJob.validationReport

Shape differs by import type.

CSV import:

{
  validCount: number;
  errorCount: number;
  errors: Array<{
    row: number;     // 1-based row number in the CSV
    field: string;   // field name that failed validation
    message: string; // zod validation message
  }>;
}

JSON workspace import:

{ valid: true }

JSON validation only checks that the top-level shape is correct (version, optional videos/blocks/templates/collaborators arrays). No per-item validation is performed during preview.


ImportJob.mappingJson

Optional. Stores the column-to-field mapping the user selected during the CSV import wizard. Free-form shape — passed through from the frontend and not parsed by the backend during commit.


JSON workspace import payload (POST /imports/json/preview + commit)

The workspace JSON format (used for both import and export):

{
  version: string;           // schema version, currently "1.0"
  exportedAt?: string;       // ISO datetime (present on exports, ignored on import)
  videos?: Video[];          // full Video rows
  videoConfigs?: VideoConfig[];
  blocks?: DescriptionBlock[];
  templates?: Template[];
  collaborators?: Collaborator[];
  savedViews?: SavedView[];
}

On commit, collaborators, blocks, and templates are upserted by id. The teamId from the current session overwrites whatever teamId is in the payload. videos, videoConfigs, and savedViews are not processed during JSON import commit — only the three content types above.


ExportJob.scopeJson

Unused. ExportJob rows are never created by the application. The export service (ExportsService) returns data directly from the database without recording an export job. This model and field are schema artefacts with no active code path.


Schema Rules

  • Never remove Prisma enum values — PostgreSQL enum removal requires raw SQL migration and is risky. Mark deprecated values in UI instead.
  • After any schema change: stop backend → npx prisma generatenpx prisma migrate deploy → restart backend
  • JSON columns use Prisma Json type. Query with array_contains operator for JSON array fields (e.g. collaboratorIds)
  • 01 - System Overview
  • 02 - Backend