Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)

This commit is contained in:
2026-08-11 12:27:44 +02:00
commit d5af006443
304 changed files with 74604 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
{}
@@ -0,0 +1 @@
{}
@@ -0,0 +1,33 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}
+22
View File
@@ -0,0 +1,22 @@
{
"collapse-filter": true,
"search": "",
"showTags": false,
"showAttachments": false,
"hideUnresolved": false,
"showOrphans": true,
"collapse-color-groups": true,
"colorGroups": [],
"collapse-display": true,
"showArrow": false,
"textFadeMultiplier": 0,
"nodeSizeMultiplier": 1,
"lineSizeMultiplier": 1,
"collapse-forces": true,
"centerStrength": 0.518713248970312,
"repelStrength": 10,
"linkStrength": 1,
"linkDistance": 250,
"scale": 1,
"close": true
}
@@ -0,0 +1,60 @@
# Claude Guide
Guidance for working with this codebase as an AI assistant. Read this first, then follow the task routing table to find the specific docs for your task.
---
## Task Routing
Find your task type below and read only the listed docs. Do not read the entire vault for every task.
| Task | Read first |
|---|---|
| Bug in description rendering or tokens | `01-Architecture/07 - Render Engine` + `05-Development/04 - Gotchas` |
| New backend module or service | `05-Development/03 - Recipes` + `04-Design-Guidelines/04 - Backend Architecture Patterns` |
| New API endpoint + frontend wiring | `05-Development/03 - Recipes` + `03-API-Reference/<relevant endpoint>` |
| Frontend UI component or page | `04-Design-Guidelines/02 - CSS Conventions` + `03 - Component Patterns` |
| Frontend styling or visual change | `04-Design-Guidelines/01 - Visual Design` + `02 - CSS Conventions` |
| Database schema change | `01-Architecture/04 - Database Schema` + `05-Development/03 - Recipes` (Prisma recipe) |
| New lint rule | `05-Development/03 - Recipes` + `02-Features/03 - Metadata Linting` |
| Queue / background job issue | `01-Architecture/05 - Queue System` + `05-Development/04 - Gotchas` |
| Auth or permissions issue | `01-Architecture/06 - Authentication` + `04-Design-Guidelines/04 - Backend Architecture Patterns` |
| Unfamiliar with the codebase | `01-Architecture/01 - System Overview``02 - Backend``03 - Frontend` |
After making changes, always verify using `05-Development/06 - Verifying Changes`.
---
## Do Not Do These
These are failure modes that come up repeatedly when working with LLMs in this codebase.
**Do not create new utility or helper files.** If something needs a helper, add it to the relevant service or component. Check `05-Development/03 - Recipes` for the established pattern before creating anything new.
**Do not add comments that describe what the code does.** Only comment when the *why* is non-obvious — a hidden constraint, a workaround for a specific bug, a subtle invariant. See `04-Design-Guidelines/05 - Code Conventions` for the full rule.
**Do not introduce abstractions for things that appear in two or three places.** This codebase favors repetition over premature abstraction. Add an abstraction only when a pattern appears three or more times with meaningful variation and the abstraction is simpler than the repetition.
**Do not use `SiYoutube` from `react-icons/si`.** It does not exist in v5. Use `FaYoutube` from `react-icons/fa` instead. All other platform icons are in `react-icons/si`.
**Do not add error handling for scenarios that cannot happen.** Trust Prisma's type guarantees and internal service calls. Only validate and handle errors at system boundaries: user input, external API calls, and queue job payloads.
**Do not register a new module only in `AppModule` if it is also needed by queue processors.** Always check whether the new module's services are used in any processor, and if so, register it in `WorkerModule` as well. Missing `WorkerModule` registration causes silent runtime errors in background jobs.
**Do not remove or rename Prisma enum values.** PostgreSQL enum removal requires a raw SQL migration and risks data loss. Mark deprecated values as hidden in the UI instead.
**Do not call `useQueryClient()` inside a callback or effect.** It must be called at the component top level and assigned to a local variable before use in `onSuccess` or event handlers.
**Do not duplicate data-fetching logic for the render engine.** `VideoRenderService` is the single source of truth for fetching all data needed for a render. Never replicate its data-fetching in a processor or service — always go through `VideoRenderService`.
---
## Key Invariants
These are easy to miss and cause subtle bugs.
- Every database query must be scoped to `req.user.teamId`. Never query across teams.
- Every user-facing mutation (create/update/delete) must call `AuditService.log()`. Background/system operations do not.
- New `{video.*}` or `{collab.*}` render tokens must be registered in `system-variables.registry.ts` or they will silently resolve to nothing.
- `Campaign.status` must be exactly the lowercase string `"active"` for the campaign to be treated as active. Any other casing is inactive.
- The `TOKEN_ENCRYPTION_KEY` env var must never change in production. Rotating it invalidates all stored YouTube OAuth tokens.
@@ -0,0 +1,84 @@
# YouTube Studio Flow — Documentation
Multi-tenant YouTube channel management tool. Teams connect channels, manage video metadata via a description block and template system, and push changes back to YouTube.
---
## For Claude — Start Here
Read [[00 - Claude Guide]] before starting any task. It contains a task routing table (which docs to read for which task type) and a list of common mistakes to avoid in this codebase.
---
## Quick Navigation
### Architecture
- [[01 - System Overview]] — Tech stack, process architecture, key decisions
- [[02 - Backend]] — NestJS modules, patterns, shared services
- [[03 - Frontend]] — Next.js routing, state management, API layer
- [[04 - Database Schema]] — Prisma models and relationships
- [[05 - Queue System]] — BullMQ processors and queue constants
- [[06 - Authentication]] — Google OAuth, JWT, role-based access
- [[07 - Render Engine]] — Description rendering pipeline, token resolution
### Features
- [[01 - Video Management]] — Video list, editor, sync
- [[02 - Description Engine]] — Blocks, templates, tokens, rendering
- [[03 - Metadata Linting]] — Rules, execution, disabling rules
- [[04 - Bulk Operations]] — Preview/confirm pattern, rollback
- [[05 - Playlists]] — YouTube playlist sync
- [[06 - Collaborators]] — Platform fields, token mapping
- [[07 - Saved Views]] — Filter presets, pinned tabs
- [[08 - Campaigns]] — Date-window promotional blocks
- [[09 - Calendar]] — Scheduled video visualization
- [[10 - Import Export]] — CSV and JSON workspace transfer
- [[11 - Quota Management]] — YouTube API quota tracking
- [[12 - Audit Log]] — Change history
- [[13 - Team Settings]] — Render settings, lint rule disabling
### API Reference
- [[01 - Auth API]]
- [[02 - Videos API]]
- [[03 - Blocks API]]
- [[04 - Templates API]]
- [[05 - Collaborators API]]
- [[06 - Linting API]]
- [[07 - Playlists API]]
- [[08 - Saved Views API]]
- [[09 - Teams API]]
- [[10 - Quota API]]
- [[11 - Bulk Jobs API]]
- [[12 - Calendar API]]
- [[13 - Import Export API]]
- [[14 - Team Variables API]]
- [[15 - Campaigns API]]
- [[16 - YouTube Sync API]]
- [[17 - Video Configs API]]
### Design Guidelines
- [[01 - Visual Design]] — Colors, typography, spacing, shadows
- [[02 - CSS Conventions]] — CSS Modules, variables, min-width rule
- [[03 - Component Patterns]] — React patterns, TanStack Query, modals
- [[04 - Backend Architecture Patterns]] — Module structure, scoping, audit
- [[05 - Code Conventions]] — TypeScript, naming, comments
### Development
- [[01 - Local Setup]] — Prerequisites and step-by-step setup
- [[02 - Environment Variables]] — All env vars with descriptions
- [[03 - Recipes]] — How to add modules, rules, endpoints, nav items
- [[04 - Gotchas]] — Non-obvious behaviors and known traps
- [[05 - Deployment and Operations]] — Production stack, secrets, job failures, backups, key rotation
- [[06 - Verifying Changes]] — Build, type-check, and lint commands
### Backlog
- [[01 - Technical Debt and Future Work]] — Dead code, missing features, deferred improvements
### Daily Notes
Chronological log of shipped work. Filename format: `YYYY-MM-DD NN Short title` (NN counts major tasks that day, ascending).
@@ -0,0 +1,65 @@
# System Overview
YouTube Studio Flow is a multi-tenant SaaS tool for YouTube channel management. Teams connect their YouTube channels, import their video library, manage video metadata using a description template and block system, and push changes back to YouTube via the API.
## Tech Stack
| Layer | Technology |
|---|---|
| Frontend | Next.js 15 App Router, TypeScript, CSS Modules |
| Data fetching | TanStack Query v5 |
| Auth state | Zustand |
| Backend | NestJS 10, TypeScript |
| ORM | Prisma (PostgreSQL) |
| Queue | BullMQ + Redis |
| Auth | Google OAuth → JWT (access + refresh tokens) |
| YouTube integration | `googleapis` package, quota-tracked |
## Process Architecture
The backend runs as **two separate Node.js processes** from the same build:
| Process | Entry point | Role |
|---|---|---|
| API server | `src/main.ts` | HTTP API on port 3001 |
| Queue worker | `src/worker.ts` | BullMQ job processor (no HTTP) |
Both must be running for full functionality. The API enqueues jobs; the worker executes them.
## System Diagram
```
Browser (Next.js)
│ HTTP/REST (NEXT_PUBLIC_API_URL)
NestJS API (:3001)
├──── PostgreSQL (Prisma)
├──── Redis (BullMQ queues)
└──── BullMQ worker process
├──── PostgreSQL (reads/writes)
└──── YouTube API (googleapis)
```
## Request Scoping
Every authenticated request is scoped to a team. The JWT payload contains `userId` and `teamId`. All database queries must filter by `channel: { teamId }` or direct `teamId` field. See [[02 - Backend]] for the auth pattern.
## Key Design Decisions
- **Denormalized `lintStatus`**: `Video.lintStatus` is a cached column. Any code path that deletes `LintResult` rows must recompute it manually — it is not computed on-the-fly.
- **YouTube token encryption**: OAuth tokens are AES-256 encrypted in the DB using `TOKEN_ENCRYPTION_KEY`. If this key changes all channels need re-authentication.
- **Queue deduplication**: BullMQ job IDs are used for deduplication. The pattern is `lint-{videoId}` for lint-once and `lint-{videoId}-{timestamp}` for forced reruns.
- **No shared code**: Backend and frontend are fully independent — no shared packages, no shared types. The frontend maintains its own API interface definitions in `api.ts`.
- **`hasPendingChanges` null-hash fallback**: `Video.lastSyncedHash` is set at import time to establish the YouTube baseline. For videos that existed before hashing was introduced (i.e. `lastSyncedHash IS NULL`), `findAll` falls back to computing a synthetic baseline from `youtubeDescription` and `recordingDate: null` and compares that against the current state hash. This means pre-existing videos correctly show "push pending" only when local changes diverge from the YouTube state, rather than always appearing as pending. Re-importing a channel resets the hash baseline and will clear any locally-pending changes that have not yet been pushed.
## Related
- [[02 - Backend]] — Module structure, patterns, shared services
- [[03 - Frontend]] — Routing, state management, API layer
- [[04 - Database Schema]] — All Prisma models
- [[05 - Queue System]] — BullMQ queues and processors
- [[06 - Authentication]] — OAuth flow, JWT, guards
@@ -0,0 +1,99 @@
# Backend Architecture
## Entry Points
| File | Role |
|---|---|
| `src/main.ts` | Starts the NestJS HTTP API on port 3001 |
| `src/worker.ts` | Starts the BullMQ worker (no HTTP server) |
## Module Map
```
src/modules/
auth/ Google OAuth, JWT strategy, guards
videos/ Video CRUD, bulk ops, sync enqueue, findAll with filters
video-configs/ Per-video description config (block order, overrides, variables)
blocks/ DescriptionBlock CRUD + versioning
templates/ Template CRUD, preview, apply-to-video
collaborators/ Collaborator CRUD
teams/ Team management, members, channels, settings, publishing schedule
team-variables/ Global team-level key/value variables
saved-views/ Saved filter presets for the video list
campaigns/ Campaign date windows (CAMPAIGN blocks auto-include when active)
playlists/ YouTube playlist sync + video↔playlist management
linting/ Rule-based lint checks on video metadata
bulk-jobs/ Bulk metadata operations (preview, execute, rollback)
youtube-sync/ Channel import (YouTubeApiClient, ChannelImportService)
calendar/ Scheduled video calendar view
imports/ CSV + JSON workspace import
exports/ CSV + JSON workspace export
quota/ YouTube API quota tracking and history
src/shared/
prisma/ PrismaService singleton
render-engine/ VideoRenderService + RenderEngineService + hashMetadata
audit/ AuditService — logs all user mutations
quota/ QuotaService — tracks and enforces YouTube API quota
system-variables/ Built-in {video.*} and {collab.*} token registry
src/queues/processors/
youtube-sync.processor.ts Renders description + pushes all fields to YouTube
render.processor.ts Background render without pushing
lint.processor.ts Runs all lint rules against a video
bulk-metadata.processor.ts Processes bulk job items
import.processor.ts CSV import processing
```
## Auth Pattern
Every controller method receives the authenticated user via `@Req() req`. Extract:
- `req.user.id` — actorId (for audit logs, ownership checks)
- `req.user.teamId` — always scope DB queries to this team
All routes are protected by `JwtAuthGuard`. Role-restricted routes additionally use `RolesGuard` with `@Roles(TeamRole.EDITOR)` or `@Roles(TeamRole.ADMIN)`.
## Team Scoping Rule
**Always** filter database queries by team. The ownership chain is:
```
Team → Channel → Video
```
To scope a video query: `where: { channel: { teamId: req.user.teamId } }`
## Audit Logging
`AuditService.log(actorId, entityType, entityId, action, before, after)` must be called for every user-facing mutation (create/update/delete). Background/system operations do not log.
Tracked entity types: `Video`, `VideoConfig`, `DescriptionBlock`, `Template`, `Collaborator`, `TeamVariable`, `SavedView`, `TeamMember`.
Modules that need audit logging must import `AuditModule` in their `@Module` imports array.
## Shared Services
### VideoRenderService
Single source of truth for description rendering. Fetches all required data (blocks, team variables, campaign blocks, collaborators, team date format, playlists) and delegates to `RenderEngineService`. Used by all three render paths:
- `youtube-sync.processor` — render + push
- `render.processor` — render only
- `videos.service.ts renderDescription()` — on-demand
**Never duplicate the data-fetching logic.** Always go through `VideoRenderService`.
### RenderEngineService
Pure computation. Takes `RenderInput`, returns `{ rendered: string, hash: string }`. Used directly only by `video-configs.service.ts renderPreview()` and `templates.service.ts renderPreview()` (which already have all data).
### QuotaService
`canSpend(units)` and `spend(units, operation, meta)` must be called before any YouTube API write. Quota resets at midnight Pacific Time. `videos.update` costs 50 units; daily limit is 10,000.
### AuditService
See Audit Logging above.
## Related
- [[01 - System Overview]]
- [[04 - Database Schema]]
- [[05 - Queue System]]
- [[07 - Render Engine]]
- [[03 - Metadata Linting]] (features)
@@ -0,0 +1,99 @@
# Frontend Architecture
## Framework
Next.js 15 App Router with TypeScript and CSS Modules. All pages under `src/app/(dashboard)/` are protected by `AuthGuard` and wrapped in the dashboard layout (sidebar + header).
## Route Map
| Route | Page |
|---|---|
| `/` | Redirects to `/overview` |
| `/overview` | Dashboard overview |
| `/videos` | Video list — paginated, sortable, filterable, with tabs |
| `/videos/[id]` | Video editor — metadata + description config |
| `/blocks` | Description block management |
| `/templates` | Template management |
| `/variables` | Team global variables |
| `/collaborators` | Collaborator management |
| `/settings` | Team members + connected channels |
| `/saved-views` | Saved filter preset management |
| `/linting` | Lint results browser — filter by severity/rule |
| `/bulk-jobs` | Bulk operation history + rollback |
| `/calendar` | Scheduled video calendar (month/week/agenda) |
| `/audit` | Change history / audit log |
| `/quota-history` | YouTube API quota usage history |
| `/io` | CSV / JSON import and export |
| `/login` | Google OAuth login |
| `/auth/callback` | OAuth callback handler |
## Key Files
| File | Role |
|---|---|
| `src/lib/api.ts` | All API call functions + TypeScript interfaces for every API response |
| `src/lib/api-client.ts` | Axios instance — sets `baseURL`, attaches JWT, handles 401 refresh |
| `src/store/useAuthStore.ts` | Zustand — `user`, `teamId`, `token`, `clearAuth` |
| `src/store/useUIStore.ts` | Zustand (persisted) — `sidebarCollapsed`, `toggleSidebar` |
| `src/middleware.ts` | Route protection — redirects unauthenticated users to `/login` |
| `src/styles/globals.css` | Design tokens (CSS variables), global utility classes |
| `src/hooks/useTheme.ts` | Dark/light theme toggle with localStorage persistence |
## State Management
| Concern | Tool |
|---|---|
| Server data (videos, blocks, etc.) | TanStack Query v5 `useQuery` / `useMutation` |
| Auth state | Zustand `useAuthStore` |
| UI state (sidebar) | Zustand `useUIStore` (localStorage-persisted) |
| Page-level UI state | `useState` / `useReducer` |
## TanStack Query Patterns
- `useQueryClient()` must be called at component level, never inside callbacks
- After mutations, invalidate related queries in `onSuccess`:
- After saving a video: invalidate `['video', id]` and `['videos']`
- After changing blocks: invalidate `['blocks']` and affected video queries
- Use `placeholderData: (prev) => prev` to keep stale data visible during page transitions
- Query keys are arrays: `['videos']`, `['video', id]`, `['preferences']`
## API Layer
All API calls are in `src/lib/api.ts`. The file exports:
- TypeScript interfaces for all API response shapes
- Named functions for each endpoint (`fetchVideos`, `updateVideo`, `lintVideo`, etc.)
The Axios client (`api-client.ts`) handles:
- Base URL from `NEXT_PUBLIC_API_URL`
- JWT `Authorization: Bearer` header injection
- 401 → automatic token refresh → retry
## Component Structure
```
src/components/
video-table/
VideoTable.tsx TanStack Table — video list with sorting, pagination
VideoTable.module.css
video-config/
VideoConfigEditor.tsx forwardRef component — exposes save() and isDirty()
VideoConfigEditor.module.css
shared/
Sidebar.tsx Collapsible navigation sidebar
Header.tsx Top bar — search, sync status, bulk change, theme, user
Modal.tsx Generic modal wrapper
FormField.module.css Shared form field utility classes
Providers.tsx TanStack Query provider wrapper
AuthGuard.tsx Redirects unauthenticated users
SortableSection.tsx DnD-kit sortable wrapper for video editor sections
```
## Video Editor Layout
The video editor (`/videos/[id]`) uses a combined horizontal header card (thumbnail + meta + actions) above a two-column sortable editing area. Sections (Basic Info, Language, Audience, Playlists, Description) can be dragged between the two columns. Column layout is persisted to `User.preferences` via `PATCH /users/me/preferences`.
## Related
- [[01 - System Overview]]
- [[01 - Visual Design]] (design guidelines)
- [[02 - CSS Conventions]] (design guidelines)
@@ -0,0 +1,453 @@
# 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
```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]]
@@ -0,0 +1,112 @@
# Queue System
BullMQ on Redis. The API process enqueues jobs; the worker process (`src/worker.ts`) consumes them.
## Critical Redis Requirement
Redis **must** run with `--maxmemory-policy noeviction`. BullMQ silently loses jobs if Redis uses `allkeys-lru`. This is pre-configured in `infrastructure/docker-compose.yml`.
## Queue Names
Defined in `backend/src/queues/queues.constants.ts`:
| Constant | Queue name |
|---|---|
| `QUEUES.YOUTUBE_SYNC` | `youtube-sync` |
| `QUEUES.RENDER` | `render` |
| `QUEUES.LINT` | `lint` |
| `QUEUES.BULK_METADATA` | `bulk-metadata` |
| `QUEUES.IMPORT` | `import` |
| `QUEUES.CONFLICT_DETECTION` | `conflict-detection` |
## Processors
### `youtube-sync.processor.ts`
**Trigger:** `POST /videos/:id/sync`
**Job data:** `{ videoId }`
**Behavior:**
1. Calls `VideoRenderService` to render description and compute hash
2. Skips push if hash matches `lastSyncedHash` (no changes)
3. Calls `QuotaService.canSpend(50)` — rejects if quota exceeded
4. Pushes all editable fields to YouTube API (`videos.update`)
5. Updates `renderedDescription`, `lastSyncedHash`, `lastSyncedAt` on the video row
### `render.processor.ts`
**Trigger:** `PUT /video-configs/:videoId` when the request body includes `"autoRender": true`
**Job data:** `{ videoId }`
**Behavior:** Renders description without pushing to YouTube. Updates `renderedDescription`.
The render job is not enqueued automatically on every config save — it is opt-in via the `autoRender` flag. `VideoConfigsService.upsert()` checks `if (dto.autoRender)` and only then calls `renderQueue.add(...)`. The frontend passes `autoRender: true` when saving from the video editor so the preview updates in the background. Saves that don't need an immediate re-render (e.g. bulk template applies) omit the flag and skip the queue.
### `lint.processor.ts`
**Trigger:** `POST /lint/bulk`, `POST /lint/channel/:id`, `POST /lint/team`
**Job data:** `{ videoId }`
**Behavior:** Calls `LintingService.lintVideo(videoId)`. Replaces all unresolved `LintResult` rows and recomputes `Video.lintStatus`.
> **Note:** `POST /lint/videos/:id` (single video) is **synchronous** — it calls `LintingService.lintVideo()` directly in the HTTP handler and returns results immediately. It does not use this queue. Only bulk operations go through BullMQ.
### `bulk-metadata.processor.ts`
**Trigger:** Bulk job confirmed by user
**Job data:** `{ bulkJobId }`
**Behavior:** Processes each `BulkJobItem` in sequence. Updates video fields, saves rollback snapshots, updates job status counters.
### `import.processor.ts`
**Trigger:** CSV import committed (`POST /imports/csv/commit`)
**Job data:** `{ importJobId }`
**Behavior:** Calls `ImportsService.executeCommit()`, which marks the `ImportJob` as `committed` and writes an audit log entry. **Does not create or update any Video rows.** The preview step does not persist the validated rows, so the processor has no data to act on. CSV import is structurally incomplete — see the backlog.
### `conflict-detection.processor.ts`
**Trigger:** BullMQ repeatable job registered on worker startup by `ConflictDetectionScheduler`. Cron pattern comes from `CONFLICT_DETECTION_CRON` (default `0 3 * * *`). Registration is gated by the global `CONFLICT_DETECTION_ENABLED` env var.
**Job data:** `{}`
**Behavior:**
1. Loads all teams with `conflictDetectionEnabled: true`
2. For each team, selects videos where `remoteConflict = false`, `youtubeDeletedAt = null`, and `lastSyncedAt < now - minAgeDays`, stalest-first, capped at `batchSize`
3. Hands the selected video IDs to `YouTubeSyncService.detectConflictsForVideos()`, which groups by `channelId` (needed for OAuth) and issues one `videos.list` call per batch of up to 50 IDs. Cost is **1 quota unit per batch**, not per video (YouTube quota is per method-call, invariant to the number of parts or IDs). `QuotaService.canSpend(1)` is checked before each batch.
4. Per video within a batch: on hash mismatch, writes `pendingRemoteSnapshot` + `pendingRemoteDescription` and sets `remoteConflict = true`. On hash match with a stale flag, clears the flag and pending fields — self-heals when the creator reverts an out-of-band edit.
5. Stops the entire sweep as soon as quota is exhausted; resumes on the next cron tick.
`Team.conflictDetectionBatchSize` (1500) and `conflictDetectionMinAgeDays` (≥0) are per-team knobs. Because the API cost is per batch (up to 50 videos each), a `batchSize` of 500 consumes ~10 units per team per run — not 500. Users resolve detected conflicts via `POST /videos/:id/accept-remote` (adopt remote, zero extra quota) or `POST /videos/:id/sync` (push local, overwrite remote).
## Frontend Job Completion Detection
There is **no push mechanism** (no SSE, no WebSocket, no EventEmitter). The frontend detects job completion entirely through polling via TanStack Query's `refetchInterval`.
### Sync queue status — dynamic polling
The `<Header>` component (`components/shared/Header.tsx`) polls `GET /youtube-sync/queue-status` continuously with an interval that adapts to queue state:
| Queue state | Poll interval |
|---|---|
| Active jobs running | 3 s |
| Jobs waiting | 8 s |
| Idle | 30 s |
This drives the sync-in-progress indicator in the header. When the queue drains, the indicator clears within one polling cycle.
### Video editor — scheduled invalidations
After `POST /videos/:id/sync` is enqueued, the video editor schedules two forced refetches of `['video', id]` — at **3 s** and **8 s** — to pick up the updated `hasPendingChanges` once the processor finishes. If the job takes longer than 8 s, the UI shows stale "push pending" status until the next sync-status poll triggers a broader refresh.
### Bulk jobs page — fixed polling
`/bulk-jobs` polls `GET /bulk-jobs` every **5 s** via `refetchInterval: 5000`. Job status transitions (pending → processing → completed) are reflected within one polling cycle.
### Linting page — fixed polling + immediate invalidation
`/linting` polls `GET /lint/results` every **30 s**. After enqueuing a bulk lint operation, the mutation's `onSuccess` immediately invalidates `['lintResults']` and `['videos']` for a faster first update.
### Implication
Because completion detection is polling-based, the UI does not reflect job results in real time — there is always a latency of up to one polling interval. For sync jobs that take longer than 8 s, the video editor in particular may lag. If tighter feedback is needed in future, SSE on the sync queue endpoint would be the natural addition.
---
## Job ID Deduplication
- Lint-once: `jobId: lint-{videoId}` — BullMQ ignores duplicate job IDs, so submitting the same video twice before the job runs only creates one job
- Forced rerun: `jobId: lint-{videoId}-{timestamp}` — always creates a new job
## Related
- [[02 - Backend]]
- [[05 - Local Setup]] (Redis setup)
@@ -0,0 +1,112 @@
# Authentication
## Flow
1. User clicks "Sign in with Google" → frontend redirects to `GET /auth/google`
2. Backend redirects to Google OAuth consent screen
3. Google redirects to `GET /auth/google/callback` with auth code
4. Backend exchanges code for tokens, upserts `User` record, then branches on team membership (see below)
5. Backend issues JWT access token (short-lived) + refresh token (long-lived)
6. Frontend stores tokens; `api-client.ts` attaches `Authorization: Bearer <token>` to all requests
7. On 401: `api-client.ts` automatically calls `POST /auth/refresh` and retries the original request
## First Login vs. Returning User
The OAuth callback handler (`AuthService.upsertGoogleUser`) checks whether the authenticating user already has any `TeamMember` rows.
### New user (no team memberships)
A full onboarding sequence runs automatically — no separate onboarding step or UI flow exists:
1. YouTube API is called with the login OAuth tokens to fetch the user's own channel (`channels.list?mine=true`), retrieving the YouTube channel ID, channel name, and uploads playlist ID.
2. A `Team` is created using the user's display name as the team name, with a URL-safe slug derived from it.
3. A `Channel` is created and linked to the team, with the YouTube OAuth tokens stored AES-256 encrypted.
4. A `TeamMember` row is created linking the user to the team with role `OWNER`.
5. The JWT is issued with this new team's ID.
This means the first Google login is both account creation **and** channel connection in one step. There is no separate "connect your channel" screen.
### Returning user (already has team memberships)
1. The user's existing `TeamMember` rows are loaded, ordered by `createdAt` ascending.
2. The first membership's `teamId` and `role` are used for the JWT.
3. The YouTube access token is refreshed on all `Channel` rows where `connectedBy = user.id`. The refresh token is only overwritten if Google returns a new one (which only happens on first auth or forced re-consent).
### Invited user
Invitations (`POST /teams/:teamId/members`) create a `TeamMember` row immediately. **The invitee must have already logged in at least once** — invite lookup is by `User.email`, and if no `User` row exists for that email the invite throws a 404. There is no pending-invite or email-notification system.
When an invited user subsequently logs in:
- They already have at least one `TeamMember` row (the invite).
- They fall into the **returning user** path — no new team is created.
- If this is their very first login, their `User` record is created by `upsertGoogleUser` before the membership check, so the invite is found correctly.
## JWT Payload
```typescript
{
sub: string; // User.id
email: string;
teamId: string; // Active team for this session
teamRole: TeamRole;
}
```
On first login the active team is the user's primary team (earliest `TeamMember.createdAt`). After calling `POST /auth/switch-team` the returned token carries the new team's ID and role.
## Team Switching
A user who belongs to multiple teams can switch the active team without logging out:
1. Call `GET /teams/mine` to get the list of teams the user belongs to.
2. Call `POST /auth/switch-team` with `{ "teamId": "<target>" }` — returns `{ "accessToken": "..." }`.
3. Replace the stored access token with the new one. All subsequent API calls are now scoped to the new team.
The backend validates that the user is actually a member of the requested team before issuing the new token.
**Current limitation:** The frontend has no UI for this. `POST /auth/switch-team` and `GET /teams/mine` are not called anywhere in the frontend codebase. Team switching is currently only possible via direct API calls.
## Guards
| Guard | Usage |
|---|---|
| `JwtAuthGuard` | Applied to all protected routes via `@UseGuards(JwtAuthGuard)` |
| `RolesGuard` | Applied alongside JwtAuthGuard; uses `@Roles(TeamRole.EDITOR)` decorator |
## Team Roles
| Role | Permissions |
|---|---|
| `OWNER` | All permissions |
| `ADMIN` | All permissions except ownership transfer |
| `EDITOR` | Create/update/delete content |
| `REVIEWER` | Read only (see note below) |
| `READONLY` | Read only |
`OWNER` is assigned automatically when a team is created (first Google login). It cannot be assigned via the invite or role-update endpoints — both reject `role: OWNER` with `403 Forbidden`. There is no ownership transfer mechanism. The OWNER's role cannot be changed by anyone, and the OWNER cannot be removed from the team.
No endpoint is gated exclusively to `OWNER` — every `@Roles` guard that lists `OWNER` also lists `ADMIN`. Because the guard uses priority ordering, ADMIN users have identical feature access. If an OWNER's Google account becomes inaccessible, ADMIN members can continue using the application without restriction. The only consequence is that the orphaned `TeamMember` row with `role: OWNER` cannot be removed via the API (it would require direct DB access). Since the YouTube channel is bound to the same Google account as the OWNER, a lost Google account means the channel itself is also inaccessible — an in-app ownership transfer would not recover YouTube access.
> **REVIEWER and READONLY are currently functionally identical.** The `REVIEWER` role sits at priority 2 in the `RolesGuard` hierarchy (above `READONLY` at 1), but no endpoint specifies `REVIEWER` as a minimum role. The intended "comment" capability does not exist — there is no comment model, no comment endpoints, and no UI for comments. Until a comment system is built, assigning `REVIEWER` grants exactly the same access as `READONLY`. See the backlog.
## sf_session Cookie
`sf_session` is a **frontend-only routing flag** — it is not a backend session and carries no auth data. It is set to `1` by the frontend via `document.cookie` after a successful login or token refresh, and cleared on logout or failed refresh.
Its sole purpose is to give Next.js `middleware.ts` something to check. Middleware runs on the edge before the React app hydrates and cannot access Zustand or `localStorage` (where the real JWT lives). Reading this cookie is the only way the middleware can distinguish authenticated from unauthenticated requests and redirect accordingly.
The backend does not read or set `sf_session`. The backend sets a `refresh_token` httpOnly cookie for token refresh purposes.
**Limitation:** `sf_session` is a presence flag, not a validity check. If the cookie persists after a token is revoked (e.g. due to a crash before the clear runs), middleware lets the request through and the client gets 401s from the API, which then redirects to `/login` client-side. This is acceptable given the current architecture.
## Token Security
- YouTube OAuth tokens are AES-256 encrypted in the database using `TOKEN_ENCRYPTION_KEY` (must be exactly 32 characters)
- JWT signing uses separate `JWT_SECRET` and `JWT_REFRESH_SECRET`
- The backend sets a `refresh_token` httpOnly cookie on login and clears it on logout
- Access tokens are stored in Zustand (persisted to `localStorage`) and attached to every request as `Authorization: Bearer <token>` by `api-client.ts`
## Related
- [[01 - System Overview]]
- [[02 - Environment Variables]] (development)
@@ -0,0 +1,151 @@
# Render Engine
## Two Services
### VideoRenderService
**Location:** `backend/src/shared/render-engine/video-render.service.ts`
The **single source of truth** for description rendering. Responsible for fetching all required data:
- Description blocks (from `blockOrder`)
- Block overrides and variable values (from `VideoConfig`)
- Team variables
- Campaign blocks (active ones are appended regardless of `blockOrder`)
- Team date format and timezone
- Playlists (for `{video.playlists}` token)
- Collaborators (for `{collab.*}` tokens)
Then delegates computation to `RenderEngineService`.
**Used by:** `youtube-sync.processor`, `render.processor`, `videos.service.ts renderDescription()`
### RenderEngineService
**Location:** `backend/src/shared/render-engine/render-engine.service.ts`
Pure computation. Takes `RenderInput`, returns `{ rendered: string, hash: string }`.
**Used directly by:** `video-configs.service.ts renderPreview()`, `templates.service.ts renderPreview()` — these callers already have all data fetched.
## Block Rendering by Type
| Block Type | Rendering Behavior |
|---|---|
| `STATIC` | Raw content output, zero token substitution |
| `VARIABLE` | Resolves `{custom_var}`, `{video.*}`, `{collab.*}` tokens |
| `CAMPAIGN` | Auto-appended at end when `campaign.startAt ≤ now ≤ campaign.endAt` |
| `COLLABORATOR` | Cloned once per assigned collaborator, joined with `\n\n` (or `\n` if compact) |
| `CONDITIONAL` | Evaluates `condition` JSON against video data |
| `GLOBAL`, `REPEATABLE` | Deprecated; render as VARIABLE |
## Freetext Entries
`blockOrder` may contain IDs prefixed `freetext:` (e.g. `freetext:abc123`). These have no corresponding `DescriptionBlock` row. Their content comes entirely from `blockOverrides[id].content`. The `compact` flag for freetext entries also comes from `blockOverrides[id].compact`.
## Token Resolution
Resolution happens in three distinct passes per block, applied sequentially:
1. **Custom variable pass**`resolveVariables(content, effectiveVars)` replaces `{custom_token}` placeholders. `effectiveVars` is built by merging team variables and video-level values: `{ ...teamVariables, ...variableValues }`. **Video-level values win on any name collision** — last-write-wins from the spread. System tokens (`video.*`, `collab.*`) are explicitly skipped here.
2. **Video token pass**`resolveVideoVars()` replaces `{video.*}` tokens using the video's own fields.
3. **Collaborator token pass**`resolveCollaborators()` or `expandCollaboratorBlock()` replaces `{collab.*}` tokens.
**Effective priority (highest → lowest):**
1. System tokens (`{video.*}`, `{collab.*}`) — always win; cannot be shadowed by any variable name
2. Video-level variables (`VideoConfig.variableValues`) — override team variables on conflict
3. Team variables (`TeamVariable` table) — baseline for custom tokens
Because system tokens are skipped in pass 1 and resolved in dedicated passes afterwards, naming a team or video variable `video.title` has no effect — it is silently ignored and the actual video title is substituted instead.
`DescriptionBlock.variableDefinitions` declares which tokens a block expects but is never read by the render engine — it is metadata only (see Backlog).
Any token in `SYSTEM_VARIABLE_TOKENS` set is **skipped** by `resolveVariables()` and handled by its dedicated resolver. If you add a new `{video.*}` or `{collab.*}` token, it must be registered in the system variables registry.
## System Variable Tokens
**Video tokens:**
| Token | Resolves to |
|---|---|
| `{video.title}` | Video title |
| `{video.tags}` | Comma-separated tag list |
| `{video.category}` | YouTube category name (e.g. `Gaming`). Falls back to the raw numeric ID if the category is not in the known map. There is no `{video.categoryId}` token. |
| `{video.scheduledAt}` | Scheduled publish date (date token — supports `\|format`) |
| `{video.recordingDate}` | Recording date (date token — supports `\|format`) |
| `{video.gameTitle}` | Game title field |
| `{video.language}` | Default language code |
| `{video.playlists}` | Comma-separated list of playlist **titles** (e.g. `Gaming, Tutorials`) |
| `{video.playlistLinks}` | Comma-separated list of full YouTube playlist **URLs** (e.g. `https://www.youtube.com/playlist?list=PLxxx`) |
**Collaborator tokens:**
`{collab.name}`, `{collab.youtube}`, `{collab.twitch}`, `{collab.instagram}`, `{collab.tiktok}`, `{collab.twitter}`, `{collab.bluesky}`, `{collab.discord}`, `{collab.aliases}`, `{collab.notes}`
Note: `{collab.youtube}` resolves to the full URL (`https://www.youtube.com/@handle`), not just the handle.
## Multi-Collaborator Rendering
Two different behaviors apply depending on block type:
### In VARIABLE, CONDITIONAL, CAMPAIGN, and freetext blocks
`{collab.*}` tokens resolve against the **first** collaborator in `Video.collaboratorIds` whose record is found in the loaded collaborator list. If no collaborator is assigned, all `{collab.*}` tokens are left unreplaced.
This means if a video has three collaborators, `{collab.name}` in a VARIABLE block only produces the first collaborator's name — not all three.
### In COLLABORATOR blocks
The block's content template is expanded **once per assigned collaborator**, in the order they appear in `collaboratorIds`. Each expansion resolves all `{collab.*}` tokens against that specific collaborator. The resulting strings are joined with `\n\n` (double newline), or `\n` if the block has `compact: true`.
**Example** — with two collaborators (Alice, Bob) and a COLLABORATOR block containing:
```
🎮 {collab.name} — {collab.youtube}
```
Renders as:
```
🎮 Alice — https://www.youtube.com/@alice
🎮 Bob — https://www.youtube.com/@bob
```
If a collaborator has no value for a given token (e.g. no `youtubeLink`), that token resolves to an empty string.
## Hash Computation
`hashMetadata()` in `shared/render-engine/hash.ts` hashes these fields: `title`, `description`, `tags`, `categoryId`, `privacyStatus`, `defaultLanguage`, `defaultAudioLanguage`, `selfDeclaredMadeForKids`, `embeddable`, `license`, `recordingDate`.
The hash is stored as `Video.lastSyncedHash` after a successful YouTube push. `hasPendingChanges` is computed by comparing current state hash against `lastSyncedHash`.
## Date Format Override Syntax
Date tokens support an inline format override using `|` as a separator:
```
{video.scheduledAt|DD.MM.YYYY}
{video.recordingDate|MMMM D, YYYY}
```
**Only date tokens support this.** The two date tokens are `video.scheduledAt` and `video.recordingDate`. All other `video.*` and `collab.*` tokens are non-date (`kind: 'simple'`) — the regex captures the `|format` portion for all tokens but non-date resolvers ignore it.
**Format string tokens** (custom implementation, not strftime or moment.js):
| Token | Output |
|---|---|
| `YYYY` | 4-digit year (e.g. `2024`) |
| `YY` | 2-digit year (e.g. `24`) |
| `MMMM` | Full month name (e.g. `January`) |
| `MMM` | Short month name (e.g. `Jan`) |
| `MM` | Zero-padded month (e.g. `01`) |
| `M` | Month without padding (e.g. `1`) |
| `DD` | Zero-padded day (e.g. `05`) |
| `D` | Day without padding (e.g. `5`) |
**Priority:** inline token format → team `dateFormat` setting → `YYYY-MM-DD` (hard default).
**Invalid format strings** are silently passed through as-is. Any character or sequence not matching a known token is output verbatim — no error, no warning.
**Null date:** If the date field is null or undefined (e.g. `video.recordingDate` not set), the token resolves to an empty string regardless of format.
## Date Formatting Implementation
`applyDateFormat()` in `render-engine.service.ts` uses a single-pass regex replacement to avoid re-substitution bugs. Do not convert to chained `.replace()` calls.
## Related
- [[02 - Backend]]
- [[02 - Description Engine]] (features)
@@ -0,0 +1,56 @@
# Video Management
## User Perspective
The Videos page (`/videos`) is the main workspace. It displays the team's full video library in a paginated, sortable table. Users can filter by status, search by title, switch between predefined tabs, and click any video to open the editor.
### Video List Features
- **Tabs**: All Content, Published, Private, Scheduled, Conflicts, Push Pending, Lint Issues, Deleted (conditionally shown)
- **Columns**: YouTube ID, Title, Privacy Status, Published Date, Lint Status, Playlists, Sync Status
- **Sorting**: Click column headers
- **Pagination**: Page size selector + prev/next
- **Search**: Title search via URL param `search=`
- **Middle-click**: Opens video in background tab (native anchor overlay)
- **Lint status badges**: OK (no badge), WARNING (amber), ERROR (pink/red)
- **Sync status badges**: In sync (green), Push pending (amber), Conflict (red)
### Video Editor Features
The video editor (`/videos/[id]`) provides:
- **Combined header**: thumbnail, YouTube/Studio links, meta info (ID, date, privacy, sync status), lint badge, action buttons
- **Sortable two-column layout**: sections (Basic Info, Language, Audience & Content, Playlists, Description) can be dragged between columns. Layout persists to user preferences.
- **Basic Info**: title, tags (chip input), privacy status, category, scheduled publish date, recording date, game title
- **Language**: title/description language, video language
- **Audience & Content**: Made for Kids toggle, Allow Embedding toggle, License selector
- **Playlists**: add/remove from channel playlists via search dropdown
- **Description**: description block config editor (see [[02 - Description Engine]])
- **Actions**: Apply Template, Refresh from YouTube, Push to YouTube, Save Changes
- **Diff view**: "See what changed" shows field-level diff against last synced YouTube state
### Game Title Field
`gameTitle` is a **custom metadata field** — it is not a YouTube API field and is never populated during channel import. Users enter it manually in the video editor's Basic Info section.
It serves two purposes:
1. **Token `{video.gameTitle}`** — resolves in description blocks, allowing game name to appear in descriptions automatically.
2. **Canva search link** — when `Team.showCanvaLink` is enabled in team settings, the video editor shows a "Search Canva" link that opens `https://www.canva.com/search?q={gameTitle}`, helping creators find thumbnail templates for their game.
It can also be set as a template default via `Template.videoFields.gameTitle`, so assigning a template pre-fills the game name.
## Developer Perspective
### Backend
- **Module**: `backend/src/modules/videos/`
- **Key service methods**: `findAll(query, teamId)`, `findOne(id)`, `update(id, dto)`, `syncVideo(id)`, `refreshFromYouTube(id)`, `renderDescription(id)`
- **Sync flow**: `POST /videos/:id/sync` enqueues a `youtube-sync` BullMQ job. The processor renders description, checks hash, calls YouTube API, updates `lastSyncedHash` and `renderedDescription`.
- **`hasPendingChanges`**: Computed in `findAll` by comparing `hashMetadata(currentFields)` against `lastSyncedHash`. If `lastSyncedHash` is null, falls back to comparing against YouTube baseline using `youtubeDescription`.
- **Filters** (`GET /videos`): `search`, `lintStatus`, `privacyStatus`, `scheduled`, `notScheduled`, `remoteConflict`, `pendingSync`, `deletedOnYouTube`, `hasLintIssues` (matches ERROR or WARNING)
### Frontend
- **Pages**: `frontend/src/app/(dashboard)/videos/page.tsx` (list) and `videos/[id]/page.tsx` (editor)
- **Table component**: `VideoTable.tsx` uses TanStack Table v8
- **URL state**: Tab, page, sort, and search are persisted in URL params
- **Column layout persistence**: Saved to `User.preferences` via `PATCH /users/me/preferences` using keys `videoEditLeftCol` and `videoEditRightCol`
## Related
- [[02 - Description Engine]]
- [[03 - Metadata Linting]]
- [[02 - Videos API]]
@@ -0,0 +1,88 @@
# Description Engine
## User Perspective
The description engine generates video descriptions from reusable building blocks. Instead of writing descriptions manually for each video, users define blocks (intro, CTA, social links, sponsor copy) and assemble them into templates. Each video can then use a template as its starting point, overriding individual blocks or variables as needed.
### Blocks (`/blocks`)
A block is a named, reusable piece of description content. Types:
- **Static**: Plain text, rendered as-is. No token substitution. Suitable for boilerplate that never changes.
- **Variable**: Text with `{token}` placeholders. Tokens are resolved from team variables, video fields, or collaborator data.
- **Campaign**: Like Variable but linked to a Campaign. Auto-included in descriptions during the campaign's date window — no manual placement needed.
- **Collaborator**: Expanded once per assigned collaborator. The block's content is used as a template, cloned for each collaborator in `VideoConfig.collaboratorIds`, with `{collab.*}` tokens resolved per-collaborator. Clones are joined with a blank line (or single newline if compact).
- **Conditional**: Shown only when its condition evaluates to true.
Blocks have a **compact** toggle: when compact is off, a blank line is inserted before the block's content during rendering.
### Templates (`/templates`)
A template defines the default block assembly for a type of video. It contains:
- **Default block order**: which blocks appear and in what sequence
- **Default variable values**: pre-filled values for variable tokens
- **Video fields**: default metadata values (privacy, category, tags, etc.) applied when assigned
- **Rules**: required links for lint checking
Applying a template to a video copies the block order, variable values, and optionally the video field defaults.
### Variable Tokens
In Variable-type blocks, `{token}` placeholders are replaced at render time:
- `{video.title}`, `{video.tags}`, `{video.category}`, `{video.gameTitle}`, `{video.language}` — from the video record
- `{video.scheduledAt}`, `{video.recordingDate}` — dates; support inline format override (see below)
- `{video.playlists}` — comma-separated list of playlist **titles** the video belongs to
- `{video.playlistLinks}` — comma-separated list of full YouTube playlist **URLs** the video belongs to
- `{collab.name}`, `{collab.youtube}`, `{collab.twitch}`, `{collab.instagram}`, `{collab.tiktok}`, `{collab.twitter}`, `{collab.bluesky}`, `{collab.discord}`, `{collab.aliases}`, `{collab.notes}` — from the **first** assigned collaborator only (see below). Note: `{collab.youtube}` resolves to the full URL (`https://www.youtube.com/@handle`), not just the handle.
- `{my_variable}` — from team-level variables or video-level variable value overrides
### Collaborator Token Behavior with Multiple Collaborators
**In VARIABLE, CONDITIONAL, CAMPAIGN, and freetext blocks:** `{collab.*}` tokens always resolve to the **first** collaborator in the video's `collaboratorIds` list. If a video has multiple collaborators, tokens in non-COLLABORATOR blocks only reflect collaborator #1.
**In COLLABORATOR blocks:** the block is cloned once per collaborator, each clone resolved against that collaborator's data, then joined with a blank line. This is the correct way to list multiple collaborators — one COLLABORATOR block produces one entry per person.
**Practical rule:** use a COLLABORATOR block whenever the content should repeat for each collaborator. Use `{collab.*}` tokens in other block types only when you have exactly one collaborator, or you intentionally want only the first.
### Date Token Format Overrides
Date tokens (`{video.scheduledAt}` and `{video.recordingDate}`) support an inline format string using `|` as a separator:
```
{video.scheduledAt|DD.MM.YYYY} → e.g. 05.03.2024
{video.recordingDate|MMMM D, YYYY} → e.g. March 5, 2024
```
Available format tokens: `YYYY` (4-digit year), `YY` (2-digit), `MMMM` (full month), `MMM` (short month), `MM` (zero-padded month), `M` (month), `DD` (zero-padded day), `D` (day).
Without an inline override, the team's date format setting is used. If no team format is set, the default is `YYYY-MM-DD`. If the date field is not set on the video, the token resolves to an empty string.
No other tokens support the `|format` syntax — it is silently ignored on non-date tokens.
### Freetext Entries
In the video editor, users can add freetext sections directly in the block order without creating a named block. These appear as free-form text areas in the editor and are stored with IDs prefixed `freetext:`.
## Developer Perspective
### Render Path
1. `VideoRenderService.render(videoId)` fetches all data
2. Builds ordered list of blocks from `VideoConfig.blockOrder`
3. Appends active CAMPAIGN blocks at the end (regardless of order)
4. For each block: applies `blockOverrides`, resolves tokens
5. Joins blocks with blank lines (unless `compact: true`)
6. Returns `{ rendered: string, hash: string }`
### Key Files
- `backend/src/shared/render-engine/video-render.service.ts` — data fetching + orchestration
- `backend/src/shared/render-engine/render-engine.service.ts` — pure rendering logic
- `backend/src/shared/system-variables/system-variables.registry.ts` — system token registry
### Adding a New System Token
1. Add the token to `SYSTEM_VARIABLES[]` in `system-variables.registry.ts`
2. Add it to `SYSTEM_VARIABLE_TOKENS` Set
3. Implement resolution in the appropriate resolver in `RenderEngineService`
4. Without step 2, `resolveVariables()` will attempt to resolve it as a team/video variable and fail silently
### Block Versioning
`DescriptionBlock` has a `BlockVersion` history table. Every content change creates a new version snapshot. Version number increments on each save.
## Related
- [[07 - Render Engine]] (architecture)
- [[03 - Blocks API]]
- [[04 - Templates API]]
- [[06 - Collaborators]]
@@ -0,0 +1,65 @@
# Metadata Linting
## User Perspective
The linting feature automatically checks video metadata and descriptions for quality issues. Lint results appear as badges on the video list and as a collapsible section in the video editor header.
### Lint Page (`/linting`)
Shows all open (unresolved) lint results across the team. Filter by severity or rule code. Results can be individually resolved (marking them as acknowledged). Navigating to the linting page automatically triggers a background heal that corrects any stale `lintStatus` values on video records.
### Lint Status on Videos
Videos show one of three statuses:
- **OK** — no open lint issues
- **WARNING** — at least one warning, no errors
- **ERROR** — at least one error
The "Lint Issues" tab on the Videos page shows all videos with WARNING or ERROR status.
### Resolving Issues
Resolving a lint result marks it as `resolvedAt = now()`. It will reappear on the next lint run if the underlying issue is not fixed. Bulk-resolve is supported.
## Developer Perspective
### Rules
All rules implement the `LintRule` interface: `{ code: string, severity: LintSeverity, check(video): LintIssue | null }`.
`LintSeverity` has three values: `ERROR`, `WARNING`, and `INFO`. No current rule uses `INFO` — it is reserved for future informational hints. **Important:** `computeStatus()` only checks for `ERROR` and `WARNING` when computing `Video.lintStatus`. A video with only `INFO` results will have `lintStatus: OK`. This is intentional — INFO is non-actionable and should not surface as a problem on the video list.
| Rule Code | Severity | What it checks |
|---|---|---|
| `TITLE_WEAK` | WARNING | Title < 20 chars or contains generic words (video, test, untitled, new video, upload) |
| `TITLE_TOO_LONG` | WARNING | Title > 100 characters |
| `DESC_MISSING_CTA` | WARNING | Description lacks CTA keywords (subscribe, abonnieren, follow, like, comment, cta) |
| `DESC_MISSING_CHAPTERS` | WARNING | Description has fewer than 2 timestamp patterns (`\d{1,2}:\d{2}`) |
| `DESC_EMPTY_PLACEHOLDER` | ERROR | Description contains unresolved `{placeholder}` patterns |
| `DESC_DUPLICATE_HASHTAG` | WARNING | Description has duplicate hashtags (case-insensitive) |
| `DESC_REQUIRED_LINK_MISSING` | ERROR | Description missing a required link defined in `template.rules.requiredLinks` |
| `DESC_OUTDATED_SPONSOR_COPY` | ERROR | A CAMPAIGN block references an expired or inactive campaign |
| `REMOTE_CONFLICT` | ERROR | `video.remoteConflict` is true — YouTube-side metadata changed since last sync |
### Lint Execution Flow
1. `LintProcessor` receives `{ videoId }` from the `lint` queue
2. Calls `LintingService.lintVideo(videoId)`
3. Fetches video with config, template, existing lint results, and team's `disabledLintRules`
4. Filters rules: removes any whose `code` is in `disabledLintRules`
5. Runs each active rule's `check()` method
6. Wraps in a transaction: deletes all unresolved results, creates new ones, updates `Video.lintStatus`
### Disabling Rules
Team admins can disable specific rules in Settings. When a rule is disabled:
1. Existing unresolved `LintResult` rows for that rule are deleted
2. `Video.lintStatus` is recomputed for all affected videos
3. Future lint runs skip that rule
### Stale lintStatus
`Video.lintStatus` is a denormalized cache. It can become stale if `LintResult` rows are deleted outside of `lintVideo()`. The heal endpoint `POST /lint/team/recompute-status` corrects all stale statuses in one query. This endpoint is called automatically when the linting page loads.
### Adding a New Lint Rule
1. Create `backend/src/modules/linting/rules/your-rule.rule.ts` implementing `LintRule`
2. Import and instantiate it in `LintingService.rules[]` array
3. The rule will run automatically on all subsequent lint jobs
## Related
- [[03 - Linting API]]
- [[01 - Video Management]]
- [[13 - Team Settings]]
@@ -0,0 +1,42 @@
# Bulk Operations
## User Perspective
Bulk operations allow users to apply a metadata change to many videos at once — without pushing to YouTube individually. Changes go through a **preview → confirm** flow.
### Supported Bulk Actions
- **Set Privacy Status** — change privacy on all/selected videos
- **Assign Template** — apply a template to multiple videos
- **Add Tags** — append tags without replacing existing ones
- **Remove Tags** — remove specific tags from videos
- **Search & Replace in Title** — find and replace text in video titles
### Push Pending Bulk Sync
The "Push Pending" feature batch-syncs all videos with pending changes to YouTube. Accessible via the Push Pending tab. Users preview field-level diffs before confirming.
### Rollback
Completed bulk jobs can be rolled back from the Bulk Jobs page (`/bulk-jobs`). Rollback restores the `beforeSnapshot` values for each affected video.
### Bulk Job Lifecycle
`PENDING → DRY_RUN → CONFIRMED → RUNNING → DONE` (or `FAILED` / `ROLLED_BACK`)
## Developer Perspective
### Backend
- **Module**: `backend/src/modules/bulk-jobs/`
- **Endpoints**: `GET /bulk-jobs`, `GET /bulk-jobs/:id`, `POST /bulk-jobs/:id/rollback`, `GET /bulk-jobs/push-pending/preview`, `POST /bulk-jobs/push-pending`
- **Header bulk change**: The Header component's "Bulk Change" modal calls `POST /bulk-jobs/preview` (via `bulkPreview()` in api.ts) and `POST /bulk-jobs/apply`
### Job Processing
The `bulk-metadata.processor.ts` processor:
1. Loads `BulkJob` with all `BulkJobItem` records
2. For each item: saves `beforeSnapshot`, applies change, saves `afterSnapshot`
3. Updates `successCount` / `errorCount` on job
4. On completion: sets `status = DONE` and `completedAt`
### Data Model
- `BulkJob` — one per operation. Has `type`, `filterSnapshot`, `rollbackData`
- `BulkJobItem` — one per video. Has `beforeSnapshot`, `afterSnapshot`, `status`
## Related
- [[11 - Bulk Jobs API]]
- [[01 - Video Management]]
@@ -0,0 +1,26 @@
# Playlists
## User Perspective
Playlists are synced from YouTube and can be managed per-video in the video editor. Users can add a video to one or more playlists, remove it from playlists, and sync the playlist list from YouTube to pick up newly created playlists.
### Video Editor — Playlists Section
- Shows current playlists as chips with remove buttons
- Search box filters available playlists from the channel
- "Sync playlists" button refreshes the playlist list from YouTube
## Developer Perspective
### Backend
- **Module**: `backend/src/modules/playlists/`
- **Endpoints**: `GET /playlists/channel/:channelId`, `GET /playlists/video/:videoId`, `POST /playlists/channel/:channelId/sync`, `POST /playlists/video/:videoId/:playlistId`, `DELETE /playlists/video/:videoId/:playlistId`
### Data Model
- `Playlist` — one per YouTube playlist, scoped to `channelId`
- `VideoPlaylist` — join table with `videoId`, `playlistId`, `position`
### Quota Usage
Adding a video to a playlist costs YouTube API quota. The `youtube-sync` processor handles this when syncing.
## Related
- [[07 - Playlists API]]
- [[01 - Video Management]]
@@ -0,0 +1,41 @@
# Collaborators
## User Perspective
Collaborators are people who appear in videos (guests, co-hosts, sponsors). Defining them centrally allows description templates to include their links automatically via tokens.
### Collaborator Fields
- Name (required)
- YouTube link (full URL)
- Twitch, Instagram, TikTok, Twitter/X, Bluesky links
- Discord handle
- Aliases (alternate names/handles)
- Notes
### Usage in Descriptions
In Variable-type blocks, use tokens like `{collab.youtube}`, `{collab.name}` etc. These are resolved based on which collaborators are assigned to the video via `Video.collaboratorIds`.
## Developer Perspective
### Tracking
Collaborators are assigned to videos via `Video.collaboratorIds` — a JSON string array on the `Video` row. There is no separate join table. This single field drives both description rendering and the `collaboratorId` filter on the video list. Use the `array_contains` Prisma operator when querying it.
### Token Mapping
| Token | Resolves to |
|---|---|
| `{collab.name}` | Collaborator.name |
| `{collab.youtube}` | Full URL: `https://www.youtube.com/@handle` (not just handle) |
| `{collab.twitch}` | Collaborator.twitchLink |
| `{collab.instagram}` | Collaborator.instagramLink |
| `{collab.tiktok}` | Collaborator.tiktokLink |
| `{collab.twitter}` | Collaborator.twitterLink |
| `{collab.bluesky}` | Collaborator.blueskyLink |
| `{collab.discord}` | Collaborator.discordHandle |
| `{collab.aliases}` | Joined alias list |
| `{collab.notes}` | Collaborator.notes |
**Note:** `{collab.handle}` is a deprecated token. It was renamed to `{collab.youtube}`. Blocks still using `{collab.handle}` will not resolve.
## Related
- [[02 - Description Engine]]
- [[05 - Collaborators API]]
- [[07 - Render Engine]] (architecture)
@@ -0,0 +1,41 @@
# Saved Views
## User Perspective
Saved Views are named filter presets for the video list. Users can create a view from their current filter state and quickly reapply it later. Views can optionally be pinned as tabs in the video list tab bar.
### Creating a View
From the Videos page, apply filters, then click the save icon or the "+" in the tab bar. Give the view a name and optionally pin it as a tab.
### Pinned Tabs
Pinned saved views appear in the tab bar after the system tabs. Clicking a tab applies that view's query.
### Global vs Personal
- `isGlobal: true` — visible to all team members
- `isGlobal: false` — private to the owner
## Developer Perspective
### Data Model
```
SavedView {
queryJson // filter state — mirrors VideosQuery
columnsJson // column visibility/order
sortJson // sort state
pinnedAsTab // show in tab bar
tabOrder // ordering among pinned tabs
isGlobal
ownerId
}
```
### Endpoints
- `GET /saved-views` — list all views for team
- `GET /saved-views/tabs` — pinned-as-tab views ordered by tabOrder
- `POST /saved-views` — create
- `PATCH /saved-views/:id` — update
- `DELETE /saved-views/:id` — delete (ADMIN+)
- `POST /saved-views/:id/execute` — returns matching video IDs
## Related
- [[08 - Saved Views API]]
- [[01 - Video Management]]
@@ -0,0 +1,35 @@
# Campaigns
## User Perspective
A Campaign represents a time-bounded promotional window (e.g. a sponsor deal running from June 1 to June 30). Description blocks can be linked to a campaign. While the campaign is active, those blocks are automatically appended to every video description — without the user needing to add them to each video's block order manually.
### Campaign Fields
- Name
- Start date (`startAt`)
- End date (`endAt`, optional — ongoing campaigns have no end date)
- Status (active / inactive)
- Notes
### Auto-inclusion
A CAMPAIGN block is auto-appended to the rendered description when:
- The block's linked campaign has `status = "active"`
- `campaign.startAt ≤ now ≤ campaign.endAt` (or no endAt)
If a campaign expires (endAt in the past), any video with that campaign block will trigger the `DESC_OUTDATED_SPONSOR_COPY` lint error.
## Developer Perspective
### Backend
- **Module**: `backend/src/modules/campaigns/`
- CAMPAIGN blocks are fetched separately from `blockOrder` in `VideoRenderService` and appended at the end of the render output
### API surface
`GET /campaigns` returns a read-only listing of all campaigns for the team. Create, update, and delete are not exposed through the REST API — campaign management currently requires direct database access. See [[15 - Campaigns API]] and the **Campaign CRUD API** entry in [[01 - Technical Debt and Future Work]].
### Lint Integration
`DescOutdatedSponsorRule` checks whether any CAMPAIGN blocks in `VideoConfig.blockOrder` reference an expired or inactive campaign. This produces a `DESC_OUTDATED_SPONSOR_COPY` ERROR.
## Related
- [[02 - Description Engine]]
- [[03 - Metadata Linting]]
- [[15 - Campaigns API]]
@@ -0,0 +1,24 @@
# Calendar
## User Perspective
The Content Calendar (`/calendar`) visualizes scheduled and published videos on a calendar grid. The UI exposes two views: **Month** and **Agenda** (next 30 days). A week view exists at the API level but is not surfaced in the frontend.
Each entry shows:
- Video title
- Scheduled/published date
- Template name
- Assigned collaborators
- Lint status
- Privacy status
## Developer Perspective
### Backend
- **Module**: `backend/src/modules/calendar/`
- **Endpoint**: `GET /calendar?view=month|week|agenda&date=YYYY-MM`
- Returns videos where `scheduledAt` OR `publishedAt` falls within the computed date range
- For `agenda` view: next 30 days from the given date
- For `week` view: the week containing day 1 of the given month. **Known limitation:** the `date` param is `YYYY-MM` (no day), so the week view always anchors to the first of the month — arbitrary week navigation is not possible at the API level. The frontend does not expose this view.
## Related
- [[12 - Calendar API]]
@@ -0,0 +1,33 @@
# Import / Export
## User Perspective
The Import/Export page (`/io`) allows migrating video metadata in bulk.
### CSV Import
1. Upload a CSV file
2. Map CSV columns to video fields (title, tags, description, etc.)
3. Review the validation report — shows errors and warnings per row
4. Commit to apply changes
### JSON Workspace Import
Import a full workspace export (blocks, templates, variables, collaborators) from another team or environment.
### Export
Export video metadata or workspace configuration as CSV or JSON.
## Developer Perspective
### Backend
- **Imports module**: `backend/src/modules/imports/`
- `POST /imports/csv/preview` — validates CSV, creates `ImportJob` with `validationReport`
- `POST /imports/csv/commit` — applies the import
- `POST /imports/json/preview` — validates JSON workspace payload
- `POST /imports/json/commit` — applies the workspace import
- **Exports module**: `backend/src/modules/exports/`
### Data Models
- `ImportJob` — tracks import with validation report and commit status
- `ExportJob` — tracks export with file reference
## Related
- [[13 - Import Export API]]
@@ -0,0 +1,31 @@
# Quota Management
## User Perspective
The Quota History page (`/quota-history`) shows YouTube API quota usage over time. Each sync or playlist operation is logged. Entries are grouped by action (e.g. all API calls for a single video sync appear as one row) with the total quota cost shown.
The display shows:
- Video title + YouTube video ID
- Action type (video_sync, playlist_add, etc.)
- Units consumed
- Time of operation
- Expandable detail rows
## Developer Perspective
### Quota Budget
YouTube imposes a **10,000 unit daily quota** that resets at midnight Pacific Time. Key costs:
- `videos.update` — 50 units
- `videos.list` — 1 unit
- Playlist operations — varies
### QuotaService
- `canSpend(units: number)` — returns true if spending is allowed
- `spend(units, operation, meta)` — logs usage to `QuotaLog`
All YouTube API writes must call `canSpend()` before proceeding and `spend()` after. This is enforced in the `youtube-sync` processor.
### QuotaLog Fields
`datePt` stores the date in Pacific Time (for correct daily boundary). `actionId` groups related log entries from one user action (e.g. one video sync). The frontend clusters entries with the same `videoId` within 60 seconds for display.
## Related
- [[10 - Quota API]]
@@ -0,0 +1,34 @@
# Audit Log
## User Perspective
The Change History page (`/audit`) shows a chronological log of all user-initiated changes to the workspace. Useful for tracking who changed what and when.
Each entry shows: actor, entity type, entity ID, action (create/update/delete), timestamp, and before/after JSON snapshots (expandable).
## Developer Perspective
### AuditService
```typescript
AuditService.log(
actorId: string,
entityType: string, // 'Video', 'Template', 'DescriptionBlock', etc.
entityId: string,
action: string, // 'create', 'update', 'delete'
before?: object,
after?: object
)
```
### When to Log
Every user-facing mutation (create/update/delete) must call `AuditService.log()`. Background/system operations (queue processors, channel import) do NOT log.
### Adding Audit Logging to a Module
1. Import `AuditModule` in the module's `@Module({ imports: [...] })` array
2. Inject `AuditService` in the service constructor
3. Call `AuditService.log()` in each mutation method
### Tracked Entities
`Video`, `VideoConfig`, `DescriptionBlock`, `Template`, `Collaborator`, `TeamVariable`, `SavedView`, `TeamMember`
## Related
- [[02 - Backend]] (architecture)
@@ -0,0 +1,56 @@
# Team Settings
## User Perspective
The Settings page (`/settings`) covers:
- **Team members**: view, invite, change role, remove
- **Connected channels**: view connected YouTube channels
- **Render settings** (ADMIN+):
- Date format for date tokens (`{video.scheduledAt}`, `{video.recordingDate}`)
- Timezone
- Publishing schedule (weekly time slots for the "Next free slot" feature in the video editor)
- Show Canva link in video editor (for teams that use Canva for thumbnails)
- Disabled lint rules
- Show deleted videos in video list
- **Remote conflict detection** (ADMIN+):
- Enable scheduled conflict detection (opt-in per team)
- Videos per run (batch size)
- Only check videos older than N days
## Developer Perspective
### Team Settings Fields
| Field | Type | Description |
|---|---|---|
| `dateFormat` | `String?` | strftime-like format for date tokens |
| `timezone` | `String` | IANA timezone string, default "UTC" |
| `publishingSchedule` | `Json?` | Array of weekly time slots |
| `showCanvaLink` | `Boolean` | Shows Canva search link in video editor |
| `disabledLintRules` | `String[]` | Rule codes excluded from lint runs |
| `showDeletedVideos` | `Boolean` | Shows the Deleted tab in video list |
| `conflictDetectionEnabled` | `Boolean` | Opt-in for the scheduled remote-conflict sweep (default `false`) |
| `conflictDetectionBatchSize` | `Int` | Max videos checked per run (1500, default 50) |
| `conflictDetectionMinAgeDays` | `Int` | Skip videos whose `lastSyncedAt` is within N days (≥0, default 7) |
### Disabling Lint Rules (Side Effect)
When `disabledLintRules` is updated via `PATCH /teams/:teamId/settings`:
1. All unresolved `LintResult` rows for the newly disabled rules are deleted
2. `Video.lintStatus` is recomputed for all affected videos
### Publishing Schedule
Used by the "Next free slot" button in the video editor's Basic Info section. The schedule defines which days/times are available for publishing. `GET /teams/:teamId/next-publish-slot?channelId=` returns the next available ISO datetime.
The slot-finder walks candidate slots day by day and checks whether any existing `scheduledAt` on the channel falls within the **collision window**. Two hardcoded constants govern this behavior (`teams.service.ts`):
| Constant | Value | Effect |
|---|---|---|
| Collision window | ±30 minutes | A slot is considered taken if any video on that channel is already scheduled within 30 minutes either side of it |
| Lookahead limit | 90 days | If no free slot is found within 90 days, the endpoint returns `{ slot: null }` |
These values are not configurable per-team. The 30-minute collision window means teams that publish multiple videos per day with closely spaced slots may find legitimate adjacent slots blocked if a video is already scheduled in between. See the backlog.
### Remote Conflict Detection Settings
The three `conflictDetection*` fields control the per-team share of the global sweep queued by `ConflictDetectionScheduler`. The sweep itself only runs if the operator has set `CONFLICT_DETECTION_ENABLED=true` on the worker (see [[02 - Environment Variables]]) — the team toggle alone does nothing without it. Validation in `TeamsService.updateSettings` enforces `1 ≤ batchSize ≤ 500` and `minAgeDays ≥ 0`. See [[05 - Queue System]] for how the processor uses these values, and [[02 - Videos API]] for how detected conflicts are resolved (`POST /videos/:id/accept-remote` vs `POST /videos/:id/sync`).
## Related
- [[09 - Teams API]]
- [[03 - Metadata Linting]]
@@ -0,0 +1,129 @@
# Auth API
Base path: `/api/v1/auth`
All endpoints require `Authorization: Bearer <jwt>` unless noted otherwise.
Related: [[02 - Authentication]], [[09 - Teams API]]
---
## Endpoints
### GET /auth/google
Initiates the Google OAuth flow. Redirects the browser to the Google consent screen.
> No authentication required.
---
### GET /auth/google/callback
OAuth callback handler. Called by Google after the user grants consent. Sets JWT tokens and redirects to the frontend.
> No authentication required. Handled entirely by the backend.
---
### POST /auth/refresh
Refreshes the access token using a valid refresh token.
**Request body:**
```json
{ "refreshToken": "string" }
```
**Response:**
```json
{
"accessToken": "string",
"refreshToken": "string"
}
```
---
### POST /auth/logout
Invalidates the current refresh token. Requires authentication.
**Response:**
```json
{ "message": "Logged out" }
```
---
### GET /auth/me
Returns the currently authenticated user.
**Response:**
```json
{
"id": "cuid",
"email": "user@example.com",
"name": "User Name",
"teamId": "cuid",
"teamRole": "EDITOR"
}
```
**Possible `teamRole` values:** `OWNER`, `ADMIN`, `EDITOR`, `REVIEWER`, `READONLY`
---
### POST /auth/switch-team
Switch the active team context. Returns a new access token scoped to the requested team. The caller must be a member of the target team.
**Request body:**
```json
{ "teamId": "cuid" }
```
**Response:**
```json
{ "accessToken": "string" }
```
Replace the stored access token with the returned one. All subsequent requests will be scoped to the new team. The refresh token is unchanged.
> **Note:** The response does not include `teamRole`. The new role is encoded inside the JWT payload (`teamRole` claim). Clients that need the role without decoding the JWT must call `GET /users/me` after switching teams. See the backlog for a possible improvement.
> **Frontend status:** No UI exists for team switching. This endpoint is only reachable via direct API call. Use `GET /teams/mine` to enumerate the teams available to switch to.
---
### GET /users/me/preferences
Returns the current user's preferences as a freeform JSON object.
**Response:** `Record<string, unknown>`
---
### PATCH /users/me/preferences
Merges new key/value pairs into the user's stored preferences. Existing keys not present in the request body are preserved.
**Request body:** `Record<string, unknown>`
**Response:** Updated preferences object
---
## Notes
- Access tokens are short-lived JWTs signed with `JWT_SECRET`.
- Refresh tokens are stored encrypted and are invalidated on logout.
- YouTube OAuth tokens (for channel connections) are separate from user auth tokens and are AES-256 encrypted in the database using `TOKEN_ENCRYPTION_KEY`. If that key changes, all channel connections must be re-authenticated.
- See [[06 - Authentication]] for the full auth flow and guard usage.
@@ -0,0 +1,160 @@
# Videos API
Base path: `/api/v1/videos`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[01 - Video Management]], [[04 - Database Schema]], [[11 - Bulk Jobs API]], [[07 - Playlists API]]
---
## GET /videos
List videos for the current team with pagination, sorting, and filtering.
**Query parameters:**
| Param | Type | Description |
|---|---|---|
| `page` | number | Page number (default: `1`) |
| `pageSize` | number | Items per page (default: `20`) |
| `sortBy` | string | Field to sort by |
| `sortOrder` | `asc\|desc` | Sort direction |
| `search` | string | Title search (case-insensitive) |
| `lintStatus` | `OK\|WARNING\|ERROR` | Exact lint status match |
| `hasLintIssues` | boolean | Match `ERROR` or `WARNING` |
| `privacyStatus` | `PUBLIC\|PRIVATE\|UNLISTED` | Privacy filter |
| `scheduled` | boolean | Videos with a future `scheduledAt` |
| `notScheduled` | boolean | Videos without a `scheduledAt` value |
| `remoteConflict` | boolean | Videos with a remote conflict flag |
| `pendingSync` | boolean | Videos where `hasPendingChanges` is `true` |
| `deletedOnYouTube` | boolean | Videos with `youtubeDeletedAt` set |
**Response:**
```json
{
"data": [ "VideoSummary[]" ],
"total": 142,
"page": 1,
"pageSize": 20
}
```
`hasPendingChanges` is computed per video by comparing the current state hash against `lastSyncedHash`. See [[01 - System Overview]] for details on the sync status logic.
---
## GET /videos/:id
Get full video detail including lint results, playlists, and collaborators.
**Response:** `VideoDetail` object
---
## PATCH /videos/:id
Update video metadata fields.
**Request body** (all fields optional):
```json
{
"title": "string",
"tags": ["string"],
"privacyStatus": "PUBLIC|PRIVATE|UNLISTED",
"scheduledAt": "ISO datetime",
"categoryId": "string",
"selfDeclaredMadeForKids": false,
"embeddable": true,
"license": "youtube|creativeCommon",
"defaultLanguage": "en",
"defaultAudioLanguage": "en",
"recordingDate": "YYYY-MM-DD",
"gameTitle": "string",
"collaboratorIds": ["cuid"],
"templateId": "cuid"
}
```
**Notes:**
- `recordingDate` is not returned by YouTube during import and is always `null` after a channel sync. Setting it locally will correctly mark the video as push-pending.
- Providing `collaboratorIds` updates `Video.collaboratorIds` (a JSON string array on the `Video` row). This is the single field the render engine reads to resolve `{collab.*}` tokens.
- Providing `templateId` applies the template's block configuration to the video's `VideoConfig`.
---
## POST /videos/:id/sync
Enqueue a YouTube sync job for the video. Returns immediately; processing is asynchronous via BullMQ.
The processor renders the description, computes a metadata hash, skips if unchanged, then pushes all editable fields to YouTube. After a successful push, `renderedDescription` and `lastSyncedHash` are updated on the video row.
**Response:**
```json
{ "queued": true }
```
**Cost:** 50 YouTube API quota units per successful push. See [[10 - Quota API]].
---
## POST /videos/:id/refresh
Pull latest metadata from YouTube and update the local record. This is a synchronous operation.
> Re-importing after making local changes will reset the `lastSyncedHash` baseline.
**Response:** Updated `VideoDetail`
---
## POST /videos/:id/accept-remote
Resolve a detected remote conflict by adopting the pending remote snapshot as the new local state. Requires **EDITOR** role. **Zero YouTube API calls** — operates entirely on the snapshot captured by `detectConflict()` when the conflict was first detected.
**Behavior:**
1. Copies `pendingRemoteSnapshot` fields into the live Video columns (`title`, `tags`, `categoryId`, `privacyStatus`, `defaultLanguage`, `defaultAudioLanguage`, `selfDeclaredMadeForKids`, `embeddable`, `license`, `recordingDate`)
2. Sets `youtubeDescription = renderedDescription = pendingRemoteDescription`
3. Promotes `pendingRemoteSnapshot` to `youtubeSnapshot`
4. Recomputes `lastSyncedHash` from the accepted state, sets `lastSyncedAt = now`
5. Clears `pendingRemoteSnapshot`, `pendingRemoteDescription`, `remoteConflict`
6. Writes an audit log entry with `action: 'accept-remote'`
**Errors:**
- `400` if there is no `pendingRemoteSnapshot` to accept
- `404` if the video is not in the caller's team
**Response:** Updated `Video` row.
For the opposite direction ("keep local, overwrite remote"), just call `POST /videos/:id/sync` — the sync processor overwrites remote and clears `remoteConflict` on success.
---
## POST /videos/:id/render
Render the video's description using the current `VideoConfig` and return the result without saving or pushing.
**Response:**
```json
{ "rendered": "string" }
```
---
## DELETE /videos/:id
Soft-delete the video (marks as deleted locally). Does not delete the video from YouTube.
---
## GET /youtube-sync/channels
List connected YouTube channels for the current team.
Base path: `/api/v1/youtube-sync/channels`
**Response:** `Channel[]`
@@ -0,0 +1,97 @@
# Description Blocks API
Base path: `/api/v1/blocks`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[02 - Description Engine]], [[04 - Templates API]], [[04 - Database Schema]]
---
## GET /blocks
List all description blocks for the current team.
**Response:** `DescriptionBlock[]`
---
## GET /blocks/:id
Get a single block including its version history.
**Response:** `DescriptionBlock` with `versions: BlockVersion[]`
---
## POST /blocks
Create a new description block. Requires `EDITOR` role.
**Request body:**
```json
{
"name": "string",
"type": "STATIC|VARIABLE|CAMPAIGN|COLLABORATOR|CONDITIONAL",
"content": "string",
"language": "de",
"campaignId": "cuid",
"compact": false,
"tags": ["string"],
"variableDefinitions": []
}
```
**Field notes:**
| Field | Notes |
|---|---|
| `type` | See block type behavior below. `GLOBAL` and `REPEATABLE` are kept in the DB enum but are not exposed in the UI. |
| `campaignId` | Only applicable when `type` is `CAMPAIGN`. Links the block to a campaign date window. |
| `compact` | Block-level default for whether a blank line precedes the block in the rendered output. Can be overridden per video in `VideoConfig.blockOverrides`. |
| `variableDefinitions` | Metadata about custom variables used in the block's content. |
**Block type behavior:**
| Type | Render behavior |
|---|---|
| `STATIC` | Raw output, no token substitution |
| `VARIABLE` | Resolves `{custom_var}`, `{video.*}`, and `{collab.*}` tokens |
| `CAMPAIGN` | Auto-included at the end of the render when the linked campaign's date window is active (`startAt <= now <= endAt`), regardless of block order |
| `COLLABORATOR` | Resolved against assigned collaborators |
| `CONDITIONAL` | Rendered based on condition evaluation |
See [[02 - Description Engine]] for full rendering pipeline details.
---
## PATCH /blocks/:id
Update a block. Creates a version snapshot before applying changes. Requires `EDITOR` role.
**Request body:** Same fields as `POST /blocks`, all optional.
---
## DELETE /blocks/:id
Delete a block. Requires `ADMIN` role.
Returns `409 Conflict` if the block is referenced in any `VideoConfig.blockOrder` or template's `defaultBlocks`. Deletion is blocked until all references are removed. The error message includes the count of affected video configs and templates.
---
## GET /blocks/:id/versions
List the version history for a block, ordered most-recent first.
**Response:** `BlockVersion[]`
---
## Notes
- Free-text entries in a video's block order use IDs prefixed `freetext:` — no `DescriptionBlock` row exists for these. Their content comes entirely from `blockOverrides[id].content`.
- The `{collab.youtube}` token resolves to the full YouTube URL (e.g. `https://www.youtube.com/@handle`), not just the handle. See [[05 - Collaborators API]] for all available `collab.*` tokens.
- System variable tokens (`video.*`, `collab.*`) are registered in `shared/system-variables/system-variables.registry.ts`. New tokens must be added there or they will never resolve.
@@ -0,0 +1,125 @@
# Templates API
Base path: `/api/v1/templates`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[02 - Description Engine]], [[03 - Blocks API]], [[02 - Videos API]]
---
## GET /templates
List all active templates for the current team.
**Response:** `Template[]`
---
## GET /templates/:id
Get a single template by ID.
**Response:** `Template`
---
## POST /templates
Create a new template. Requires `EDITOR` role.
**Request body:**
```json
{
"name": "string",
"description": "string",
"defaultBlocks": ["blockId", "freetext:uuid"],
"defaultOverrides": {},
"rules": {
"requiredLinks": ["https://..."]
},
"variables": {},
"videoFields": {
"privacyStatus": "PUBLIC",
"tags": ["tag1"]
}
}
```
**Field notes:**
| Field | Notes |
|---|---|
| `description` | Optional human-readable description of the template's purpose. |
| `defaultBlocks` | Ordered list of block IDs. May include `freetext:uuid` entries for inline free-text entries. |
| `defaultOverrides` | Per-block overrides in the same shape as `VideoConfig.blockOverrides`. Stores free-text content keyed by `freetext:*` ID. |
| `rules` | Optional lint/validation rules applied when the template is in use, e.g. required links. |
| `variables` | Default variable values to seed into `VideoConfig.variableValues` when the template is applied. |
| `videoFields` | Video metadata fields to apply when the template is applied to a video (e.g. `privacyStatus`, `tags`). |
---
## PATCH /templates/:id
Update a template. Requires `EDITOR` role.
**Request body:** Same fields as `POST /templates`, all optional.
---
## DELETE /templates/:id
Delete a template. Requires `ADMIN` role.
---
## POST /templates/:id/preview
Render a preview of the template's description for a given variable context. Does not require a video — useful for inspecting template output before applying.
**Request body:**
```json
{
"variableValues": {
"custom_var": "value"
}
}
```
**Response:**
```json
{ "rendered": "string" }
```
---
## Version history
`TemplateVersion` rows are written to the database on every `PATCH /templates/:id` call, storing a snapshot of the template content before the update. When a template is deleted, its version rows are also deleted.
There is **no `GET /templates/:id/versions` endpoint**. The version history is stored but unreachable via the API. Template version history is not exposed to the frontend. See the backlog.
---
## POST /templates/:templateId/apply/:videoId
Apply a template to a specific video. Requires `EDITOR` role.
When applied:
- `template.defaultBlocks` is written to `VideoConfig.blockOrder`
- `template.defaultOverrides` is written to `VideoConfig.blockOverrides`
- If `applyVideoFields` is `true`, video metadata fields from `template.videoFields` are applied to the video row
**Request body:**
```json
{
"applyVideoFields": true,
"applyDescriptionConfig": true
}
```
**Response:** Updated `VideoDetail`
@@ -0,0 +1,93 @@
# Collaborators API
Base path: `/api/v1/collaborators`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[02 - Description Engine]], [[03 - Blocks API]], [[04 - Database Schema]]
---
## GET /collaborators
List all collaborators for the current team.
**Response:** `Collaborator[]`
---
## GET /collaborators/:id
Get a single collaborator by ID.
**Response:** `Collaborator`
---
## POST /collaborators
Create a new collaborator. Requires `EDITOR` role.
**Request body:**
```json
{
"name": "string",
"youtubeLink": "https://www.youtube.com/@handle",
"twitchLink": "https://www.twitch.tv/handle",
"instagramLink": "https://www.instagram.com/handle",
"tiktokLink": "https://www.tiktok.com/@handle",
"twitterLink": "https://x.com/handle",
"blueskyLink": "https://bsky.app/profile/handle",
"discordHandle": "username",
"aliases": ["string"],
"notes": "string"
}
```
All platform link fields are optional. `discordHandle` stores a username string rather than a URL.
---
## PATCH /collaborators/:id
Update a collaborator. Requires `EDITOR` role.
**Request body:** Same fields as `POST /collaborators`, all optional.
---
## DELETE /collaborators/:id
Delete a collaborator. Requires `ADMIN` role.
> Before deleting, check whether the collaborator is still referenced in any `Video.collaboratorIds` JSON array. Use the `array_contains` Prisma operator to query this field.
---
## Collaborator tokens in descriptions
When a `COLLABORATOR` or `VARIABLE` block references a collaborator, the following `{collab.*}` tokens are available:
| Token | Resolves to |
|---|---|
| `{collab.name}` | Collaborator's display name |
| `{collab.youtube}` | Full YouTube URL (e.g. `https://www.youtube.com/@handle`) |
| `{collab.twitch}` | Twitch link |
| `{collab.instagram}` | Instagram link |
| `{collab.tiktok}` | TikTok link |
| `{collab.twitter}` | Twitter/X link |
| `{collab.bluesky}` | Bluesky link |
| `{collab.discord}` | Discord handle |
| `{collab.aliases}` | Aliases list |
| `{collab.notes}` | Notes field |
> The legacy `{collab.handle}` token is no longer supported. Any block content still using it will not resolve — update those blocks manually to `{collab.youtube}`.
All tokens are registered in `shared/system-variables/system-variables.registry.ts`. See [[02 - Description Engine]] for how they are resolved.
---
## Collaborator tracking
Collaborators are assigned to a video via `Video.collaboratorIds` — a JSON string array of collaborator IDs stored directly on the `Video` row. This is the single source for both rendering (`{collab.*}` token resolution) and filtering (the `collaboratorId` query param on `GET /videos`). There is no separate join table.
@@ -0,0 +1,142 @@
# Linting API
Base path: `/api/v1/lint`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[03 - Metadata Linting]], [[02 - Videos API]], [[09 - Teams API]]
---
## POST /lint/videos/:id
Run lint rules against a single video synchronously. Returns the issues found immediately.
**Response:** `LintResult[]`
`resolvedAt` is always `null` on freshly created results — it is only set when a result is explicitly resolved via `PATCH /lint/results/:id/resolve`. `GET /lint/results` also always returns `null` for `resolvedAt` because it queries only unresolved rows (`WHERE resolvedAt IS NULL`).
---
## POST /lint/bulk
Enqueue lint jobs for multiple videos. Processing is asynchronous via BullMQ.
**Request body:**
```json
{ "videoIds": ["cuid", "cuid"] }
```
**Response:**
```json
{ "queued": number }
```
---
## POST /lint/channel/:channelId
Enqueue lint jobs for all videos in a given channel. Processing is asynchronous.
**Response:**
```json
{ "queued": number }
```
---
## POST /lint/team
Enqueue lint jobs for all videos in the current team. Processing is asynchronous.
**Response:**
```json
{ "queued": number }
```
---
## GET /lint/results
Get all open (unresolved) lint results for the current team.
**Query parameters:**
| Param | Type | Description |
|---|---|---|
| `severity` | `INFO\|WARNING\|ERROR` | Filter by severity level. `INFO` is valid but no current rule produces it — reserved for future informational hints. |
| `ruleCode` | string | Filter by rule code (e.g. `TITLE_WEAK`) |
| `videoId` | string | Filter by video ID |
**Response:**
```json
[
{
"id": "cuid",
"videoId": "cuid",
"ruleCode": "TITLE_WEAK",
"severity": "WARNING",
"targetField": "title",
"message": "Title appears too generic",
"fixSuggestion": "Use a more descriptive title",
"resolvedAt": null,
"createdAt": "ISO datetime",
"video": {
"id": "cuid",
"title": "Video Title"
}
}
]
```
---
## PATCH /lint/results/:id/resolve
Mark a single lint result as resolved by setting `resolvedAt` to the current timestamp.
**Response:** Updated `LintResult`
---
## POST /lint/results/bulk-resolve
Mark multiple lint results as resolved in one request.
**Request body:**
```json
{ "ids": ["cuid", "cuid"] }
```
**Response:**
```json
{ "resolved": number }
```
---
## POST /lint/team/recompute-status
Recompute `lintStatus` for all videos in the current team based on their actual open `LintResult` rows. Use this to heal stale or inconsistent status values.
**Response:**
```json
{ "checked": number, "updated": number }
```
---
## Notes
- Individual lint rules can be disabled at the team level via `PATCH /teams/:teamId/settings` (`disabledLintRules` array). When a rule is disabled, its existing `LintResult` rows are deleted and affected video `lintStatus` values are recomputed automatically.
- `lintStatus` on `Video` reflects the most severe open result: `OK``WARNING``ERROR`.
- See [[03 - Metadata Linting]] for the full list of rule codes and their descriptions.
- See [[09 - Teams API]] for managing disabled lint rules at the team level.
@@ -0,0 +1,57 @@
# Playlists API
Base path: `/api/v1/playlists`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[02 - Videos API]], [[04 - Database Schema]], [[10 - Quota API]]
---
## GET /playlists/channel/:channelId
List all playlists for a given channel.
**Response:** `Playlist[]`
---
## GET /playlists/video/:videoId
List all playlists that a specific video belongs to.
**Response:** `Playlist[]`
---
## POST /playlists/channel/:channelId/sync
Sync playlists from YouTube for the given channel, creating or updating local records to match the YouTube source.
**Response:**
```json
{ "synced": number }
```
**Cost:** Consumes YouTube API quota. See [[10 - Quota API]].
---
## POST /playlists/video/:videoId/:playlistId
Add a video to a playlist. Creates a `VideoPlaylist` join record.
**Response:** `VideoPlaylist`
---
## DELETE /playlists/video/:videoId/:playlistId
Remove a video from a playlist. Deletes the `VideoPlaylist` join record.
**Response:**
```json
{ "removed": true }
```
@@ -0,0 +1,91 @@
# Saved Views API
Base path: `/api/v1/saved-views`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[02 - Videos API]], [[11 - Bulk Jobs API]]
---
## GET /saved-views
List all saved views for the current team.
**Response:** `SavedView[]`
---
## GET /saved-views/tabs
List saved views that are pinned as tabs, ordered by `tabOrder`.
**Response:** `SavedView[]`
---
## POST /saved-views
Create a new saved view. Requires `EDITOR` role.
**Request body:**
```json
{
"name": "string",
"description": "string",
"isGlobal": false,
"queryJson": {
"lintStatus": "ERROR"
},
"columnsJson": {
"visible": [],
"order": []
},
"sortJson": {
"field": "publishedAt",
"order": "desc"
},
"pinnedAsTab": false,
"tabOrder": null
}
```
**Field notes:**
| Field | Notes |
|---|---|
| `isGlobal` | If `true`, the view is visible to all team members. If `false`, it is personal. |
| `queryJson` | Filter parameters — same shape as the query params accepted by `GET /videos`. |
| `columnsJson` | Column visibility and order configuration for the video table. |
| `sortJson` | Default sort field and direction for the view. |
| `pinnedAsTab` | Whether the view appears as a tab in the video list UI. |
| `tabOrder` | Integer position in the tab bar. `null` if not pinned. |
---
## PATCH /saved-views/:id
Update a saved view. Requires `EDITOR` role.
**Request body:** Same fields as `POST /saved-views`, all optional.
---
## DELETE /saved-views/:id
Delete a saved view. Requires `ADMIN` role.
---
## POST /saved-views/:id/execute
Execute the saved view's stored query and return the matching video IDs.
**Response:**
```json
{ "videoIds": ["cuid", "cuid"] }
```
> The returned `videoIds` can be passed directly to bulk job endpoints such as `POST /bulk-jobs/push-pending` or `POST /bulk-jobs/preview`. See [[11 - Bulk Jobs API]].
@@ -0,0 +1,161 @@
# Teams API
Base path: `/api/v1/teams`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[01 - Auth API]], [[06 - Linting API]], [[04 - Database Schema]]
---
## GET /teams/mine
List all teams the current user belongs to.
**Response:** `Team[]`
---
## GET /teams/:teamId
Get team details including members and connected channels.
**Response:** `TeamDetail`
---
## GET /teams/:teamId/channels
List connected YouTube channels for a team.
**Response:** `Channel[]`
---
## POST /teams/:teamId/members
Invite a user to the team by email address. Requires `ADMIN` role.
**Request body:**
```json
{
"email": "user@example.com",
"role": "EDITOR"
}
```
**Possible `role` values:** `ADMIN`, `EDITOR`, `REVIEWER`, `READONLY`
> `OWNER` is assigned at team creation and cannot be set via this endpoint.
**Response:** `TeamMember`
---
## PATCH /teams/:teamId/members/:userId
Change a team member's role. Requires `ADMIN` role.
**Request body:**
```json
{ "role": "EDITOR" }
```
---
## DELETE /teams/:teamId/members/:userId
Remove a member from the team. Requires `ADMIN` role.
---
## GET /teams/:teamId/settings
Get team-level render and feature settings.
**Response:**
```json
{
"dateFormat": "DD.MM.YYYY",
"timezone": "Europe/Berlin",
"publishingSchedule": [],
"showCanvaLink": false,
"disabledLintRules": [],
"showDeletedVideos": false,
"conflictDetectionEnabled": false,
"conflictDetectionBatchSize": 50,
"conflictDetectionMinAgeDays": 7
}
```
**Field notes:**
| Field | Notes |
|---|---|
| `dateFormat` | Default date format applied by the render engine to `{video.scheduledAt}` and `{video.recordingDate}` tokens when no inline format is specified (e.g. `{video.scheduledAt\|DD.MM.YYYY}` overrides this). |
| `timezone` | Team's local timezone, used for scheduling display. |
| `publishingSchedule` | Array of preferred publishing time slots used by the next-slot calculator. |
| `showCanvaLink` | Whether to show Canva thumbnail link in the video editor UI. |
| `disabledLintRules` | Array of rule codes (e.g. `TITLE_WEAK`) that are suppressed for this team. |
| `showDeletedVideos` | Whether soft-deleted videos appear in the video list. |
| `conflictDetectionEnabled` | Opt-in for the scheduled remote-conflict sweep. Only takes effect when the operator has also set `CONFLICT_DETECTION_ENABLED=true` on the worker. |
| `conflictDetectionBatchSize` | Max videos checked per sweep for this team (1500). |
| `conflictDetectionMinAgeDays` | Skip videos whose `lastSyncedAt` is within the last N days (≥0). |
---
## PATCH /teams/:teamId/settings
Update team settings. Requires `ADMIN` role.
**Request body** (all fields optional):
```json
{
"dateFormat": "DD.MM.YYYY",
"timezone": "Europe/Berlin",
"publishingSchedule": [],
"showCanvaLink": true,
"disabledLintRules": ["TITLE_WEAK"],
"showDeletedVideos": false,
"conflictDetectionEnabled": true,
"conflictDetectionBatchSize": 100,
"conflictDetectionMinAgeDays": 14
}
```
**Validation:**
- `conflictDetectionBatchSize` must be `1 ≤ n ≤ 500`
- `conflictDetectionMinAgeDays` must be `≥ 0`
Invalid values return `400 Bad Request`.
**Side effects:**
- If `disabledLintRules` changes, existing `LintResult` rows for any newly disabled rules are deleted and `Video.lintStatus` is recomputed for all affected videos automatically.
See [[06 - Linting API]] for more on lint result management. See [[05 - Queue System]] for how the conflict-detection settings drive the scheduled sweep.
---
## GET /teams/:teamId/next-publish-slot
Find the next available publishing slot based on the team's configured publishing schedule.
**Query parameters:**
| Param | Type | Required | Description |
|---|---|---|---|
| `channelId` | string | Yes | The channel to check slots for |
**Response:**
```json
{ "slot": "ISO datetime | null" }
```
`null` is returned if no publishing schedule has been configured for the team.
@@ -0,0 +1,78 @@
# Quota API
Base path: `/api/v1/quota`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[02 - Videos API]], [[01 - System Overview]]
---
## GET /quota/history
Get YouTube API quota usage history for the current team.
**Query parameters:**
| Param | Type | Description |
|---|---|---|
| `from` | ISO date | Start of the date range |
| `to` | ISO date | End of the date range |
| `channelId` | string | Filter by channel |
**Response:**
```json
[
{
"id": "cuid",
"datePt": "ISO datetime",
"units": 50,
"operation": "videos.update",
"actionId": "uuid",
"actionType": "video_sync",
"channelId": "cuid",
"videoId": "cuid",
"video": {
"id": "cuid",
"title": "Video Title",
"youtubeVideoId": "abc123"
}
}
]
```
**Field notes:**
| Field | Notes |
|---|---|
| `datePt` | Timestamp of the quota spend, in Pacific Time (quota resets at midnight PT). |
| `units` | Number of quota units consumed by the operation. |
| `operation` | YouTube API method called (e.g. `videos.update`). |
| `actionType` | Internal action that triggered the spend (e.g. `video_sync`). |
---
## GET /quota/today
Get the total YouTube API quota consumed today (Pacific Time), plus the daily limit and remaining units.
**Response:**
```json
{
"used": 450,
"limit": 10000,
"remaining": 9550
}
```
---
## Notes
- The YouTube API quota limit is **10,000 units per day**, resetting at midnight Pacific Time.
- A `videos.update` call (triggered by a sync job) costs **50 units**.
- `QuotaService.canSpend()` and `.spend()` are called before every YouTube write. If the remaining quota is insufficient, the operation is rejected.
- Quota is tracked per team and per channel.
- See [[02 - Videos API]] — `POST /videos/:id/sync` for the sync flow that consumes quota.
@@ -0,0 +1,148 @@
# Bulk Jobs API
Base path: `/api/v1/bulk-jobs`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[02 - Videos API]], [[08 - Saved Views API]], [[10 - Quota API]]
---
## GET /bulk-jobs
List bulk jobs for the current team.
**Query parameters:**
| Param | Type | Description |
|---|---|---|
| `status` | string | Optional filter by job status |
**Response:** `BulkJob[]`
---
## GET /bulk-jobs/:id
Get a single bulk job with all of its individual item records.
**Response:** `BulkJob` with `items: BulkJobItem[]`
---
## POST /bulk-jobs/:id/rollback
Roll back a completed bulk job, reverting the changes made to each item. Requires `EDITOR` role.
**Response:** Updated `BulkJob`
**What rollback does:**
For each `BulkJobItem` with `status: "done"`, the rollback service writes `item.beforeSnapshot` back to the `Video` row. `beforeSnapshot` contains the `youtubeSnapshot` fields captured before the push: `title`, `tags`, `categoryId`, `privacyStatus`, `defaultLanguage`, `defaultAudioLanguage`, `selfDeclaredMadeForKids`, `embeddable`, `license`, `recordingDate`.
**For `SYNC_PUSH` jobs specifically:**
Rollback is a **local-only operation**. YouTube is not contacted and the data already pushed to YouTube is not reversed. After rollback:
- The `Video` row's fields are restored to their pre-push values.
- `lastSyncedHash` is **not** restored — it retains the hash computed at push time.
- Because the local fields no longer match `lastSyncedHash`, the video will show as "push pending" again. This is the expected outcome: the local DB now diverges from YouTube, and a new push is required to re-align them.
This means rollback on a `SYNC_PUSH` job does not undo anything on YouTube — it only rewinds the local record and marks the video as needing another sync.
---
## GET /bulk-jobs/push-pending/preview
Preview all push-pending videos with field-level diffs showing what will change when pushed to YouTube.
**Query parameters:**
| Param | Type | Description |
|---|---|---|
| `sort` | string | Field to sort by |
| `order` | `asc\|desc` | Sort direction |
**Response:** Array of per-video diff previews
---
## POST /bulk-jobs/push-pending
Create a bulk sync job for a selected set of push-pending videos. Enqueues YouTube sync jobs for each video. Requires `EDITOR` role.
**Request body:**
```json
{ "videoIds": ["cuid", "cuid"] }
```
> Tip: use `POST /saved-views/:id/execute` to get a `videoIds` list from a saved view. See [[08 - Saved Views API]].
**Response:** Created `BulkJob`
---
## Bulk Change (Header modal)
The following endpoints power the bulk metadata change modal in the application header.
### POST /bulk-jobs/preview
Preview the effect of a bulk metadata change before applying it.
**Request body:**
```json
{
"type": "SET_PRIVACY|SET_TEMPLATE|ADD_TAGS|REMOVE_TAGS|SEARCH_REPLACE_TITLE",
"payload": {},
"savedViewId": "cuid"
}
```
**`type` values:**
| Type | Description |
|---|---|
| `SET_PRIVACY` | Change privacy status for matched videos |
| `SET_TEMPLATE` | Apply a template to matched videos |
| `ADD_TAGS` | Add tags to matched videos |
| `REMOVE_TAGS` | Remove tags from matched videos |
| `SEARCH_REPLACE_TITLE` | Find and replace in video titles |
`savedViewId` is optional. When provided, the operation is scoped to videos matching that saved view.
**Response:**
```json
{
"count": 5,
"type": "SET_PRIVACY",
"previews": [
{
"videoId": "cuid",
"before": { "privacyStatus": "PRIVATE" },
"after": { "privacyStatus": "PUBLIC" }
}
]
}
```
---
### POST /bulk-jobs/apply
Apply a previewed bulk metadata change. Creates a `BulkJob` record and processes each matched video. Requires `EDITOR` role.
**Request body:** Same shape as `POST /bulk-jobs/preview`.
**Response:** Created `BulkJob`
---
## Notes
- Bulk sync jobs consume YouTube API quota (50 units per video pushed). Check remaining quota via `GET /quota/today` before triggering large bulk pushes. See [[10 - Quota API]].
- `BulkJob` items record `before` and `after` snapshots for each video, enabling rollback.
- Rollback is only available for completed jobs and reverses the stored `before` snapshot.
@@ -0,0 +1,61 @@
# Calendar API
Base path: `/api/v1/calendar`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[02 - Videos API]], [[04 - Database Schema]]
---
## GET /calendar
Get calendar entries for the current team. Returns scheduled and published videos within the requested date range.
**Query parameters:**
| Param | Values | Description |
|---|---|---|
| `view` | `month\|week\|agenda` | Calendar view mode |
| `date` | `YYYY-MM` | Reference month or date |
**Date ranges by view mode:**
| View | Range returned |
|---|---|
| `month` | Full calendar month for the given `YYYY-MM` |
| `week` | The week containing day 1 of the given month. Because `date` is `YYYY-MM` with no day component, the week always anchors to the 1st — arbitrary week navigation is not supported. **Not used by the frontend.** |
| `agenda` | Next 30 days from the given date |
**Response:**
```json
[
{
"videoId": "cuid",
"title": "Video Title",
"date": "ISO datetime",
"templateName": "Template Name",
"collaborators": [
{
"id": "cuid",
"name": "Collaborator Name",
"youtubeLink": "https://www.youtube.com/@handle"
}
],
"lintStatus": "OK",
"channelId": "cuid",
"privacyStatus": "PRIVATE"
}
]
```
**Response field notes:**
| Field | Notes |
|---|---|
| `date` | The video's `scheduledAt` or `publishedAt` datetime used to place it on the calendar. |
| `templateName` | Name of the template applied to this video, if any. |
| `collaborators` | Collaborators assigned via `Video.collaboratorIds`, resolved to name and YouTube link. |
| `lintStatus` | Current lint status: `OK`, `WARNING`, or `ERROR`. |
| `privacyStatus` | `PUBLIC`, `PRIVATE`, or `UNLISTED`. |
@@ -0,0 +1,160 @@
# Import / Export API
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[02 - Videos API]], [[03 - Blocks API]], [[04 - Templates API]], [[05 - Collaborators API]]
---
## Import
Base path: `/api/v1/imports`
All import endpoints require `EDITOR` role.
---
### POST /imports/csv/preview
Upload and validate a CSV file before committing. Returns a validation report and an `importJobId` to use in the commit step.
**Content-Type:** `multipart/form-data`
**Form fields:**
| Field | Type | Description |
|---|---|---|
| `file` | file | CSV file to import |
| `mapping` | string (JSON) | Maps CSV column names to video fields |
**Response:**
```json
{
"importJobId": "cuid",
"validRows": 50,
"errors": [
{ "row": 3, "field": "privacyStatus", "message": "Invalid enum value" }
]
}
```
| Field | Description |
|---|---|
| `importJobId` | Token to pass to the commit step. |
| `validRows` | Count of rows that passed validation. |
| `errors` | Array of per-row validation failures. Each entry has `row` (1-based), `field`, and `message`. Empty array means all rows are valid. |
The full validation detail is also stored in `ImportJob.validationReport` (shape: `{ validCount, errorCount, errors }`). There is no `warnings` array.
---
### POST /imports/csv/commit
Enqueues a BullMQ job to process a previously validated CSV import. Returns immediately — the actual row processing happens asynchronously in the worker.
> **Known limitation:** The import processor currently only marks the `ImportJob` as `committed` and writes an audit log entry. It does not create or update any `Video` rows. The validated rows from the preview step are not persisted and are not available to the processor. CSV import is effectively incomplete — see the backlog.
**Request body:**
```json
{ "importJobId": "cuid" }
```
**Response:**
```json
{ "queued": true, "importJobId": "cuid" }
```
---
### POST /imports/json/preview
Validate a JSON workspace payload (blocks, templates, variables, collaborators) before committing. Returns an `importJobId` to use in the commit step.
> **Note:** The payload is **not stored** during preview. The `importJobId` is a commit-guard token only — it confirms a preview was completed and prevents double-commit. The full payload must be resent on commit.
**Request body:** Workspace JSON payload
**Response:**
```json
{
"importJobId": "cuid",
"valid": true
}
```
---
### POST /imports/json/commit
Apply a previously validated JSON workspace import. The full workspace payload must be included again — it was not stored during the preview step.
Commit is **synchronous** (no queue). Upserts `collaborators`, `blocks`, and `templates` by `id`. `videos`, `videoConfigs`, and `savedViews` in the payload are silently ignored.
**Request body:**
```json
{
"importJobId": "cuid",
"payload": { }
}
```
**Response:**
```json
{ "committed": true }
```
---
## Export
Base path: `/api/v1/exports`
---
### POST /exports/csv
Export video metadata as a CSV file download. There is no `scope` parameter — filtering is controlled by `videoIds` or `savedViewId`.
**Request body:**
```json
{
"videoIds": ["cuid1", "cuid2"],
"savedViewId": "cuid"
}
```
| Field | Notes |
|---|---|
| `videoIds` | Export only these specific videos. If omitted, falls back to `savedViewId`. |
| `savedViewId` | If `videoIds` is not provided, loads the saved view's stored `queryJson` and uses it to select which videos to export. |
If neither field is provided, all videos in the team are exported.
**CSV columns:** `youtube_video_id`, `title`, `tags`, `category_id`, `privacy_status`, `published_at`, `scheduled_at`, `template`, `lint_status`
**Response:** `text/csv` file download (`studioflow-export-<timestamp>.csv`)
---
### POST /exports/json
Export the full workspace as a JSON object. No request body, no filtering — exports everything.
**Response:** JSON object containing: `version`, `exportedAt`, `videos`, `videoConfigs`, `blocks`, `templates`, `collaborators`, `savedViews`
> **Note:** `ExportJob` rows are never written. There is no export history or re-download — each call generates the export fresh. See the backlog.
---
## Notes
- The preview-then-commit pattern for imports allows validation errors to be surfaced and reviewed before any data is written.
- CSV column mapping must be provided as a JSON string in the `mapping` form field, mapping CSV header names to the corresponding video model fields accepted by `PATCH /videos/:id`.
- JSON workspace exports can be re-imported via `POST /imports/json/preview` and `POST /imports/json/commit`, enabling workspace migration between teams or environments.
@@ -0,0 +1,133 @@
# Team Variables API
Base path: `/api/v1/team-variables`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[07 - Render Engine]], [[04 - Database Schema]], [[02 - Description Engine]]
---
## GET /team-variables
List all team variables, ordered alphabetically by name.
**Minimum role:** Any authenticated team member
**Response:** `TeamVariable[]`
```json
[
{
"id": "cuid",
"teamId": "cuid",
"name": "sponsor_link",
"value": "https://example.com/sponsor",
"createdAt": "ISO datetime",
"updatedAt": "ISO datetime"
}
]
```
---
## POST /team-variables
Create a new team variable.
**Minimum role:** `EDITOR`
**Request body:**
```json
{
"name": "sponsor_link",
"value": "https://example.com/sponsor"
}
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | Yes | Used as the token key: `{name}` in description blocks |
| `value` | string | Yes | The string substituted at render time |
**Response:** The created `TeamVariable` object.
**Side effects:**
- All non-deleted videos in the team are enqueued for background re-render (queue: `render`, job ID `render-{videoId}` — deduplicated).
- Mutation is audit-logged under entity type `TeamVariable`.
---
## PATCH /team-variables/:id
Update an existing team variable.
**Minimum role:** `EDITOR`
**Request body** (all fields optional):
```json
{
"name": "sponsor_link",
"value": "https://new-sponsor.com"
}
```
**Response:** The updated `TeamVariable` object.
**Side effects:** Same as POST — full team re-render is enqueued, mutation is audit-logged.
---
## GET /team-variables/:id/usage
Return the description blocks that reference this variable by name token.
**Minimum role:** Any authenticated team member
**Response:**
```json
{
"blocks": [
{ "id": "cuid", "name": "Sponsor Block" }
]
}
```
Blocks are matched by scanning their `content` field for the literal string `{name}` where `name` is the variable's current name. Only blocks belonging to the same team are returned.
> **Note:** This endpoint uses the variable's current name. If you rename a variable, existing blocks that still use the old token name will not appear here — and will silently resolve to an empty string at render time until updated.
---
## DELETE /team-variables/:id
Delete a team variable.
**Minimum role:** `EDITOR`
> **Note:** Every other destructive delete in the API (blocks, templates, collaborators, saved views) requires `ADMIN`. Team variable delete only requires `EDITOR`, despite triggering a full team re-render and silently breaking any block that references the deleted token. This appears to be an oversight — see the backlog.
**Response:**
```json
{ "deleted": true }
```
**Side effects:** Same as POST — full team re-render is enqueued, mutation is audit-logged.
> **Warning:** Deleting a variable does not remove references to its token from description blocks. Blocks that used `{name}` will continue to contain the token string; it will resolve to an empty string at render time.
---
## How variables are resolved at render time
Team variables form the baseline layer of variable resolution. When a video is rendered:
1. All team variables are loaded as a flat `Record<string, string>` map (name → value).
2. Video-level variable overrides from `VideoConfig.variableValues` are merged on top — video-level values win.
3. The combined map is used to substitute `{variable_name}` tokens in `VARIABLE`-type description blocks.
System variable tokens (`{video.*}`, `{collab.*}`) are skipped during this pass and resolved by dedicated resolvers. See [[07 - Render Engine]] for the full resolution order.
@@ -0,0 +1,55 @@
# Campaigns API
Base path: `/api/v1/campaigns`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[08 - Campaigns]], [[07 - Render Engine]], [[04 - Database Schema]]
---
## GET /campaigns
List all campaigns for the current team, ordered by `startAt` descending (most recent first).
**Minimum role:** Any authenticated team member
**Response:** `CampaignSummary[]`
```json
[
{
"id": "cuid",
"name": "Summer Sale 2026",
"startAt": "2026-06-01T00:00:00.000Z",
"endAt": "2026-08-31T23:59:59.000Z",
"status": "active"
}
]
```
| Field | Type | Notes |
|---|---|---|
| `id` | string | cuid |
| `name` | string | Display name of the campaign |
| `startAt` | ISO datetime | Campaign window start (inclusive) |
| `endAt` | ISO datetime \| null | Campaign window end (inclusive). `null` means open-ended |
| `status` | string | Free-form status string, default `"active"`. **Must be exactly `"active"` (lowercase) for the campaign to be treated as active** — see warning below. |
> **Warning:** `Campaign.status` is a free-form `String` column with no enum constraint. The `DESC_OUTDATED_SPONSOR_COPY` lint rule checks `status !== 'active'` (exact lowercase match). Any other value — `"ACTIVE"`, `"paused"`, `"inactive"`, `"disabled"`, or any typo — is treated as inactive: the campaign's blocks are excluded from rendering and all videos referencing them receive a lint ERROR. There is no input validation at the API or DB level. See the backlog.
> **Note:** The full `notes` field and the linked `blocks` relation are not included in this response. Campaigns are read here for display and for populating dropdowns when assigning blocks to a campaign. Full campaign management (create, update, delete, assign blocks) is not exposed via the REST API — it is managed directly in the database or through future admin tooling.
---
## How campaigns affect rendering
Campaign blocks are `CAMPAIGN`-type `DescriptionBlock` rows linked to a campaign via `campaignId`. At render time, the engine checks whether `startAt ≤ now ≤ endAt` for each linked campaign. If the window is active, all of that campaign's blocks are automatically appended to the rendered description — regardless of whether they appear in the video's `blockOrder`. If the window is inactive, they are silently omitted.
This is the only block type that bypasses `blockOrder`. See [[07 - Render Engine]] for the full rendering pipeline.
---
## Current limitations
Campaign CRUD (create, update, delete) and block assignment are not yet exposed through the API. The `GET /campaigns` endpoint exists to allow the frontend to display campaign names and populate selectors. Full campaign management requires direct database access or a future admin interface. See **Campaign CRUD API** in [[01 - Technical Debt and Future Work]].
@@ -0,0 +1,200 @@
# YouTube Sync API
Base path: `/api/v1/youtube-sync`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[13 - Team Settings]], [[01 - Video Management]], [[05 - Queue System]], [[06 - Authentication]]
---
## How channel connection works
There is **no separate channel-connect endpoint**. A YouTube channel is connected automatically during the user's **first Google OAuth login**:
1. User hits `GET /auth/google` — redirected to Google consent screen.
2. Google returns an OAuth callback with `accessToken` and `refreshToken`.
3. `AuthService.upsertGoogleUser` detects that the user has no team memberships.
4. It calls the YouTube API (`channels.list?mine=true`) using the login OAuth tokens to fetch the user's channel ID, name, and uploads playlist ID.
5. A `Team` record is created (user as `OWNER`), and a `Channel` record is created and linked — with the YouTube access and refresh tokens stored AES-256 encrypted (`TOKEN_ENCRYPTION_KEY`).
On **subsequent logins**, if the user already has a team, the access token is refreshed on all channels they own (`connectedBy = user.id`). A new refresh token is only stored if Google returns one (which only happens on first auth or after explicit re-consent).
**Listing connected channels:** `GET /teams/:teamId/channels` — see [[09 - Teams API]].
**Channel disconnect:** Not currently implemented. Removing a channel requires direct database access.
> **Important:** The channel OAuth tokens are the **same tokens** obtained during user login — not a separate OAuth flow. This means one Google account = one YouTube channel connection at team-creation time. Adding a second channel to an existing team is not currently supported through the API.
---
## POST /youtube-sync/channel-import
Pull all videos from a connected YouTube channel into the local database.
**Minimum role:** `EDITOR`
**Request body:**
```json
{ "channelId": "cuid" }
```
**Response:** Import summary
```json
{
"total": 57,
"created": 12,
"updated": 45,
"deleted": 0,
"deletedTitles": []
}
```
| Field | Description |
|---|---|
| `total` | Total number of video IDs processed from the YouTube playlist |
| `created` | New `Video` rows created |
| `updated` | Existing `Video` rows updated |
| `deleted` | Videos soft- or hard-deleted during this import (see `channel-purge-deleted` for the purge lifecycle) |
| `deletedTitles` | Titles of soft-deleted videos |
There is no `skipped` field. A video item returned by the YouTube API with no `id` field is silently dropped and counted in neither `created` nor `updated` — this is an edge case in malformed API responses and does not appear in the summary.
**Behavior:**
- Fetches the channel's uploads playlist from YouTube page by page.
- For each video: creates a new `Video` row (with `VideoConfig`) or updates the existing one.
- Sets `lastSyncedHash` on every upserted video so the YouTube state is the baseline (no false "push pending" after import).
- Also syncs playlists and their video memberships — subject to the `itemCount` optimization described below.
- Does **not** delete local videos that are missing from YouTube — use `channel-purge-deleted` for that.
**Playlist `itemCount` optimization:** During playlist membership sync, each playlist's current `itemCount` (from the YouTube API) is compared against the cached count stored in the local `Playlist` row from the previous import. If the counts match, that playlist's full video-membership sync is **skipped entirely** — no `playlistItems.list` API call is made for it. This saves quota on large channels where most playlists are unchanged. The cached `itemCount` is updated on every import regardless of whether the sync was skipped.
**Known limitation:** If videos are moved between playlists such that one playlist gains a video and another loses one in the same import run, both playlists may have unchanged `itemCount` values and their membership sync will be skipped, leaving the local `VideoPlaylist` join table stale. Use `channel-full-refresh` to force-sync all playlists.
> Costs YouTube API quota. Each page of 50 videos costs 1 `playlistItems.list` unit; individual video detail fetches cost 1 unit each.
---
## POST /youtube-sync/channel-full-refresh
Force-reimport all videos and playlists for a channel, bypassing the `itemCount` optimization — all playlists have their full video membership re-synced regardless of cached counts. Use this when a normal import appears to have missed playlist membership changes.
**Minimum role:** `ADMIN`
**Request body:**
```json
{ "channelId": "cuid" }
```
**Response:** Same import summary shape as `channel-import`.
---
## POST /youtube-sync/import-video-ids
Immediately import specific YouTube video IDs into the database, bypassing the normal playlist-based discovery.
**Minimum role:** `ADMIN`
**Request body:**
```json
{
"channelId": "cuid",
"videoIds": ["dQw4w9WgXcQ", "abc123"]
}
```
**Response:**
```json
{
"total": 2,
"created": 1,
"updated": 1,
"notFound": []
}
```
`notFound` lists any requested YouTube video IDs that the YouTube API did not return (private, deleted, or wrong ID). Unlike `channel-import`, this endpoint has no `deleted`/`deletedTitles` fields — it does not run the purge lifecycle.
Use this for videos that are unlisted or otherwise not reachable via the uploads playlist.
---
## POST /youtube-sync/channels/:channelId/supplemental-ids
Add YouTube video IDs to the channel's supplemental list. Videos on this list are always imported during future `channel-import` runs, regardless of playlist membership.
**Minimum role:** `ADMIN`
**Request body:**
```json
{ "videoIds": ["dQw4w9WgXcQ"] }
```
**Response:** Updated supplemental ID list.
---
## POST /youtube-sync/channel-purge-deleted
Check each local video against YouTube and soft-delete any that no longer exist. Also restores soft-deleted videos that have reappeared.
**Minimum role:** `ADMIN`
**Request body:**
```json
{ "channelId": "cuid" }
```
**Response:**
```json
{
"checked": 200,
"softDeleted": 3,
"restored": 0,
"hardDeleted": 0,
"softDeletedTitles": ["Old Video Title", ...]
}
```
Each video follows a three-state lifecycle during purge:
1. **First miss → soft delete.** If a video is not found on YouTube and `youtubeDeletedAt` is null, `youtubeDeletedAt` is set to now. The video is hidden from normal list queries but remains in the database.
2. **Grace period → no action.** If `youtubeDeletedAt` is already set but fewer than 30 days have passed, the video is left untouched.
3. **30-day expiry → hard delete.** If `youtubeDeletedAt` is set and is more than 30 days old, the video row (and its related records) is permanently deleted from the database. This is what `hardDeleted` counts.
4. **Reappearance → restore.** If a previously soft-deleted video reappears on YouTube (e.g. it was temporarily private), `youtubeDeletedAt` is cleared and the video is visible again.
---
## GET /youtube-sync/queue-status
Inspect the current state of the YouTube sync (`youtube-sync`) BullMQ queue.
**Minimum role:** Any authenticated team member
**Response:**
```json
{
"active": [
{ "jobId": "string", "videoId": "cuid", "videoTitle": "string", "addedAt": "ISO datetime" }
],
"waiting": [...],
"recentFailed": [
{ "jobId": "string", "videoId": "cuid", "videoTitle": "string", "addedAt": "ISO datetime", "failedReason": "string", "failedAt": "ISO datetime" }
],
"recentCompleted": [
{ "jobId": "string", "videoId": "cuid", "videoTitle": "string", "addedAt": "ISO datetime", "completedAt": "ISO datetime" }
]
}
```
Returns up to 10 recent failed jobs and 5 recent completed jobs.
@@ -0,0 +1,102 @@
# Video Configs API
Base path: `/api/v1/video-configs`
All endpoints require `Authorization: Bearer <jwt>`.
Related: [[02 - Videos API]], [[07 - Render Engine]], [[04 - Templates API]]
---
## GET /video-configs/:videoId
Returns the current `VideoConfig` for a video.
**Response:**
```json
{
"id": "cuid",
"videoId": "cuid",
"templateId": "cuid or null",
"blockOrder": ["blockId1", "freetext:abc123"],
"blockOverrides": {
"blockId1": { "content": "override text", "active": true, "compact": false }
},
"variableValues": {
"sponsor_name": "Acme Corp"
},
"version": 3,
"renderHash": "sha256hex or null",
"renderedAt": "ISO datetime or null",
"createdAt": "ISO datetime",
"updatedAt": "ISO datetime"
}
```
Returns `null` if the video has no config yet.
---
## PUT /video-configs/:videoId
Save (create or update) the description config for a video. Requires `EDITOR` role.
This is the endpoint the video editor calls when the user saves their block configuration.
**Request body:**
```json
{
"blockOrder": ["blockId1", "freetext:abc123"],
"blockOverrides": {
"blockId1": { "content": "override text", "active": true, "compact": false },
"freetext:abc123": { "content": "free text content", "compact": false }
},
"variableValues": {
"sponsor_name": "Acme Corp"
},
"templateId": "cuid",
"collaboratorIds": ["collabId1"],
"autoRender": true
}
```
| Field | Required | Notes |
|---|---|---|
| `blockOrder` | Yes | Ordered array of block IDs. May include `freetext:<id>` entries — these have no DB block; content comes from `blockOverrides`. |
| `blockOverrides` | Yes | Per-block overrides for content, active state, and compact flag. |
| `variableValues` | Yes | Video-level custom variable values. Override team variables on name collision. |
| `templateId` | No | Associates the config with a template. Does not re-apply template defaults — it is a reference only. |
| `collaboratorIds` | No | Updates `Video.collaboratorIds` on the video row. Controls which collaborators are resolved during description rendering. |
| `autoRender` | No | If `true`, enqueues a background render job after saving. Updates `Video.renderedDescription` asynchronously. |
**Response:** The saved `VideoConfig` row (same shape as GET response above).
**Notes:**
- `version` is auto-incremented on every save.
- `collaboratorIds` in the request body updates `Video.collaboratorIds` — this is handled separately from the `VideoConfig` fields. The VideoConfig row itself does not store collaborator IDs.
- If `autoRender` is omitted or `false`, the description is not re-rendered immediately. The rendered description updates only when a sync job runs or the render queue processes a background render.
---
## POST /video-configs/:videoId/render-preview
Render the description using the provided config, without saving anything. Used by the video editor's live preview panel.
**Request body:** Same shape as `PUT /video-configs/:videoId`.
**Response:**
```json
{
"rendered": "Full rendered description string",
"hash": "sha256hex"
}
```
**Notes:**
- Nothing is persisted. The video's `renderedDescription`, `lastSyncedHash`, and `VideoConfig` row are all unchanged.
- The preview renders against live data: the video's current metadata, active campaigns, and team variables at the time of the call.
- `collaboratorIds` in the request body is used for the preview render directly — it does not need to match the currently saved `Video.collaboratorIds`. This allows the editor to preview with a collaborator selection before saving.
- Active campaign blocks are appended automatically if their date window is currently active.
@@ -0,0 +1,154 @@
# 01 - Visual Design
This document defines the visual language of YouTube Studio Flow. All UI work must follow these rules. For CSS implementation details, see [[02 - CSS Conventions]].
---
## Colors
The app uses a warm off-white background with a teal primary color. Two themes are supported: light and dark. Both use the same CSS variable names — the values swap via a `[data-theme="dark"]` selector in `globals.css`.
**Never use inline hex values.** Always reference the variables below. Test every new color usage in both themes before committing.
### Light Theme
| Variable | Value | Usage |
| --------------------------- | --------- | --------------------------------------------------------------------- |
| `--color-bg` | `#f7f6f2` | Page background |
| `--color-surface` | `#f9f8f5` | Card and panel backgrounds |
| `--color-surface-2` | `#fbfbf9` | Elevated surfaces (nested cards) |
| `--color-surface-offset` | `#f3f0ec` | Inset/recessed areas |
| `--color-border` | `#d4d1ca` | Borders |
| `--color-divider` | `#dcd9d5` | Subtle dividers between sections |
| `--color-text` | `#28251d` | Primary text |
| `--color-text-muted` | `#66645d` | Secondary/supporting text |
| `--color-text-faint` | `#9f9c94` | Placeholder and hint text |
| `--color-text-inverse` | `#f9f8f4` | Text placed on dark backgrounds |
| `--color-primary` | `#01696f` | Teal — primary actions, active nav, links |
| `--color-primary-hover` | `#0c4e54` | Primary button hover state |
| `--color-primary-highlight` | `#cedcd8` | Primary tint — active nav items, selection backgrounds |
| `--color-success` | `#437a22` | Success states |
| `--color-warning` | `#964219` | Warning states |
| `--color-error` | `#a12c7b` | Error states — magenta, not red |
| `--color-blue` | `#006494` | Info and link accent |
| `--color-purple` | `#7a39bb` | Secondary accent |
### Dark Theme
Dark theme uses the same variable names. Key differences:
- **Surfaces** shift to near-black: `#171614`, `#1c1b19`, `#201f1d`
- **Primary** lightens for contrast: `#4f98a3`
- **Error, success, and warning** all lighten to maintain legibility on dark backgrounds
When adding any new color usage, verify it works in both themes. The dark theme overrides are defined in `globals.css` under `[data-theme="dark"]`.
### Semantic mapping quick reference
| Situation | Variable |
|---|---|
| Page background | `--color-bg` |
| Card or panel | `--color-surface` |
| Input background | `--color-surface` |
| Sidebar / elevated container | `--color-surface-2` |
| Recessed input or inner area | `--color-surface-offset` |
| Standard border | `--color-border` |
| Divider line | `--color-divider` |
| Body copy | `--color-text` |
| Labels, captions | `--color-text-muted` |
| Placeholders | `--color-text-faint` |
| Primary button, active state | `--color-primary` |
| Hover on primary | `--color-primary-hover` |
| Active nav background | `--color-primary-highlight` |
---
## Typography
### Fonts
| Role | Family | Fallback | CSS Variable |
|---|---|---|---|
| Body / UI | General Sans | Inter, sans-serif | `--font-body` |
| Display / Headings | Cabinet Grotesk | Inter, sans-serif | `--font-display` |
Both fonts are loaded from the Fontshare CDN via `<link>` in `layout.tsx`. Do not use system fonts for headings.
### Type Scale
All sizes use fluid `clamp()` values so text scales smoothly between viewport breakpoints.
| Variable | Approximate range | Typical use |
|---|---|---|
| `--text-xs` | 0.75rem → 0.875rem | Badges, footnotes, timestamps |
| `--text-sm` | 0.875rem → 1rem | Labels, captions, secondary UI |
| `--text-base` | 1rem → 1.125rem | Body copy, inputs |
| `--text-lg` | 1.125rem → 1.5rem | Sub-headings, card titles |
| `--text-xl` | 1.5rem → 2.25rem | Section headings |
| `--text-2xl` | 2rem → 3.5rem | Page titles |
### Usage rules
- **Page titles** — `font-family: var(--font-display)`, `font-weight: 700`, `font-size: var(--text-xl)` or `--text-2xl`
- **Section labels** — `font-size: var(--text-sm)`, `text-transform: uppercase`, `letter-spacing: 0.05em`, `color: var(--color-text-muted)`
- **Body text** — `font-family: var(--font-body)`, `font-size: var(--text-base)`, `color: var(--color-text)`
- **Input text** — `font-size: var(--text-base)` or `--text-sm`
Never set font sizes in raw `px` or `rem` values — always use the scale variables.
---
## Spacing
Use the spacing scale for all margins, padding, and gaps. Never use raw pixel values in component CSS.
| Variable | Value | Rough equivalent |
|---|---|---|
| `--space-1` | `0.25rem` | 4px |
| `--space-2` | `0.5rem` | 8px |
| `--space-3` | `0.75rem` | 12px |
| `--space-4` | `1rem` | 16px |
| `--space-5` | `1.25rem` | 20px |
| `--space-6` | `1.5rem` | 24px |
| `--space-8` | `2rem` | 32px |
| `--space-10` | `2.5rem` | 40px |
| `--space-12` | `3rem` | 48px |
| `--space-16` | `4rem` | 64px |
---
## Borders and Radius
| Variable | Value | Typical use |
|---|---|---|
| `--radius-sm` | `0.375rem` | Small inputs, tight chips |
| `--radius-md` | `0.5rem` | Buttons, standard inputs |
| `--radius-lg` | `0.75rem` | Cards, panels |
| `--radius-xl` | `1rem` | Large cards, modals |
| `--radius-full` | `9999px` | Pills, badges, avatar circles, fully-round buttons |
All bordered elements use `--color-border` for their border color unless a semantic variant applies (e.g. `--color-error` for invalid inputs).
---
## Shadows
| Variable | Typical use |
|---|---|
| `--shadow-sm` | Subtle card lift — separates surface from background |
| `--shadow-md` | Popovers and dropdowns |
| `--shadow-lg` | Modals and full-screen overlays |
Use the lightest shadow that achieves the visual separation needed. Do not stack multiple shadows.
---
## Layout Constants
| Variable / Value | Definition |
|---|---|
| `--sidebar-width: 280px` | Expanded navigation sidebar |
| `--sidebar-width-collapsed: 64px` | Collapsed sidebar (icon-only) |
| `--header-height: 72px` | Top bar height |
The main content area is offset by the sidebar width. See [[03 - Component Patterns]] for the sidebar collapse pattern.
@@ -0,0 +1,188 @@
# 02 - CSS Conventions
Rules for writing CSS in YouTube Studio Flow. These apply to all frontend work. For the token values these rules reference, see [[01 - Visual Design]].
---
## Always use CSS Modules
Every component gets its own `ComponentName.module.css` file, co-located with the component. Class names are consumed as:
```tsx
import styles from './ComponentName.module.css';
<div className={styles.container}>...</div>
```
Global styles live exclusively in `globals.css`. Do not add component-specific rules to global files.
---
## Never use inline hex colors
All color values must come from CSS variables defined in `globals.css`. This is what makes the dark theme work — swapping variable values at the root switches the entire app.
```css
/* WRONG */
color: #01696f;
background: #f9f8f5;
border: 1px solid #d4d1ca;
/* CORRECT */
color: var(--color-primary);
background: var(--color-surface);
border: 1px solid var(--color-border);
```
This applies everywhere: component CSS, inline styles, and any dynamically constructed style objects.
---
## Never use raw px for spacing
Use `--space-*` variables for all margins, padding, and gaps. Raw pixel values make the spacing system incoherent and break visual rhythm.
```css
/* WRONG */
padding: 16px 24px;
gap: 8px;
margin-bottom: 12px;
/* CORRECT */
padding: var(--space-4) var(--space-6);
gap: var(--space-2);
margin-bottom: var(--space-3);
```
The spacing scale runs from `--space-1` (0.25rem) to `--space-16` (4rem). See [[01 - Visual Design]] for the full table.
---
## The `min-width: 0` rule
Flex and grid children default to `min-width: auto`, which means they cannot shrink below their content's natural size. In practice this causes horizontal overflow — the child pushes past its container instead of wrapping or truncating.
Add `min-width: 0` to any flex or grid child that contains text, a table, a wide image, or another flex/grid container:
```css
.wrapper {
display: flex;
}
.main {
flex: 1;
min-width: 0; /* required — prevents horizontal scrollbar */
}
```
This must be applied at every level of nesting. A `min-width: 0` on an outer element does not propagate to inner flex containers.
---
## The `overflow-x: hidden` backstop
The `.content` wrapper in `DashboardLayout.module.css` carries `overflow-x: hidden` as a definitive backstop against page-level horizontal overflow. Do not remove this rule. It is a last-resort containment boundary, not a substitute for fixing `min-width` at the source.
---
## Global utility classes
Prefer these over writing new styles for common UI elements.
### Buttons — from `globals.css`
```html
<button class="btn btn-primary">Save</button>
<button class="btn btn-secondary">Cancel</button>
```
Do not write custom button styles for standard primary/secondary actions.
### Pills and badges — from `globals.css`
```html
<span class="pill pill-primary">Active</span>
<span class="pill pill-warn">Warning</span>
<span class="pill pill-purple">Draft</span>
```
Pills use `--radius-full` and are intended for status chips, labels, and category tags.
### Form fields — from `FormField.module.css`
Import this file as `f` by convention:
```tsx
import f from '@/components/shared/FormField.module.css';
```
Available classes:
```tsx
<div className={f.field}>
<label className={f.label}>Title</label>
<input className={f.input} />
</div>
<div className={f.row}>
{/* two fields side by side */}
</div>
<div className={f.actions}>
{/* right-aligned action buttons */}
</div>
```
| Class | Purpose |
|---|---|
| `f.field` | Vertical label + input stack |
| `f.label` | Styled form label |
| `f.input` | Standard text input |
| `f.select` | Dropdown/select element |
| `f.row` | Horizontal pair of fields |
| `f.actions` | Right-aligned button row |
---
## Dropdowns and selects
Use `className={f.select}` from `FormField.module.css`. When a select needs to be inline or auto-sized, override width only:
```tsx
<select className={f.select} style={{ width: 'auto' }}>
```
Do not write custom select styles. The `f.select` class handles appearance, border, padding, color, and focus state consistently across themes.
---
## Transitions
Use these values for interactive elements:
| Situation | Value |
|---|---|
| Color, background, border on hover | `transition: color 0.15s, background 0.15s` |
| General interactive element | `transition: all 0.15s` |
| Layout change (sidebar width) | `transition: width 0.2s` |
Do not use durations longer than `0.2s` for micro-interactions. Reserve longer durations for full-screen transitions if they are ever introduced.
---
## Comments
Only comment CSS when the reason is not obvious from the code. Acceptable:
```css
.main {
flex: 1;
min-width: 0; /* prevents horizontal overflow in flex child */
}
.overlay {
pointer-events: none; /* must not intercept clicks on siblings */
}
```
Not acceptable: describing what the rule does, restating what is visually apparent, notes about which feature uses the class.
@@ -0,0 +1,178 @@
# 03 - Component Patterns
Established patterns for React components in YouTube Studio Flow. Follow these consistently. For CSS rules, see [[02 - CSS Conventions]]. For backend patterns, see [[04 - Backend Architecture Patterns]].
---
## File structure
Each component lives in two co-located files:
```
ComponentName.tsx
ComponentName.module.css
```
For complex components with internal sub-components, keep everything in one file unless a sub-component is genuinely reused elsewhere. Sub-components that exist only to decompose a large render function are not worth extracting.
---
## forwardRef pattern
Components that expose an imperative API (save, reset, isDirty) use `forwardRef` with a typed handle interface. The primary example is `VideoConfigEditor`.
```typescript
export interface MyComponentHandle {
save(): Promise<void>;
isDirty(): boolean;
}
const MyComponent = forwardRef<MyComponentHandle, Props>((props, ref) => {
useImperativeHandle(ref, () => ({
save: async () => {
// ...
},
isDirty: () => isDirty,
}));
// ...
});
```
The parent calls `configRef.current.save()` from a unified "Save Changes" button. There is no separate save button per sub-component — config and video metadata save together through a single user action.
---
## TanStack Query
### Rules
- Call `useQueryClient()` at the component top level, never inside a callback or effect.
- Query keys are always arrays: `['videos']`, `['video', id]`, `['blocks']`.
- Always invalidate related queries in `onSuccess`.
- Use `placeholderData: (prev) => prev` to keep stale data visible during page transitions and prevent content flash.
### Standard mutation pattern
```typescript
const qc = useQueryClient();
const mut = useMutation({
mutationFn: () => updateVideo(id, form),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['video', id] });
qc.invalidateQueries({ queryKey: ['videos'] });
},
});
```
### Query with stable placeholder
```typescript
const { data } = useQuery({
queryKey: ['videos', page, filters],
queryFn: () => fetchVideos({ page, ...filters }),
placeholderData: (prev) => prev,
});
```
---
## Modal pattern
Use the shared `<Modal>` component for all overlays. Do not implement custom dialog/overlay solutions.
```tsx
import Modal from '@/components/shared/Modal';
{isOpen && (
<Modal title="Edit Block" onClose={() => setIsOpen(false)} width={480}>
{/* modal content */}
</Modal>
)}
```
The `width` prop accepts a pixel number. Standard widths: `480` for forms, `600` for wider editors. The modal handles backdrop click, `Escape` key, and focus trap.
---
## Row-level navigation in tables
For table rows that must support both left-click (navigate via router) and middle-click (open in background tab), use an absolutely positioned anchor overlay inside the first cell:
```tsx
<tr className={styles.clickableRow}>
<td>
<a
href={`/videos/${id}`}
className={styles.rowOverlayLink}
onClick={(e) => {
e.preventDefault();
router.push(`/videos/${id}`);
}}
tabIndex={-1}
aria-hidden="true"
/>
{/* actual cell content */}
</td>
</tr>
```
```css
.clickableRow {
position: relative;
cursor: pointer;
}
.rowOverlayLink {
position: absolute;
inset: 0;
z-index: 1;
}
```
The anchor covers the entire row. Left-click is intercepted by `onClick` and delegates to `router.push` for client-side navigation. Middle-click bypasses the handler entirely, which gives native browser behavior — the link opens in a background tab on Windows. Interactive elements within the row (buttons, checkboxes) sit above the overlay via their own stacking context or `z-index`.
---
## Icon libraries
| Library | Import | Use for |
|---|---|---|
| `lucide-react` | `import { Save, X, AlertCircle } from 'lucide-react'` | General UI icons |
| `react-icons/fa` | `import { FaYoutube } from 'react-icons/fa'` | YouTube icon only |
| `react-icons/si` | `import { SiTwitch, SiInstagram, SiTiktok, SiX, SiBluesky, SiDiscord } from 'react-icons/si'` | Platform brand icons |
**`SiYoutube` does not exist in `react-icons/si` v5.** Always use `FaYoutube` from `react-icons/fa` for the YouTube icon.
---
## Zustand stores
Read specific slices to avoid unnecessary re-renders:
```typescript
// Preferred — subscribe only to what you need:
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
// Also acceptable when you need several values:
const { sidebarCollapsed, toggleSidebar } = useUIStore();
```
Auth state comes from `useAuthStore` (`src/store/useAuthStore.ts`). This store holds the current user, `teamId`, and JWT token. The Axios interceptor in `api-client.ts` reads the token from this store automatically — you do not need to attach it manually to requests.
---
## Sidebar collapse
`Sidebar.tsx` has two display states:
| State | Width | Content |
|---|---|---|
| Expanded | 280px (`--sidebar-width`) | Icon + label for each nav item |
| Collapsed | 64px (`--sidebar-width-collapsed`) | Icon only |
State is persisted to `localStorage` via Zustand's persist middleware under the key `ui-store`. The collapse toggle is a small circular button on the right border of the sidebar, visible only on hover.
When adding new navigation items, provide both the icon (always) and the label (hidden when collapsed via CSS, not conditional rendering).
@@ -0,0 +1,251 @@
# 04 - Backend Architecture Patterns
Established patterns for the NestJS backend of YouTube Studio Flow. These rules define how modules, services, and controllers are structured. For frontend patterns, see [[03 - Component Patterns]].
---
## Module structure
Every feature module follows this layout:
```
modules/feature-name/
feature-name.module.ts
feature-name.controller.ts
feature-name.service.ts
dto/
create-feature.dto.ts
update-feature.dto.ts
```
The `dto/` folder is optional for read-only modules, but required for any module that accepts request bodies.
---
## Controller responsibilities
Controllers handle routing and guards only. They must not contain business logic.
**What belongs in a controller:**
- Route decorators (`@Get`, `@Post`, `@Patch`, `@Delete`)
- Guard declarations (`@UseGuards`)
- Role decorators (`@Roles`)
- Extracting `req.user.id` and `req.user.teamId`
- Calling one service method and returning the result
**What does not belong in a controller:**
- Database queries
- Conditional logic
- Transformations beyond passing arguments
### Auth pattern
```typescript
@UseGuards(JwtAuthGuard)
@Controller('blocks')
export class BlocksController {
// Read operations — JwtAuthGuard on class is sufficient
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(TeamRole.EDITOR)
@Post()
create(@Request() req, @Body() dto: CreateBlockDto) {
return this.blocksService.create(req.user.teamId, req.user.id, dto);
}
}
```
- `JwtAuthGuard` on the class: all routes require authentication
- `RolesGuard` + `@Roles(TeamRole.EDITOR)` on write methods only
---
## Service responsibilities
All business logic lives in services.
**What belongs in a service:**
- All database access via `PrismaService`
- Team scoping on every query
- Audit logging for every mutation
- Queue enqueue calls
- Validation that requires database state
Services receive `teamId` and `actorId` as arguments from the controller — they never extract these from a request object.
---
## Team scoping — the cardinal rule
Every database query must be scoped to the current team. The ownership chain is:
```
Team → Channel → Video
```
For video queries, the scope travels through the channel:
```typescript
// CORRECT
await this.prisma.video.findMany({
where: { channel: { teamId } },
});
// WRONG — missing team scope
await this.prisma.video.findMany({
where: { id: videoId },
});
```
For resources owned directly by a team (blocks, templates, collaborators, variables):
```typescript
await this.prisma.descriptionBlock.findMany({
where: { teamId },
});
```
Violating team scoping is a data leak between tenants. There are no exceptions.
---
## Audit logging
Required for every user-facing mutation: create, update, and delete. Background/system operations (queue processors, scheduled jobs) do not get audit logs.
**Setup — in the module:**
```typescript
@Module({
imports: [AuditModule, PrismaModule],
controllers: [BlocksController],
providers: [BlocksService],
})
export class BlocksModule {}
```
**Usage — in the service:**
```typescript
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async update(teamId: string, actorId: string, id: string, dto: UpdateBlockDto) {
const before = await this.prisma.descriptionBlock.findUnique({ where: { id } });
const after = await this.prisma.descriptionBlock.update({
where: { id },
data: dto,
});
await this.audit.log(actorId, 'DescriptionBlock', id, 'update', before, after);
return after;
}
```
Tracked entity types: `Video`, `VideoConfig`, `DescriptionBlock`, `Template`, `Collaborator`, `TeamVariable`, `SavedView`, `TeamMember`.
---
## Render engine usage
Never fetch blocks, variables, or collaborators manually to construct a render. Always go through `VideoRenderService`:
```typescript
// In module imports:
// VideoRenderModule (re-exports VideoRenderService)
constructor(private readonly videoRenderService: VideoRenderService) {}
const { rendered, hash } = await this.videoRenderService.render(videoId);
```
`VideoRenderService` is the single source of truth for fetching render data and delegating to `RenderEngineService`. The only exceptions are `video-configs.service.ts` and `templates.service.ts`, which may call `RenderEngineService` directly for preview rendering because they already hold all the data.
See the CLAUDE.md render engine section for the full block type behavior reference.
---
## YouTube API quota
All YouTube write operations cost quota. `videos.update` costs 50 units. The daily budget is 10,000 units, resetting at midnight Pacific Time.
Required pattern before every YouTube write:
```typescript
const ok = await this.quotaService.canSpend(50);
if (!ok) throw new Error('Quota exceeded');
await this.quotaService.spend(50, 'videos.update', { videoId, channelId });
// ... make the YouTube API call
```
Never make a YouTube write call without checking quota first.
---
## Queue enqueue pattern
Jobs are enqueued with a deterministic `jobId` to prevent duplicate queuing:
```typescript
// Standard enqueue — deduplicates by videoId:
await this.lintQueue.add('lint', { videoId }, {
jobId: `lint-${videoId}`,
});
// Forced rerun — bypass deduplication:
await this.lintQueue.add('lint', { videoId }, {
jobId: `lint-${videoId}-${Date.now()}`,
});
```
Queue names are defined in a `QUEUES` constant. Job name strings use kebab-case: `'lint'`, `'youtube-sync'`, `'render'`.
---
## Prisma enum rule
Never remove a value from a Prisma enum. Removing an enum value in PostgreSQL requires a raw SQL migration and risks data loss if existing rows reference the removed value.
When a concept is retired from the UI, mark it as deprecated in comments and hide it from the frontend. Leave the enum value in the schema.
Current example: `BlockType.GLOBAL` and `BlockType.REPEATABLE` are removed from the UI but remain in the DB enum.
---
## Schema change workflow
After any change to `backend/prisma/schema.prisma`:
```bash
# 1. Stop the running backend process
npx prisma generate # regenerates the Prisma client
npx prisma migrate deploy # applies pending migrations
# 2. Restart the backend
```
Run these from the `backend/` directory. Both the API process (`src/main.ts`) and the worker process (`src/worker.ts`) must be restarted.
---
## Error handling
Use NestJS built-in HTTP exceptions at service boundaries. Do not add generic `try/catch` blocks for errors that cannot occur.
| Situation | Exception |
|---|---|
| Entity not found | `NotFoundException` |
| Team scoping violation | `ForbiddenException` |
| Invalid input state | `BadRequestException` |
```typescript
const block = await this.prisma.descriptionBlock.findUnique({ where: { id } });
if (!block) throw new NotFoundException(`Block ${id} not found`);
if (block.teamId !== teamId) throw new ForbiddenException();
```
Let NestJS handle unhandled exceptions. The default exception filter returns structured error responses with the correct HTTP status codes.
@@ -0,0 +1,160 @@
# 05 - Code Conventions
Language and style conventions for all TypeScript/TSX code in YouTube Studio Flow. These apply to both the frontend and backend unless noted otherwise. For CSS-specific rules, see [[02 - CSS Conventions]].
---
## Comments
Default to writing no comments.
Only add a comment when the **why** is non-obvious: a hidden constraint, a subtle invariant, a browser quirk, a workaround for a specific external bug, or behavior that would surprise a competent reader encountering it for the first time. If removing the comment wouldn't cause confusion, don't write it.
**Never write:**
- Comments that describe what the code does (the code already does that)
- Multi-paragraph docstrings on functions or classes
- Multi-line comment blocks
- Cross-reference notes ("added for issue #123", "used by the sync feature")
- Section dividers (`// --- helpers ---`)
**Acceptable:**
```typescript
// BullMQ silently drops jobs if Redis evicts keys — must use noeviction policy
const client = new Redis({ maxmemoryPolicy: 'noeviction' });
// Single-pass replacement avoids re-substituting inside already-replaced values
const result = template.replace(pattern, (match) => tokens[match] ?? match);
```
---
## No premature abstraction
Add abstractions exactly when they are needed, not before. Three similar lines of code is better than a helper function introduced speculatively for hypothetical future use.
If a pattern appears twice, note it. If it appears three times with meaningful variation, consider abstracting. If the abstraction would be more complex than the repetition, don't.
---
## Error handling scope
Only validate and handle errors at system boundaries:
- User input (request bodies, form submissions)
- External API calls (YouTube API, Google OAuth)
- Queue job payloads at the processor entry point
Trust internal code. Trust Prisma's type guarantees. Do not add defensive `try/catch` around internal service calls for errors that cannot happen under normal operation.
---
## No backwards-compat shims
When changing or removing behavior, change or remove it. Do not leave:
- Unused variables prefixed with `_` to signal "formerly used"
- Re-exports of deleted types for "compatibility"
- `// removed` comments where code used to be
- Feature flags gating old vs. new behavior
---
## TypeScript
- Use strict TypeScript throughout. `"strict": true` is set in both `tsconfig.json` files.
- Avoid `any`. The only acceptable uses are at Prisma enum boundaries where the type system cannot express a legitimate constraint, and when interfacing with genuinely untyped external data (e.g. raw OAuth token payloads).
- Use `as any` sparingly. If you reach for it, consider whether a type assertion (`as SpecificType`) or a type guard is more appropriate.
- Prefer explicit return types on exported functions and service methods. Inference is acceptable for small private helpers.
- Use `type` for object shapes and union types. Use `interface` for contracts intended to be extended or implemented.
```typescript
// Object shape — use type
type VideoFilters = {
search?: string;
status?: PrivacyStatus;
page: number;
};
// Extendable contract — use interface
interface RenderInput {
videoId: string;
blockOrder: string[];
blockOverrides: Record<string, BlockOverride>;
}
```
---
## Naming conventions
| Context | Convention | Examples |
|---|---|---|
| React components | PascalCase | `VideoTable`, `BlockEditor` |
| CSS module classes | camelCase | `styles.clickableRow`, `styles.headerCell` |
| TypeScript interfaces and types | PascalCase | `VideoFilters`, `RenderInput` |
| Frontend API functions | camelCase verb + noun | `fetchVideos`, `updateVideo`, `createBlock` |
| Backend service methods | camelCase verb + noun | `findAll`, `findOne`, `create`, `update`, `remove` |
| Queue job name strings | kebab-case | `'lint'`, `'youtube-sync'`, `'bulk-metadata'` |
| TanStack Query keys | array of strings | `['videos']`, `['video', id]`, `['blocks']` |
| Zustand store files | camelCase with `use` prefix | `useAuthStore.ts`, `useUIStore.ts` |
---
## Import order
Organize imports in this order within any TypeScript or TSX file. An empty line between each group:
1. React imports
2. Next.js imports (`next/navigation`, `next/image`, etc.)
3. Third-party libraries (`@tanstack/react-query`, `lucide-react`, etc.)
4. Internal aliases (`@/components/...`, `@/lib/...`, `@/store/...`)
5. Relative imports (`../utils`, `./helpers`)
6. Style imports (CSS modules, always last)
```typescript
import { useState, useRef, forwardRef } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Save, X } from 'lucide-react';
import Modal from '@/components/shared/Modal';
import { updateVideo } from '@/lib/api';
import { useAuthStore } from '@/store/useAuthStore';
import { formatDate } from '../utils/date';
import styles from './VideoEditor.module.css';
import f from '@/components/shared/FormField.module.css';
```
---
## No dead code
Remove unused imports as you work. Remove unused variables. Remove unreachable branches. Delete unused files rather than leaving them in place.
Do not leave `console.log` statements in committed code. Use the logger (`nestjs/common` `Logger` on the backend) for intentional output.
---
## Validation
**Backend:** Use `class-validator` decorators on DTO classes. Every request body that reaches a controller must go through a validated DTO.
```typescript
export class CreateBlockDto {
@IsString()
@IsNotEmpty()
name: string;
@IsEnum(BlockType)
type: BlockType;
@IsString()
@IsOptional()
content?: string;
}
```
**Frontend:** Validate at the form submission boundary only. Inside a component, trust the types from `@/lib/api.ts`. Do not add runtime type checks on data returned by the API.
@@ -0,0 +1,94 @@
# Local Setup
Step-by-step guide to running YouTube Studio Flow on a local machine for development.
---
## Prerequisites
- **Node.js 18+** — required by both backend and frontend
- **Docker** — used to run Postgres and Redis locally
- **Git** — for cloning the repository
---
## Step 1 — Start Infrastructure
Start Postgres and Redis using Docker Compose:
```bash
cd infrastructure
docker compose up -d postgres redis
```
> **Important:** Redis must run with `--maxmemory-policy noeviction`. BullMQ silently drops jobs if Redis uses `allkeys-lru` eviction. This policy is already pre-configured in `infrastructure/docker-compose.yml`. Do not change it. See [[04 - Gotchas]] for more detail.
---
## Step 2 — Backend Setup
In a terminal, set up and start the NestJS API:
```bash
cd backend
npm install
cp .env.example .env
# Fill in all required values in .env — see [[02 - Environment Variables]]
npx prisma generate
npx prisma migrate deploy
npm run start:dev
```
The API is now running on **http://localhost:3001**.
---
## Step 3 — Queue Worker
Open a **second terminal** and start the BullMQ queue processor:
```bash
cd backend
npx ts-node src/worker.ts
```
The worker runs from the same codebase as the API but through a separate entry point (`src/worker.ts``WorkerModule`). It handles all background jobs: YouTube sync, description rendering, linting, bulk operations, and CSV imports.
---
## Step 4 — Frontend Setup
Open a **third terminal** and start the Next.js frontend:
```bash
cd frontend
npm install
# Create frontend/.env.local with the following content:
# NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1
npm run dev
```
The app is now running on **http://localhost:3000**.
---
## Verify the Setup
1. Navigate to **http://localhost:3000**
2. Click **"Sign in with Google"**
3. Complete the Google OAuth flow
4. You should be redirected to the **Overview** page
If the redirect fails, check that `GOOGLE_CALLBACK_URL` and `FRONTEND_URL` are set correctly in `backend/.env`. See [[02 - Environment Variables]].
---
## Notes
- All three processes must run simultaneously for full functionality:
- `npm run start:dev` — HTTP API on :3001
- `npx ts-node src/worker.ts` — BullMQ job processor
- `npm run dev` — Next.js frontend on :3000
- Database migrations run automatically with `npx prisma migrate deploy`
- After any schema change: stop the backend → `npx prisma generate``npx prisma migrate deploy` → restart both the API and worker
- For non-obvious behaviors and known traps, see [[04 - Gotchas]]
@@ -0,0 +1,68 @@
# Environment Variables
Complete reference for all environment variables used by the backend and frontend.
---
## Backend (`backend/.env`)
Copy `backend/.env.example` to `backend/.env` and fill in all required values before starting the API or worker. See [[01 - Local Setup]] for the full setup sequence.
| Variable | Required | Description |
|---|---|---|
| `DATABASE_URL` | Yes | PostgreSQL connection string. Format: `postgresql://user:pass@localhost:5432/dbname` |
| `REDIS_URL` | Yes | Redis connection string. Format: `redis://:password@localhost:6379` |
| `JWT_SECRET` | Yes | Secret for signing access tokens. Use a long random string. |
| `JWT_REFRESH_SECRET` | Yes | Secret for signing refresh tokens. Must differ from `JWT_SECRET`. |
| `GOOGLE_CLIENT_ID` | Yes | Google OAuth app client ID |
| `GOOGLE_CLIENT_SECRET` | Yes | Google OAuth app client secret |
| `GOOGLE_CALLBACK_URL` | Yes | OAuth redirect URL. Local: `http://localhost:3001/api/v1/auth/google/callback` |
| `TOKEN_ENCRYPTION_KEY` | Yes | Exactly 32 characters. AES-256 key for encrypting YouTube OAuth tokens in the DB. **If this changes, all channel connections break.** |
| `FRONTEND_URL` | Yes | Frontend origin for OAuth redirect. Local: `http://localhost:3000` |
| `PORT` | No | API port. Default: `3001` |
| `NODE_ENV` | No | `development` or `production` |
| `CONFLICT_DETECTION_ENABLED` | No | Global kill switch for the scheduled remote-conflict sweep. Default: `false`. Only takes effect on the worker process; the API doesn't read it. Per-team opt-in still required via `Team.conflictDetectionEnabled`. See [[05 - Queue System]]. |
| `CONFLICT_DETECTION_CRON` | No | Cron pattern (BullMQ format) for the sweep. Default: `0 3 * * *` (daily at 03:00 UTC). Only read when `CONFLICT_DETECTION_ENABLED=true`. |
### Example `backend/.env`
```env
DATABASE_URL=postgresql://studioflow:yourpassword@localhost:5432/studioflow
REDIS_URL=redis://:yourpassword@localhost:6379
JWT_SECRET=a-very-long-random-string-for-access-tokens
JWT_REFRESH_SECRET=a-different-very-long-random-string-for-refresh-tokens
GOOGLE_CLIENT_ID=123456789-abc.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-yourGoogleSecret
GOOGLE_CALLBACK_URL=http://localhost:3001/api/v1/auth/google/callback
TOKEN_ENCRYPTION_KEY=exactly32characterslongkeyhere!!
FRONTEND_URL=http://localhost:3000
PORT=3001
NODE_ENV=development
CONFLICT_DETECTION_ENABLED=false
CONFLICT_DETECTION_CRON=0 3 * * *
```
---
## Frontend (`frontend/.env.local`)
Create `frontend/.env.local` manually (it is not committed to git and has no `.example` counterpart).
| Variable | Required | Description |
|---|---|---|
| `NEXT_PUBLIC_API_URL` | Yes | Backend API base URL. Local: `http://localhost:3001/api/v1` |
### Example `frontend/.env.local`
```env
NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1
```
---
## Security Notes
- Never commit `.env` or `.env.local` files to git — both are already listed in `.gitignore`
- `TOKEN_ENCRYPTION_KEY` must remain stable for the entire lifetime of the database. Rotating it invalidates all stored YouTube OAuth tokens, requiring every channel to be re-authenticated. See [[04 - Gotchas]] for more detail.
- Use distinct values for `JWT_SECRET` and `JWT_REFRESH_SECRET` — reusing the same secret across both token types weakens the separation between access and refresh token validation
- In production, use a secrets manager or CI/CD secret injection rather than plain `.env` files
@@ -0,0 +1,209 @@
# Recipes
Step-by-step instructions for common development tasks. For module structure conventions and shared service patterns, see the [[02 - Backend]] architecture reference.
---
## Recipe: Add a New Backend Module
1. Create the module directory: `backend/src/modules/my-feature/`
2. Create `my-feature.module.ts`:
```typescript
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../shared/prisma/prisma.module';
import { AuditModule } from '../../shared/audit/audit.module';
import { MyFeatureController } from './my-feature.controller';
import { MyFeatureService } from './my-feature.service';
@Module({
imports: [PrismaModule, AuditModule],
controllers: [MyFeatureController],
providers: [MyFeatureService],
})
export class MyFeatureModule {}
```
3. Create `my-feature.controller.ts` with the required decorators:
```typescript
import { Controller, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('my-feature')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('my-feature')
export class MyFeatureController {
constructor(private readonly myFeatureService: MyFeatureService) {}
}
```
4. Create `my-feature.service.ts` with `PrismaService` injection. Always scope DB queries to `req.user.teamId`:
```typescript
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
@Injectable()
export class MyFeatureService {
constructor(private readonly prisma: PrismaService) {}
}
```
5. Register the module in `AppModule` imports array at `backend/src/app.module.ts`
6. If the worker also needs access to this module's services (e.g. for use in a queue processor), register it in `WorkerModule` at `backend/src/worker.module.ts` as well
> **Audit logging:** If the module performs user-facing mutations (create/update/delete), it must import `AuditModule` and inject `AuditService`. Call `AuditService.log(actorId, entityType, entityId, action, before, after)` in every mutation. See [[04 - Backend Architecture Patterns]] for the full audit pattern.
---
## Recipe: Add a New Lint Rule
1. Create `backend/src/modules/linting/rules/my-rule.rule.ts`:
```typescript
import { LintRule, LintIssue } from './base.rule';
import { LintSeverity } from '@prisma/client';
export class MyRule implements LintRule {
code = 'MY_RULE_CODE';
severity = LintSeverity.WARNING;
check(video: any): LintIssue | null {
if (/* condition */) {
return {
targetField: 'title',
message: 'Describe the problem clearly',
fixSuggestion: 'Explain how to fix it',
};
}
return null;
}
}
```
2. Open `backend/src/modules/linting/linting.service.ts`, import the new rule class, and add an instance to the `rules[]` array:
```typescript
private readonly rules: LintRule[] = [
new ExistingRule(),
new MyRule(), // add here
];
```
3. The rule runs automatically on all subsequent lint jobs. It can be disabled per-team in the Settings page (lint rule disabling is stored on the `Team` model). See [[03 - Metadata Linting]] for the full lint system overview.
---
## Recipe: Add a New API Endpoint to the Frontend
1. If the endpoint returns a new response shape, add a TypeScript interface to `frontend/src/lib/api.ts`:
```typescript
export interface MyResponse {
id: string;
name: string;
// ...
}
```
2. Add the API function in the same file:
```typescript
export const myNewAction = (id: string, data: MyData): Promise<MyResponse> =>
apiClient.post<MyResponse>(`/my-feature/${id}/action`, data).then(r => r.data);
```
3. Use it in a component with TanStack Query:
```typescript
const qc = useQueryClient(); // must be called at component level, not inside a callback
const mut = useMutation({
mutationFn: () => myNewAction(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['affected-key'] });
},
});
```
> See [[03 - Component Patterns]] for TanStack Query conventions, including `placeholderData` usage and multi-key invalidation.
---
## Recipe: Add a New Sidebar Navigation Item
1. Open `frontend/src/components/shared/Sidebar.tsx`
2. Import the icon from `lucide-react`:
```typescript
import { MyIcon } from 'lucide-react';
```
3. Add an entry to the `navItems` array:
```typescript
{ label: 'My Page', href: '/my-page', icon: MyIcon, group: 'WORKSPACE' },
```
Available groups and their current members:
| Group | Used for |
|---|---|
| `WORKSPACE` | Overview, Videos, Calendar |
| `OPERATIONS` | Bulk Jobs, Import/Export |
| `PEOPLE` | Collaborators, Teams |
| `LOGGING` | Audit Log, Quota |
4. Create the corresponding route at `frontend/src/app/(dashboard)/my-page/page.tsx`
---
## Recipe: Add a New Prisma Field
1. Add the field to the appropriate model in `backend/prisma/schema.prisma`
2. Stop the backend (and worker if running)
3. Create and apply the migration:
```bash
npx prisma migrate dev --name add-my-field
```
4. Regenerate the Prisma client:
```bash
npx prisma generate
```
5. Restart the backend and worker
> **Never remove enum values** from the Prisma schema. PostgreSQL enum removal requires a raw SQL migration and risks data loss if any rows reference the removed value. Mark deprecated values in the UI instead. See [[04 - Gotchas]] for detail.
---
## Recipe: Add a New System Token (e.g. `{video.myField}`)
System tokens are built-in substitution tokens like `{video.title}` or `{collab.youtube}` that are handled by dedicated resolvers rather than team variable lookup.
1. Add the new token definition to `SYSTEM_VARIABLES[]` in:
`backend/src/shared/system-variables/system-variables.registry.ts`
2. Add the token string to the `SYSTEM_VARIABLE_TOKENS` Set in the same file:
```typescript
export const SYSTEM_VARIABLE_TOKENS = new Set([
// existing tokens...
'{video.myField}',
]);
```
3. Implement the resolution logic in `RenderEngineService` (`backend/src/shared/render-engine/render-engine.service.ts`) in the appropriate resolver method
> **Critical:** Without step 2, `resolveVariables()` will treat the token as a team variable key, find nothing, and silently produce an empty string in the rendered description. This is a common source of invisible rendering bugs. See [[07 - Render Engine]] for the full token resolution pipeline.
@@ -0,0 +1,196 @@
# Gotchas
Non-obvious behaviors, known traps, and decisions that have caused bugs or confusion during development. Read this before debugging anything that "should work."
---
## CONDITIONAL Block — Unknown Rule Types and Operators Silently Default to `true`
`evaluateCondition()` in `render-engine.service.ts` evaluates each rule in a CONDITIONAL block's `condition.rules` array via a `switch` statement. The `default` branch returns `true` for any unrecognised `rule.type`. A second `default: return true` exists inside the `collab_count` case for unrecognised operators.
Practical consequences:
- A typo in `rule.type` (e.g. `"variable_fille"` instead of `"variable_filled"`) silently makes the rule pass, causing the block to render unconditionally.
- An unrecognised `operator` on a `collab_count` rule (e.g. `"neq"` instead of `"eq"`) also silently passes.
- No error is thrown, no warning is logged, and no lint rule checks condition JSON for valid rule types.
Known valid `rule.type` values: `"variable_filled"`, `"variable_empty"`, `"collab_count"`.
Known valid `collab_count` operators: `"eq"`, `"gt"`, `"lt"`, `"gte"`, `"lte"`.
If a CONDITIONAL block appears to render even when its condition should not be met, check the condition JSON for typos in `type` or `operator`.
---
## Campaign.status Must Be Exactly `"active"` (Lowercase)
`Campaign.status` is a free-form `String` column with no enum constraint and no API-level validation. The `DESC_OUTDATED_SPONSOR_COPY` lint rule (`linting/rules/desc-outdated-sponsor.rule.ts`) checks `status !== 'active'` — an exact case-sensitive string match.
Any value other than the lowercase string `"active"` is treated as inactive:
- `"ACTIVE"` → inactive (lint ERROR fires)
- `"paused"`, `"disabled"`, `"inactive"` → inactive (lint ERROR fires)
- Any typo → inactive (lint ERROR fires)
When a campaign is incorrectly treated as inactive, its CAMPAIGN blocks are excluded from all rendered descriptions and every video that references them shows `DESC_OUTDATED_SPONSOR_COPY`. If campaigns appear to have stopped working for no obvious reason, check `Campaign.status` for a case mismatch or typo.
---
## remoteConflict — Detection Paths and Resolution
`Video.remoteConflict` can be set by three paths:
1. **Manual refresh**`POST /videos/:id/refresh` fetches YouTube-side metadata and recomputes `lastSyncedHash`. If the new hash differs from the stored one, the flag flips on.
2. **Scheduled sweep** — the `CONFLICT_DETECTION` BullMQ queue runs on a cron pattern (`CONFLICT_DETECTION_CRON`, default 03:00 daily) when the operator sets `CONFLICT_DETECTION_ENABLED=true` on the worker AND the team opts in via `Team.conflictDetectionEnabled`. See [[05 - Queue System]].
3. **Implicit via full channel refresh** — a channel-level re-import overwrites local fields wholesale, which is not conflict *detection* but effectively resolves any conflict by clobbering local state.
When path 1 or 2 detects a mismatch, `pendingRemoteSnapshot` (JSON) and `pendingRemoteDescription` (String) capture the freshly fetched remote state so the user can review and resolve it without a second YouTube call.
**Resolution options:**
- `POST /videos/:id/accept-remote` — adopts the stored pending snapshot as the new local state. Zero YouTube API calls.
- `POST /videos/:id/sync` — pushes local over remote (standard sync). Clears the flag on success.
**Self-heal:** if the sweep re-checks a video that was previously flagged but the remote now matches `lastSyncedHash` again (e.g. the creator reverted their out-of-band edit), the flag and pending fields are cleared automatically on the next pass.
**Cost:** the scheduled sweep uses `videos.list` batched up to 50 IDs at a time — **1 quota unit per batch**, not per video. A `batchSize` of 250 costs roughly 5 units per team per run, plus one extra call whenever a batch spans a channel boundary (batches are grouped by channel first because the OAuth client is per-channel). `QuotaService.canSpend(1)` guards each batch and the sweep stops early on exhaustion.
---
## lintStatus Is a Denormalized Cache
`Video.lintStatus` is not computed on read — it is a stored value written when lint jobs complete. Any code path that deletes `LintResult` rows (e.g. re-importing a channel, removing a lint rule) must recompute it afterward. Use `POST /lint/team/recompute-status` to heal all stale statuses across the team. The linting page calls this automatically on load.
---
## Redis Eviction Policy
BullMQ silently drops jobs if Redis is configured with `allkeys-lru` eviction. Redis must run with `--maxmemory-policy noeviction`. This is already set in `infrastructure/docker-compose.yml`. Do not change it. If jobs seem to disappear without being processed, check the Redis eviction policy first.
---
## TOKEN_ENCRYPTION_KEY Rotation
YouTube OAuth tokens are AES-256 encrypted in the database using `TOKEN_ENCRYPTION_KEY`. If this key is changed or lost, all stored tokens become unreadable and every connected channel breaks. Channels must then be fully re-authenticated. The key must remain stable for the entire lifetime of the database. See [[02 - Environment Variables]] for the full variable reference.
---
## Recording Date Is Never Imported from YouTube
The YouTube API does not return `recordingDate` in video list or playlist responses. It always imports as `null`. If a user sets a recording date locally, the video will show as "Push pending" (because the hash changes) and will remain so until the change is pushed to YouTube. This is expected behavior, not a bug.
---
## freetext: IDs in blockOrder
`VideoConfig.blockOrder` and `Template.defaultBlocks` are JSON string arrays that may contain IDs with the prefix `freetext:` (e.g. `"freetext:abc123"`). These do not correspond to any `DescriptionBlock` row in the database. Their content comes entirely from `blockOverrides[id].content`. Always check for this prefix before performing a DB lookup on a block ID. See [[02 - Description Engine]] for the full block rendering model.
---
## hashMetadata Fields
`hashMetadata()` (in `backend/src/shared/render-engine/hash.ts`) hashes exactly these fields:
- `title`
- `description`
- `tags`
- `categoryId`
- `privacyStatus`
- `defaultLanguage`
- `defaultAudioLanguage`
- `selfDeclaredMadeForKids`
- `embeddable`
- `license`
- `recordingDate`
All of these fields are included in the YouTube push payload. A local change to any of them will flip `hasPendingChanges` to true and trigger a sync. `privacyStatus` was added to the hash after it was discovered that privacy-only changes were silently dropped (the hash did not change, so the push was skipped).
---
## Date Formatting Single-Pass Regex
`applyDateFormat()` in `backend/src/shared/render-engine/render-engine.service.ts` uses a single-pass regex replacement. Do not refactor it to use chained `.replace()` calls. Chained replacements cause re-substitution bugs — for example, the `M` token in a date format would match again inside the already-substituted word "March", corrupting the output. The single-pass approach was introduced specifically to fix this class of bug.
---
## collab.youtube Resolves to a Full URL
The token `{collab.youtube}` resolves to the full YouTube channel URL, for example `https://www.youtube.com/@handle`. It does not resolve to just the handle string. If you need only the handle portion, that is not currently a supported token.
---
## {video.publishedAt} Does Not Exist
`{video.publishedAt}` is not a supported render token and has never been registered in `VIDEO_RESOLVERS` or `SYSTEM_VARIABLE_TOKENS`. It was present in earlier versions of this documentation in error.
If any description block contains `{video.publishedAt}`, the placeholder will remain unreplaced in the rendered output — it resolves to nothing, leaving the literal string `{video.publishedAt}` in the description. The `DESC_EMPTY_PLACEHOLDER` lint rule will flag it.
The correct tokens for dates are `{video.scheduledAt}` and `{video.recordingDate}`. `publishedAt` is a database field accessible via the API but has no corresponding render token.
---
## collab.handle Is Deprecated
The token `{collab.handle}` was renamed to `{collab.youtube}`. Any description block content still containing `{collab.handle}` will not resolve — it will remain as an unresolved placeholder in the rendered output. The `DESC_EMPTY_PLACEHOLDER` lint rule will catch this and flag it. Update affected blocks manually by replacing `{collab.handle}` with `{collab.youtube}`.
---
## Prisma Enum Values Cannot Be Removed
Removing a value from a PostgreSQL enum requires a raw SQL migration and risks data corruption if any existing rows reference the removed value. Never remove values from Prisma enums. Instead, mark them as deprecated in the UI so they are hidden from users but remain valid in the database. The `BlockType` values `GLOBAL` and `REPEATABLE` are the current examples of this pattern.
---
## Two Separate Entry Points
The backend has two separate NestJS entry points that must both be running:
- `src/main.ts``AppModule` → HTTP API on port 3001
- `src/worker.ts``WorkerModule` → BullMQ queue processor (no HTTP)
When you add a new module, register it in `AppModule`. If the worker's queue processors also need to use services from that module, register it in `WorkerModule` as well. Forgetting the `WorkerModule` registration causes runtime errors in background jobs that are invisible until a relevant job is actually processed.
---
## Collaborator IDs Live on Video, Not VideoConfig
Collaborators are assigned via `Video.collaboratorIds` — a JSON string array on the `Video` row itself, not on `VideoConfig`. There is no `VideoCollaborator` join table. This single field is used by the render engine to resolve `{collab.*}` tokens, by the video list filter (`collaboratorId` query param), and by the calendar.
Use the `array_contains` Prisma operator when querying this JSON field.
---
## SiYoutube Does Not Exist
`react-icons/si` v5 does not export `SiYoutube`. Attempting to import it will cause a build error. Use `FaYoutube` from `react-icons/fa` instead for the YouTube icon. All other platform icons (`SiTwitch`, `SiInstagram`, `SiTiktok`, `SiX`, `SiBluesky`, `SiDiscord`) are available in `react-icons/si`.
---
## Middle-Click on Windows
Windows browsers intercept `mousedown` for middle-click before the `auxclick` event fires, entering autoscroll mode instead. Using `onAuxClick` or `window.open()` to handle middle-click will not work reliably on Windows. To support middle-click navigation on table rows or cards, use a real `<a>` element as an absolutely positioned overlay over the clickable area. The row overlay pattern in `VideoTable.tsx` is the reference implementation for this.
---
## min-width: 0 on Flex and Grid Children
Flex and grid children default to `min-width: auto`, meaning they cannot shrink below their content's natural size. This causes horizontal overflow on any flex or grid child that contains long text, wide tables, or deeply nested content. Add `min-width: 0` to every flex/grid child at every nesting level that might contain wide content. This applies in CSS Modules and must be repeated at each level — the parent setting does not propagate. See [[02 - CSS Conventions]] for the full CSS pattern reference.
---
## Deleted Blocks Are Silently Skipped at Render Time
If a block ID in `VideoConfig.blockOrder` no longer has a corresponding `DescriptionBlock` row (because the block was deleted), the render engine silently skips it — `render-engine.service.ts` line 196: `if (!block) continue;`. No error is thrown, no warning is logged, and no lint result is produced.
The deleted block simply disappears from the rendered description without any indication that the output is incomplete. A video can silently lose description content with no user-facing signal.
There is no lint rule that checks for orphaned block IDs in `blockOrder`. If a block that is referenced by many videos is deleted, all of those videos will render incomplete descriptions until their `blockOrder` is manually cleaned up.
---
## BullMQ Job Deduplication
BullMQ deduplicates jobs by `jobId` — if a job with the same ID already exists in the queue and has not yet run, the new submission is silently ignored. Using a static ID like `lint-{videoId}` is intentional for normal lint enqueueing (prevents duplicate lint jobs from piling up). For forced reruns — such as "rerun all lint checks" — use a timestamp suffix to bypass deduplication:
```typescript
jobId: `lint-${videoId}-${Date.now()}`
```
Without the suffix, the "rerun" submits a job that is immediately deduplicated against the existing queued job and never actually runs.
@@ -0,0 +1,99 @@
# Deployment and Operations
---
## Production Stack
The production setup is a single-host Docker Compose deployment using **Traefik** as a reverse proxy. There is no Kubernetes, cloud-managed infrastructure, or horizontal scaling configuration.
**Services** (`infrastructure/docker-compose.yml`):
| Service | Image | Role |
|---|---|---|
| `migrate` | backend Dockerfile | Runs `prisma migrate deploy` once before API starts |
| `api` | backend Dockerfile | NestJS HTTP API on port 3001 |
| `worker` | backend Dockerfile | BullMQ queue processor (`node dist/worker.js`) |
| `frontend` | frontend Dockerfile | Next.js on port 3000 |
| `postgres` | postgres:16-alpine | Database — bound to `127.0.0.1:5432` (not public) |
| `redis` | redis:7-alpine | BullMQ queues — `noeviction` policy, AOF persistence |
**SSL / routing**: Traefik handles TLS termination with automatic Let's Encrypt certificates. Both API (`/api` prefix) and frontend run on the same domain — Traefik routes by path prefix. The `traefik-network` external network must exist before deploy.
**Build**: Multi-stage Dockerfile (`node:22-alpine`). Builder compiles TypeScript; runner installs prod-only deps. The Prisma CLI is copied from builder stage so the `migrate` service can run schema migrations.
---
## Secrets Management
All secrets are passed as environment variables from `infrastructure/.env` (based on `.env.example`). There is no secrets vault, no encrypted secret store, and no runtime secret injection. The `.env` file must be present on the host before `docker compose up`.
Secrets that must be set:
| Variable | Notes |
|---|---|
| `TOKEN_ENCRYPTION_KEY` | Exactly 32 characters. AES-256 key for YouTube OAuth tokens. See rotation note below. |
| `JWT_SECRET` / `JWT_REFRESH_SECRET` | Generate with `openssl rand -base64 48` |
| `POSTGRES_PASSWORD` / `REDIS_PASSWORD` | Strong random passwords |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | From Google Cloud Console |
---
## Health Check
`GET /api/v1/health` returns `{ "status": "ok" }`. This is a **shallow** check — it only confirms the Node process is responding; it does not verify database connectivity or Redis availability. Docker healthcheck polls this endpoint every 15 s; the `frontend` service waits for the API to be healthy before starting.
---
## Logging
No structured logging is configured. The application uses NestJS's default logger (`console.log`/`console.error`). In production, logs go to stdout and are captured by Docker's logging driver (default: `json-file`). There is no log aggregation, no Sentry integration, and no OpenTelemetry instrumentation.
---
## Job Failure Handling
BullMQ jobs do not have automatic retry configured except where noted:
| Queue | Retry | Failed job retention |
|---|---|---|
| `youtube-sync` | None | Last 5 failed jobs kept (`removeOnFail: { count: 5 }`); completed jobs removed |
| `render` | None | BullMQ default (kept until manually cleared) |
| `lint` | None | BullMQ default |
| `bulk-metadata` | None | BullMQ default |
| `import` | None | BullMQ default |
| `conflict-detection` | None | Last 10 failed jobs kept (`removeOnFail: 10`); last 10 completed kept (`removeOnComplete: 10`). Registered as a BullMQ repeatable by `ConflictDetectionScheduler` when `CONFLICT_DETECTION_ENABLED=true`. |
There is no dead-letter queue and no mechanism to notify users when a background job fails. A failed sync job means the user's video was not pushed to YouTube — the UI will continue to show "push pending" with no error indication. Failed jobs can be inspected directly in Redis or via a BullMQ dashboard (none is currently deployed).
---
## Rate Limiting
There is no rate limiting on any API endpoint. `@nestjs/throttler` is not installed. All routes are unthrottled.
---
## Database Backups
No backup strategy is configured. PostgreSQL data lives in the `postgres_data` Docker volume on the host. There is no automated backup job, no pg_dump schedule, and no offsite backup.
To back up manually:
```bash
docker exec <postgres_container> pg_dump -U $POSTGRES_USER $POSTGRES_DB > backup.sql
```
---
## TOKEN_ENCRYPTION_KEY Rotation
YouTube OAuth tokens are AES-256 encrypted in the database using `TOKEN_ENCRYPTION_KEY`. **Changing this key breaks all channel connections** — all stored tokens become unreadable and every connected channel must be fully re-authenticated by its owner.
There is no tooling for key rotation. If rotation is required (e.g. key compromise), the procedure is:
1. Take the application offline.
2. Write a one-off migration script that decrypts every `Channel.accessToken` and `Channel.refreshToken` with the old key and re-encrypts them with the new key.
3. Update `TOKEN_ENCRYPTION_KEY` in `.env`.
4. Bring the application back online.
Until such a script is written, **key rotation = forced re-authentication for all channel owners**. See also the [[01 - Technical Debt and Future Work]] backlog.
@@ -0,0 +1,82 @@
# Verifying Changes
Commands to check your work after making changes. Run these before considering a task done.
---
## Backend
Run from the `backend/` directory.
### Type check + compile
```bash
npm run build
```
`nest build` compiles all TypeScript and surfaces type errors. This is the primary way to verify backend changes are type-correct. Fix all errors before finishing.
### Lint
```bash
npm run lint
```
Runs ESLint with auto-fix on `src/**/*.ts`. Run after making changes to catch style violations. If auto-fix changes files, review the diff.
### Tests
```bash
npm run test
```
Runs the Jest test suite. Run when modifying shared services, the render engine, or any logic that has existing tests.
---
## Frontend
Run from the `frontend/` directory.
### Type check + build
```bash
npm run build
```
`next build` compiles TypeScript and runs the full Next.js production build. It catches type errors, missing imports, and invalid JSX. The most thorough check available for the frontend.
### Lint
```bash
npm run lint
```
Runs `next lint` (ESLint). Run after making changes to catch import order violations, unused variables, and React-specific issues.
---
## After schema changes
After any modification to `backend/prisma/schema.prisma`:
```bash
# Stop the backend and worker first, then:
npx prisma generate # regenerate the Prisma client
npx prisma migrate deploy # apply pending migrations
# Restart both backend processes
```
If you used `migrate dev` locally to create a migration file, commit the generated file in `backend/prisma/migrations/`.
---
## Checklist
| Change type | Commands to run |
|---|---|
| Backend logic change | `npm run build``npm run lint` |
| Render engine / shared service | `npm run build``npm run test` |
| Prisma schema change | `npx prisma generate``npx prisma migrate deploy``npm run build` |
| Frontend component change | `npm run build``npm run lint` |
| Both frontend and backend | Run both sets above |
@@ -0,0 +1,190 @@
# Technical Debt and Future Work
Items that surfaced during development or documentation review but were not immediately actioned. Add entries freely — this is a scratchpad, not a formal spec.
---
## Missing Features / Incomplete Implementation
### Team Switcher UI
`POST /auth/switch-team` and `GET /teams/mine` are fully implemented on the backend. A user who belongs to multiple teams can switch via API, but the frontend has no UI for it — no team selector in the sidebar, header, or settings page. A user with multiple teams has no way to switch without making raw API calls.
Natural placement: a team-name dropdown in the sidebar header or at the top of the Settings page.
### Invite Flow — Pre-Registration Requirement
`POST /teams/:teamId/members` looks up the invitee by `User.email`. If no `User` row exists for that email (i.e. the person has never logged in), the endpoint throws a 404 and the invite silently fails. There is no pending-invite queue, no email notification, and no way to pre-invite someone who hasn't signed up yet.
This means the current invite UX requires an out-of-band coordination step: the invitee must visit the app and log in with Google before the inviter can add them. This is a significant UX gap for onboarding new team members.
Options to consider:
- Add a pending-invite table keyed by email; claim the invite automatically on first login
- Send an invitation email with a signup link (requires an email provider integration)
- At minimum, return a clear error message to the inviter explaining why the invite failed, rather than a generic 404
---
### Channel Disconnect
There is no API endpoint to disconnect a YouTube channel from a team. Removing a channel currently requires direct database access. A `DELETE /teams/:teamId/channels/:channelId` endpoint is the natural home for this.
### Multi-Channel Support
The channel connection flow (triggered at first Google OAuth login) creates exactly one channel per team at creation time. There is no way to add a second channel to an existing team through the API. `Channel` has a `teamId` and the schema supports multiple channels per team — the limitation is entirely in the auth flow.
### Campaign CRUD API
`GET /campaigns` exists for read-only listing, but create / update / delete for campaigns has no API surface. Campaign management currently requires direct database access.
### No Lint Rule for Orphaned Block IDs in blockOrder
When a `DescriptionBlock` is deleted, any `VideoConfig.blockOrder` arrays that still reference its ID are not updated. At render time the orphaned ID is silently skipped (`render-engine.service.ts`: `if (!block) continue`), so the video's description is rendered incomplete with no error or warning.
A lint rule (`BLOCK_MISSING` or similar) should check each non-`freetext:` ID in `blockOrder` against the set of existing block IDs for the team and flag videos with orphaned references. Alternatively, deleting a block could cascade-remove it from all `blockOrder` arrays — but that's a broader schema operation.
### TemplateVersion History Is Stored but Unreachable
`TemplateVersion` rows are created on every `PATCH /templates/:id` call and deleted when the template is deleted. The data exists but there is no `GET /templates/:id/versions` endpoint — the version history cannot be accessed via the API or the frontend. Either add the endpoint (mirroring `GET /blocks/:id/versions`) or drop the `TemplateVersion` table if version tracking for templates is not planned.
### ExportJob Model Is Never Written
`ExportJob` (and its `scopeJson` field) exists in the Prisma schema but the exports service never creates `ExportJob` rows. Exports return data directly with no audit trail. If export history or re-download is needed, this model is already in place — it just needs to be wired up.
### `BulkJob.rollbackData` Is Never Written
`BulkJob.rollbackData` (Json?) exists in the schema but is never populated. Rollback works through `BulkJobItem.beforeSnapshot` per-item. Either wire up `rollbackData` as a job-level rollback summary or remove it from the schema.
### JSON Import Does Not Store Payload at Preview Time
`POST /imports/json/preview` creates an `ImportJob` row but does not persist the payload — only `{ valid: true }` is stored. As a result, `POST /imports/json/commit` must resend the full payload, making the two-step preview/commit UX misleading: the "preview" step holds no data, and the `importJobId` is only a duplicate-commit lock and audit anchor.
The CSV flow does not have this problem — the validated rows are processed by a BullMQ worker that reads the stored job.
Fix: store the payload (or a validated/normalised form of it) in `ImportJob.mappingJson` or a dedicated `payloadJson` field during preview. The commit step would then only need the `importJobId`, consistent with how CSV import works.
---
### CSV Import Does Not Actually Import Anything
`POST /imports/csv/commit` enqueues a BullMQ job, but the processor (`import.processor.ts`) only calls `executeCommit()`, which marks the `ImportJob` as `committed` and writes an audit log. No `Video` rows are created or updated.
The root cause: `previewCsv` validates the uploaded rows but stores only a validation summary (`{ validCount, errorCount, errors }`) in `ImportJob.validationReport` — the actual rows are never persisted. By the time the commit processor runs, the data is gone.
To complete the implementation:
1. Store the validated rows in `ImportJob.mappingJson` (or a dedicated field) during `previewCsv`
2. Have `executeCommit` read those rows and upsert `Video` records accordingly
3. Update the response of `POST /imports/csv/commit` to report actual committed row counts
Until then, calling this endpoint changes no video data.
---
### JSON Workspace Import Does Not Process Videos
`POST /imports/json/commit` upserts `collaborators`, `blocks`, and `templates` — but `videos`, `videoConfigs`, and `savedViews` present in the payload are silently ignored. Either document this as intentional scope limitation, or implement video/config import.
---
## CONDITIONAL Block — Unknown Rule Types Default to `true` (Needs Review)
`evaluateCondition()` in `render-engine.service.ts` has a `default: return true` branch for unrecognised `rule.type` values (line 326), and a second `default: return true` inside the `collab_count` case for unrecognised operators (line 323).
This means any typo in a condition rule — e.g. `"variable_fille"` instead of `"variable_filled"` — silently passes, causing the CONDITIONAL block to render unconditionally with no error or warning.
**Needs review:** Confirm whether the fail-open (`true`) behaviour is intentional (defensive — avoids hiding content on schema evolution) or a mistake (should fail-closed with `false` or throw). If fail-open is intentional, add a comment in the code. If not, change the `default` branches to `return false` and add a lint rule to validate condition JSON against the known rule type/operator set.
---
## Campaign.status Should Be an Enum
`Campaign.status` is a free-form `String` with no validation. The `DESC_OUTDATED_SPONSOR_COPY` lint rule performs an exact case-sensitive check: `status !== 'active'`. Any typo or alternate casing silently breaks campaign rendering and floods videos with lint errors.
Fix: migrate `Campaign.status` to a Prisma enum (`ACTIVE` / `INACTIVE` / `PAUSED`) and update the lint rule to check `status !== CampaignStatus.ACTIVE`. Add a DTO validation constraint in any future campaign create/update endpoint.
---
## REVIEWER Role Is a No-Op
The `REVIEWER` role (priority 2 in `RolesGuard`) is intended to allow "read + comment" access. No comment system exists — there is no `Comment` model, no comment endpoints, and no UI. No endpoint in the codebase specifies `@Roles(TeamRole.REVIEWER)` as a minimum, so `REVIEWER` currently grants exactly the same access as `READONLY` (priority 1).
Either build the comment system and add `REVIEWER`-gated endpoints, or remove the `REVIEWER` role from the enum and UI. Until then, the two roles are indistinguishable at runtime.
---
## Permission Inconsistency — Team Variable Delete Requires Only EDITOR
`DELETE /team-variables/:id` is guarded by `EDITOR` role (`team-variables.controller.ts`). Every other destructive delete in the API — blocks, templates, collaborators, saved views — requires `ADMIN`. Team variable delete is arguably more impactful: it triggers a full team re-render and silently breaks every description block that referenced the deleted token (the token resolves to an empty string at render time with no warning).
This appears to be an oversight — all three operations (create, update, delete) were given the same `EDITOR` guard without considering the blast radius of delete. Should be raised to `ADMIN` to match the rest of the API.
---
## `privacyStatus` Hash Migration
After adding `privacyStatus` to `hashMetadata()` (done in this session), existing `lastSyncedHash` values stored in the DB were computed without it. Videos will appear as "push pending" until they are either synced or refreshed from YouTube, at which point the hash is recomputed correctly. A one-time migration script that recomputes `lastSyncedHash` for all videos using their current `privacyStatus` would avoid this false-positive window.
---
## Operational Gaps
### BullMQ Job Failure Notification
Failed background jobs (sync, render, lint, bulk, import) are silently dropped from the user's perspective. The UI shows stale state (e.g. "push pending" forever) with no error message. Only the sync queue retains the last 5 failed jobs in Redis; other queues use BullMQ defaults. No dead-letter queue exists.
Options: surface job failure state via a `jobStatus` field on the relevant entity (video, bulk job), add a BullMQ dashboard (Bull Board), or implement a failure webhook/notification.
### BullMQ Retry Configuration
No queue has automatic retry or backoff configured. A transient YouTube API error or a momentary Redis hiccup fails the job permanently. Adding `attempts: 3` with `backoff: { type: 'exponential', delay: 5000 }` to queue `defaultJobOptions` would handle the majority of transient failures without code changes.
### Database Backup Strategy
No automated backup is configured. PostgreSQL data lives in the `postgres_data` Docker volume with no offsite copy. A daily `pg_dump` cron job (or a managed database service with point-in-time recovery) should be implemented before running in production with real user data.
### Health Check Depth
`GET /api/v1/health` only confirms the Node process is responding (`{ status: 'ok' }`). It does not verify database connectivity or Redis availability. A deep health check that tests a trivial Prisma query and a Redis ping would catch infrastructure failures earlier (e.g. for load balancer drain or alerting).
### Rate Limiting
No API endpoint has rate limiting. `@nestjs/throttler` is not installed. All routes are fully unthrottled, making the API vulnerable to abuse and brute-force on auth endpoints.
### TOKEN_ENCRYPTION_KEY Rotation Tooling
There is no script to re-encrypt stored YouTube OAuth tokens when rotating `TOKEN_ENCRYPTION_KEY`. Until one exists, key rotation forces all channel owners to re-authenticate. A migration script that reads with the old key and writes with the new key (in a transaction) would make rotation safe. See [[05 - Deployment and Operations]] for the manual procedure.
---
### Publishing Schedule Collision Window Is Hardcoded
`findNextFreeSlot` in `teams.service.ts` uses two hardcoded constants:
- **Collision window:** ±30 minutes — a candidate slot is skipped if any existing `scheduledAt` on the channel is within 30 minutes either side
- **Lookahead limit:** 90 days — returns `{ slot: null }` if no free slot is found within 90 days
Neither is configurable per-team. The 30-minute window blocks teams that publish multiple videos per day with closely spaced schedule slots (e.g. two videos scheduled 45 minutes apart would prevent a third from being suggested between them).
Consider exposing these as optional team settings (`collisionWindowMinutes`, `maxLookaheadDays`), or at minimum extracting them as named constants with a comment explaining the rationale.
---
## Improvements Worth Considering
### POST /auth/switch-team Should Return teamRole
`POST /auth/switch-team` returns only `{ accessToken }`. The user's role in the new team is encoded inside the JWT but not returned explicitly. A client that needs to update its role-based UI state after switching teams must either decode the JWT or make a follow-up `GET /users/me` call. Returning `{ accessToken, teamRole }` would eliminate that round-trip and match the shape of `POST /auth/login`.
### JWT in httpOnly Cookie (Auth Architecture Refactor)
Currently the JWT access token is stored in Zustand (persisted to `localStorage`) and the `sf_session` cookie exists solely as a routing flag for Next.js middleware, which cannot read `localStorage`.
The cleaner alternative is to store the access token in an httpOnly cookie instead. This would:
- Eliminate the `sf_session` workaround — middleware reads the real token directly
- Improve security by removing the token from JavaScript-accessible storage (mitigates XSS token theft)
- Require the backend to set the access token as a cookie on login/refresh, and the API client to stop sending `Authorization: Bearer` headers in favour of cookie-based transport
- Require CSRF protection if the app ever accepts cookie-auth on state-mutating endpoints
This is a meaningful architectural change touching `auth.controller.ts`, `api-client.ts`, `useAuthStore`, `middleware.ts`, and `CallbackHandler.tsx`. Worth evaluating if security posture becomes a priority.
---
### Calendar Week View — Arbitrary Week Navigation
The backend supports `GET /calendar?view=week&date=YYYY-MM` but the date parameter has no day component, so the week view always anchors to the Sunday of the week containing the 1st of the given month. There is no way to navigate to an arbitrary week via the API.
The frontend does not expose a week view toggle at all — only Month and Agenda are available in the UI.
To make week view useful, the `date` param would need to accept `YYYY-MM-DD`. `parseRange` in `calendar.service.ts` already calls `new Date(date)` for the agenda path, so the plumbing is close. The week case would need to be updated to parse a full date and compute the containing week from that instead of always using day 1.
---
### `variableDefinitions` Is Stored But Not Enforced
`DescriptionBlock.variableDefinitions` declares which custom tokens a block expects, but the render engine does not validate or warn when a required variable is missing — it silently resolves to an empty string. A lint rule checking for unfilled declared variables would catch authoring errors early.
@@ -0,0 +1,88 @@
# 2026-06-29 01 — Scheduled Remote Conflict Detection
**Status:** Shipped
**Scope:** Backend, frontend Settings UI, ops env, docs
---
## What we set out to do
Resolve the "Dead Code — `YouTubeSyncService.detectConflict()`" backlog entry. Before this session, the method was fully implemented but never called; conflict handling as a user-facing flow was effectively broken:
- **Detection** only happened on manual "Refresh from YouTube" per video.
- **Resolution** either meant running a full channel refresh (clobbers *every* video's local fields) or re-pushing local (silently overwrites remote). Neither offered a per-video accept/reject choice and neither showed a diff.
Decision: wire `detectConflict()` up as a scheduled sweep AND build a proper per-video accept-remote path. Direction "push local" reuses the existing `POST /videos/:id/sync`.
## Design decisions (locked in during the session)
| Question | Decision |
|---|---|
| Granularity | Per-video accept/reject (v1). Field-level merge deferred. |
| Settings location | Per-team, with cadence global via env var |
| Skip already-conflicted videos in the sweep? | Yes — no point re-detecting an unresolved conflict |
| Add a `POST /videos/:id/force-local` endpoint? | No — reuse existing `POST /videos/:id/sync` |
| Scheduler mechanism | BullMQ repeatable job (no new dependency) |
| Selection strategy | Stalest-first per team, filtered by `lastSyncedAt < now - minAgeDays`, `remoteConflict = false`, `youtubeDeletedAt = null` |
## What changed
### Backend
- **Schema** — new migration `20260629000000_add_conflict_detection_settings`:
- `Team.conflictDetectionEnabled Boolean @default(false)`
- `Team.conflictDetectionBatchSize Int @default(50)`
- `Team.conflictDetectionMinAgeDays Int @default(7)`
- `Video.pendingRemoteSnapshot Json?`
- `Video.pendingRemoteDescription String?`
- **`YouTubeSyncService.detectConflict()`** (`backend/src/modules/youtube-sync/youtube-sync.service.ts`) — on hash mismatch now persists `pendingRemoteSnapshot` (mirrors `youtubeSnapshot` shape) and `pendingRemoteDescription`. On hash match with a stale flag, self-heals (clears flag + pending fields). No more dead code.
- **New queue** `CONFLICT_DETECTION` in `backend/src/queues/queues.constants.ts`.
- **New processor** `backend/src/queues/processors/conflict-detection.processor.ts` — iterates enabled teams, stalest-first selection, per-call quota guard, per-team batch cap. Logs summary. Stops the whole sweep on quota exhaustion.
- **New scheduler** `backend/src/queues/schedulers/conflict-detection.scheduler.ts``OnModuleInit` registers a BullMQ repeatable driven by `CONFLICT_DETECTION_CRON` (default `0 3 * * *`), gated by `CONFLICT_DETECTION_ENABLED`. Wipes stale repeatables on boot so config changes take effect.
- **Wired both** into `backend/src/worker.module.ts`.
- **New endpoint** `POST /videos/:id/accept-remote` (EDITOR role) in `backend/src/modules/videos/videos.controller.ts` + `.service.ts`. Zero YouTube API calls — promotes stored `pendingRemoteSnapshot` into live columns and `youtubeSnapshot`, sets `renderedDescription = youtubeDescription = pendingRemoteDescription`, recomputes `lastSyncedHash`, sets `lastSyncedAt = now`, clears pending + flag. Emits audit log entry `action: 'accept-remote'`.
- **Team settings** — three new fields added to `GET`/`PATCH /teams/:teamId/settings` with bounds validation (batch 1500, min-age ≥ 0).
### Frontend
- `TeamSettings` interface (`frontend/src/lib/api.ts`) extended with the three new fields.
- New "Remote Conflict Detection" section on `/settings`: Enable toggle, "Videos per run" (number, 1500), "Only check videos older than (days)" (number, ≥0). Admin-only edit. Own Save button (kept separate from the schedule section to avoid mixed-scope saves).
### Ops / env
- `CONFLICT_DETECTION_ENABLED` and `CONFLICT_DETECTION_CRON` added to:
- `backend/.env` (locally set to `true` / default cron)
- `backend/.env.example` (default `false`)
- `infrastructure/.env.example`
- `infrastructure/docker-compose.yml` worker service (uses `:-` defaults)
### Documentation
- **Backlog entries removed** from `06 - Backlog/01 - Technical Debt and Future Work.md`:
- "Dead Code — `YouTubeSyncService.detectConflict()`"
- "Improvements Worth Considering — Automatic Remote Conflict Detection"
- **Docs brought back in sync** (11 findings across 8 files identified by an audit agent, all applied):
- Rewrote the two aggressively-wrong sections (Gotchas `remoteConflict Is Not Automatically Detected`, Schema `remoteConflict` paragraph) which literally claimed the opposite of the new behavior.
- Filled content gaps in `04 - Database Schema`, `05 - Queue System`, `13 - Team Settings`, `02 - Videos API`, `09 - Teams API`, `02 - Environment Variables`, `05 - Deployment and Operations`.
- Vault gained a new `07 - Daily Notes/` section (this note is the first entry).
## Verification
- Backend `tsc --noEmit` clean after `prisma generate`.
- Frontend `tsc --noEmit` clean for the touched files (`settings/page.tsx`, `api.ts`); pre-existing errors elsewhere untouched.
- Migration file present but not yet applied on any prod DB — user ran `prisma migrate` locally.
## Follow-ups worth flagging
- **UI for the conflict itself** — the video editor should show a per-field diff between the current local state and `pendingRemoteSnapshot` when `remoteConflict === true`, with "Accept remote" and "Keep local" buttons. Backend is ready; frontend diff view doesn't exist yet.
- **Field-level merge** — declined for v1; if requested later, the storage layer already supports it (fields are individually addressable in `pendingRemoteSnapshot`).
- **Multi-team frequency** — if teams later want different cadences, `frequency` needs to move from env-var to a per-team column and the scheduler must manage a repeatable-per-team.
## Related
- [[04 - Database Schema]] — new Team + Video columns
- [[05 - Queue System]] — processor details, quota model
- [[13 - Team Settings]] — per-team knobs, validation
- [[02 - Videos API]] — `accept-remote` endpoint
- [[04 - Gotchas]] — resolution options and self-heal
- [[02 - Environment Variables]] — `CONFLICT_DETECTION_*`
@@ -0,0 +1,55 @@
# 2026-07-04 01 — Batched Conflict Detection
**Status:** Shipped
**Scope:** Backend refactor + docs
---
## What we set out to do
The scheduled conflict-detection sweep from [[2026-06-29 01 Scheduled Remote Conflict Detection]] was doing one `videos.list` API call per video. YouTube's `videos.list` accepts up to 50 IDs per call and costs the same 1 quota unit either way — the per-video pattern was wasting ~98% of the quota it consumed.
Goal: batch, without changing observable behavior for users or the API surface.
## Design decisions locked in
| Question | Decision |
|---|---|
| Refactor `detectConflict` in place or add a new method? | Add `detectConflictsForVideos(videoIds[])` and delete the single-video `detectConflict()` — nothing else called it |
| Grouping strategy for batches | Group by `channelId` first (batch API needs a per-channel OAuth client), then chunks of 50 within each channel |
| Where does the quota check live? | Moved from the processor's per-video loop into the service's per-batch loop — `canSpend(1)` before each `getVideosBatch` call |
| Extend `getVideosBatch` to fetch `recordingDetails`? | Yes — the batch endpoint previously fetched only `snippet` + `status`, which would have dropped `recordingDate` from the hash and produced false positives. Adding `recordingDetails` is free (quota is per method-call, not per part) |
## What changed
- **`backend/src/modules/youtube-sync/youtube-api.client.ts:106`** — added `recordingDetails` to `getVideosBatch()`'s `part` array. No behavior change for existing callers (channel-import already falls back to DB values for missing fields).
- **`backend/src/modules/youtube-sync/youtube-sync.service.ts`** — removed `detectConflict(videoId)`, added `detectConflictsForVideos(videoIds[])`, extracted the per-video hash+persist logic into a private `applyConflictDetection(video, remoteItem)` helper. The batch method returns `{ scanned, conflicts, quotaExhausted }` so the processor can stop the sweep cleanly.
- **`backend/src/queues/processors/conflict-detection.processor.ts`** — inner loop replaced with a single service call per team. `QuotaService` no longer injected (moved into the service). Error handling now per-team (not per-video), which changes granularity of the `errors` counter — an API failure aborts one team but doesn't leak quota.
## Cost math — before vs after
| Team `batchSize` | Old quota per team per run | New quota per team per run |
|---:|---:|---:|
| 50 | 50 | 1 |
| 250 | 250 | 5 |
| 500 | 500 | 10 |
Numbers assume all videos on one channel. Extra API call per channel boundary within a batch, so multi-channel teams pay slightly more (still ~50× cheaper than before).
## Follow-ups worth flagging
- **Default `conflictDetectionBatchSize = 50` is now overly conservative.** Existing team settings unchanged out of caution. Users could safely raise to 250500. Worth a mention in release notes if you ship an announcement.
- **`applyConflictDetection` is `private` on the service** — if a manual `POST /videos/:id/detect-conflict` endpoint ever becomes a thing, it should call `detectConflictsForVideos([id])` rather than making a single-video sibling method reappear.
- **Batch API doesn't return `contentDetails`.** Not needed by conflict detection but referenced by `refreshFromYouTube`. That path still uses the single-video `getVideoMetadata` — no change needed.
## Verification
- Backend `tsc --noEmit` clean
- No migration required — this is a service-level refactor only
- Frontend untouched
## Related
- [[2026-06-29 01 Scheduled Remote Conflict Detection]] — original feature
- [[05 - Queue System]] — updated processor description with new cost model
- [[04 - Gotchas]] — updated `remoteConflict` cost section