Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/cms/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ Scope: `apps/cms`.
| `easter-dates` | `easter-dates.json` | No | blocks |
| `related-questions` | `related-questions.json` | Yes (`related-question-item`) | blocks, Section DZ |

---

## Local Testing: Gateway Sync

Full runbook: [`docs/solutions/cms/gateway-sync-local-testing.md`](../../docs/solutions/cms/gateway-sync-local-testing.md)

Covers: env setup, admin creation, API token generation (not admin JWT — see the auth gotcha), dry-run, live import, status polling, and guard verification.

---

### Seed script conventions (`scripts/seed-easter.mjs`)

- Top-level blocks use `__typename: "ComponentSections<PascalName>"` (GraphQL format)
Expand Down
37 changes: 33 additions & 4 deletions apps/cms/src/api/gateway-sync/controllers/gateway-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,57 @@ import type { Core } from "@strapi/strapi"
import { runSync, resolveScope, getSyncStatus } from "../services/gateway-sync"
import { formatError } from "../services/strapi-helpers"

type TriggerBody = {
scope?: string | string[]
collectionIds?: string[]
videoIds?: string[]
dryRun?: boolean
}

type StrapiContext = {
request: { body?: { scope?: string | string[] } }
request: { body?: TriggerBody }
status: number
body: unknown
}

export default ({ strapi }: { strapi: Core.Strapi }) => ({
async trigger(ctx: StrapiContext) {
const scope = ctx.request.body?.scope
const { scope, collectionIds, videoIds, dryRun } = ctx.request.body ?? {}
const phases = resolveScope(scope)

const options = { scope, collectionIds, videoIds, dryRun }
const isLimited =
(collectionIds && collectionIds.length > 0) ||
(videoIds && videoIds.length > 0)

// Dry-run requests are synchronous — return the resolved selection
if (dryRun && isLimited) {
try {
const result = await runSync(strapi, options)
ctx.status = 200
ctx.body = result
} catch (error) {
strapi.log.error(`[gateway-sync] Dry-run failed: ${formatError(error)}`)
ctx.status = 500
ctx.body = { error: formatError(error) }
}
return
}

// Fire and forget — sync runs in background
runSync(strapi, scope).catch((error) => {
runSync(strapi, options).catch((error) => {
strapi.log.error(
`[gateway-sync] Background sync failed: ${formatError(error)}`,
)
})

ctx.status = 202
ctx.body = {
message: `Gateway sync started`,
message: isLimited
? "Gateway limited seed import started"
: "Gateway sync started",
phases,
isLimited: !!isLimited,
status: getSyncStatus(),
}
},
Expand Down
8 changes: 8 additions & 0 deletions apps/cms/src/api/gateway-sync/routes/gateway-sync.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
/**
* Gateway sync API routes.
*
* Auth note: these routes use `admin::isAuthenticatedAdmin` under the content-API scope.
* Use a full-access API token (not an admin JWT) — admin JWTs return 401 here.
*
* @see docs/solutions/cms/gateway-sync-local-testing.md — local testing runbook
*/
export default {
routes: [
{
Expand Down
235 changes: 221 additions & 14 deletions apps/cms/src/api/gateway-sync/services/gateway-sync.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import type { Core } from "@strapi/strapi"
import { type SyncStats, formatError } from "./strapi-helpers"
import {
type SyncStats,
formatError,
publishDrafts,
} from "./strapi-helpers"
import { syncLanguages } from "./sync-languages"
import { syncCountries } from "./sync-countries"
import { syncKeywords } from "./sync-keywords"
import { syncVideos } from "./sync-videos"
import { syncVideoVariants } from "./sync-video-variants"
import {
resolveCollectionVideoIds,
type ResolveCollectionVideoIdsResult,
} from "./resolve-collection-video-ids"

export type SyncPhase =
| "languages"
Expand Down Expand Up @@ -32,15 +40,42 @@ type SyncResult = {
error?: string
}

type PublishStage = {
name: string
contentTypes: string[]
}

const REPUBLISH_UPDATED_UIDS = new Set<string>([
"api::video.video",
"api::video-subtitle.video-subtitle",
"api::video-variant.video-variant",
"api::bible-citation.bible-citation",
"api::video-study-question.video-study-question",
])

/** Selection context for limited seed imports */
export type SyncSelection = {
collectionIds: string[]
videoIds: string[]
resolvedVideoIds: string[]
collectionVideoIds: Record<string, string[]>
missingCollectionIds: string[]
isFullSync: boolean
dryRun: boolean
}

/** Maximum total IDs per limited import request */
const MAX_LIMITED_IDS = 500

const PHASE_RUNNERS: Record<
SyncPhase,
(strapi: Core.Strapi) => Promise<SyncStats>
(strapi: Core.Strapi, selection: SyncSelection) => Promise<SyncStats>
> = {
languages: syncLanguages,
countries: syncCountries,
keywords: syncKeywords,
videos: syncVideos,
"video-variants": syncVideoVariants,
languages: (strapi) => syncLanguages(strapi),
countries: (strapi) => syncCountries(strapi),
keywords: (strapi) => syncKeywords(strapi),
videos: (strapi, selection) => syncVideos(strapi, selection),
"video-variants": (strapi, selection) => syncVideoVariants(strapi, selection),
}

let syncInProgress = false
Expand Down Expand Up @@ -77,38 +112,210 @@ function logPhase(strapi: Core.Strapi, phase: PhaseResult) {
)
}

const PUBLISH_STAGES: PublishStage[] = [
{
name: "references",
contentTypes: [
"api::continent.continent",
"api::language.language",
"api::country.country",
"api::country-language.country-language",
"api::keyword.keyword",
"api::bible-book.bible-book",
"api::video-origin.video-origin",
"api::video-edition.video-edition",
"api::mux-video.mux-video",
],
},
{
name: "videos",
contentTypes: ["api::video.video"],
},
{
name: "video-children",
contentTypes: [
"api::video-subtitle.video-subtitle",
"api::video-variant.video-variant",
"api::bible-citation.bible-citation",
"api::video-study-question.video-study-question",
],
},
]

async function publishStageDrafts(
strapi: Core.Strapi,
stage: PublishStage,
): Promise<void> {
const failures: string[] = []

for (const uid of stage.contentTypes) {
const result = await publishDrafts(strapi, uid, {
includeUpdatedDrafts: REPUBLISH_UPDATED_UIDS.has(uid),
})

if (result.published > 0) {
strapi.log.info(
`[gateway-sync] Published ${result.published} draft ${uid.split(".")[1]} records`,
)
}

if (result.failed > 0) {
failures.push(
`${uid} (${result.failed} failed: ${result.failedDocumentIds.slice(0, 3).join(", ")})`,
)
}
}

if (failures.length > 0) {
throw new Error(
`Publish stage ${stage.name} failed for ${failures.join("; ")}`,
)
}
}

export type SyncOptions = {
scope?: string | string[]
collectionIds?: string[]
videoIds?: string[]
dryRun?: boolean
}

function isLimitedImportEnabled(): boolean {
return process.env.GATEWAY_SYNC_ENABLE_LIMITED_IMPORT === "true"
}

export async function buildSelection(
options: SyncOptions,
): Promise<SyncSelection> {
const collectionIds = options.collectionIds ?? []
const videoIds = options.videoIds ?? []
const isFullSync = collectionIds.length === 0 && videoIds.length === 0

if (isFullSync) {
return {
collectionIds: [],
videoIds: [],
resolvedVideoIds: [],
collectionVideoIds: {},
missingCollectionIds: [],
isFullSync: true,
dryRun: false,
}
}

const resolved: ResolveCollectionVideoIdsResult =
collectionIds.length > 0
? await resolveCollectionVideoIds({ collectionIds })
: {
collectionVideoIds: {},
resolvedVideoIds: [],
missingCollectionIds: [],
}

// Union resolved collection video IDs with explicit videoIds, deduped
const allVideoIds = new Set([...resolved.resolvedVideoIds, ...videoIds])

return {
collectionIds,
videoIds,
resolvedVideoIds: [...allVideoIds],
collectionVideoIds: resolved.collectionVideoIds,
missingCollectionIds: resolved.missingCollectionIds,
isFullSync: false,
dryRun: options.dryRun ?? false,
}
}

export async function runSync(
strapi: Core.Strapi,
scope?: string | string[],
options: SyncOptions = {},
): Promise<SyncResult> {
if (syncInProgress) {
strapi.log.warn("[gateway-sync] Sync already in progress, skipping")
return { skipped: true }
}

const phasesToRun = resolveScope(scope)
const phasesToRun = resolveScope(options.scope)

if (phasesToRun.length === 0) {
strapi.log.warn("[gateway-sync] No valid phases in scope, skipping")
return { skipped: true }
}

// Build selection context
const selection = await buildSelection(options)

// Reject limited imports if env guard is not enabled
if (!selection.isFullSync && !isLimitedImportEnabled()) {
strapi.log.warn(
"[gateway-sync] Limited import rejected: GATEWAY_SYNC_ENABLE_LIMITED_IMPORT is not enabled",
)
return {
error:
"Limited imports are disabled. Set GATEWAY_SYNC_ENABLE_LIMITED_IMPORT=true to enable.",
}
}

// Validate total ID count for limited imports
if (
!selection.isFullSync &&
selection.collectionIds.length + (options.videoIds?.length ?? 0) >
MAX_LIMITED_IDS
) {
return {
error: `Too many IDs in limited import request. Maximum ${MAX_LIMITED_IDS} total collectionIds + videoIds allowed.`,
}
}

// Dry run: return resolved selection without executing sync
if (selection.dryRun) {
return {
scope: phasesToRun,
duration: 0,
dryRun: {
isFullSync: false,
requestedCollectionIds: selection.collectionIds,
requestedVideoIds: selection.videoIds,
collectionVideoIds: selection.collectionVideoIds,
resolvedVideoIds: selection.resolvedVideoIds,
missingCollectionIds: selection.missingCollectionIds,
phases: phasesToRun,
},
} as SyncResult & { dryRun: unknown }
}

syncInProgress = true
const startTime = Date.now()

try {
const mode = selection.isFullSync ? "full" : "limited"
strapi.log.info(
`[gateway-sync] ========== Starting sync (${phasesToRun.join(", ")}) ==========`,
`[gateway-sync] ========== Starting ${mode} sync (${phasesToRun.join(", ")}) ==========`,
)

if (!selection.isFullSync) {
strapi.log.info(
`[gateway-sync] Limited import: ${selection.resolvedVideoIds.length} resolved video IDs from ${selection.collectionIds.length} collections + ${selection.videoIds.length} explicit videos`,
)
if (selection.missingCollectionIds.length > 0) {
strapi.log.warn(
`[gateway-sync] Missing collection IDs (not found in gateway): ${selection.missingCollectionIds.join(", ")}`,
)
}
}

const phases: PhaseResult[] = []

for (const phase of phasesToRun) {
const runner = PHASE_RUNNERS[phase]
const stats = await runner(strapi)
const stats = await runner(strapi, selection)
phases.push({ phase, ...stats })
}

for (const stage of PUBLISH_STAGES) {
await publishStageDrafts(strapi, stage)
}

const duration = Date.now() - startTime
const result: SyncResult = { scope: phasesToRun, duration, phases }

Expand Down Expand Up @@ -143,12 +350,12 @@ export async function runSync(
}

export async function runFullSync(strapi: Core.Strapi): Promise<SyncResult> {
return runSync(strapi, "all")
return runSync(strapi, { scope: "all" })
}

export default {
runFullSync: ({ strapi }: { strapi: Core.Strapi }) => runFullSync(strapi),
runSync: ({ strapi }: { strapi: Core.Strapi }, scope?: string | string[]) =>
runSync(strapi, scope),
runSync: ({ strapi }: { strapi: Core.Strapi }, options?: SyncOptions) =>
runSync(strapi, options),
getSyncStatus,
}
Loading
Loading