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
@@ -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]]