# Gotchas Non-obvious behaviors, known traps, and decisions that have caused bugs or confusion during development. Read this before debugging anything that "should work." --- ## CONDITIONAL Block — Unknown Rule Types and Operators Silently Default to `true` `evaluateCondition()` in `render-engine.service.ts` evaluates each rule in a CONDITIONAL block's `condition.rules` array via a `switch` statement. The `default` branch returns `true` for any unrecognised `rule.type`. A second `default: return true` exists inside the `collab_count` case for unrecognised operators. Practical consequences: - A typo in `rule.type` (e.g. `"variable_fille"` instead of `"variable_filled"`) silently makes the rule pass, causing the block to render unconditionally. - An unrecognised `operator` on a `collab_count` rule (e.g. `"neq"` instead of `"eq"`) also silently passes. - No error is thrown, no warning is logged, and no lint rule checks condition JSON for valid rule types. Known valid `rule.type` values: `"variable_filled"`, `"variable_empty"`, `"collab_count"`. Known valid `collab_count` operators: `"eq"`, `"gt"`, `"lt"`, `"gte"`, `"lte"`. If a CONDITIONAL block appears to render even when its condition should not be met, check the condition JSON for typos in `type` or `operator`. --- ## Campaign.status Must Be Exactly `"active"` (Lowercase) `Campaign.status` is a free-form `String` column with no enum constraint and no API-level validation. The `DESC_OUTDATED_SPONSOR_COPY` lint rule (`linting/rules/desc-outdated-sponsor.rule.ts`) checks `status !== 'active'` — an exact case-sensitive string match. Any value other than the lowercase string `"active"` is treated as inactive: - `"ACTIVE"` → inactive (lint ERROR fires) - `"paused"`, `"disabled"`, `"inactive"` → inactive (lint ERROR fires) - Any typo → inactive (lint ERROR fires) When a campaign is incorrectly treated as inactive, its CAMPAIGN blocks are excluded from all rendered descriptions and every video that references them shows `DESC_OUTDATED_SPONSOR_COPY`. If campaigns appear to have stopped working for no obvious reason, check `Campaign.status` for a case mismatch or typo. --- ## remoteConflict — Detection Paths and Resolution `Video.remoteConflict` can be set by three paths: 1. **Manual refresh** — `POST /videos/:id/refresh` fetches YouTube-side metadata and recomputes `lastSyncedHash`. If the new hash differs from the stored one, the flag flips on. 2. **Scheduled sweep** — the `CONFLICT_DETECTION` BullMQ queue runs on a cron pattern (`CONFLICT_DETECTION_CRON`, default 03:00 daily) when the operator sets `CONFLICT_DETECTION_ENABLED=true` on the worker AND the team opts in via `Team.conflictDetectionEnabled`. See [[05 - Queue System]]. 3. **Implicit via full channel refresh** — a channel-level re-import overwrites local fields wholesale, which is not conflict *detection* but effectively resolves any conflict by clobbering local state. When path 1 or 2 detects a mismatch, `pendingRemoteSnapshot` (JSON) and `pendingRemoteDescription` (String) capture the freshly fetched remote state so the user can review and resolve it without a second YouTube call. **Resolution options:** - `POST /videos/:id/accept-remote` — adopts the stored pending snapshot as the new local state. Zero YouTube API calls. - `POST /videos/:id/sync` — pushes local over remote (standard sync). Clears the flag on success. **Self-heal:** if the sweep re-checks a video that was previously flagged but the remote now matches `lastSyncedHash` again (e.g. the creator reverted their out-of-band edit), the flag and pending fields are cleared automatically on the next pass. **Cost:** the scheduled sweep uses `videos.list` batched up to 50 IDs at a time — **1 quota unit per batch**, not per video. A `batchSize` of 250 costs roughly 5 units per team per run, plus one extra call whenever a batch spans a channel boundary (batches are grouped by channel first because the OAuth client is per-channel). `QuotaService.canSpend(1)` guards each batch and the sweep stops early on exhaustion. --- ## lintStatus Is a Denormalized Cache `Video.lintStatus` is not computed on read — it is a stored value written when lint jobs complete. Any code path that deletes `LintResult` rows (e.g. re-importing a channel, removing a lint rule) must recompute it afterward. Use `POST /lint/team/recompute-status` to heal all stale statuses across the team. The linting page calls this automatically on load. --- ## Redis Eviction Policy BullMQ silently drops jobs if Redis is configured with `allkeys-lru` eviction. Redis must run with `--maxmemory-policy noeviction`. This is already set in `infrastructure/docker-compose.yml`. Do not change it. If jobs seem to disappear without being processed, check the Redis eviction policy first. --- ## TOKEN_ENCRYPTION_KEY Rotation YouTube OAuth tokens are AES-256 encrypted in the database using `TOKEN_ENCRYPTION_KEY`. If this key is changed or lost, all stored tokens become unreadable and every connected channel breaks. Channels must then be fully re-authenticated. The key must remain stable for the entire lifetime of the database. See [[02 - Environment Variables]] for the full variable reference. --- ## Recording Date Is Never Imported from YouTube The YouTube API does not return `recordingDate` in video list or playlist responses. It always imports as `null`. If a user sets a recording date locally, the video will show as "Push pending" (because the hash changes) and will remain so until the change is pushed to YouTube. This is expected behavior, not a bug. --- ## freetext: IDs in blockOrder `VideoConfig.blockOrder` and `Template.defaultBlocks` are JSON string arrays that may contain IDs with the prefix `freetext:` (e.g. `"freetext:abc123"`). These do not correspond to any `DescriptionBlock` row in the database. Their content comes entirely from `blockOverrides[id].content`. Always check for this prefix before performing a DB lookup on a block ID. See [[02 - Description Engine]] for the full block rendering model. --- ## hashMetadata Fields `hashMetadata()` (in `backend/src/shared/render-engine/hash.ts`) hashes exactly these fields: - `title` - `description` - `tags` - `categoryId` - `privacyStatus` - `defaultLanguage` - `defaultAudioLanguage` - `selfDeclaredMadeForKids` - `embeddable` - `license` - `recordingDate` All of these fields are included in the YouTube push payload. A local change to any of them will flip `hasPendingChanges` to true and trigger a sync. `privacyStatus` was added to the hash after it was discovered that privacy-only changes were silently dropped (the hash did not change, so the push was skipped). --- ## Date Formatting Single-Pass Regex `applyDateFormat()` in `backend/src/shared/render-engine/render-engine.service.ts` uses a single-pass regex replacement. Do not refactor it to use chained `.replace()` calls. Chained replacements cause re-substitution bugs — for example, the `M` token in a date format would match again inside the already-substituted word "March", corrupting the output. The single-pass approach was introduced specifically to fix this class of bug. --- ## collab.youtube Resolves to a Full URL The token `{collab.youtube}` resolves to the full YouTube channel URL, for example `https://www.youtube.com/@handle`. It does not resolve to just the handle string. If you need only the handle portion, that is not currently a supported token. --- ## {video.publishedAt} Does Not Exist `{video.publishedAt}` is not a supported render token and has never been registered in `VIDEO_RESOLVERS` or `SYSTEM_VARIABLE_TOKENS`. It was present in earlier versions of this documentation in error. If any description block contains `{video.publishedAt}`, the placeholder will remain unreplaced in the rendered output — it resolves to nothing, leaving the literal string `{video.publishedAt}` in the description. The `DESC_EMPTY_PLACEHOLDER` lint rule will flag it. The correct tokens for dates are `{video.scheduledAt}` and `{video.recordingDate}`. `publishedAt` is a database field accessible via the API but has no corresponding render token. --- ## collab.handle Is Deprecated The token `{collab.handle}` was renamed to `{collab.youtube}`. Any description block content still containing `{collab.handle}` will not resolve — it will remain as an unresolved placeholder in the rendered output. The `DESC_EMPTY_PLACEHOLDER` lint rule will catch this and flag it. Update affected blocks manually by replacing `{collab.handle}` with `{collab.youtube}`. --- ## Prisma Enum Values Cannot Be Removed Removing a value from a PostgreSQL enum requires a raw SQL migration and risks data corruption if any existing rows reference the removed value. Never remove values from Prisma enums. Instead, mark them as deprecated in the UI so they are hidden from users but remain valid in the database. The `BlockType` values `GLOBAL` and `REPEATABLE` are the current examples of this pattern. --- ## Two Separate Entry Points The backend has two separate NestJS entry points that must both be running: - `src/main.ts` → `AppModule` → HTTP API on port 3001 - `src/worker.ts` → `WorkerModule` → BullMQ queue processor (no HTTP) When you add a new module, register it in `AppModule`. If the worker's queue processors also need to use services from that module, register it in `WorkerModule` as well. Forgetting the `WorkerModule` registration causes runtime errors in background jobs that are invisible until a relevant job is actually processed. --- ## Collaborator IDs Live on Video, Not VideoConfig Collaborators are assigned via `Video.collaboratorIds` — a JSON string array on the `Video` row itself, not on `VideoConfig`. There is no `VideoCollaborator` join table. This single field is used by the render engine to resolve `{collab.*}` tokens, by the video list filter (`collaboratorId` query param), and by the calendar. Use the `array_contains` Prisma operator when querying this JSON field. --- ## SiYoutube Does Not Exist `react-icons/si` v5 does not export `SiYoutube`. Attempting to import it will cause a build error. Use `FaYoutube` from `react-icons/fa` instead for the YouTube icon. All other platform icons (`SiTwitch`, `SiInstagram`, `SiTiktok`, `SiX`, `SiBluesky`, `SiDiscord`) are available in `react-icons/si`. --- ## Middle-Click on Windows Windows browsers intercept `mousedown` for middle-click before the `auxclick` event fires, entering autoscroll mode instead. Using `onAuxClick` or `window.open()` to handle middle-click will not work reliably on Windows. To support middle-click navigation on table rows or cards, use a real `` element as an absolutely positioned overlay over the clickable area. The row overlay pattern in `VideoTable.tsx` is the reference implementation for this. --- ## min-width: 0 on Flex and Grid Children Flex and grid children default to `min-width: auto`, meaning they cannot shrink below their content's natural size. This causes horizontal overflow on any flex or grid child that contains long text, wide tables, or deeply nested content. Add `min-width: 0` to every flex/grid child at every nesting level that might contain wide content. This applies in CSS Modules and must be repeated at each level — the parent setting does not propagate. See [[02 - CSS Conventions]] for the full CSS pattern reference. --- ## Deleted Blocks Are Silently Skipped at Render Time If a block ID in `VideoConfig.blockOrder` no longer has a corresponding `DescriptionBlock` row (because the block was deleted), the render engine silently skips it — `render-engine.service.ts` line 196: `if (!block) continue;`. No error is thrown, no warning is logged, and no lint result is produced. The deleted block simply disappears from the rendered description without any indication that the output is incomplete. A video can silently lose description content with no user-facing signal. There is no lint rule that checks for orphaned block IDs in `blockOrder`. If a block that is referenced by many videos is deleted, all of those videos will render incomplete descriptions until their `blockOrder` is manually cleaned up. --- ## BullMQ Job Deduplication BullMQ deduplicates jobs by `jobId` — if a job with the same ID already exists in the queue and has not yet run, the new submission is silently ignored. Using a static ID like `lint-{videoId}` is intentional for normal lint enqueueing (prevents duplicate lint jobs from piling up). For forced reruns — such as "rerun all lint checks" — use a timestamp suffix to bypass deduplication: ```typescript jobId: `lint-${videoId}-${Date.now()}` ``` Without the suffix, the "rerun" submits a job that is immediately deduplicated against the existing queued job and never actually runs.