Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -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 (1–500). |
|
||||
| `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.
|
||||
Reference in New Issue
Block a user