# 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 (1–500); `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 ```typescript 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. ```typescript 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. ```typescript 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`): ```typescript { 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`): ```typescript { 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: ```typescript { 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:** ```typescript { 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:** ```typescript { 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): ```typescript { 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 generate` → `npx prisma migrate deploy` → restart backend - **JSON columns** use Prisma `Json` type. Query with `array_contains` operator for JSON array fields (e.g. `collaboratorIds`) ## Related - [[01 - System Overview]] - [[02 - Backend]]