From e59ad608d0556ca7ca2bac95c6f1e05bcab596d8 Mon Sep 17 00:00:00 2001 From: Vlad Date: Thu, 23 Apr 2026 05:10:03 -0300 Subject: [PATCH 1/3] docs: plan feat-002 manager metadata cms sync --- ...001-feat-manager-metadata-cms-sync-plan.md | 276 ++++++++++++++++++ ...eat-002-wire-enrichment-metadata-to-cms.md | 2 +- 2 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-04-23-001-feat-manager-metadata-cms-sync-plan.md diff --git a/docs/plans/2026-04-23-001-feat-manager-metadata-cms-sync-plan.md b/docs/plans/2026-04-23-001-feat-manager-metadata-cms-sync-plan.md new file mode 100644 index 000000000..27a6c4f3a --- /dev/null +++ b/docs/plans/2026-04-23-001-feat-manager-metadata-cms-sync-plan.md @@ -0,0 +1,276 @@ +--- +title: "feat: Sync manager metadata into CMS keywords" +type: feat +status: active +date: 2026-04-23 +origin: docs/roadmap/topic-experiences/feat-002-wire-enrichment-metadata-to-cms.md +--- + +# feat: Sync manager metadata into CMS keywords + +## Overview + +`feat-002` should turn manager-generated enrichment metadata into first-class CMS records that downstream search, filtering, and topic-generation work can reuse. On `origin/planning`, the metadata pipeline already writes `metadata.json` artifacts, the CMS already has `Keyword` and `Video.keywords` relations, and the manager already has a dormant `notifyCms` / `cms_notify` lane. The missing piece is a repo-native sync path that attaches manager-authored keywords to the correct CMS video without colliding with core-sync ownership rules. + +## Problem Frame + +The roadmap ticket is directionally correct but no longer matches the planning-branch architecture in a few important ways: + +1. Manager CMS writes should follow the existing GraphQL client pattern in `apps/manager/src/cms/client.ts`, not add fresh Strapi REST usage. +2. The workflow already has an existing `notifyCms` concept and `cms_notify` step name. Adding a separate `metadataSync` step would duplicate that lane unless implementation proves it is insufficient. +3. `Keyword.coreId` is required and unique, so manager-authored keyword/topic/speaker rows need stable synthetic IDs rather than blank values. +4. `EnrichmentJob` already has a `video` relation in CMS, which is the right place to persist the target video for downstream metadata sync. The current manager state layer simply does not use it yet. +5. Overwriting `Video.title` / `description` from manager metadata would fight the current core-owned video model. This slice should focus on keyword relations plus `aiMetadata`, not replace canonical video text. + +## Requirements Trace + +- R1. Manager metadata artifacts sync into CMS records tied to the correct `Video` +- R2. Tags, topics, and speakers become idempotent manager-owned keyword relations +- R3. The workflow exposes CMS sync as a real optional step, using the existing notify-CMS lane +- R4. Re-running enrichment does not duplicate keywords or break core-sync ownership rules +- R5. CMS schema changes regenerate GraphQL types in the same PR +- R6. Verification proves both sync behavior and re-run idempotency + +## Scope Boundaries + +- Only `apps/manager`, `apps/cms`, and generated GraphQL contract output are in scope +- This slice syncs manager metadata to existing CMS videos only; it does not create new videos +- This slice does not overwrite core-authored `Video.title`, `Video.description`, or other core-managed localized fields +- This slice does not add separate Topic or Speaker content types +- This slice does not broaden `/api/jobs` ingest-from-URL flows to support CMS sync unless a related video is explicitly present +- Search UI, report UI, and topic-generation consumers stay unchanged in this ticket + +## Context & Research + +### Relevant Code and Patterns + +- `apps/manager/src/services/metadata.ts` already extracts and persists `metadata.json` +- `apps/manager/src/services/storage.ts` already exposes `readArtifact(...)` for replayable sync inputs +- `apps/manager/src/workflows/videoEnrichment.ts` currently runs `metadata` in parallel and already has a reserved downstream `cms_notify` concept in manager types/UI +- `apps/manager/src/app/api/enrich/route.ts` already looks up the target CMS video and has the data needed to persist the `EnrichmentJob.video` relation +- `apps/manager/src/lib/state.ts` is the existing typed GraphQL write path to Strapi from manager +- `apps/cms/src/api/keyword/content-types/keyword/schema.json` and `apps/cms/src/api/video/content-types/video/schema.json` already provide the relation model to reuse +- `apps/cms/src/api/core-sync/services/strapi-helpers.ts` and `apps/cms/src/api/core-sync/services/bulk-upsert.ts` already preserve manager-owned records by skipping `source === "manager"` during core sync + +### Institutional Learnings + +- `docs/solutions/platform/videoforge-manager-integration.md` documents manager’s intended CMS integration path through `@forge/graphql` +- Core-sync’s `source === "manager"` skip behavior is already encoded in CMS sync helpers, so manager-authored metadata should lean on that ownership model rather than inventing a parallel source-of-truth + +## Key Technical Decisions + +- **Use GraphQL mutations from manager, not Strapi REST.** + The roadmap ticket predates the current manager GraphQL client pattern. New CMS writes should follow `apps/manager/src/cms/client.ts` plus typed operations in manager/state-layer modules. + +- **Reuse `cms_notify` / `notifyCms` as the workflow-facing step name.** + The planning branch already contains this option and step label. This implementation should turn that placeholder into real metadata sync rather than add a second CMS-sync vocabulary unless code-level constraints force a rename. + +- **Represent tags, topics, and speakers with the existing `Keyword` model plus a new `type` enum.** + Reusing `Video.keywords` keeps the first slice small and avoids adding fresh `Video.speakers` storage. `Keyword.type` should distinguish `"keyword"`, `"topic"`, and `"speaker"`. + +- **Generate stable synthetic `coreId` values for manager keywords.** + Because `Keyword.coreId` is required + unique, manager-owned records need deterministic IDs such as `manager:::`. This preserves idempotency and lets core-sync continue skipping manager-owned rows safely. + +- **Persist and reuse the target video through `EnrichmentJob.video`.** + The CMS schema already has this relation. `/api/enrich` should create jobs with the related video attached, and the sync step should resolve that relation from job state rather than trying to rediscover the video from raw artifact storage. + +- **Only mark `Video.aiMetadata` and keyword relations in this slice.** + AI-generated title/description text should remain artifact-only for now so manager does not overwrite core-managed video text fields. If editorially-visible text sync is desired later, it should land as a follow-up ticket with explicit ownership rules. + +## Open Questions + +### Resolved During Planning + +- **Should this add a new `metadataSync` step?** + No by default. The planning branch already has `notifyCms` and `cms_notify`, so the implementation should extend that existing lane. + +- **How does the workflow know which video to update?** + Use the existing `EnrichmentJob.video` relation and thread it through manager state + job creation. + +- **Where should speakers live?** + Use the existing `Keyword` relation with `type: "speaker"` for this slice. + +### Deferred to Implementation + +- **Normalization details for synthetic keyword IDs** + Implementers should choose one shared normalizer for case-folding, whitespace collapse, and punctuation stripping, then use it consistently for both read and write paths. + +- **Whether to expose synced keyword types in current manager APIs** + This ticket does not require new read APIs; only add them if verification or operator workflows need them during implementation. + +## High-Level Technical Design + +> This is directional guidance for implementation, not code to reproduce verbatim. + +1. `/api/enrich` creates an `EnrichmentJob` that includes the target `video` relation and `notifyCms: true` intent for jobs launched against existing CMS videos. +2. The workflow keeps metadata extraction where it is today, then conditionally runs `cms_notify` after `metadata` completes successfully. +3. The CMS-sync step reads the structured metadata artifact (or receives the parsed metadata directly), loads the related job/video context, and upserts manager-owned keywords by stable synthetic `coreId`. +4. The step updates the target `Video` by setting `keywords` to the merged manager keyword relation set and `aiMetadata` to `true`. +5. Re-runs find the same synthetic IDs, update the same keyword rows, and leave relation counts stable. + +## Implementation Units + +- [ ] **Unit 1: Extend CMS schema/contracts for typed manager keywords** + + **Goal:** Make the CMS contract capable of distinguishing manager-created keywords, topics, and speakers. + + **Requirements:** R2, R5 + + **Dependencies:** None + + **Files:** + - Modify: `apps/cms/src/api/keyword/content-types/keyword/schema.json` + - Modify: `apps/cms/schema.graphql` + - Modify: `packages/graphql/src/graphql-env.d.ts` + + **Approach:** + - Add `Keyword.type` enum with `"keyword" | "topic" | "speaker"` and default `"keyword"` + - Keep `source` as the ownership discriminator (`"core"` vs `"manager"`) + - Regenerate GraphQL types immediately after the schema change + + **Patterns to follow:** + - Existing enum usage in CMS schema JSON + - Repo rule: generated GraphQL outputs land in the same PR as CMS schema changes + + **Test scenarios:** + - GraphQL schema exposes `Keyword.type` + - Generated types include the new field/input enum + +- [ ] **Unit 2: Persist video + CMS-sync intent in manager job state** + + **Goal:** Ensure enrichment jobs launched for existing CMS videos retain enough context for downstream metadata sync. + + **Requirements:** R1, R3 + + **Dependencies:** Unit 1 not required + + **Files:** + - Modify: `apps/manager/src/app/api/enrich/route.ts` + - Modify: `apps/manager/src/lib/state.ts` + - Modify: `apps/manager/src/types/job.ts` + - Modify: `apps/cms/src/api/enrichment-job/content-types/enrichment-job/schema.json` only if the current GraphQL input/output shape is missing required job-option fields + - Add/modify tests: `apps/manager/src/app/api/enrich/route.test.ts`, `apps/manager/src/lib/state.test.ts` + + **Approach:** + - Update manager’s create-job path to accept an optional `videoDocumentId` / `videoCoreId` context and `notifyCms` option + - Attach the related CMS video when `/api/enrich` creates a job for an existing video + - Surface `notifyCms` and video identity in the manager state mapping if the workflow needs them later + - Keep `/api/jobs` out of scope unless it has a valid related CMS video + + **Patterns to follow:** + - Existing GraphQL job creation in `apps/manager/src/lib/state.ts` + - Existing `notifyCms` option names in `apps/manager/src/types/job.ts` and `apps/manager/src/app/dashboard/jobs/new-job-form.tsx` + + **Test scenarios:** + - `/api/enrich` stores the target video relation on the created job + - Jobs without a related video do not attempt CMS sync + - Existing non-CMS job flows keep working unchanged + +- [ ] **Unit 3: Implement manager-side metadata-to-CMS sync service** + + **Goal:** Upsert typed manager keywords and attach them to the target video idempotently. + + **Requirements:** R1, R2, R4 + + **Dependencies:** Units 1 and 2 + + **Files:** + - Modify: `apps/manager/src/services/metadata.ts` or add a focused companion module such as `apps/manager/src/services/metadata-sync.ts` + - Modify: `apps/manager/src/cms/client.ts` only if auth/query helpers are insufficient + - Add tests: `apps/manager/src/services/metadata-sync.test.ts` + + **Approach:** + - Load parsed metadata from the existing extraction result or `readArtifact(assetId, "metadata", "json")` + - Normalize tags/topics/speakers into one typed keyword list + - Generate stable synthetic `coreId` values for manager records + - Query existing manager/core keywords as needed, then create or update manager-owned keyword rows via GraphQL mutations + - Merge the resulting keyword document IDs onto the target `Video.keywords` relation and set `aiMetadata: true` + - Never overwrite `Video.title` / `description` in this slice + + **Patterns to follow:** + - Existing manager GraphQL mutation style in `apps/manager/src/lib/state.ts` + - Existing artifact IO pattern in `apps/manager/src/services/storage.ts` + - Existing manager-ownership preservation logic in CMS core-sync helpers + + **Test scenarios:** + - First sync creates manager-owned keywords for tags/topics/speakers + - Second sync reuses the same synthetic IDs and does not duplicate keyword rows + - Existing core-owned keywords on a video remain attached after manager sync + - `aiMetadata` flips to `true` only after a successful sync + +- [ ] **Unit 4: Wire the workflow + UI step through `cms_notify`** + + **Goal:** Make CMS sync a visible optional workflow phase that runs after metadata extraction. + + **Requirements:** R3, R4 + + **Dependencies:** Units 2 and 3 + + **Files:** + - Modify: `apps/manager/src/workflows/videoEnrichment.ts` + - Modify: `apps/manager/src/lib/workflow-steps.ts` + - Modify: `apps/manager/src/types/job.ts` + - Modify: `apps/cms/src/components/enrichment/job-step.json` + - Modify: `apps/manager/src/features/jobs/live-job-steps-table.tsx` + - Add/modify tests: `apps/manager/src/workflows/videoEnrichment.test.ts` + + **Approach:** + - Add `cms_notify` to the initial step set only for jobs that opt into CMS sync, or explicitly mark it skipped when not requested + - Run the step after metadata succeeds and before the job is marked complete + - Ensure failures record on the `cms_notify` step rather than being misattributed to `metadata` + - Reuse the existing step description/UI lane instead of inventing a second CMS sync label + + **Test scenarios:** + - `notifyCms: true` runs the CMS sync step after metadata + - `notifyCms: false` skips or omits the step deterministically + - CMS sync failure marks only `cms_notify` failed and leaves prior completed steps intact + +- [ ] **Unit 5: End-to-end verification** + + **Goal:** Prove real CMS linkage and re-run idempotency. + + **Requirements:** R6 + + **Dependencies:** Units 1-4 + + **Files:** + - No required code changes + + **Approach:** + - Run an enrichment job against an existing CMS-backed video via `/api/enrich` + - Query the target video through manager/CMS GraphQL and confirm `keywords` + `aiMetadata` + - Re-run the same job and confirm keyword row counts stay stable + - Confirm core-sync still preserves manager-owned keywords on a subsequent sync dry run or characterization test + + **Verification:** + - Manager/CMS query shows typed manager keywords attached to the correct video + - `aiMetadata` is `true` after sync + - Re-run does not create duplicate manager keyword rows + +## System-Wide Impact + +- **Manager workflow:** gains a real post-metadata CMS sync phase for jobs tied to existing videos +- **CMS contract:** `Keyword` gains a type discriminator, and generated GraphQL types change accordingly +- **Core-sync interaction:** unchanged by design except that manager-owned keywords now participate in the existing skip/preservation rules +- **Operator UX:** existing `Notify CMS (Strapi)` language becomes truthful for enrichment jobs launched from existing videos + +## Risks & Dependencies + +- **Risk: synthetic keyword IDs drift** — inconsistent normalization would break idempotency. Mitigation: one shared normalization helper with test coverage. +- **Risk: relation replacement drops existing keywords** — a naive `updateVideo(keywords: [...])` could blow away existing relations. Mitigation: load current keyword IDs first and merge manager additions deterministically. +- **Risk: planning-branch drift around CMS gateway abstraction** — `apps/manager/AGENTS.md` references `src/cms/gateway.ts`, but `origin/planning` still uses `src/cms/client.ts`. Mitigation: implement against current planning-branch code and only introduce a gateway if the branch adds it during execution. +- **Dependency: GraphQL regeneration** — required immediately after CMS schema changes. + +## Sources & References + +- Origin ticket: `docs/roadmap/topic-experiences/feat-002-wire-enrichment-metadata-to-cms.md` +- Related code: + - `apps/manager/src/services/metadata.ts` + - `apps/manager/src/services/storage.ts` + - `apps/manager/src/workflows/videoEnrichment.ts` + - `apps/manager/src/app/api/enrich/route.ts` + - `apps/manager/src/lib/state.ts` + - `apps/cms/src/api/keyword/content-types/keyword/schema.json` + - `apps/cms/src/api/enrichment-job/content-types/enrichment-job/schema.json` + - `apps/cms/src/api/core-sync/services/strapi-helpers.ts` +- Related learning: `docs/solutions/platform/videoforge-manager-integration.md` diff --git a/docs/roadmap/topic-experiences/feat-002-wire-enrichment-metadata-to-cms.md b/docs/roadmap/topic-experiences/feat-002-wire-enrichment-metadata-to-cms.md index 1269374fd..49082cae1 100644 --- a/docs/roadmap/topic-experiences/feat-002-wire-enrichment-metadata-to-cms.md +++ b/docs/roadmap/topic-experiences/feat-002-wire-enrichment-metadata-to-cms.md @@ -3,7 +3,7 @@ id: "feat-002" title: "Wire Enrichment Metadata Back to CMS" owner: "vlad" priority: "P0" -status: "not-started" +status: "in-progress" start_date: "2026-04-01" duration: 14 depends_on: [] From 7b48c12ad75b2555af10982626f530c4f140e179 Mon Sep 17 00:00:00 2001 From: Vlad Date: Thu, 23 Apr 2026 13:06:28 -0300 Subject: [PATCH 2/3] feat(cms): type manager keywords --- apps/cms/schema.graphql | 11 ++++++++++- .../src/api/keyword/content-types/keyword/schema.json | 5 +++++ ...6-04-23-001-feat-manager-metadata-cms-sync-plan.md | 4 +++- packages/graphql/src/graphql-env.d.ts | 7 ++++--- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/cms/schema.graphql b/apps/cms/schema.graphql index 45436cfe4..5247acc21 100644 --- a/apps/cms/schema.graphql +++ b/apps/cms/schema.graphql @@ -1317,6 +1317,12 @@ enum ENUM_KEYWORD_SOURCE { manager } +enum ENUM_KEYWORD_TYPE { + keyword + speaker + topic +} + enum ENUM_LANGUAGEAUDIOPREVIEW_SOURCE { core manager @@ -1724,6 +1730,7 @@ type Keyword { language: Language publishedAt: DateTime source: ENUM_KEYWORD_SOURCE! + type: ENUM_KEYWORD_TYPE updatedAt: DateTime value: String videos(filters: VideoFiltersInput, pagination: PaginationArg = {}, sort: [String] = []): [Video]! @@ -1754,6 +1761,7 @@ input KeywordFiltersInput { or: [KeywordFiltersInput] publishedAt: DateTimeFilterInput source: StringFilterInput + type: StringFilterInput updatedAt: DateTimeFilterInput value: StringFilterInput videos: VideoFiltersInput @@ -1764,6 +1772,7 @@ input KeywordInput { language: ID publishedAt: DateTime source: ENUM_KEYWORD_SOURCE + type: ENUM_KEYWORD_TYPE value: String videos: [ID] } @@ -3570,4 +3579,4 @@ input VideoVariantInput { type VideoVariantRelationResponseCollection { nodes: [VideoVariant!]! -} \ No newline at end of file +} diff --git a/apps/cms/src/api/keyword/content-types/keyword/schema.json b/apps/cms/src/api/keyword/content-types/keyword/schema.json index 3a8cacfda..36fdf7fa1 100644 --- a/apps/cms/src/api/keyword/content-types/keyword/schema.json +++ b/apps/cms/src/api/keyword/content-types/keyword/schema.json @@ -37,6 +37,11 @@ "enum": ["core", "manager"], "default": "core", "required": true + }, + "type": { + "type": "enumeration", + "enum": ["keyword", "topic", "speaker"], + "default": "keyword" } } } diff --git a/docs/plans/2026-04-23-001-feat-manager-metadata-cms-sync-plan.md b/docs/plans/2026-04-23-001-feat-manager-metadata-cms-sync-plan.md index 27a6c4f3a..0245610f8 100644 --- a/docs/plans/2026-04-23-001-feat-manager-metadata-cms-sync-plan.md +++ b/docs/plans/2026-04-23-001-feat-manager-metadata-cms-sync-plan.md @@ -110,7 +110,7 @@ The roadmap ticket is directionally correct but no longer matches the planning-b ## Implementation Units -- [ ] **Unit 1: Extend CMS schema/contracts for typed manager keywords** +- [x] **Unit 1: Extend CMS schema/contracts for typed manager keywords** **Goal:** Make the CMS contract capable of distinguishing manager-created keywords, topics, and speakers. @@ -136,6 +136,8 @@ The roadmap ticket is directionally correct but no longer matches the planning-b - GraphQL schema exposes `Keyword.type` - Generated types include the new field/input enum + **Automation progress, 2026-04-23:** Added `Keyword.type`, updated `apps/cms/schema.graphql`, regenerated `packages/graphql/src/graphql-env.d.ts`, and validated JSON/schema formatting plus gql.tada generation. + - [ ] **Unit 2: Persist video + CMS-sync intent in manager job state** **Goal:** Ensure enrichment jobs launched for existing CMS videos retain enough context for downstream metadata sync. diff --git a/packages/graphql/src/graphql-env.d.ts b/packages/graphql/src/graphql-env.d.ts index cb9482faf..aff80e97e 100644 --- a/packages/graphql/src/graphql-env.d.ts +++ b/packages/graphql/src/graphql-env.d.ts @@ -141,6 +141,7 @@ export type introspection_types = { 'ENUM_COUNTRY_SOURCE': { name: 'ENUM_COUNTRY_SOURCE'; enumValues: 'core' | 'manager'; }; 'ENUM_ENRICHMENTJOB_STATUS': { name: 'ENUM_ENRICHMENTJOB_STATUS'; enumValues: 'completed' | 'failed' | 'pending' | 'running'; }; 'ENUM_KEYWORD_SOURCE': { name: 'ENUM_KEYWORD_SOURCE'; enumValues: 'core' | 'manager'; }; + 'ENUM_KEYWORD_TYPE': { name: 'ENUM_KEYWORD_TYPE'; enumValues: 'keyword' | 'speaker' | 'topic'; }; 'ENUM_LANGUAGEAUDIOPREVIEW_SOURCE': { name: 'ENUM_LANGUAGEAUDIOPREVIEW_SOURCE'; enumValues: 'core' | 'manager'; }; 'ENUM_LANGUAGE_SOURCE': { name: 'ENUM_LANGUAGE_SOURCE'; enumValues: 'core' | 'manager'; }; 'ENUM_MUXVIDEO_SOURCE': { name: 'ENUM_MUXVIDEO_SOURCE'; enumValues: 'core' | 'manager'; }; @@ -190,12 +191,12 @@ export type introspection_types = { 'IntFilterInput': { kind: 'INPUT_OBJECT'; name: 'IntFilterInput'; isOneOf: false; inputFields: [{ name: 'and'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; }; defaultValue: null }, { name: 'between'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; }; defaultValue: null }, { name: 'contains'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'containsi'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'endsWith'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'eq'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'eqi'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'gt'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'gte'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'in'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; }; defaultValue: null }, { name: 'lt'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'lte'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'ne'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'nei'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'not'; type: { kind: 'INPUT_OBJECT'; name: 'IntFilterInput'; ofType: null; }; defaultValue: null }, { name: 'notContains'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'notContainsi'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'notIn'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; }; defaultValue: null }, { name: 'notNull'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: null }, { name: 'null'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: null }, { name: 'or'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; }; defaultValue: null }, { name: 'startsWith'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }]; }; 'JSON': unknown; 'JSONFilterInput': { kind: 'INPUT_OBJECT'; name: 'JSONFilterInput'; isOneOf: false; inputFields: [{ name: 'and'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; }; defaultValue: null }, { name: 'between'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; }; defaultValue: null }, { name: 'contains'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'containsi'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'endsWith'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'eq'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'eqi'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'gt'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'gte'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'in'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; }; defaultValue: null }, { name: 'lt'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'lte'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'ne'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'nei'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'not'; type: { kind: 'INPUT_OBJECT'; name: 'JSONFilterInput'; ofType: null; }; defaultValue: null }, { name: 'notContains'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'notContainsi'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'notIn'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; }; defaultValue: null }, { name: 'notNull'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: null }, { name: 'null'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: null }, { name: 'or'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; }; defaultValue: null }, { name: 'startsWith'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }]; }; - 'Keyword': { kind: 'OBJECT'; name: 'Keyword'; fields: { 'coreId': { name: 'coreId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'documentId': { name: 'documentId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'language': { name: 'language'; type: { kind: 'OBJECT'; name: 'Language'; ofType: null; } }; 'publishedAt': { name: 'publishedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'source': { name: 'source'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'ENUM_KEYWORD_SOURCE'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'value': { name: 'value'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'videos': { name: 'videos'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Video'; ofType: null; }; }; } }; 'videos_connection': { name: 'videos_connection'; type: { kind: 'OBJECT'; name: 'VideoRelationResponseCollection'; ofType: null; } }; }; }; + 'Keyword': { kind: 'OBJECT'; name: 'Keyword'; fields: { 'coreId': { name: 'coreId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'documentId': { name: 'documentId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'language': { name: 'language'; type: { kind: 'OBJECT'; name: 'Language'; ofType: null; } }; 'publishedAt': { name: 'publishedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'source': { name: 'source'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'ENUM_KEYWORD_SOURCE'; ofType: null; }; } }; 'type': { name: 'type'; type: { kind: 'ENUM'; name: 'ENUM_KEYWORD_TYPE'; ofType: null; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'value': { name: 'value'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'videos': { name: 'videos'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Video'; ofType: null; }; }; } }; 'videos_connection': { name: 'videos_connection'; type: { kind: 'OBJECT'; name: 'VideoRelationResponseCollection'; ofType: null; } }; }; }; 'KeywordEntity': { kind: 'OBJECT'; name: 'KeywordEntity'; fields: { 'attributes': { name: 'attributes'; type: { kind: 'OBJECT'; name: 'Keyword'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'SCALAR'; name: 'ID'; ofType: null; } }; }; }; 'KeywordEntityResponse': { kind: 'OBJECT'; name: 'KeywordEntityResponse'; fields: { 'data': { name: 'data'; type: { kind: 'OBJECT'; name: 'Keyword'; ofType: null; } }; }; }; 'KeywordEntityResponseCollection': { kind: 'OBJECT'; name: 'KeywordEntityResponseCollection'; fields: { 'nodes': { name: 'nodes'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Keyword'; ofType: null; }; }; }; } }; 'pageInfo': { name: 'pageInfo'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Pagination'; ofType: null; }; } }; }; }; - 'KeywordFiltersInput': { kind: 'INPUT_OBJECT'; name: 'KeywordFiltersInput'; isOneOf: false; inputFields: [{ name: 'and'; type: { kind: 'LIST'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'KeywordFiltersInput'; ofType: null; }; }; defaultValue: null }, { name: 'coreId'; type: { kind: 'INPUT_OBJECT'; name: 'StringFilterInput'; ofType: null; }; defaultValue: null }, { name: 'createdAt'; type: { kind: 'INPUT_OBJECT'; name: 'DateTimeFilterInput'; ofType: null; }; defaultValue: null }, { name: 'documentId'; type: { kind: 'INPUT_OBJECT'; name: 'IDFilterInput'; ofType: null; }; defaultValue: null }, { name: 'language'; type: { kind: 'INPUT_OBJECT'; name: 'LanguageFiltersInput'; ofType: null; }; defaultValue: null }, { name: 'not'; type: { kind: 'INPUT_OBJECT'; name: 'KeywordFiltersInput'; ofType: null; }; defaultValue: null }, { name: 'or'; type: { kind: 'LIST'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'KeywordFiltersInput'; ofType: null; }; }; defaultValue: null }, { name: 'publishedAt'; type: { kind: 'INPUT_OBJECT'; name: 'DateTimeFilterInput'; ofType: null; }; defaultValue: null }, { name: 'source'; type: { kind: 'INPUT_OBJECT'; name: 'StringFilterInput'; ofType: null; }; defaultValue: null }, { name: 'updatedAt'; type: { kind: 'INPUT_OBJECT'; name: 'DateTimeFilterInput'; ofType: null; }; defaultValue: null }, { name: 'value'; type: { kind: 'INPUT_OBJECT'; name: 'StringFilterInput'; ofType: null; }; defaultValue: null }, { name: 'videos'; type: { kind: 'INPUT_OBJECT'; name: 'VideoFiltersInput'; ofType: null; }; defaultValue: null }]; }; - 'KeywordInput': { kind: 'INPUT_OBJECT'; name: 'KeywordInput'; isOneOf: false; inputFields: [{ name: 'coreId'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'language'; type: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; defaultValue: null }, { name: 'publishedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; defaultValue: null }, { name: 'source'; type: { kind: 'ENUM'; name: 'ENUM_KEYWORD_SOURCE'; ofType: null; }; defaultValue: null }, { name: 'value'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'videos'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; }; defaultValue: null }]; }; + 'KeywordFiltersInput': { kind: 'INPUT_OBJECT'; name: 'KeywordFiltersInput'; isOneOf: false; inputFields: [{ name: 'and'; type: { kind: 'LIST'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'KeywordFiltersInput'; ofType: null; }; }; defaultValue: null }, { name: 'coreId'; type: { kind: 'INPUT_OBJECT'; name: 'StringFilterInput'; ofType: null; }; defaultValue: null }, { name: 'createdAt'; type: { kind: 'INPUT_OBJECT'; name: 'DateTimeFilterInput'; ofType: null; }; defaultValue: null }, { name: 'documentId'; type: { kind: 'INPUT_OBJECT'; name: 'IDFilterInput'; ofType: null; }; defaultValue: null }, { name: 'language'; type: { kind: 'INPUT_OBJECT'; name: 'LanguageFiltersInput'; ofType: null; }; defaultValue: null }, { name: 'not'; type: { kind: 'INPUT_OBJECT'; name: 'KeywordFiltersInput'; ofType: null; }; defaultValue: null }, { name: 'or'; type: { kind: 'LIST'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'KeywordFiltersInput'; ofType: null; }; }; defaultValue: null }, { name: 'publishedAt'; type: { kind: 'INPUT_OBJECT'; name: 'DateTimeFilterInput'; ofType: null; }; defaultValue: null }, { name: 'source'; type: { kind: 'INPUT_OBJECT'; name: 'StringFilterInput'; ofType: null; }; defaultValue: null }, { name: 'type'; type: { kind: 'INPUT_OBJECT'; name: 'StringFilterInput'; ofType: null; }; defaultValue: null }, { name: 'updatedAt'; type: { kind: 'INPUT_OBJECT'; name: 'DateTimeFilterInput'; ofType: null; }; defaultValue: null }, { name: 'value'; type: { kind: 'INPUT_OBJECT'; name: 'StringFilterInput'; ofType: null; }; defaultValue: null }, { name: 'videos'; type: { kind: 'INPUT_OBJECT'; name: 'VideoFiltersInput'; ofType: null; }; defaultValue: null }]; }; + 'KeywordInput': { kind: 'INPUT_OBJECT'; name: 'KeywordInput'; isOneOf: false; inputFields: [{ name: 'coreId'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'language'; type: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; defaultValue: null }, { name: 'publishedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; defaultValue: null }, { name: 'source'; type: { kind: 'ENUM'; name: 'ENUM_KEYWORD_SOURCE'; ofType: null; }; defaultValue: null }, { name: 'type'; type: { kind: 'ENUM'; name: 'ENUM_KEYWORD_TYPE'; ofType: null; }; defaultValue: null }, { name: 'value'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'videos'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; }; defaultValue: null }]; }; 'KeywordRelationResponseCollection': { kind: 'OBJECT'; name: 'KeywordRelationResponseCollection'; fields: { 'nodes': { name: 'nodes'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Keyword'; ofType: null; }; }; }; } }; }; }; 'Language': { kind: 'OBJECT'; name: 'Language'; fields: { 'audioPreview': { name: 'audioPreview'; type: { kind: 'OBJECT'; name: 'LanguageAudioPreview'; ofType: null; } }; 'bcp47': { name: 'bcp47'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'coreId': { name: 'coreId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'countryLanguages': { name: 'countryLanguages'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'CountryLanguage'; ofType: null; }; }; } }; 'countryLanguages_connection': { name: 'countryLanguages_connection'; type: { kind: 'OBJECT'; name: 'CountryLanguageRelationResponseCollection'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'documentId': { name: 'documentId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'iso3': { name: 'iso3'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'keywords': { name: 'keywords'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Keyword'; ofType: null; }; }; } }; 'keywords_connection': { name: 'keywords_connection'; type: { kind: 'OBJECT'; name: 'KeywordRelationResponseCollection'; ofType: null; } }; 'locale': { name: 'locale'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'localizations': { name: 'localizations'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Language'; ofType: null; }; }; } }; 'localizations_connection': { name: 'localizations_connection'; type: { kind: 'OBJECT'; name: 'LanguageRelationResponseCollection'; ofType: null; } }; 'name': { name: 'name'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'publishedAt': { name: 'publishedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'slug': { name: 'slug'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'source': { name: 'source'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'ENUM_LANGUAGE_SOURCE'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'videoSubtitles': { name: 'videoSubtitles'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'VideoSubtitle'; ofType: null; }; }; } }; 'videoSubtitles_connection': { name: 'videoSubtitles_connection'; type: { kind: 'OBJECT'; name: 'VideoSubtitleRelationResponseCollection'; ofType: null; } }; 'videoVariants': { name: 'videoVariants'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'VideoVariant'; ofType: null; }; }; } }; 'videoVariants_connection': { name: 'videoVariants_connection'; type: { kind: 'OBJECT'; name: 'VideoVariantRelationResponseCollection'; ofType: null; } }; 'videosAsPrimaryLanguage': { name: 'videosAsPrimaryLanguage'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Video'; ofType: null; }; }; } }; 'videosAsPrimaryLanguage_connection': { name: 'videosAsPrimaryLanguage_connection'; type: { kind: 'OBJECT'; name: 'VideoRelationResponseCollection'; ofType: null; } }; }; }; 'LanguageAudioPreview': { kind: 'OBJECT'; name: 'LanguageAudioPreview'; fields: { 'bitrate': { name: 'bitrate'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'codec': { name: 'codec'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'coreId': { name: 'coreId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'documentId': { name: 'documentId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'duration': { name: 'duration'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'language': { name: 'language'; type: { kind: 'OBJECT'; name: 'Language'; ofType: null; } }; 'publishedAt': { name: 'publishedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'size': { name: 'size'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'source': { name: 'source'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'ENUM_LANGUAGEAUDIOPREVIEW_SOURCE'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'value': { name: 'value'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; }; }; From ed1f11f88b9c177b46b4e4cfd2ae18f19a3f4361 Mon Sep 17 00:00:00 2001 From: Vlad Date: Thu, 7 May 2026 11:11:12 -0300 Subject: [PATCH 3/3] feat(manager): persist cms sync job context --- apps/cms/schema.graphql | 2 + .../content-types/enrichment-job/schema.json | 3 ++ apps/manager/src/app/api/enrich/route.ts | 5 ++- apps/manager/src/lib/state.ts | 42 ++++++++++++++++++- apps/manager/src/types/job.ts | 2 + ...001-feat-manager-metadata-cms-sync-plan.md | 4 +- packages/graphql/src/graphql-env.d.ts | 4 +- 7 files changed, 57 insertions(+), 5 deletions(-) diff --git a/apps/cms/schema.graphql b/apps/cms/schema.graphql index 5247acc21..8b8845552 100644 --- a/apps/cms/schema.graphql +++ b/apps/cms/schema.graphql @@ -1419,6 +1419,7 @@ type EnrichmentJob { languages: JSON muxAssetId: String! muxPlaybackId: String + options: JSON publishedAt: DateTime retries: Int startedAt: DateTime @@ -1472,6 +1473,7 @@ input EnrichmentJobInput { languages: JSON muxAssetId: String muxPlaybackId: String + options: JSON publishedAt: DateTime retries: Int startedAt: DateTime diff --git a/apps/cms/src/api/enrichment-job/content-types/enrichment-job/schema.json b/apps/cms/src/api/enrichment-job/content-types/enrichment-job/schema.json index dec33b19f..88c59d7db 100644 --- a/apps/cms/src/api/enrichment-job/content-types/enrichment-job/schema.json +++ b/apps/cms/src/api/enrichment-job/content-types/enrichment-job/schema.json @@ -26,6 +26,9 @@ "languages": { "type": "json" }, + "options": { + "type": "json" + }, "status": { "type": "enumeration", "enum": ["pending", "running", "completed", "failed"], diff --git a/apps/manager/src/app/api/enrich/route.ts b/apps/manager/src/app/api/enrich/route.ts index 4b22bab09..3432bd198 100644 --- a/apps/manager/src/app/api/enrich/route.ts +++ b/apps/manager/src/app/api/enrich/route.ts @@ -105,7 +105,10 @@ export async function POST(request: Request) { } try { - const job = await createJob(muxAssetId, muxPlaybackId, languages) + const job = await createJob(muxAssetId, muxPlaybackId, languages, { + options: { notifyCms: true }, + videoDocumentId: video.documentId, + }) jobs.push({ videoId: coreId, jobId: job.id }) // Run enrichment in the background after the response is sent diff --git a/apps/manager/src/lib/state.ts b/apps/manager/src/lib/state.ts index e0101816d..f08b44600 100644 --- a/apps/manager/src/lib/state.ts +++ b/apps/manager/src/lib/state.ts @@ -8,6 +8,7 @@ import type { JobRecord, JobStatus, JobStepState, + JobOptions, WorkflowStepName, StepStatus, } from "@/types/job" @@ -23,6 +24,7 @@ const JOB_FIELDS = graphql(` documentId muxAssetId muxPlaybackId + options languages status currentStep @@ -41,6 +43,11 @@ const JOB_FIELDS = graphql(` finishedAt error } + video { + documentId + coreId + title + } } `) @@ -93,6 +100,10 @@ const LIST_JOBS = graphql( // --------------------------------------------------------------------------- type EnrichmentJobNode = NonNullable["enrichmentJob"]> +type CreateJobContext = { + options?: JobOptions + videoDocumentId?: string +} // --------------------------------------------------------------------------- // Mapping helpers @@ -104,8 +115,11 @@ export function toJobRecord(node: EnrichmentJobNode): JobRecord { id: node.documentId, muxAssetId: node.muxAssetId, muxPlaybackId: node.muxPlaybackId ?? "", + videoDocumentId: node.video?.documentId, + videoCoreId: node.video?.coreId ?? undefined, languages: (node.languages ?? []) as string[], - options: {}, + sourceMediaTitle: node.video?.title ?? undefined, + options: toJobOptions(node.options), status: node.status as JobStatus, currentStep: node.currentStep as WorkflowStepName | undefined, retries: node.retries ?? 0, @@ -119,6 +133,25 @@ export function toJobRecord(node: EnrichmentJobNode): JobRecord { } } +function toJobOptions(value: unknown): JobOptions { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {} + } + + const raw = value as Record + const options: JobOptions = {} + if (typeof raw.generateVoiceover === "boolean") { + options.generateVoiceover = raw.generateVoiceover + } + if (typeof raw.uploadMux === "boolean") { + options.uploadMux = raw.uploadMux + } + if (typeof raw.notifyCms === "boolean") { + options.notifyCms = raw.notifyCms + } + return options +} + function toStepState( s: NonNullable[number], ): JobStepState { @@ -169,9 +202,14 @@ export async function createJob( muxAssetId: string, muxPlaybackId: string, languages: string[] = [], + context: CreateJobContext = {}, ): Promise { const client = getClient() const steps = buildInitialSteps() + const options: JobOptions = { ...context.options } + if (options.notifyCms && !context.videoDocumentId) { + options.notifyCms = false + } const result = await client.mutate({ mutation: CREATE_JOB, @@ -180,6 +218,8 @@ export async function createJob( muxAssetId, muxPlaybackId, languages, + options, + ...(context.videoDocumentId ? { video: context.videoDocumentId } : {}), status: "pending", retries: 0, artifacts: {}, diff --git a/apps/manager/src/types/job.ts b/apps/manager/src/types/job.ts index cff88d894..1812fbb37 100644 --- a/apps/manager/src/types/job.ts +++ b/apps/manager/src/types/job.ts @@ -55,6 +55,8 @@ export interface JobRecord { id: string muxAssetId: string muxPlaybackId: string // Forge extension — stored at job creation + videoDocumentId?: string + videoCoreId?: string languages: string[] sourceCollectionTitle?: string sourceMediaTitle?: string diff --git a/docs/plans/2026-04-23-001-feat-manager-metadata-cms-sync-plan.md b/docs/plans/2026-04-23-001-feat-manager-metadata-cms-sync-plan.md index 0245610f8..df10f64b4 100644 --- a/docs/plans/2026-04-23-001-feat-manager-metadata-cms-sync-plan.md +++ b/docs/plans/2026-04-23-001-feat-manager-metadata-cms-sync-plan.md @@ -138,7 +138,7 @@ The roadmap ticket is directionally correct but no longer matches the planning-b **Automation progress, 2026-04-23:** Added `Keyword.type`, updated `apps/cms/schema.graphql`, regenerated `packages/graphql/src/graphql-env.d.ts`, and validated JSON/schema formatting plus gql.tada generation. -- [ ] **Unit 2: Persist video + CMS-sync intent in manager job state** +- [x] **Unit 2: Persist video + CMS-sync intent in manager job state** **Goal:** Ensure enrichment jobs launched for existing CMS videos retain enough context for downstream metadata sync. @@ -168,6 +168,8 @@ The roadmap ticket is directionally correct but no longer matches the planning-b - Jobs without a related video do not attempt CMS sync - Existing non-CMS job flows keep working unchanged + **Automation progress, 2026-05-07:** Added persistent `EnrichmentJob.options`, mapped `video` relation context into Manager job records, and made `/api/enrich` create CMS-backed jobs with `notifyCms: true` while leaving URL-ingest jobs without CMS sync intent. + - [ ] **Unit 3: Implement manager-side metadata-to-CMS sync service** **Goal:** Upsert typed manager keywords and attach them to the target video idempotently. diff --git a/packages/graphql/src/graphql-env.d.ts b/packages/graphql/src/graphql-env.d.ts index aff80e97e..1142460c7 100644 --- a/packages/graphql/src/graphql-env.d.ts +++ b/packages/graphql/src/graphql-env.d.ts @@ -156,12 +156,12 @@ export type introspection_types = { 'ENUM_VIDEO_LABEL': { name: 'ENUM_VIDEO_LABEL'; enumValues: 'behindTheScenes' | 'collection' | 'episode' | 'featureFilm' | 'segment' | 'series' | 'shortFilm' | 'trailer'; }; 'ENUM_VIDEO_SOURCE': { name: 'ENUM_VIDEO_SOURCE'; enumValues: 'core' | 'manager'; }; 'ENUM_VIDEO_VIDEOSOURCE': { name: 'ENUM_VIDEO_VIDEOSOURCE'; enumValues: 'cloudflare' | 'internal' | 'mux' | 'youTube'; }; - 'EnrichmentJob': { kind: 'OBJECT'; name: 'EnrichmentJob'; fields: { 'artifacts': { name: 'artifacts'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; } }; 'completedAt': { name: 'completedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'currentStep': { name: 'currentStep'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'documentId': { name: 'documentId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'errors': { name: 'errors'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; } }; 'languages': { name: 'languages'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; } }; 'muxAssetId': { name: 'muxAssetId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'muxPlaybackId': { name: 'muxPlaybackId'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'publishedAt': { name: 'publishedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'retries': { name: 'retries'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'startedAt': { name: 'startedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'status': { name: 'status'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'ENUM_ENRICHMENTJOB_STATUS'; ofType: null; }; } }; 'steps': { name: 'steps'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'ComponentEnrichmentJobStep'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'video': { name: 'video'; type: { kind: 'OBJECT'; name: 'Video'; ofType: null; } }; }; }; + 'EnrichmentJob': { kind: 'OBJECT'; name: 'EnrichmentJob'; fields: { 'artifacts': { name: 'artifacts'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; } }; 'completedAt': { name: 'completedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'currentStep': { name: 'currentStep'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'documentId': { name: 'documentId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'errors': { name: 'errors'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; } }; 'languages': { name: 'languages'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; } }; 'muxAssetId': { name: 'muxAssetId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'muxPlaybackId': { name: 'muxPlaybackId'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'options': { name: 'options'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; } }; 'publishedAt': { name: 'publishedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'retries': { name: 'retries'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'startedAt': { name: 'startedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'status': { name: 'status'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'ENUM_ENRICHMENTJOB_STATUS'; ofType: null; }; } }; 'steps': { name: 'steps'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'ComponentEnrichmentJobStep'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'video': { name: 'video'; type: { kind: 'OBJECT'; name: 'Video'; ofType: null; } }; }; }; 'EnrichmentJobEntity': { kind: 'OBJECT'; name: 'EnrichmentJobEntity'; fields: { 'attributes': { name: 'attributes'; type: { kind: 'OBJECT'; name: 'EnrichmentJob'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'SCALAR'; name: 'ID'; ofType: null; } }; }; }; 'EnrichmentJobEntityResponse': { kind: 'OBJECT'; name: 'EnrichmentJobEntityResponse'; fields: { 'data': { name: 'data'; type: { kind: 'OBJECT'; name: 'EnrichmentJob'; ofType: null; } }; }; }; 'EnrichmentJobEntityResponseCollection': { kind: 'OBJECT'; name: 'EnrichmentJobEntityResponseCollection'; fields: { 'nodes': { name: 'nodes'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'EnrichmentJob'; ofType: null; }; }; }; } }; 'pageInfo': { name: 'pageInfo'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Pagination'; ofType: null; }; } }; }; }; 'EnrichmentJobFiltersInput': { kind: 'INPUT_OBJECT'; name: 'EnrichmentJobFiltersInput'; isOneOf: false; inputFields: [{ name: 'and'; type: { kind: 'LIST'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'EnrichmentJobFiltersInput'; ofType: null; }; }; defaultValue: null }, { name: 'artifacts'; type: { kind: 'INPUT_OBJECT'; name: 'JSONFilterInput'; ofType: null; }; defaultValue: null }, { name: 'completedAt'; type: { kind: 'INPUT_OBJECT'; name: 'DateTimeFilterInput'; ofType: null; }; defaultValue: null }, { name: 'createdAt'; type: { kind: 'INPUT_OBJECT'; name: 'DateTimeFilterInput'; ofType: null; }; defaultValue: null }, { name: 'currentStep'; type: { kind: 'INPUT_OBJECT'; name: 'StringFilterInput'; ofType: null; }; defaultValue: null }, { name: 'documentId'; type: { kind: 'INPUT_OBJECT'; name: 'IDFilterInput'; ofType: null; }; defaultValue: null }, { name: 'errors'; type: { kind: 'INPUT_OBJECT'; name: 'JSONFilterInput'; ofType: null; }; defaultValue: null }, { name: 'languages'; type: { kind: 'INPUT_OBJECT'; name: 'JSONFilterInput'; ofType: null; }; defaultValue: null }, { name: 'muxAssetId'; type: { kind: 'INPUT_OBJECT'; name: 'StringFilterInput'; ofType: null; }; defaultValue: null }, { name: 'muxPlaybackId'; type: { kind: 'INPUT_OBJECT'; name: 'StringFilterInput'; ofType: null; }; defaultValue: null }, { name: 'not'; type: { kind: 'INPUT_OBJECT'; name: 'EnrichmentJobFiltersInput'; ofType: null; }; defaultValue: null }, { name: 'or'; type: { kind: 'LIST'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'EnrichmentJobFiltersInput'; ofType: null; }; }; defaultValue: null }, { name: 'publishedAt'; type: { kind: 'INPUT_OBJECT'; name: 'DateTimeFilterInput'; ofType: null; }; defaultValue: null }, { name: 'retries'; type: { kind: 'INPUT_OBJECT'; name: 'IntFilterInput'; ofType: null; }; defaultValue: null }, { name: 'startedAt'; type: { kind: 'INPUT_OBJECT'; name: 'DateTimeFilterInput'; ofType: null; }; defaultValue: null }, { name: 'status'; type: { kind: 'INPUT_OBJECT'; name: 'StringFilterInput'; ofType: null; }; defaultValue: null }, { name: 'steps'; type: { kind: 'INPUT_OBJECT'; name: 'ComponentEnrichmentJobStepFiltersInput'; ofType: null; }; defaultValue: null }, { name: 'updatedAt'; type: { kind: 'INPUT_OBJECT'; name: 'DateTimeFilterInput'; ofType: null; }; defaultValue: null }, { name: 'video'; type: { kind: 'INPUT_OBJECT'; name: 'VideoFiltersInput'; ofType: null; }; defaultValue: null }]; }; - 'EnrichmentJobInput': { kind: 'INPUT_OBJECT'; name: 'EnrichmentJobInput'; isOneOf: false; inputFields: [{ name: 'artifacts'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'completedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; defaultValue: null }, { name: 'currentStep'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'errors'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'languages'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'muxAssetId'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'muxPlaybackId'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'publishedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; defaultValue: null }, { name: 'retries'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'startedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; defaultValue: null }, { name: 'status'; type: { kind: 'ENUM'; name: 'ENUM_ENRICHMENTJOB_STATUS'; ofType: null; }; defaultValue: null }, { name: 'steps'; type: { kind: 'LIST'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'ComponentEnrichmentJobStepInput'; ofType: null; }; }; defaultValue: null }, { name: 'video'; type: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; defaultValue: null }]; }; + 'EnrichmentJobInput': { kind: 'INPUT_OBJECT'; name: 'EnrichmentJobInput'; isOneOf: false; inputFields: [{ name: 'artifacts'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'completedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; defaultValue: null }, { name: 'currentStep'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'errors'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'languages'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'muxAssetId'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'muxPlaybackId'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'options'; type: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; defaultValue: null }, { name: 'publishedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; defaultValue: null }, { name: 'retries'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'startedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; defaultValue: null }, { name: 'status'; type: { kind: 'ENUM'; name: 'ENUM_ENRICHMENTJOB_STATUS'; ofType: null; }; defaultValue: null }, { name: 'steps'; type: { kind: 'LIST'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'ComponentEnrichmentJobStepInput'; ofType: null; }; }; defaultValue: null }, { name: 'video'; type: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; defaultValue: null }]; }; 'EnrichmentJobRelationResponseCollection': { kind: 'OBJECT'; name: 'EnrichmentJobRelationResponseCollection'; fields: { 'nodes': { name: 'nodes'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'EnrichmentJob'; ofType: null; }; }; }; } }; }; }; 'Error': { kind: 'OBJECT'; name: 'Error'; fields: { 'code': { name: 'code'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'message': { name: 'message'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; }; }; 'Experience': { kind: 'OBJECT'; name: 'Experience'; fields: { 'blocks': { name: 'blocks'; type: { kind: 'LIST'; name: never; ofType: { kind: 'UNION'; name: 'ExperienceBlocksDynamicZone'; ofType: null; }; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'documentId': { name: 'documentId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'isHomepage': { name: 'isHomepage'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'locale': { name: 'locale'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'localizations': { name: 'localizations'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Experience'; ofType: null; }; }; } }; 'localizations_connection': { name: 'localizations_connection'; type: { kind: 'OBJECT'; name: 'ExperienceRelationResponseCollection'; ofType: null; } }; 'metaDescription': { name: 'metaDescription'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'ogDescription': { name: 'ogDescription'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'ogImage': { name: 'ogImage'; type: { kind: 'OBJECT'; name: 'UploadFile'; ofType: null; } }; 'ogTitle': { name: 'ogTitle'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'pathSegment': { name: 'pathSegment'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'publishedAt': { name: 'publishedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'slug': { name: 'slug'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'title': { name: 'title'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; }; };