diff --git a/.changeset/config-as-code.md b/.changeset/config-as-code.md new file mode 100644 index 0000000..4cf0a4f --- /dev/null +++ b/.changeset/config-as-code.md @@ -0,0 +1,18 @@ +--- +"@promocean/cli": minor +"@promocean/contracts": minor +--- + +New package **`@promocean/cli`** (`promocean` binary): `export`/`import` +commands for config-as-code — pull a project's placements, achievements, +timed events, offers, rewards, and project settings into a single JSON +file, and push edits back through a plan-before-apply workflow +(`--dry-run` prints the plan and exits 2 if it would change anything, +0 if not — a ready-made CI drift check; `--prune` additionally deletes +server-side content absent from the file). The config-plane secret is read +only from `PROMOCEAN_CONFIG_SECRET`, never a flag. + +`@promocean/contracts` gains the schemas backing the config file and the +import request/response (`configFileSchema`, `importRequestSchema`, +`importResponseSchema`, and their inferred `ConfigFile`/`ImportRequest`/ +`ImportResponse` types) — additive, no existing schema changed. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6925f7f..84c7c28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,11 @@ jobs: # complexity for 3 small alpine images. Revisit if build time grows. - run: docker compose --profile stack build - run: docker compose --profile stack up -d --wait + # config-sync.spec.ts execFiles packages/cli/dist/cli.js and imports + # @promocean/contracts's dist — neither is produced by the docker build + # above (that builds the stack images, not the host workspace). Build the + # CLI here (turbo pulls in @promocean/contracts as a dependency). + - run: pnpm turbo run build --filter=@promocean/cli - run: pnpm --filter demo exec playwright install --with-deps chromium - run: pnpm --filter demo e2e - if: failure() diff --git a/LICENSING.md b/LICENSING.md index 3f84d4d..e07272a 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -1,10 +1,11 @@ # Licensing -Embeddable client code (the SDK, widgets, shared schemas, and shared tooling -config) ships MIT so integrators can adopt it in their own codebases without a -copyleft obligation; everything that makes up the Promocean platform itself -(the CMS, the runtime API, the demo app, and the core/domain and adapter -packages behind them) stays GPL-3.0-only for the open-core angle. +Embeddable client code (the SDK, widgets, shared schemas, the config-as-code +CLI, and shared tooling config) ships MIT so integrators can adopt it in +their own codebases without a copyleft obligation; everything that makes up +the Promocean platform itself (the CMS, the runtime API, the demo app, and +the core/domain and adapter packages behind them) stays GPL-3.0-only for the +open-core angle. | Package | License | | ---------------------------- | ------------ | @@ -18,6 +19,7 @@ packages behind them) stays GPL-3.0-only for the open-core angle. | `packages/contracts` | MIT | | `packages/sdk` | MIT | | `packages/widgets` | MIT | +| `packages/cli` | MIT | | `packages/config` | MIT | Each MIT package carries its own `LICENSE` file; the root `LICENSE` covers diff --git a/README.md b/README.md index 41907c1..7ac4abe 100644 --- a/README.md +++ b/README.md @@ -190,8 +190,15 @@ coupons; `campaign-lifecycle.spec.ts` proves the seeded recurring `Weekly Happy Hour` event reports a consistent `recurrence`/`nextOccurrenceStartsAt` on the live feed and renders in the countdown widget, and that retroactive achievement backfill is idempotent after a live unlock (both via a direct -API call and the `/stats` page's operator-facing backfill form). With cms + -api already running (per above): +API call and the `/stats` page's operator-facing backfill form); +`config-sync.spec.ts` drives `@promocean/cli` as a real subprocess against +the running stack — export the seeded project, a scripted edit (raise +`first_lesson`'s `pointsValue`, add a new achievement), a `--dry-run` plan +check (exit code 2, exactly the expected creates/updates), an apply (exit +0), polling `/v1/users/:userId/achievements` and `/v1/users/:userId/wallet` +until the change is visible through the api's config-plane cache (see +"Config as code" above), and a final re-import proving the file and server +now agree (`--dry-run` exits 0). With cms + api already running (per above): pnpm --filter demo exec playwright install chromium pnpm --filter demo e2e @@ -408,6 +415,71 @@ code via validate/redeem). This is deliberate: the catalog is safe to expose to a publishable key/browser context without leaking a shared promo code to anyone who hasn't earned it. +## Config as code + +`@promocean/cli` (`npm i -g @promocean/cli`, MIT) exports a project's whole +configuration — project settings (`pointRules`, `registeredEventTypes`, +`allowedOrigins`), placements, achievements, timed events, offers, and +rewards — to a single JSON file, and imports one back with a +plan-before-apply workflow. This is the operator-facing alternative to +hand-editing content in the Strapi admin: put the file under version +control, review changes as a diff, and apply them the same way in every +environment. + +**Authoring loop:** + + promocean export --url https://cms.example.com --project --out config.json + # edit config.json by hand (or with tooling) ... + promocean import --url https://cms.example.com --project --file config.json --dry-run + # inspect the printed plan, then apply for real: + promocean import --url https://cms.example.com --project --file config.json + +Both commands read the config-plane secret from the `PROMOCEAN_CONFIG_SECRET` +environment variable only (never a flag), matching the `x-config-secret` +header the config-plane endpoints require — the same operator-only trust +model as every other config-plane read. Content is matched between the file +and the server **by slug** (achievements, timed events, offers, rewards, +and placements each carry a required, project-unique `slug`), not by +internal id — a file authored against one project imports cleanly into any +other project with the same slugs, which is what makes the format portable +across environments/instances (see the runtime-history caveat below for the +one place this portability doesn't fully extend). + +**CI drift check via exit code 2:** `import --dry-run` exits `0` when the +computed plan has no creates/updates/deletes anywhere (the file already +matches the server), `2` when it would change something, and `1` on any +error (bad file, HTTP failure, or a partially-applied 422). A CI job that +runs `promocean import --dry-run` against your checked-in config file and +fails the build on a non-zero exit catches config drift — someone edited +content directly in the CMS admin instead of through the file — before it +silently diverges further; exit `2` specifically means "the file and the +server disagree," which is exactly the drift signal such a job wants to +gate on (as opposed to exit `1`, which means the check itself couldn't run). + +**Prune semantics:** by default, import only creates and updates — content +that exists on the server but is absent from the file is left alone. Pass +`--prune` to additionally delete server-side content (per content type) that +the file doesn't mention. Without `--prune`, deleting a row from your config +file is a no-op on the next import; with it, deleting a row from the file +deletes that row on the server. When `--prune` is used, an import is rejected +upfront (HTTP 400, before any write) if a kept offer references a placement or +timed event that the file omits — because that target would be deleted by the +prune, orphaning the offer. + +**Runtime-history caveat:** import matches existing content by slug and +updates it *in place* when the file's fields differ — this preserves the +underlying documentId, so anything keyed off it (analytics rows, wallet +ledger `sourceRef`s, delivered-webhook state, achievement unlock history) +stays attached to the same logical achievement/offer/reward/timed-event +across edits. Deleting a row from the file (with `--prune`) and re-adding it +later under the same slug is a **delete + recreate**, not an update — the +new row gets a brand-new documentId, so continuity with anything that +referenced the old one is **not** promised. Prefer editing a row in place +over delete-then-recreate whenever preserving that history matters. + +See `packages/cli/README.md` for the full command reference (flags, exit +codes, 422 rendering, programmatic use). + ## Webhooks The api dispatches signed `POST` webhooks for `timed_event.live` / @@ -467,7 +539,7 @@ Scheduler tuning (all optional, read once at process start): ## Publishing -MIT packages (`@promocean/contracts`, `@promocean/sdk`, `@promocean/widgets`) publish via a two-step manual flow: +MIT packages (`@promocean/contracts`, `@promocean/sdk`, `@promocean/widgets`, `@promocean/cli`) publish via a two-step manual flow: 1. **Describe the change**: Run `pnpm changeset` to create a `.changeset/*.md` file (describes the change type and affected packages). Commit this file with your PR. diff --git a/RELEASING.md b/RELEASING.md index 0672756..618f1d0 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,12 +1,13 @@ # Releasing -The publishable packages are the three MIT-licensed ones (see `LICENSING.md`): +The publishable packages are the four MIT-licensed ones (see `LICENSING.md`): | Package | Publish? | |---|---| | `@promocean/contracts` | yes (npm) | | `@promocean/sdk` | yes (npm) | | `@promocean/widgets` | yes (npm) | +| `@promocean/cli` | yes (npm) — ships a `promocean` binary, not a library; same publish path | | `@promocean/core`, `@promocean/adapter-db`, `@promocean/adapter-strapi`, `@promocean/config`, `api`, `cms`, `demo` | no — `"private": true` in their `package.json` | Versioning is driven by [Changesets](https://github.com/changesets/changesets). @@ -48,24 +49,30 @@ pnpm publish -r --dry-run --no-git-checks ``` For each of `@promocean/contracts`, `@promocean/sdk`, `@promocean/widgets`, -check the printed tarball contents: +`@promocean/cli`, check the printed tarball contents: - [ ] `dist/` present; `src/` and `test/` **absent** (the `files` allowlist is - `["dist", "README.md", "LICENSE"]`) + `["dist", "README.md", "LICENSE"]` — `@promocean/cli`'s is the same set, + listed as `["dist", "LICENSE", "README.md"]`) - [ ] `LICENSE` (MIT) and `README.md` present - [ ] version matches the `changeset version` bump +- [ ] for `@promocean/cli` only: `dist/cli.js` keeps its `#!/usr/bin/env node` + shebang and `bin.promocean` in the packed manifest points at it Then inspect the packed manifests directly (dry-run output doesn't show them): ```sh -pnpm --filter @promocean/contracts --filter @promocean/sdk --filter @promocean/widgets exec \ +pnpm --filter @promocean/contracts --filter @promocean/sdk --filter @promocean/widgets --filter @promocean/cli exec \ pnpm pack --pack-destination /tmp/promocean-pack for f in /tmp/promocean-pack/*.tgz; do tar -xOzf "$f" package/package.json; done ``` - [ ] every `workspace:*` dep is rewritten to a real version (e.g. `"@promocean/contracts": "0.1.0"`) -- [ ] `main`/`types` point into `dist/` +- [ ] `main`/`types` point into `dist/` — **except `@promocean/cli`**, which is a + bin-only package (no library entry point) and by design declares neither; + for it, verify `bin.promocean` points into `dist/` instead (see the shebang + check above) Note: `@promocean/core`, `@promocean/adapter-db`, `@promocean/adapter-strapi`, `@promocean/config`, and `api` are all `"private": true`, so `pnpm publish -r` @@ -103,7 +110,7 @@ TOKEN=$(curl -fsS -XPUT http://localhost:4873/-/user/org.couchdb.user:rehearsal npm config set //localhost:4873/:_authToken "$TOKEN" npm whoami --registry http://localhost:4873 # -> rehearsal -pnpm --filter @promocean/contracts --filter @promocean/sdk --filter @promocean/widgets \ +pnpm --filter @promocean/contracts --filter @promocean/sdk --filter @promocean/widgets --filter @promocean/cli \ publish --registry http://localhost:4873 --no-git-checks ``` @@ -123,9 +130,17 @@ npm init -y npm install @promocean/contracts @promocean/sdk @promocean/widgets react react-dom \ --registry http://localhost:4873 node smoke.mjs # see below + +# @promocean/cli ships a binary, not a library — its own smoke check is +# installing it globally and confirming the bin resolves and runs: +npm install -g @promocean/cli --registry http://localhost:4873 +promocean export --url http://localhost:1 --project x 2>&1 | grep -q PROMOCEAN_CONFIG_SECRET \ + && echo 'cli bin OK (env-guard message printed)' +npm uninstall -g @promocean/cli ``` -`smoke.mjs` must exercise all three packages: +`smoke.mjs` must exercise all three library packages (`@promocean/cli` is +smoke-tested separately above, as a binary rather than an import): - parse one `@promocean/contracts` schema (e.g. `rewardSchema.parse({...})`) - `new Promocean({ publishableKey, baseUrl, fetchImpl: mockFetch })` and diff --git a/apps/cms/Dockerfile b/apps/cms/Dockerfile index 24506f6..910ee98 100644 --- a/apps/cms/Dockerfile +++ b/apps/cms/Dockerfile @@ -50,10 +50,16 @@ COPY --from=prod-deps --chown=node:node /app . # - public/ static assets + local upload provider target # - database/ app-level migrations dir (appDir/database/migrations) # - favicon.png strapi::favicon middleware resolves it at appDir root +# - packages/contracts/dist the config-plane controller's runtime `require('@promocean/contracts')` +# (Sprint 11) — prod-deps only installs from out/json (package.json skeletons, no +# built output), so without this copy the workspace symlink resolves to a +# dist-less package and Strapi fails to boot (matches apps/api/Dockerfile's +# existing pattern for its own workspace deps). COPY --from=installer --chown=node:node /app/apps/cms/dist ./apps/cms/dist COPY --from=installer --chown=node:node /app/apps/cms/public ./apps/cms/public COPY --from=installer --chown=node:node /app/apps/cms/database ./apps/cms/database COPY --from=installer --chown=node:node /app/apps/cms/tsconfig.json /app/apps/cms/favicon.png ./apps/cms/ +COPY --from=installer --chown=node:node /app/packages/contracts/dist ./packages/contracts/dist USER node EXPOSE 1337 # Strapi exposes /_health returning 204 with no auth — perfect for a probe. diff --git a/apps/cms/package.json b/apps/cms/package.json index 6065e79..44a4632 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -15,10 +15,12 @@ "typecheck": "tsc --noEmit", "test": "echo ok", "verify:lifecycles": "tsx scripts/verify-lifecycles.ts", + "verify:config-sync": "tsx scripts/verify-config-sync.ts", "upgrade": "npx @strapi/upgrade latest", "upgrade:dry": "npx @strapi/upgrade latest --dry" }, "dependencies": { + "@promocean/contracts": "workspace:*", "@strapi/plugin-cloud": "5.50.0", "@strapi/plugin-users-permissions": "5.50.0", "@strapi/strapi": "5.50.0", @@ -38,7 +40,7 @@ "typescript": "^5" }, "engines": { - "node": ">=20.0.0 <=26.x.x", + "node": ">=20.19.0 <=26.x.x", "npm": ">=6.0.0" }, "strapi": { diff --git a/apps/cms/scripts/verify-config-sync.ts b/apps/cms/scripts/verify-config-sync.ts new file mode 100644 index 0000000..fa15045 --- /dev/null +++ b/apps/cms/scripts/verify-config-sync.ts @@ -0,0 +1,563 @@ +/** + * Durable, checked-in verification script for the config-plane import endpoint + * and its plan/prune/dry-run/recompute semantics (Sprint 11 Task 4). + * + * Boots a standalone Strapi instance the same way `strapi console` does + * (compileStrapi -> createStrapi(...).load()) against the configured + * DATABASE_URL — resolved the same way Strapi itself resolves it (env var, or + * apps/cms/.env; dev Postgres on 5433 by default). There is no hardcoded + * fallback: if DATABASE_URL can't be resolved at all, the script refuses to + * run rather than guess. Every scenario writes disposable fixture rows (a + * throwaway project per scenario, cleaned up in a finally), so the script + * refuses to run against a DATABASE_URL whose host isn't localhost/127.0.0.1 + * unless `--allow-remote` is passed. Also brings the HTTP server up (on + * VERIFY_PORT, default 18338, to avoid colliding with a dev `strapi develop` + * on 1337 or verify-lifecycles on 18337) so scenarios hit the REAL controller + * (export + import) over HTTP rather than a reimplementation. + * + * Nine named scenarios (each a hard finding + non-zero exit on failure): + * 1. round-trip invariant — populate a project, export it, re-import: every + * plan bucket empty except unchanged. + * 2. create+update+unchanged — one file mixes all three; assert exact slug + * lists (+ a positive control so the diff can't vacuously pass). + * 3. registeredEventTypes order-insensitivity — a reordered array is + * unchanged; a genuinely different set is a positive-control update. + * 4. prune only-with-flag AND only-covered-types — an absent slug survives + * without prune, is deleted with it; a seeded webhook endpoint (uncovered + * type) survives a prune. + * 5. dry-run plan deep-equals the subsequent apply's plan, and dry-run + * writes NOTHING (row counts before == after). + * 6. unknown-ref 400 before any write — offer references a ghost placement; + * 400 with the right details, and row counts are unchanged. + * 7. mid-run 422 — a static reward with staticCode:null trips the S8 + * lifecycle; assert 422, stage rewards/, earlier types genuinely + * applied, later types absent, and the RECOMPUTED plan matches DB state. + * 8. update-in-place preserves documentId (capture before/after). + * 9. prune orphan-ref guard — a prune file that keeps an offer but drops its + * placement is a 400 unknown reference BEFORE any write (that placement is + * about to be deleted, orphaning the offer); the same file WITHOUT prune + * passes the cross-ref (the existing placement survives). Both asserted. + * + * Usage: + * pnpm --filter cms verify:config-sync + * DATABASE_URL=postgres://... pnpm --filter cms verify:config-sync + * + * Exit code is non-zero if any scenario reports a finding. + */ + +import path from 'node:path' +import assert from 'node:assert' + +const ALLOW_REMOTE = process.argv.includes('--allow-remote') +const VERIFY_PORT = process.env.VERIFY_PORT ?? '18338' +process.env.PORT = VERIFY_PORT +// This script manages its own fixtures; demo seeding would just be noise. +process.env.SEED_DEMO = 'false' + +type Finding = { scenario: string; message: string } +const findings: Finding[] = [] + +function finding(scenario: string, message: string) { + findings.push({ scenario, message }) + console.log(`[verify-config-sync] FINDING (${scenario}): ${message}`) +} +function ok(message: string) { + console.log(`[verify-config-sync] ok: ${message}`) +} +function truthy(scenario: string, cond: boolean, label: string) { + if (cond) ok(label) + else finding(scenario, label) +} +function eq(scenario: string, actual: unknown, expected: unknown, label: string) { + try { + assert.deepStrictEqual(actual, expected) + ok(label) + } catch { + finding(scenario, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`) + } +} + +// --- blast-radius guard ------------------------------------------------------ + +function assertLocalDatabase(databaseUrl: string) { + let host: string + try { + host = new URL(databaseUrl).hostname + } catch (e: any) { + console.error(`[verify-config-sync] refusing to run: DATABASE_URL is not a parseable URL (${e.message})`) + process.exit(1) + } + const isLocal = host === 'localhost' || host === '127.0.0.1' || host === '::1' + if (isLocal || ALLOW_REMOTE) return + console.error( + `[verify-config-sync] refusing to run: DATABASE_URL host "${host}" is not localhost/127.0.0.1. ` + + 'This script writes disposable fixture rows to the target database. Re-run against a ' + + 'local/disposable database, or pass --allow-remote if you really intend to target this host.', + ) + process.exit(1) +} + +// --- HTTP helpers ------------------------------------------------------------ + +let SECRET = '' + +async function importFile( + projectId: string, + file: any, + opts: { prune?: boolean; dryRun?: boolean } = {}, +): Promise<{ status: number; body: any }> { + const res = await fetch(`http://127.0.0.1:${VERIFY_PORT}/api/config-plane/projects/${projectId}/import`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-config-secret': SECRET }, + body: JSON.stringify({ file, prune: opts.prune ?? false, dryRun: opts.dryRun ?? false }), + }) + return { status: res.status, body: await res.json() } +} + +async function exportFile(projectId: string): Promise { + const res = await fetch(`http://127.0.0.1:${VERIFY_PORT}/api/config-plane/projects/${projectId}/export`, { + headers: { 'x-config-secret': SECRET }, + }) + if (!res.ok) throw new Error(`export responded ${res.status}`) + return res.json() +} + +// --- file builders ----------------------------------------------------------- + +const T_START = '2026-08-01T00:00:00.000Z' +const T_END = '2026-08-02T00:00:00.000Z' + +function buildFile(overrides: Record = {}): any { + return { + formatVersion: 1, + project: { pointRules: {}, registeredEventTypes: [], allowedOrigins: null }, + placements: [], + achievements: [], + timedEvents: [], + offers: [], + rewards: [], + ...overrides, + } +} +function fPlacement(slug: string, name: string) { + return { slug, name } +} +function fAchievement(slug: string, name: string, over: Record = {}) { + return { slug, name, description: null, artworkUrl: null, eventType: 'lesson_completed', targetCount: 1, pointsValue: 0, ...over } +} +function fTimedEvent(slug: string, name: string, over: Record = {}) { + return { + slug, + name, + description: null, + startsAt: T_START, + endsAt: T_END, + endingSoonMinutes: 1440, + multiplier: 1, + recurrence: 'none', + recurrenceEndsAt: null, + enabled: true, + ...over, + } +} +function fOffer(slug: string, name: string, placement: string, over: Record = {}) { + return { + slug, + name, + headline: name, + body: null, + imageUrl: null, + ctaText: null, + ctaUrl: null, + startsAt: null, + endsAt: null, + priority: 0, + placement, + timedEvent: null, + ...over, + } +} +function fReward(slug: string, name: string, over: Record = {}) { + return { + slug, + name, + description: null, + codeType: 'generated', + staticCode: null, + codePrefix: null, + pointsPrice: 0, + startsAt: null, + endsAt: null, + perUserLimit: 1, + inventory: null, + enabled: true, + ...over, + } +} + +// --- fixture project lifecycle ---------------------------------------------- + +const createdProjectIds: string[] = [] + +async function newProject(app: any, name: string, slug: string, settings: Record = {}): Promise { + // Clean up any leftover project with this slug from a prior interrupted run. + const existing = await app.documents('api::project.project').findMany({ filters: { slug }, limit: 1 }) + if (existing.length > 0) await destroyProject(app, existing[0].documentId) + const project = await app.documents('api::project.project').create({ + data: { name, slug, pointRules: {}, registeredEventTypes: [], allowedOrigins: null, ...settings }, + }) + createdProjectIds.push(project.documentId) + return project.documentId +} + +async function destroyProject(app: any, projectId: string) { + for (const uid of [ + 'api::offer.offer', + 'api::reward.reward', + 'api::achievement.achievement', + 'api::timed-event.timed-event', + 'api::placement.placement', + 'api::webhook-endpoint.webhook-endpoint', + ]) { + const rows = await app.documents(uid).findMany({ filters: { project: { documentId: projectId } } }).catch(() => []) + for (const r of rows) await app.documents(uid).delete({ documentId: r.documentId }).catch(() => {}) + } + await app.documents('api::project.project').delete({ documentId: projectId }).catch(() => {}) +} + +async function findBySlug(app: any, uid: string, projectId: string, slug: string): Promise { + const rows = await app.documents(uid).findMany({ filters: { project: { documentId: projectId }, slug } }) + return rows[0] +} +async function countAll(app: any, projectId: string): Promise { + let total = 0 + for (const uid of [ + 'api::placement.placement', + 'api::timed-event.timed-event', + 'api::achievement.achievement', + 'api::reward.reward', + 'api::offer.offer', + ]) { + const rows = await app.documents(uid).findMany({ filters: { project: { documentId: projectId } } }) + total += rows.length + } + return total +} + +// --- scenarios --------------------------------------------------------------- + +async function scenario1RoundTrip(app: any) { + console.log('\n[verify-config-sync] === Scenario 1: round-trip invariant ===') + const S = 'round-trip' + const projectId = await newProject(app, 'Round Trip', 'verify-cfgsync-roundtrip', { + pointRules: { lesson_completed: 10 }, + registeredEventTypes: ['lesson_completed', 'quiz_passed'], + allowedOrigins: ['https://a.example'], + }) + + const initial = buildFile({ + project: { pointRules: { lesson_completed: 10 }, registeredEventTypes: ['lesson_completed', 'quiz_passed'], allowedOrigins: ['https://a.example'] }, + placements: [fPlacement('p1', 'Placement One'), fPlacement('p2', 'Placement Two')], + timedEvents: [fTimedEvent('te1', 'Timed One')], + achievements: [fAchievement('a1', 'Ach One')], + offers: [fOffer('o1', 'Offer One', 'p1', { timedEvent: 'te1' })], + rewards: [fReward('r1', 'Reward Gen'), fReward('r2', 'Reward Static', { codeType: 'static', staticCode: 'STATIC-R2' })], + }) + const seed = await importFile(projectId, initial) + truthy(S, seed.status === 200 && seed.body.applied === true, `seed import applied (status ${seed.status})`) + + const exported = await exportFile(projectId) + const reimport = await importFile(projectId, exported) + truthy(S, reimport.status === 200, `re-import of export succeeded (status ${reimport.status})`) + const plan = reimport.body.plan + for (const [type, expectUnchanged] of [ + ['project', 1], + ['placements', 2], + ['achievements', 1], + ['timedEvents', 1], + ['offers', 1], + ['rewards', 2], + ] as const) { + const b = plan[type] + truthy( + S, + b.creates.length === 0 && b.updates.length === 0 && b.deletes.length === 0 && b.unchanged === expectUnchanged, + `${type}: all-unchanged (creates=${b.creates.length} updates=${b.updates.length} deletes=${b.deletes.length} unchanged=${b.unchanged}, expected unchanged=${expectUnchanged})`, + ) + } +} + +async function scenario2CreateUpdateUnchanged(app: any) { + console.log('\n[verify-config-sync] === Scenario 2: create+update+unchanged exact slug lists ===') + const S = 'create-update-unchanged' + const projectId = await newProject(app, 'CUU', 'verify-cfgsync-cuu') + await app.documents('api::placement.placement').create({ data: { slug: 'p-keep', name: 'Keep', project: projectId } }) + await app.documents('api::placement.placement').create({ data: { slug: 'p-change', name: 'Old Name', project: projectId } }) + + const file = buildFile({ + placements: [fPlacement('p-keep', 'Keep'), fPlacement('p-change', 'New Name'), fPlacement('p-new', 'Brand New')], + }) + const res = await importFile(projectId, file) + truthy(S, res.status === 200, `import applied (status ${res.status})`) + const b = res.body.plan.placements + eq(S, b.creates, ['p-new'], 'placements.creates == [p-new]') + eq(S, b.updates, ['p-change'], 'placements.updates == [p-change]') + eq(S, b.unchanged, 1, 'placements.unchanged == 1 (p-keep)') + // positive control: p-change's name really changed in the DB + const changed = await findBySlug(app, 'api::placement.placement', projectId, 'p-change') + truthy(S, changed?.name === 'New Name', `p-change name updated to "New Name" (got "${changed?.name}")`) +} + +async function scenario3RegisteredEventTypesOrder(app: any) { + console.log('\n[verify-config-sync] === Scenario 3: registeredEventTypes order-insensitivity ===') + const S = 'registered-event-types-order' + const projectId = await newProject(app, 'RET', 'verify-cfgsync-ret', { + registeredEventTypes: ['login', 'signup', 'purchase'], + }) + const reordered = buildFile({ + project: { pointRules: {}, registeredEventTypes: ['purchase', 'login', 'signup'], allowedOrigins: null }, + }) + const res = await importFile(projectId, reordered, { dryRun: true }) + eq(S, res.body.plan.project.updates, [], 'reordered set -> project.updates == []') + eq(S, res.body.plan.project.unchanged, 1, 'reordered set -> project.unchanged == 1') + // positive control: a genuinely different set must register as an update + const changed = buildFile({ + project: { pointRules: {}, registeredEventTypes: ['login'], allowedOrigins: null }, + }) + const res2 = await importFile(projectId, changed, { dryRun: true }) + truthy(S, res2.body.plan.project.updates.length === 1, 'different set -> project.updates non-empty (positive control)') +} + +async function scenario4Prune(app: any) { + console.log('\n[verify-config-sync] === Scenario 4: prune only-with-flag and only-covered-types ===') + const S = 'prune' + const projectId = await newProject(app, 'Prune', 'verify-cfgsync-prune') + await app.documents('api::placement.placement').create({ data: { slug: 'p1', name: 'One', project: projectId } }) + await app.documents('api::placement.placement').create({ data: { slug: 'p2', name: 'Two', project: projectId } }) + const webhook = await app.documents('api::webhook-endpoint.webhook-endpoint').create({ + data: { url: 'https://hook.example/verify', secret: 'whsec', enabled: true, project: projectId }, + }) + + const file = buildFile({ placements: [fPlacement('p1', 'One')] }) + + // without prune: p2 survives + const noPrune = await importFile(projectId, file, { prune: false }) + eq(S, noPrune.body.plan.placements.deletes, [], 'without prune -> placements.deletes == []') + const p2AfterNoPrune = await findBySlug(app, 'api::placement.placement', projectId, 'p2') + truthy(S, !!p2AfterNoPrune, 'without prune -> p2 survives') + + // with prune: p2 deleted, p1 survives + const withPrune = await importFile(projectId, file, { prune: true }) + eq(S, withPrune.body.plan.placements.deletes, ['p2'], 'with prune -> placements.deletes == [p2]') + const p2AfterPrune = await findBySlug(app, 'api::placement.placement', projectId, 'p2') + const p1AfterPrune = await findBySlug(app, 'api::placement.placement', projectId, 'p1') + truthy(S, !p2AfterPrune, 'with prune -> p2 deleted') + truthy(S, !!p1AfterPrune, 'with prune -> p1 survives') + + // uncovered type: the webhook endpoint is untouched by prune + const webhookAfter = await app.documents('api::webhook-endpoint.webhook-endpoint').findOne({ documentId: webhook.documentId }) + truthy(S, !!webhookAfter, 'with prune -> seeded webhook endpoint (uncovered type) survives') +} + +async function scenario5DryRunEqualsApply(app: any) { + console.log('\n[verify-config-sync] === Scenario 5: dry-run plan equals apply plan, zero writes ===') + const S = 'dry-run' + const projectId = await newProject(app, 'DryRun', 'verify-cfgsync-dryrun') + await app.documents('api::placement.placement').create({ data: { slug: 'p-exist', name: 'Old', project: projectId } }) + + const file = buildFile({ + placements: [fPlacement('p-exist', 'New'), fPlacement('p-add', 'Added')], + rewards: [fReward('r-new', 'New Reward')], + }) + + const before = await countAll(app, projectId) + const dry = await importFile(projectId, file, { dryRun: true }) + truthy(S, dry.status === 200 && dry.body.applied === false, `dry-run -> applied=false (status ${dry.status})`) + const afterDry = await countAll(app, projectId) + truthy(S, afterDry === before, `dry-run wrote nothing (rows before=${before}, after=${afterDry})`) + + const applied = await importFile(projectId, file, { dryRun: false }) + truthy(S, applied.body.applied === true, 'apply -> applied=true') + eq(S, dry.body.plan, applied.body.plan, 'dry-run plan deep-equals apply plan') + const afterApply = await countAll(app, projectId) + truthy(S, afterApply === before + 2, `apply wrote the creates (rows before=${before}, after=${afterApply}, expected ${before + 2})`) +} + +async function scenario6UnknownRef(app: any) { + console.log('\n[verify-config-sync] === Scenario 6: unknown-ref 400 before writes ===') + const S = 'unknown-ref' + const projectId = await newProject(app, 'UnknownRef', 'verify-cfgsync-unknownref') + + const file = buildFile({ + placements: [fPlacement('p-real', 'Real')], + rewards: [fReward('r-would-write', 'Would Write')], + offers: [fOffer('o-bad', 'Bad Offer', 'ghost-placement')], + }) + const before = await countAll(app, projectId) + const res = await importFile(projectId, file) + truthy(S, res.status === 400, `unknown ref -> 400 (status ${res.status})`) + truthy(S, res.body?.error === 'unknown reference', `body.error == 'unknown reference' (got ${JSON.stringify(res.body?.error)})`) + const hasDetail = Array.isArray(res.body?.details) && res.body.details.some( + (d: any) => d.offer === 'o-bad' && d.ref === 'ghost-placement' && d.type === 'placement', + ) + truthy(S, hasDetail, `details include {offer:o-bad, ref:ghost-placement, type:placement} (got ${JSON.stringify(res.body?.details)})`) + const after = await countAll(app, projectId) + truthy(S, after === before, `no writes before the 400 (rows before=${before}, after=${after})`) +} + +async function scenario7MidRun422(app: any) { + console.log('\n[verify-config-sync] === Scenario 7: mid-run 422 with recomputed plan ===') + const S = 'mid-run-422' + const projectId = await newProject(app, 'MidRun', 'verify-cfgsync-midrun') + + const file = buildFile({ + placements: [fPlacement('p1', 'One')], + timedEvents: [fTimedEvent('te1', 'TE One')], + achievements: [fAchievement('ach1', 'Ach One')], + // static reward with null staticCode: the S8 reward lifecycle rejects it + rewards: [fReward('r-bad', 'Bad Reward', { codeType: 'static', staticCode: null })], + offers: [fOffer('o1', 'Offer One', 'p1')], + }) + const res = await importFile(projectId, file) + truthy(S, res.status === 422, `mid-run rejection -> 422 (status ${res.status})`) + truthy(S, res.body?.applied === true, 'body.applied === true') + truthy(S, res.body?.error?.stage === 'rewards/r-bad', `error.stage == 'rewards/r-bad' (got ${JSON.stringify(res.body?.error?.stage)})`) + + // earlier types genuinely applied; the failing reward + not-yet-reached offer absent + const p1 = await findBySlug(app, 'api::placement.placement', projectId, 'p1') + const te1 = await findBySlug(app, 'api::timed-event.timed-event', projectId, 'te1') + const ach1 = await findBySlug(app, 'api::achievement.achievement', projectId, 'ach1') + const rBad = await findBySlug(app, 'api::reward.reward', projectId, 'r-bad') + const o1 = await findBySlug(app, 'api::offer.offer', projectId, 'o1') + truthy(S, !!p1 && !!te1 && !!ach1, 'placement/timedEvent/achievement genuinely applied before the failure') + truthy(S, !rBad, 'failing reward was NOT written') + truthy(S, !o1, 'offer (after rewards in order) was NOT reached') + + // recomputed plan matches DB state: applied types collapse to unchanged, + // the failing reward stays a create, the unreached offer stays a create. + const plan = res.body.plan + eq(S, plan.placements.creates, [], 'recomputed: placements.creates == [] (applied)') + eq(S, plan.placements.unchanged, 1, 'recomputed: placements.unchanged == 1') + eq(S, plan.timedEvents.creates, [], 'recomputed: timedEvents.creates == [] (applied)') + eq(S, plan.achievements.creates, [], 'recomputed: achievements.creates == [] (applied)') + eq(S, plan.rewards.creates, ['r-bad'], 'recomputed: rewards.creates == [r-bad] (still pending)') + eq(S, plan.offers.creates, ['o1'], 'recomputed: offers.creates == [o1] (still pending)') +} + +async function scenario8UpdatePreservesDocumentId(app: any) { + console.log('\n[verify-config-sync] === Scenario 8: update-in-place preserves documentId ===') + const S = 'update-preserves-id' + const projectId = await newProject(app, 'UpdateId', 'verify-cfgsync-updateid') + const created = await app.documents('api::placement.placement').create({ data: { slug: 'p-x', name: 'Before', project: projectId } }) + const beforeId = created.documentId + + const file = buildFile({ placements: [fPlacement('p-x', 'After')] }) + const res = await importFile(projectId, file) + truthy(S, res.status === 200, `update import applied (status ${res.status})`) + eq(S, res.body.plan.placements.updates, ['p-x'], 'placements.updates == [p-x]') + + const after = await findBySlug(app, 'api::placement.placement', projectId, 'p-x') + truthy(S, after?.documentId === beforeId, `documentId preserved across update (before=${beforeId}, after=${after?.documentId})`) + truthy(S, after?.name === 'After', `name updated to "After" (got "${after?.name}")`) +} + +async function scenario9PruneOrphanRef(app: any) { + console.log('\n[verify-config-sync] === Scenario 9: prune orphan-ref guard ===') + const S = 'prune-orphan-ref' + const projectId = await newProject(app, 'PruneOrphan', 'verify-cfgsync-pruneorphan') + const placement = await app.documents('api::placement.placement').create({ + data: { slug: 'p-existing', name: 'Existing', project: projectId }, + }) + await app.documents('api::offer.offer').create({ + data: { slug: 'o1', name: 'Offer One', headline: 'Head', priority: 0, placement: placement.documentId, project: projectId }, + }) + + // The file keeps offer o1 but omits placement p-existing. With prune, that + // placement is about to be deleted, so the surviving offer would be orphaned: + // a 400 unknown reference BEFORE any write. + const file = buildFile({ + placements: [], + offers: [fOffer('o1', 'Offer One', 'p-existing')], + }) + + const before = await countAll(app, projectId) + const pruned = await importFile(projectId, file, { prune: true }) + truthy(S, pruned.status === 400, `prune dropping the offer's placement -> 400 (status ${pruned.status})`) + truthy(S, pruned.body?.error === 'unknown reference', `body.error == 'unknown reference' (got ${JSON.stringify(pruned.body?.error)})`) + const hasDetail = + Array.isArray(pruned.body?.details) && + pruned.body.details.some((d: any) => d.offer === 'o1' && d.ref === 'p-existing' && d.type === 'placement') + truthy(S, hasDetail, `details include {offer:o1, ref:p-existing, type:placement} (got ${JSON.stringify(pruned.body?.details)})`) + const afterPrune = await countAll(app, projectId) + truthy(S, afterPrune === before, `prune 400 wrote nothing (rows before=${before}, after=${afterPrune})`) + + // Same file WITHOUT prune: the existing placement survives, so the cross-ref + // resolves and the import is accepted (no unknown-ref 400). Dry-run keeps it + // write-free while still exercising findUnknownRefs (which runs before the + // dry-run short-circuit). + const noPrune = await importFile(projectId, file, { prune: false, dryRun: true }) + truthy(S, noPrune.status === 200, `without prune -> cross-ref passes, status 200 (status ${noPrune.status})`) + truthy(S, noPrune.body?.error !== 'unknown reference', 'without prune -> not an unknown-reference rejection') +} + +// --- main -------------------------------------------------------------------- + +async function main() { + const appDir = path.resolve(__dirname, '..') + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { compileStrapi, createStrapi } = require('@strapi/strapi') + + const databaseUrl = process.env.DATABASE_URL + if (!databaseUrl) { + console.error( + '[verify-config-sync] refusing to run: DATABASE_URL could not be resolved (not set in the environment, and not present in apps/cms/.env). Set DATABASE_URL explicitly.', + ) + process.exit(1) + } + console.log(`[verify-config-sync] target DB: ${databaseUrl.replace(/:[^:@/]*@/, ':***@')}`) + assertLocalDatabase(databaseUrl) + + SECRET = process.env.CONFIG_PLANE_SECRET ?? '' + if (!SECRET) { + console.error('[verify-config-sync] refusing to run: CONFIG_PLANE_SECRET is not set — cannot exercise the guarded endpoint.') + process.exit(1) + } + + console.log('[verify-config-sync] compiling cms...') + const appContext = await compileStrapi({ appDir }) + const app = await createStrapi(appContext).load() + await app.listen() + console.log(`[verify-config-sync] strapi loaded and listening on :${VERIFY_PORT}`) + + let exitCode = 0 + try { + await scenario1RoundTrip(app) + await scenario2CreateUpdateUnchanged(app) + await scenario3RegisteredEventTypesOrder(app) + await scenario4Prune(app) + await scenario5DryRunEqualsApply(app) + await scenario6UnknownRef(app) + await scenario7MidRun422(app) + await scenario8UpdatePreservesDocumentId(app) + await scenario9PruneOrphanRef(app) + } catch (e: any) { + console.error('[verify-config-sync] unexpected error:', e) + exitCode = 1 + } finally { + for (const projectId of createdProjectIds) { + await destroyProject(app, projectId).catch(() => {}) + } + await app.destroy().catch(() => {}) + } + + console.log(`\n[verify-config-sync] ${findings.length} finding(s)`) + for (const f of findings) console.log(` - [${f.scenario}] ${f.message}`) + + process.exit(exitCode || (findings.length > 0 ? 1 : 0)) +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/apps/cms/scripts/verify-lifecycles.ts b/apps/cms/scripts/verify-lifecycles.ts index 268de76..c29d71e 100644 --- a/apps/cms/scripts/verify-lifecycles.ts +++ b/apps/cms/scripts/verify-lifecycles.ts @@ -28,6 +28,11 @@ * (from `@strapi/utils`) specifically — any other error type is its own * distinct finding, since a bare "it threw" can't distinguish the check * firing from something upstream (e.g. resolveProjectId) crashing. + * Sprint 11: the same per-shape loop also exercises the slug lifecycle + * (regex rejection on create, in-project duplicate rejection on update, + * self-update-with-unchanged-slug staying clean) for achievement, offer, + * and timed-event — the three content types that gained `slug` alongside + * reward's pre-existing one. * 2. Duplicate staticCode scan (#20.2) — read-only: groups static rewards * by (project, staticCode) across the whole target DB; any duplicate * group is a finding. No auto-fix — operator decides. Pages explicitly @@ -140,6 +145,62 @@ async function assertValidationErrorFires(probe: string, shapeName: string, labe finding(probe, `${label} did NOT fire on update with project shape "${shapeName}" — silently skipped`) } +// Generic per-content-type slug lifecycle check, shared across achievement, +// offer, and timed-event (each mirrors the reward slug pattern exactly — +// see their lifecycles.ts files). Exercises, for a given project relation +// shape: +// - regex rejection on create (invalid slug thrown as ValidationError) +// - in-project duplicate rejection on update (b's slug -> a's slug) +// - self-update-with-unchanged-slug staying clean (the self-exclusion +// `where.id = { $ne: existingId }` must not flag a row against itself) +// `buildData` supplies the type-specific required fields (e.g. achievement's +// eventType/targetCount, offer's headline, timed-event's startsAt/endsAt) +// alongside name/slug. +async function checkSlugLifecycle( + app: any, + uid: string, + probePrefix: string, + shapeName: string, + shapeVal: unknown, + buildData: (name: string, slug: string) => Record, +) { + let aId: string | undefined + let bId: string | undefined + try { + const a = await app.documents(uid).create({ data: { ...buildData(`Verify A ${shapeName}`, `verify-a-${shapeName}`), project: shapeVal } }) + aId = a.documentId + const b = await app.documents(uid).create({ data: { ...buildData(`Verify B ${shapeName}`, `verify-b-${shapeName}`), project: shapeVal } }) + bId = b.documentId + + try { + await app.documents(uid).create({ data: { ...buildData(`Verify Bad ${shapeName}`, '1-bad-slug'), project: shapeVal } }) + finding(`${probePrefix}-regex`, `slug regex rejection did NOT fire on create with project shape "${shapeName}" — invalid slug "1-bad-slug" was accepted`) + } catch (e: any) { + if (isValidationError(e)) { + ok(`slug regex rejection fired on create (shape: ${shapeName})`) + } else { + finding(`${probePrefix}-regex`, `shape "${shapeName}": invalid-slug create threw a non-validation error: ${e?.message ?? String(e)}`) + } + } + + await assertValidationErrorFires(`${probePrefix}-duplicate`, shapeName, 'slug uniqueness check', () => + app.documents(uid).update({ documentId: bId!, data: { slug: a.slug, project: shapeVal } }), + ) + + try { + await app.documents(uid).update({ documentId: bId!, data: { slug: b.slug, project: shapeVal } }) + ok(`self-update with unchanged slug stayed clean (shape: ${shapeName})`) + } catch (e: any) { + finding(`${probePrefix}-self-update`, `shape "${shapeName}": self-update with unchanged slug incorrectly threw: ${e?.message ?? String(e)}`) + } + } catch (e: any) { + finding(`${probePrefix}-setup`, `setup failed for shape "${shapeName}": ${e.message}`) + } finally { + if (aId) await app.documents(uid).delete({ documentId: aId }).catch(() => {}) + if (bId) await app.documents(uid).delete({ documentId: bId }).catch(() => {}) + } +} + // --- fixture project ------------------------------------------------------- async function cleanupLeftoverProject(app: any) { @@ -220,6 +281,7 @@ async function probe1(app: any, project: any) { const te = await app.documents('api::timed-event.timed-event').create({ data: { name: `Verify TE ${shapeName}`, + slug: `verify-te-${shapeName}`, startsAt, endsAt, project: shapeVal, @@ -236,6 +298,28 @@ async function probe1(app: any, project: any) { } catch (e: any) { finding('probe1-timedevent-setup', `setup failed for shape "${shapeName}": ${e.message}`) } + + // --- achievement: slug lifecycle (regex, in-project duplicate, self-update clean) --- + await checkSlugLifecycle(app, 'api::achievement.achievement', 'probe1-achievement-slug', shapeName, shapeVal, (name, slug) => ({ + name, + slug, + eventType: 'lesson_completed', + targetCount: 1, + })) + + // --- offer: slug lifecycle --- + await checkSlugLifecycle(app, 'api::offer.offer', 'probe1-offer-slug', shapeName, shapeVal, (name, slug) => ({ + name, + slug, + headline: name, + })) + + // --- timed-event: slug lifecycle (separate fixtures from the dates check above) --- + await checkSlugLifecycle(app, 'api::timed-event.timed-event', 'probe1-timedevent-slug', shapeName, shapeVal, (name, slug) => { + const s = new Date() + const e = new Date(s.getTime() + 3600_000) + return { name, slug, startsAt: s, endsAt: e } + }) } for (const documentId of createdRewardIds) { @@ -339,6 +423,7 @@ async function probe3(app: any, project: any, pg: Client) { const control = await app.documents('api::timed-event.timed-event').create({ data: { name: 'Verify Probe3 Control (recurrence=none)', + slug: 'verify-probe3-control', startsAt, endsAt, recurrence: 'none', @@ -357,6 +442,7 @@ async function probe3(app: any, project: any, pg: Client) { const positiveControl = await app.documents('api::timed-event.timed-event').create({ data: { name: 'Verify Probe3 Positive Control (active)', + slug: 'verify-probe3-positive-control', startsAt: new Date(), endsAt: new Date(Date.now() + 3600_000), recurrence: 'none', @@ -369,10 +455,10 @@ async function probe3(app: any, project: any, pg: Client) { const syntheticDocId = randomDocumentId() const insertRes = await pg.query( `INSERT INTO timed_events - (document_id, name, starts_at, ends_at, ending_soon_minutes, multiplier, enabled, recurrence, recurrence_ends_at, created_at, updated_at, published_at) - VALUES ($1, $2, $3, $4, 1440, 1, true, NULL, NULL, now(), now(), now()) + (document_id, name, slug, starts_at, ends_at, ending_soon_minutes, multiplier, enabled, recurrence, recurrence_ends_at, created_at, updated_at, published_at) + VALUES ($1, $2, $3, $4, $5, 1440, 1, true, NULL, NULL, now(), now(), now()) RETURNING id`, - [syntheticDocId, 'Verify Probe3 Legacy NULL Recurrence', startsAt.toISOString(), endsAt.toISOString()], + [syntheticDocId, 'Verify Probe3 Legacy NULL Recurrence', 'verify-probe3-legacy-null-recurrence', startsAt.toISOString(), endsAt.toISOString()], ) syntheticId = insertRes.rows[0].id await pg.query('INSERT INTO timed_events_project_lnk (timed_event_id, project_id) VALUES ($1, $2)', [syntheticId, project.id]) diff --git a/apps/cms/src/api/achievement/content-types/achievement/lifecycles.ts b/apps/cms/src/api/achievement/content-types/achievement/lifecycles.ts new file mode 100644 index 0000000..89f22f9 --- /dev/null +++ b/apps/cms/src/api/achievement/content-types/achievement/lifecycles.ts @@ -0,0 +1,78 @@ +import { errors } from '@strapi/utils' + +const SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/ + +function fail(message: string): never { + throw new errors.ValidationError(message) +} + +// Merge incoming (possibly partial, on update) data over the current row so +// cross-field validation always sees the resulting full record. +async function loadCurrent(event: any): Promise> { + const where = event.params.where + if (!where) return {} + const existing = await strapi.db.query('api::achievement.achievement').findOne({ where, populate: ['project'] }) + return existing ?? {} +} + +function validate(merged: Record) { + const slug = merged.slug + if (typeof slug !== 'string' || !SLUG_PATTERN.test(slug)) { + fail(`slug must match ${SLUG_PATTERN} (got: ${JSON.stringify(slug)})`) + } +} + +// Relation values arrive in whatever shape the caller used: a raw internal +// id, a documentId string, or a { connect/set: [...] } mutation descriptor +// (Content Manager / entityService all take slightly different shapes). +// Resolve any of them down to the project's internal numeric id so it can be +// compared/queried at this (db-level) lifecycle layer. +async function resolveProjectId(raw: any): Promise { + if (raw == null) return null + if (typeof raw === 'number') return raw + if (typeof raw === 'string') { + const byDocumentId = await strapi.db.query('api::project.project').findOne({ where: { documentId: raw } }) + if (byDocumentId) return byDocumentId.id + const asNumber = Number(raw) + return Number.isFinite(asNumber) ? asNumber : null + } + if (typeof raw === 'object') { + const list = raw.connect ?? raw.set + if (Array.isArray(list) && list.length > 0) { + const first = list[0] + return resolveProjectId(typeof first === 'object' ? first.id ?? first.documentId : first) + } + if (raw.id != null) return resolveProjectId(raw.id) + if (raw.documentId != null) return resolveProjectId(raw.documentId) + } + return null +} + +async function checkSlugUnique(event: any, merged: Record) { + const slug = merged.slug + const projectId = await resolveProjectId(merged.project) + if (!slug || projectId == null) return // no project set yet — nothing to scope uniqueness by + const where: Record = { slug, project: projectId } + const existingId = event.params.where?.id + if (existingId != null) { + where.id = { $ne: existingId } + } + const count = await strapi.db.query('api::achievement.achievement').count({ where }) + if (count > 0) { + fail(`slug "${slug}" is already in use for this project`) + } +} + +export default { + async beforeCreate(event: any) { + const merged = { ...event.params.data } + validate(merged) + await checkSlugUnique(event, merged) + }, + async beforeUpdate(event: any) { + const current = await loadCurrent(event) + const merged = { ...current, ...event.params.data } + validate(merged) + await checkSlugUnique(event, merged) + }, +} diff --git a/apps/cms/src/api/achievement/content-types/achievement/schema.json b/apps/cms/src/api/achievement/content-types/achievement/schema.json index c8cbe81..f77a084 100644 --- a/apps/cms/src/api/achievement/content-types/achievement/schema.json +++ b/apps/cms/src/api/achievement/content-types/achievement/schema.json @@ -5,6 +5,7 @@ "options": { "draftAndPublish": false }, "attributes": { "name": { "type": "string", "required": true }, + "slug": { "type": "string", "required": true, "regex": "^[a-z][a-z0-9_-]*$" }, "description": { "type": "text" }, "artworkUrl": { "type": "string" }, "eventType": { "type": "string", "required": true, "regex": "^[a-z][a-z0-9_]*$" }, diff --git a/apps/cms/src/api/config-plane/controllers/config-plane.ts b/apps/cms/src/api/config-plane/controllers/config-plane.ts index 0fcbcf3..12bb5c6 100644 --- a/apps/cms/src/api/config-plane/controllers/config-plane.ts +++ b/apps/cms/src/api/config-plane/controllers/config-plane.ts @@ -1,4 +1,6 @@ import { timingSafeEqual } from 'node:crypto' +import { importRequestSchema, type ConfigFile, type ImportResponse } from '@promocean/contracts' +import { computePlan, findUnknownRefs, type CurrentState } from '../services/import-plan' // mirrors packages/contracts/src/events.ts EVENT_TYPE_PATTERN — cms doesn't import contracts const EVENT_TYPE_PATTERN = /^[a-z][a-z0-9_]*$/ @@ -11,6 +13,133 @@ function configSecretOk(ctx: any): boolean { return provided.length === expectedBuf.length && timingSafeEqual(provided, expectedBuf) } +// exportProject's own output ordering, independent of whatever order findMany happens to +// return rows in (DB id-assignment order, which isn't guaranteed to match input file order +// for content created via import — e.g. concurrent creates — nor guaranteed stable across +// reseeds of the same seed script). Config files are meant to be diffed/version-controlled +// and re-imported into other projects, so export's array order must be deterministic and +// depend only on content, not incidental DB history. Slug is unique per project per type, so +// sorting by it is a total order. +function sortBySlug(rows: T[]): T[] { + // Null-safe as defense in depth: callers are expected to have already gated out + // null/missing slugs (see exportProject's findings check), so this should never + // see one in practice, but a total, non-throwing compare costs nothing here. + return [...rows].sort((a, b) => (a.slug ?? '').localeCompare(b.slug ?? '')) +} + +// Tolerant project-settings mappers (mirror exportProject's inline logic): +// coerce whatever is stored in the JSON columns into the file's shape, +// dropping malformed entries rather than surfacing them. +function mapPointRules(raw: any): Record { + if (raw == null) return {} + if (typeof raw !== 'object' || Array.isArray(raw)) return {} + const out: Record = {} + for (const [key, value] of Object.entries(raw as Record)) { + if (!EVENT_TYPE_PATTERN.test(key) || typeof value !== 'number' || !Number.isInteger(value) || value < 0) continue + out[key] = value + } + return out +} +function mapRegisteredEventTypes(raw: any): string[] { + if (Array.isArray(raw)) return raw.filter((t: unknown): t is string => typeof t === 'string' && EVENT_TYPE_PATTERN.test(t)) + return [] +} +function mapAllowedOrigins(raw: any): string[] | null { + if (Array.isArray(raw) && raw.every((o: unknown) => typeof o === 'string')) return raw + return null +} + +// Load the current project state mapped into the file's shape (for diffing), +// plus a slug->documentId map per type (for apply's update/delete writes), plus +// the project's own slug (for the project plan bucket). Re-queried fresh on +// each call, so the post-partial-apply recompute sees the real DB state. +async function loadCurrentState( + projectId: string, +): Promise<{ state: CurrentState; ids: Record>; projectSlug: string } | null> { + const project = await strapi.documents('api::project.project').findOne({ documentId: projectId }) + if (!project) return null + + const [placements, achievements, timedEvents, offers, rewards] = await Promise.all([ + strapi.documents('api::placement.placement').findMany({ filters: { project: { documentId: projectId } } }), + strapi.documents('api::achievement.achievement').findMany({ filters: { project: { documentId: projectId } } }), + strapi.documents('api::timed-event.timed-event').findMany({ filters: { project: { documentId: projectId } } }), + strapi.documents('api::offer.offer').findMany({ + filters: { project: { documentId: projectId } }, + populate: ['placement', 'timedEvent'], + }), + strapi.documents('api::reward.reward').findMany({ filters: { project: { documentId: projectId } } }), + ]) + + const state: CurrentState = { + project: { + pointRules: mapPointRules(project.pointRules), + registeredEventTypes: mapRegisteredEventTypes(project.registeredEventTypes), + allowedOrigins: mapAllowedOrigins(project.allowedOrigins), + }, + placements: placements.map((r: any) => ({ slug: r.slug, name: r.name })), + achievements: achievements.map((r: any) => ({ + slug: r.slug, + name: r.name, + description: r.description ?? null, + artworkUrl: r.artworkUrl ?? null, + eventType: r.eventType, + targetCount: r.targetCount, + pointsValue: r.pointsValue ?? 0, + })), + timedEvents: timedEvents.map((r: any) => ({ + slug: r.slug, + name: r.name, + description: r.description ?? null, + startsAt: r.startsAt, + endsAt: r.endsAt, + endingSoonMinutes: r.endingSoonMinutes, + multiplier: r.multiplier, + recurrence: r.recurrence ?? 'none', + recurrenceEndsAt: r.recurrenceEndsAt ?? null, + enabled: r.enabled, + })), + offers: offers.map((r: any) => ({ + slug: r.slug, + name: r.name, + headline: r.headline, + body: r.body ?? null, + imageUrl: r.imageUrl ?? null, + ctaText: r.ctaText ?? null, + ctaUrl: r.ctaUrl ?? null, + startsAt: r.startsAt ?? null, + endsAt: r.endsAt ?? null, + priority: r.priority ?? 0, + placement: r.placement?.slug ?? '', + timedEvent: r.timedEvent?.slug ?? null, + })), + rewards: rewards.map((r: any) => ({ + slug: r.slug, + name: r.name, + description: r.description ?? null, + codeType: r.codeType, + staticCode: r.staticCode ?? null, + codePrefix: r.codePrefix ?? null, + pointsPrice: r.pointsPrice ?? 0, + startsAt: r.startsAt ?? null, + endsAt: r.endsAt ?? null, + perUserLimit: r.perUserLimit ?? 1, + inventory: r.inventory ?? null, + enabled: r.enabled, + })), + } + + const idMap = (rows: any[]) => new Map(rows.map((r: any) => [r.slug, r.documentId])) + const ids = { + placements: idMap(placements), + timedEvents: idMap(timedEvents), + achievements: idMap(achievements), + offers: idMap(offers), + rewards: idMap(rewards), + } + + return { state, ids, projectSlug: project.slug ?? 'project' } +} + export default { async achievements(ctx: any) { if (!configSecretOk(ctx)) return ctx.unauthorized() @@ -22,6 +151,7 @@ export default { ctx.body = { achievements: rows.map((r: any) => ({ id: r.documentId, + slug: r.slug, name: r.name, description: r.description ?? null, artworkUrl: r.artworkUrl ?? null, @@ -44,6 +174,7 @@ export default { .filter((r: any) => r.placement?.slug) .map((r: any) => ({ id: r.documentId, + slug: r.slug, placementSlug: r.placement.slug, headline: r.headline, body: r.body ?? null, @@ -67,6 +198,7 @@ export default { ctx.body = { events: rows.map((r: any) => ({ id: r.documentId, + slug: r.slug, name: r.name, description: r.description ?? null, startsAt: r.startsAt, @@ -116,6 +248,7 @@ export default { .filter((r: any) => r.project?.documentId) .map((r: any) => ({ id: r.documentId, + slug: r.slug, name: r.name, description: r.description ?? null, startsAt: r.startsAt, @@ -213,6 +346,165 @@ export default { } ctx.body = { pointRules } }, + async exportProject(ctx: any) { + if (!configSecretOk(ctx)) return ctx.unauthorized() + const projectId = String(ctx.params.projectId ?? '') + if (!projectId) return ctx.badRequest('projectId is required') + const project = await strapi.documents('api::project.project').findOne({ documentId: projectId }) + if (!project) return ctx.notFound() + + const [placementsRaw, achievementsRaw, timedEventsRaw, offersRaw, rewardsRaw] = await Promise.all([ + strapi.documents('api::placement.placement').findMany({ filters: { project: { documentId: projectId } } }), + strapi.documents('api::achievement.achievement').findMany({ filters: { project: { documentId: projectId } } }), + strapi.documents('api::timed-event.timed-event').findMany({ filters: { project: { documentId: projectId } } }), + strapi.documents('api::offer.offer').findMany({ + filters: { project: { documentId: projectId } }, + populate: ['placement', 'timedEvent'], + }), + strapi.documents('api::reward.reward').findMany({ filters: { project: { documentId: projectId } } }), + ]) + // Every content type covered by the export must carry a non-empty slug — + // the file format cross-references content by slug (offers -> placement/ + // timedEvent), so a row missing one would either silently break those + // refs or produce a file that fails configFileSchema. Collect EVERY + // offender rather than failing fast on the first, so an operator can fix + // them all in one pass. An offer whose populated `placement` relation + // itself has no resolvable slug is listed the same way — from the + // export's perspective that offer can't be represented either. + // + // MUST run before sortBySlug: sortBySlug's compare calls slug.localeCompare + // unconditionally, which throws on a null/undefined slug. Legacy rows with + // slug === null (pre-existing data from before slugs were required at the + // schema level) are exactly the case this gate exists to catch and report + // as a clean 500 with findings — not crash the sort into a generic 500. + const findings: string[] = [] + function offenderLine(type: string, row: any): string { + return `${type} "${row.name}" (documentId ${row.documentId})` + } + function hasSlug(row: any): boolean { + return typeof row.slug === 'string' && row.slug.length > 0 + } + for (const row of placementsRaw) if (!hasSlug(row)) findings.push(offenderLine('placement', row)) + for (const row of achievementsRaw) if (!hasSlug(row)) findings.push(offenderLine('achievement', row)) + for (const row of timedEventsRaw) if (!hasSlug(row)) findings.push(offenderLine('timed-event', row)) + for (const row of offersRaw as any[]) + if (!hasSlug(row) || !hasSlug(row.placement ?? {})) findings.push(offenderLine('offer', row)) + for (const row of rewardsRaw) if (!hasSlug(row)) findings.push(offenderLine('reward', row)) + + if (findings.length > 0) { + ctx.status = 500 + ctx.body = { error: 'unexported definitions missing slugs', findings } + return + } + + // Sorted by slug (see sortBySlug) so the export's array order is deterministic and + // content-derived, not an artifact of DB creation/id order — required for the file to be + // diff-stable across re-imports, reseeds, and different target projects. Safe here: every + // row passed the findings gate above, so every slug is a non-null non-empty string. + const placements = sortBySlug(placementsRaw as Array<{ slug: string; [k: string]: any }>) + const achievements = sortBySlug(achievementsRaw as Array<{ slug: string; [k: string]: any }>) + const timedEvents = sortBySlug(timedEventsRaw as Array<{ slug: string; [k: string]: any }>) + const offers = sortBySlug(offersRaw as Array<{ slug: string; [k: string]: any }>) + const rewards = sortBySlug(rewardsRaw as Array<{ slug: string; [k: string]: any }>) + + const rawPointRules = project.pointRules + let pointRules: Record + if (rawPointRules == null) { + pointRules = {} + } else if (typeof rawPointRules === 'object' && !Array.isArray(rawPointRules)) { + pointRules = {} + for (const [key, value] of Object.entries(rawPointRules as Record)) { + if (!EVENT_TYPE_PATTERN.test(key) || typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + strapi.log.warn(`[promocean] project ${project.documentId} pointRules entry "${key}" is invalid; dropping`) + continue + } + pointRules[key] = value + } + } else { + strapi.log.warn(`[promocean] project ${project.documentId} pointRules is not an object; ignoring`) + pointRules = {} + } + + const rawEventTypes = project.registeredEventTypes + let registeredEventTypes: string[] + if (Array.isArray(rawEventTypes)) { + registeredEventTypes = rawEventTypes.filter((t: unknown): t is string => typeof t === 'string' && EVENT_TYPE_PATTERN.test(t)) + } else if (rawEventTypes == null) { + registeredEventTypes = [] + } else { + strapi.log.warn(`[promocean] project ${project.documentId} registeredEventTypes is not an array; ignoring`) + registeredEventTypes = [] + } + + const rawOrigins = project.allowedOrigins + let allowedOrigins: string[] | null + if (Array.isArray(rawOrigins) && rawOrigins.every((o: unknown) => typeof o === 'string')) { + allowedOrigins = rawOrigins + } else if (rawOrigins == null) { + allowedOrigins = null + } else { + strapi.log.warn(`[promocean] project ${project.documentId} allowedOrigins is not a string array; ignoring`) + allowedOrigins = null + } + + ctx.body = { + formatVersion: 1, + project: { pointRules, registeredEventTypes, allowedOrigins }, + placements: placements.map((r: any) => ({ + slug: r.slug, + name: r.name, + })), + achievements: achievements.map((r: any) => ({ + slug: r.slug, + name: r.name, + description: r.description ?? null, + artworkUrl: r.artworkUrl ?? null, + eventType: r.eventType, + targetCount: r.targetCount, + pointsValue: r.pointsValue ?? 0, + })), + timedEvents: timedEvents.map((r: any) => ({ + slug: r.slug, + name: r.name, + description: r.description ?? null, + startsAt: r.startsAt, + endsAt: r.endsAt, + endingSoonMinutes: r.endingSoonMinutes, + multiplier: r.multiplier, + recurrence: r.recurrence ?? 'none', + recurrenceEndsAt: r.recurrenceEndsAt ?? null, + enabled: r.enabled, + })), + offers: offers.map((r: any) => ({ + slug: r.slug, + name: r.name, + headline: r.headline, + body: r.body ?? null, + imageUrl: r.imageUrl ?? null, + ctaText: r.ctaText ?? null, + ctaUrl: r.ctaUrl ?? null, + startsAt: r.startsAt ?? null, + endsAt: r.endsAt ?? null, + priority: r.priority ?? 0, + placement: r.placement.slug, + timedEvent: r.timedEvent?.slug ?? null, + })), + rewards: rewards.map((r: any) => ({ + slug: r.slug, + name: r.name, + description: r.description ?? null, + codeType: r.codeType, + staticCode: r.staticCode ?? null, + codePrefix: r.codePrefix ?? null, + pointsPrice: r.pointsPrice ?? 0, + startsAt: r.startsAt ?? null, + endsAt: r.endsAt ?? null, + perUserLimit: r.perUserLimit ?? 1, + inventory: r.inventory ?? null, + enabled: r.enabled, + })), + } + }, async verifyKey(ctx: any) { if (!configSecretOk(ctx)) return ctx.unauthorized() const { keyHash } = ctx.request.body ?? {} @@ -240,4 +532,225 @@ export default { allowedOrigins, } }, + async importProject(ctx: any) { + // 1. Guard -> 401. + if (!configSecretOk(ctx)) return ctx.unauthorized() + const projectId = String(ctx.params.projectId ?? '') + if (!projectId) return ctx.badRequest('projectId is required') + + // 2. Parse body -> 400 with zod issues. + const parsed = importRequestSchema.safeParse(ctx.request.body) + if (!parsed.success) { + ctx.status = 400 + ctx.body = { error: 'invalid config file', issues: parsed.error.issues } + return + } + const { file, prune, dryRun } = parsed.data + + const loaded = await loadCurrentState(projectId) + if (!loaded) return ctx.notFound() + const { state, ids, projectSlug } = loaded + + // 3. Cross-ref resolution -> 400 BEFORE any write. + const unknownRefs = findUnknownRefs(file, state, prune) + if (unknownRefs.length > 0) { + ctx.status = 400 + ctx.body = { error: 'unknown reference', details: unknownRefs } + return + } + + // 4. Plan. + const plan = computePlan(file, state, projectSlug, prune) + + // 5. dryRun short-circuit -> zero writes. + if (dryRun) { + ctx.body = { applied: false, plan } satisfies ImportResponse + return + } + + // 6. Apply through strapi.documents() (lifecycles fire) in dependency + // order; deletes last, reverse order. On a mid-run lifecycle rejection, + // recompute the actually-applied plan from the re-queried DB and 422. + let stage = '' + try { + // project settings (update-or-unchanged) + if (plan.project.updates.length > 0) { + stage = `project/${projectSlug}` + await strapi.documents('api::project.project').update({ + documentId: projectId, + data: { + pointRules: file.project.pointRules, + registeredEventTypes: file.project.registeredEventTypes, + allowedOrigins: file.project.allowedOrigins, + }, + }) + } + + // placements — track created ids so offers can resolve refs written this run + const placementIds = new Map(ids.placements) + for (const p of file.placements) { + if (plan.placements.creates.includes(p.slug)) { + stage = `placements/${p.slug}` + const created = await strapi.documents('api::placement.placement').create({ + data: { slug: p.slug, name: p.name, project: projectId }, + }) + placementIds.set(p.slug, created.documentId) + } else if (plan.placements.updates.includes(p.slug)) { + stage = `placements/${p.slug}` + await strapi.documents('api::placement.placement').update({ + documentId: placementIds.get(p.slug)!, + data: { name: p.name }, + }) + } + } + + // timedEvents + const timedEventIds = new Map(ids.timedEvents) + for (const t of file.timedEvents) { + const data: any = { + slug: t.slug, + name: t.name, + description: t.description, + startsAt: t.startsAt, + endsAt: t.endsAt, + endingSoonMinutes: t.endingSoonMinutes, + multiplier: t.multiplier, + recurrence: t.recurrence, + recurrenceEndsAt: t.recurrenceEndsAt, + enabled: t.enabled, + } + if (plan.timedEvents.creates.includes(t.slug)) { + stage = `timedEvents/${t.slug}` + const created = await strapi.documents('api::timed-event.timed-event').create({ + data: { ...data, project: projectId }, + }) + timedEventIds.set(t.slug, created.documentId) + } else if (plan.timedEvents.updates.includes(t.slug)) { + stage = `timedEvents/${t.slug}` + await strapi.documents('api::timed-event.timed-event').update({ + documentId: timedEventIds.get(t.slug)!, + data, + }) + } + } + + // achievements + for (const a of file.achievements) { + const data: any = { + slug: a.slug, + name: a.name, + description: a.description, + artworkUrl: a.artworkUrl, + eventType: a.eventType, + targetCount: a.targetCount, + pointsValue: a.pointsValue, + } + if (plan.achievements.creates.includes(a.slug)) { + stage = `achievements/${a.slug}` + await strapi.documents('api::achievement.achievement').create({ data: { ...data, project: projectId } }) + } else if (plan.achievements.updates.includes(a.slug)) { + stage = `achievements/${a.slug}` + await strapi.documents('api::achievement.achievement').update({ + documentId: ids.achievements.get(a.slug)!, + data, + }) + } + } + + // rewards + for (const r of file.rewards) { + const data: any = { + slug: r.slug, + name: r.name, + description: r.description, + codeType: r.codeType, + staticCode: r.staticCode, + codePrefix: r.codePrefix, + pointsPrice: r.pointsPrice, + startsAt: r.startsAt, + endsAt: r.endsAt, + perUserLimit: r.perUserLimit, + inventory: r.inventory, + enabled: r.enabled, + } + if (plan.rewards.creates.includes(r.slug)) { + stage = `rewards/${r.slug}` + await strapi.documents('api::reward.reward').create({ data: { ...data, project: projectId } }) + } else if (plan.rewards.updates.includes(r.slug)) { + stage = `rewards/${r.slug}` + await strapi.documents('api::reward.reward').update({ documentId: ids.rewards.get(r.slug)!, data }) + } + } + + // offers — resolve placement/timedEvent slugs to documentIds at write time + for (const o of file.offers) { + const data: any = { + slug: o.slug, + name: o.name, + headline: o.headline, + body: o.body, + imageUrl: o.imageUrl, + ctaText: o.ctaText, + ctaUrl: o.ctaUrl, + startsAt: o.startsAt, + endsAt: o.endsAt, + priority: o.priority, + placement: placementIds.get(o.placement) ?? null, + timedEvent: o.timedEvent != null ? timedEventIds.get(o.timedEvent) ?? null : null, + } + if (plan.offers.creates.includes(o.slug)) { + stage = `offers/${o.slug}` + await strapi.documents('api::offer.offer').create({ data: { ...data, project: projectId } }) + } else if (plan.offers.updates.includes(o.slug)) { + stage = `offers/${o.slug}` + await strapi.documents('api::offer.offer').update({ documentId: ids.offers.get(o.slug)!, data }) + } + } + + // deletes LAST, reverse dependency order (offers -> ... -> placements) + for (const slug of plan.offers.deletes) { + stage = `offers/${slug}` + await strapi.documents('api::offer.offer').delete({ documentId: ids.offers.get(slug)! }) + } + for (const slug of plan.rewards.deletes) { + stage = `rewards/${slug}` + await strapi.documents('api::reward.reward').delete({ documentId: ids.rewards.get(slug)! }) + } + for (const slug of plan.achievements.deletes) { + stage = `achievements/${slug}` + await strapi.documents('api::achievement.achievement').delete({ documentId: ids.achievements.get(slug)! }) + } + for (const slug of plan.timedEvents.deletes) { + stage = `timedEvents/${slug}` + await strapi.documents('api::timed-event.timed-event').delete({ documentId: ids.timedEvents.get(slug)! }) + } + for (const slug of plan.placements.deletes) { + stage = `placements/${slug}` + await strapi.documents('api::placement.placement').delete({ documentId: ids.placements.get(slug)! }) + } + } catch (e: any) { + // Recompute the ACTUALLY-applied plan by re-querying and re-diffing — + // never report the intended plan. Fully-applied types collapse to + // unchanged; the failing/not-yet-reached ones remain in their buckets. + // If the recompute itself throws (re-query/diff failure), fall back to the + // intended plan rather than let it mask the real 422 as a generic 500. + let recomputed = plan + try { + const after = await loadCurrentState(projectId) + if (after) recomputed = computePlan(file, after.state, after.projectSlug, prune) + } catch { + recomputed = plan + } + ctx.status = 422 + ctx.body = { + applied: true, + plan: recomputed, + error: { stage, message: e?.message ?? String(e) }, + } satisfies ImportResponse + return + } + + // 7. Full success. + ctx.body = { applied: true, plan } satisfies ImportResponse + }, } diff --git a/apps/cms/src/api/config-plane/routes/config-plane.ts b/apps/cms/src/api/config-plane/routes/config-plane.ts index f1e110b..97032a4 100644 --- a/apps/cms/src/api/config-plane/routes/config-plane.ts +++ b/apps/cms/src/api/config-plane/routes/config-plane.ts @@ -8,6 +8,8 @@ export default { { method: 'GET', path: '/config-plane/webhook-endpoints', handler: 'config-plane.webhookEndpoints', config: { auth: false } }, { method: 'GET', path: '/config-plane/projects/:projectId/event-types', handler: 'config-plane.eventTypes', config: { auth: false } }, { method: 'GET', path: '/config-plane/projects/:projectId/point-rules', handler: 'config-plane.pointRules', config: { auth: false } }, + { method: 'GET', path: '/config-plane/projects/:projectId/export', handler: 'config-plane.exportProject', config: { auth: false } }, + { method: 'POST', path: '/config-plane/projects/:projectId/import', handler: 'config-plane.importProject', config: { auth: false } }, { method: 'POST', path: '/config-plane/verify-key', handler: 'config-plane.verifyKey', config: { auth: false } }, ], } diff --git a/apps/cms/src/api/config-plane/services/import-plan.ts b/apps/cms/src/api/config-plane/services/import-plan.ts new file mode 100644 index 0000000..10e9ba1 --- /dev/null +++ b/apps/cms/src/api/config-plane/services/import-plan.ts @@ -0,0 +1,233 @@ +/** + * Pure plan computation for the config-plane import endpoint (Sprint 11 Task 4). + * + * Keeps the controller thin: given the parsed config file, the current + * project state mapped into the same file shape, the project's slug, and the + * prune flag, produce the slug-keyed plan (creates/updates/deletes/unchanged) + * the response reports. Cross-reference resolution (offers -> placement / + * timedEvent by slug) also lives here so the handler can reject unknown refs + * BEFORE any write. + * + * Everything here is a pure function of its inputs — no strapi, no I/O — so the + * exact same computation drives (a) the dry-run response, (b) the applied + * plan, and (c) the RECOMPUTED plan after a mid-run lifecycle rejection + * (re-run against the re-queried DB state; see the controller). Determinism + * matters: the dry-run plan must deep-equal the subsequent apply's plan, so + * every bucket is built in a fixed order (file order for creates/updates, + * current order for deletes). + */ + +import type { ConfigFile, ImportResponse } from '@promocean/contracts' + +export type TypePlan = ImportResponse['plan']['placements'] +export type Plan = ImportResponse['plan'] + +/** + * The current project state, mapped into the same shape the file uses (the + * export handler's output shape, minus formatVersion). Diffs compare file + * definitions against this. + */ +export type CurrentState = { + project: ConfigFile['project'] + placements: ConfigFile['placements'] + achievements: ConfigFile['achievements'] + timedEvents: ConfigFile['timedEvents'] + offers: ConfigFile['offers'] + rewards: ConfigFile['rewards'] +} + +export type UnknownRef = { offer: string; ref: string; type: 'placement' | 'timedEvent' } + +// --- normalization --------------------------------------------------------- + +// Datetimes cross the boundary as ISO strings on both sides (the file carries +// z.iso.datetime() strings; strapi.documents() returns ISO strings), but their +// precision/offset spelling can differ (e.g. a trailing ".000", a "+00:00" vs +// "Z"). Normalize both through Date.toISOString() before comparing so an +// idempotent re-import is seen as unchanged. +function normDate(v: string | null | undefined): string | null { + if (v == null) return null + return new Date(v).toISOString() +} + +// Unify explicit null and undefined (a field the mapper left off) to null, so +// they never register as a spurious diff. +function nullish(v: T | null | undefined): T | null { + return v == null ? null : v +} + +function recordEqual(a: Record, b: Record): boolean { + const ak = Object.keys(a) + const bk = Object.keys(b) + if (ak.length !== bk.length) return false + for (const k of ak) { + if (!Object.prototype.hasOwnProperty.call(b, k) || a[k] !== b[k]) return false + } + return true +} + +// registeredEventTypes is compared as a SET — order-insensitive, per the plan. +function setEqual(a: string[], b: string[]): boolean { + const sa = new Set(a) + const sb = new Set(b) + if (sa.size !== sb.size) return false + for (const x of sa) if (!sb.has(x)) return false + return true +} + +// allowedOrigins is an ordered list-or-null; both-null is equal, otherwise +// element-wise (order preserved by round-trip). +function arrayOrNullEqual(a: string[] | null, b: string[] | null): boolean { + if (a == null && b == null) return true + if (a == null || b == null) return false + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false + return true +} + +// --- per-type diff --------------------------------------------------------- + +function diffType( + fileItems: F[], + currentItems: C[], + prune: boolean, + differs: (f: F, c: C) => boolean, +): TypePlan { + const currentBySlug = new Map(currentItems.map((c) => [c.slug, c])) + const fileSlugs = new Set(fileItems.map((f) => f.slug)) + const creates: string[] = [] + const updates: string[] = [] + let unchanged = 0 + for (const f of fileItems) { + const c = currentBySlug.get(f.slug) + if (!c) { + creates.push(f.slug) + continue + } + if (differs(f, c)) updates.push(f.slug) + else unchanged++ + } + const deletes = prune ? currentItems.filter((c) => !fileSlugs.has(c.slug)).map((c) => c.slug) : [] + return { creates, updates, deletes, unchanged } +} + +// Project settings are a singleton: update-or-unchanged, its creates/deletes +// buckets always empty. The updates bucket names the project's own slug when +// any of the three settings fields diverge. +function projectPlan( + file: ConfigFile['project'], + current: ConfigFile['project'], + projectSlug: string, +): TypePlan { + const differs = + !recordEqual(file.pointRules, current.pointRules) || + !setEqual(file.registeredEventTypes, current.registeredEventTypes) || + !arrayOrNullEqual(file.allowedOrigins, current.allowedOrigins) + return { + creates: [], + updates: differs ? [projectSlug] : [], + deletes: [], + unchanged: differs ? 0 : 1, + } +} + +export function computePlan( + file: ConfigFile, + current: CurrentState, + projectSlug: string, + prune: boolean, +): Plan { + return { + project: projectPlan(file.project, current.project, projectSlug), + placements: diffType(file.placements, current.placements, prune, (f, c) => f.name !== c.name), + achievements: diffType( + file.achievements, + current.achievements, + prune, + (f, c) => + f.name !== c.name || + nullish(f.description) !== nullish(c.description) || + nullish(f.artworkUrl) !== nullish(c.artworkUrl) || + f.eventType !== c.eventType || + f.targetCount !== c.targetCount || + f.pointsValue !== c.pointsValue, + ), + timedEvents: diffType( + file.timedEvents, + current.timedEvents, + prune, + (f, c) => + f.name !== c.name || + nullish(f.description) !== nullish(c.description) || + normDate(f.startsAt) !== normDate(c.startsAt) || + normDate(f.endsAt) !== normDate(c.endsAt) || + f.endingSoonMinutes !== c.endingSoonMinutes || + f.multiplier !== c.multiplier || + f.recurrence !== c.recurrence || + normDate(f.recurrenceEndsAt) !== normDate(c.recurrenceEndsAt) || + f.enabled !== c.enabled, + ), + offers: diffType( + file.offers, + current.offers, + prune, + (f, c) => + f.name !== c.name || + f.headline !== c.headline || + nullish(f.body) !== nullish(c.body) || + nullish(f.imageUrl) !== nullish(c.imageUrl) || + nullish(f.ctaText) !== nullish(c.ctaText) || + nullish(f.ctaUrl) !== nullish(c.ctaUrl) || + normDate(f.startsAt) !== normDate(c.startsAt) || + normDate(f.endsAt) !== normDate(c.endsAt) || + f.priority !== c.priority || + f.placement !== c.placement || + nullish(f.timedEvent) !== nullish(c.timedEvent), + ), + rewards: diffType( + file.rewards, + current.rewards, + prune, + (f, c) => + f.name !== c.name || + nullish(f.description) !== nullish(c.description) || + f.codeType !== c.codeType || + nullish(f.staticCode) !== nullish(c.staticCode) || + nullish(f.codePrefix) !== nullish(c.codePrefix) || + f.pointsPrice !== c.pointsPrice || + normDate(f.startsAt) !== normDate(c.startsAt) || + normDate(f.endsAt) !== normDate(c.endsAt) || + f.perUserLimit !== c.perUserLimit || + nullish(f.inventory) !== nullish(c.inventory) || + f.enabled !== c.enabled, + ), + } +} + +// Every offer.placement must resolve against the set of placement slugs that will +// EXIST after the import, and every non-null offer.timedEvent against the surviving +// timed-event slugs. Anything unresolved is a hard 400 BEFORE any write. Collect +// every violation (not fail-fast) so an operator sees them all. +// +// The valid set depends on prune: without it, existing rows survive, so the target +// is (existing ∪ file). WITH prune, any existing target absent from the file is +// about to be deleted — a surviving file offer pointing at it would be orphaned and +// the project left unexportable — so the target must be FILE slugs only. +export function findUnknownRefs(file: ConfigFile, current: CurrentState, prune: boolean): UnknownRef[] { + const placementSlugs = prune + ? new Set(file.placements.map((p) => p.slug)) + : new Set([...current.placements.map((p) => p.slug), ...file.placements.map((p) => p.slug)]) + const timedEventSlugs = prune + ? new Set(file.timedEvents.map((t) => t.slug)) + : new Set([...current.timedEvents.map((t) => t.slug), ...file.timedEvents.map((t) => t.slug)]) + const out: UnknownRef[] = [] + for (const o of file.offers) { + if (!placementSlugs.has(o.placement)) { + out.push({ offer: o.slug, ref: o.placement, type: 'placement' }) + } + if (o.timedEvent != null && !timedEventSlugs.has(o.timedEvent)) { + out.push({ offer: o.slug, ref: o.timedEvent, type: 'timedEvent' }) + } + } + return out +} diff --git a/apps/cms/src/api/offer/content-types/offer/lifecycles.ts b/apps/cms/src/api/offer/content-types/offer/lifecycles.ts new file mode 100644 index 0000000..37374e7 --- /dev/null +++ b/apps/cms/src/api/offer/content-types/offer/lifecycles.ts @@ -0,0 +1,78 @@ +import { errors } from '@strapi/utils' + +const SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/ + +function fail(message: string): never { + throw new errors.ValidationError(message) +} + +// Merge incoming (possibly partial, on update) data over the current row so +// cross-field validation always sees the resulting full record. +async function loadCurrent(event: any): Promise> { + const where = event.params.where + if (!where) return {} + const existing = await strapi.db.query('api::offer.offer').findOne({ where, populate: ['project'] }) + return existing ?? {} +} + +function validate(merged: Record) { + const slug = merged.slug + if (typeof slug !== 'string' || !SLUG_PATTERN.test(slug)) { + fail(`slug must match ${SLUG_PATTERN} (got: ${JSON.stringify(slug)})`) + } +} + +// Relation values arrive in whatever shape the caller used: a raw internal +// id, a documentId string, or a { connect/set: [...] } mutation descriptor +// (Content Manager / entityService all take slightly different shapes). +// Resolve any of them down to the project's internal numeric id so it can be +// compared/queried at this (db-level) lifecycle layer. +async function resolveProjectId(raw: any): Promise { + if (raw == null) return null + if (typeof raw === 'number') return raw + if (typeof raw === 'string') { + const byDocumentId = await strapi.db.query('api::project.project').findOne({ where: { documentId: raw } }) + if (byDocumentId) return byDocumentId.id + const asNumber = Number(raw) + return Number.isFinite(asNumber) ? asNumber : null + } + if (typeof raw === 'object') { + const list = raw.connect ?? raw.set + if (Array.isArray(list) && list.length > 0) { + const first = list[0] + return resolveProjectId(typeof first === 'object' ? first.id ?? first.documentId : first) + } + if (raw.id != null) return resolveProjectId(raw.id) + if (raw.documentId != null) return resolveProjectId(raw.documentId) + } + return null +} + +async function checkSlugUnique(event: any, merged: Record) { + const slug = merged.slug + const projectId = await resolveProjectId(merged.project) + if (!slug || projectId == null) return // no project set yet — nothing to scope uniqueness by + const where: Record = { slug, project: projectId } + const existingId = event.params.where?.id + if (existingId != null) { + where.id = { $ne: existingId } + } + const count = await strapi.db.query('api::offer.offer').count({ where }) + if (count > 0) { + fail(`slug "${slug}" is already in use for this project`) + } +} + +export default { + async beforeCreate(event: any) { + const merged = { ...event.params.data } + validate(merged) + await checkSlugUnique(event, merged) + }, + async beforeUpdate(event: any) { + const current = await loadCurrent(event) + const merged = { ...current, ...event.params.data } + validate(merged) + await checkSlugUnique(event, merged) + }, +} diff --git a/apps/cms/src/api/offer/content-types/offer/schema.json b/apps/cms/src/api/offer/content-types/offer/schema.json index 845cbed..92048fc 100644 --- a/apps/cms/src/api/offer/content-types/offer/schema.json +++ b/apps/cms/src/api/offer/content-types/offer/schema.json @@ -5,6 +5,7 @@ "options": { "draftAndPublish": false }, "attributes": { "name": { "type": "string", "required": true }, + "slug": { "type": "string", "required": true, "regex": "^[a-z][a-z0-9_-]*$" }, "headline": { "type": "string", "required": true }, "body": { "type": "text" }, "imageUrl": { "type": "string" }, diff --git a/apps/cms/src/api/timed-event/content-types/timed-event/lifecycles.ts b/apps/cms/src/api/timed-event/content-types/timed-event/lifecycles.ts index 888f161..828b126 100644 --- a/apps/cms/src/api/timed-event/content-types/timed-event/lifecycles.ts +++ b/apps/cms/src/api/timed-event/content-types/timed-event/lifecycles.ts @@ -7,20 +7,70 @@ const INTERVAL_MS: Record = { monthly: 28 * MS_PER_DAY, } +const SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/ + function fail(message: string): never { throw new errors.ValidationError(message) } // Merge incoming (possibly partial, on update) data over the current row so -// cross-field validation always sees the resulting full record. +// cross-field validation always sees the resulting full record. Must populate +// `project` — the S8 lesson: without it, resolveProjectId below has nothing +// to resolve on update and the slug-uniqueness check silently no-ops. async function loadCurrent(event: any): Promise> { const where = event.params.where if (!where) return {} - const existing = await strapi.db.query('api::timed-event.timed-event').findOne({ where }) + const existing = await strapi.db.query('api::timed-event.timed-event').findOne({ where, populate: ['project'] }) return existing ?? {} } +// Relation values arrive in whatever shape the caller used: a raw internal +// id, a documentId string, or a { connect/set: [...] } mutation descriptor +// (Content Manager / entityService all take slightly different shapes). +// Resolve any of them down to the project's internal numeric id so it can be +// compared/queried at this (db-level) lifecycle layer. +async function resolveProjectId(raw: any): Promise { + if (raw == null) return null + if (typeof raw === 'number') return raw + if (typeof raw === 'string') { + const byDocumentId = await strapi.db.query('api::project.project').findOne({ where: { documentId: raw } }) + if (byDocumentId) return byDocumentId.id + const asNumber = Number(raw) + return Number.isFinite(asNumber) ? asNumber : null + } + if (typeof raw === 'object') { + const list = raw.connect ?? raw.set + if (Array.isArray(list) && list.length > 0) { + const first = list[0] + return resolveProjectId(typeof first === 'object' ? first.id ?? first.documentId : first) + } + if (raw.id != null) return resolveProjectId(raw.id) + if (raw.documentId != null) return resolveProjectId(raw.documentId) + } + return null +} + +async function checkSlugUnique(event: any, merged: Record) { + const slug = merged.slug + const projectId = await resolveProjectId(merged.project) + if (!slug || projectId == null) return // no project set yet — nothing to scope uniqueness by + const where: Record = { slug, project: projectId } + const existingId = event.params.where?.id + if (existingId != null) { + where.id = { $ne: existingId } + } + const count = await strapi.db.query('api::timed-event.timed-event').count({ where }) + if (count > 0) { + fail(`slug "${slug}" is already in use for this project`) + } +} + function validate(merged: Record) { + const slug = merged.slug + if (typeof slug !== 'string' || !SLUG_PATTERN.test(slug)) { + fail(`slug must match ${SLUG_PATTERN} (got: ${JSON.stringify(slug)})`) + } + const startsAt = merged.startsAt const endsAt = merged.endsAt const startsAtMs = startsAt != null ? new Date(startsAt).getTime() : null @@ -54,10 +104,12 @@ export default { async beforeCreate(event: any) { const merged = { ...event.params.data } validate(merged) + await checkSlugUnique(event, merged) }, async beforeUpdate(event: any) { const current = await loadCurrent(event) const merged = { ...current, ...event.params.data } validate(merged) + await checkSlugUnique(event, merged) }, } diff --git a/apps/cms/src/api/timed-event/content-types/timed-event/schema.json b/apps/cms/src/api/timed-event/content-types/timed-event/schema.json index 371c6ce..cb362f1 100644 --- a/apps/cms/src/api/timed-event/content-types/timed-event/schema.json +++ b/apps/cms/src/api/timed-event/content-types/timed-event/schema.json @@ -5,6 +5,7 @@ "options": { "draftAndPublish": false }, "attributes": { "name": { "type": "string", "required": true }, + "slug": { "type": "string", "required": true, "regex": "^[a-z][a-z0-9_-]*$" }, "description": { "type": "text" }, "startsAt": { "type": "datetime", "required": true }, "endsAt": { "type": "datetime", "required": true }, diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts index 4433599..4a224f5 100644 --- a/apps/cms/src/index.ts +++ b/apps/cms/src/index.ts @@ -64,9 +64,9 @@ export default { }, }) const achievements = [ - { name: 'First Lesson', description: 'Complete your first lesson.', eventType: 'lesson_completed', targetCount: 1, pointsValue: 50 }, - { name: 'Getting Started', description: 'Complete ten lessons.', eventType: 'lesson_completed', targetCount: 10, pointsValue: 100 }, - { name: 'Profiled', description: 'Complete your profile.', eventType: 'profile_completed', targetCount: 1, pointsValue: 75 }, + { slug: 'first_lesson', name: 'First Lesson', description: 'Complete your first lesson.', eventType: 'lesson_completed', targetCount: 1, pointsValue: 50 }, + { slug: 'getting_started', name: 'Getting Started', description: 'Complete ten lessons.', eventType: 'lesson_completed', targetCount: 10, pointsValue: 100 }, + { slug: 'profiled', name: 'Profiled', description: 'Complete your profile.', eventType: 'profile_completed', targetCount: 1, pointsValue: 75 }, ] for (const a of achievements) { await strapi.documents('api::achievement.achievement').create({ @@ -78,6 +78,7 @@ export default { }) await strapi.documents('api::offer.offer').create({ data: { + slug: 'welcome_offer', name: 'Welcome offer', headline: 'Welcome to Promocean', body: 'Track achievements and run promos from one API.', @@ -90,6 +91,7 @@ export default { }) await strapi.documents('api::timed-event.timed-event').create({ data: { + slug: 'double_progress_weekend', name: 'Double Progress Weekend', description: 'All achievement progress counts double.', startsAt: new Date(Date.now() - 3600_000), @@ -104,6 +106,7 @@ export default { happyHourStartsAt.setUTCHours(17, 0, 0, 0) await strapi.documents('api::timed-event.timed-event').create({ data: { + slug: 'weekly_happy_hour', name: 'Weekly Happy Hour', description: 'A recurring window of double points every week.', startsAt: happyHourStartsAt, diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts index 1b2c673..c8dd724 100644 --- a/apps/cms/types/generated/contentTypes.d.ts +++ b/apps/cms/types/generated/contentTypes.d.ts @@ -475,6 +475,7 @@ export interface ApiAchievementAchievement extends Struct.CollectionTypeSchema { Schema.Attribute.DefaultTo<0>; project: Schema.Attribute.Relation<'manyToOne', 'api::project.project'>; publishedAt: Schema.Attribute.DateTime; + slug: Schema.Attribute.String & Schema.Attribute.Required; targetCount: Schema.Attribute.Integer & Schema.Attribute.Required & Schema.Attribute.SetMinMax< @@ -559,6 +560,7 @@ export interface ApiOfferOffer extends Struct.CollectionTypeSchema { Schema.Attribute.DefaultTo<0>; project: Schema.Attribute.Relation<'manyToOne', 'api::project.project'>; publishedAt: Schema.Attribute.DateTime; + slug: Schema.Attribute.String & Schema.Attribute.Required; startsAt: Schema.Attribute.DateTime; timedEvent: Schema.Attribute.Relation< 'manyToOne', @@ -750,6 +752,7 @@ export interface ApiTimedEventTimedEvent extends Struct.CollectionTypeSchema { Schema.Attribute.Required & Schema.Attribute.DefaultTo<'none'>; recurrenceEndsAt: Schema.Attribute.DateTime; + slug: Schema.Attribute.String & Schema.Attribute.Required; startsAt: Schema.Attribute.DateTime & Schema.Attribute.Required; updatedAt: Schema.Attribute.DateTime; updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & diff --git a/apps/demo/e2e/config-sync.spec.ts b/apps/demo/e2e/config-sync.spec.ts new file mode 100644 index 0000000..600ee1d --- /dev/null +++ b/apps/demo/e2e/config-sync.spec.ts @@ -0,0 +1,213 @@ +import { expect, test } from '@playwright/test' +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { configFileSchema } from '@promocean/contracts' +import type { ConfigFile } from '@promocean/contracts' + +// CMS base + pk/sk match the seeded demo project (apps/cms/src/index.ts) and the +// docker-compose.yml/.env.example defaults — see rewards-loop.spec.ts / campaign-lifecycle.spec.ts +// for the same key constants. CONFIG_SECRET falls back to .env.example's CONFIG_PLANE_SECRET +// default (what a fresh `cp .env.example .env` + `docker compose --profile stack up` produces) +// but honors an actual env var override the same way docker compose itself does. +const CMS_URL = 'http://localhost:1337' +const API_BASE = 'http://localhost:3001' +const PUBLISHABLE_KEY = 'pk_test_demo_1234567890abcdef' +const SECRET_KEY = 'sk_test_demo_1234567890abcdef' +const CONFIG_SECRET = process.env.CONFIG_PLANE_SECRET ?? 'dev-config-secret' + +// packages/cli/dist/cli.js relative to this spec file (apps/demo/e2e/) — built by +// `pnpm --filter @promocean/cli build` before this spec runs. +const CLI_PATH = resolve(__dirname, '../../../packages/cli/dist/cli.js') + +const SEEDED_ACHIEVEMENT_SLUGS = ['first_lesson', 'getting_started', 'profiled'] +const SEEDED_OFFER_SLUGS = ['welcome_offer'] +const SEEDED_TIMED_EVENT_SLUGS = ['double_progress_weekend', 'weekly_happy_hour'] + +interface CliResult { + code: number + stdout: string + stderr: string +} + +/** Drives the CLI as a real subprocess — never imports its source. */ +function runCli(args: string[]): Promise { + return new Promise((resolvePromise) => { + execFile( + 'node', + [CLI_PATH, ...args], + { env: { ...process.env, PROMOCEAN_CONFIG_SECRET: CONFIG_SECRET } }, + (err, stdout, stderr) => { + const code = err ? (typeof (err as NodeJS.ErrnoException).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0 + resolvePromise({ code, stdout, stderr }) + }, + ) + }) +} + +/** Resolves the seeded demo project's documentId via the config-plane's own key-verification + * endpoint (the same lookup the api itself does), keyed off the seeded secret key's hash — + * no admin login needed for this (read-only, config-secret-guarded) resolution. */ +async function fetchSeededProjectId(): Promise { + const keyHash = createHash('sha256').update(SECRET_KEY).digest('hex') + const res = await fetch(`${CMS_URL}/api/config-plane/verify-key`, { + method: 'POST', + headers: { 'x-config-secret': CONFIG_SECRET, 'content-type': 'application/json' }, + body: JSON.stringify({ keyHash }), + }) + if (!res.ok) throw new Error(`verify-key failed: HTTP ${res.status}`) + const body = (await res.json()) as { projectId: string } + return body.projectId +} + +async function fetchAchievements(): Promise> { + const res = await fetch(`${API_BASE}/v1/users/e2e-cfgsync-probe/achievements`, { + headers: { authorization: `Bearer ${PUBLISHABLE_KEY}` }, + }) + if (!res.ok) throw new Error(`achievements list failed: HTTP ${res.status}`) + const body = (await res.json()) as { achievements: Array<{ achievementId: string; name: string; target: number }> } + return body.achievements +} + +/** Tracks one lesson_completed event for a brand-new, disposable user and returns the + * unlock bonus (wallet ledger delta) it earned for `achievementId` — `undefined` if it + * didn't unlock at all. A fresh user is required each call: the achievement's own + * idempotence means an already-unlocked user contributes nothing to re-exercise the + * *currently cached* pointsValue. */ +async function probeUnlockBonus(achievementId: string): Promise { + const probeUser = `e2e-cfgsync-points-${Date.now()}-${Math.random().toString(36).slice(2)}` + const trackRes = await fetch(`${API_BASE}/v1/events`, { + method: 'POST', + headers: { authorization: `Bearer ${PUBLISHABLE_KEY}`, 'content-type': 'application/json' }, + body: JSON.stringify({ userId: probeUser, type: 'lesson_completed', idempotencyKey: `cfgsync-${probeUser}` }), + }) + if (!trackRes.ok) throw new Error(`track failed: HTTP ${trackRes.status}`) + + const walletRes = await fetch(`${API_BASE}/v1/users/${encodeURIComponent(probeUser)}/wallet`, { + headers: { authorization: `Bearer ${PUBLISHABLE_KEY}` }, + }) + if (!walletRes.ok) throw new Error(`wallet fetch failed: HTTP ${walletRes.status}`) + const wallet = (await walletRes.json()) as { recent: Array<{ delta: number; source: string; sourceRef: string }> } + return wallet.recent.find((r) => r.source === 'unlock' && r.sourceRef === achievementId)?.delta +} + +test('export -> scripted edit -> dry-run plan -> import -> api visibility -> re-import all-unchanged', async () => { + // Two condition-wait polls (forward visibility + post-restore revert visibility) can each + // legitimately take up to the ~30s config-plane cache TTL, plus the rest of the flow. + test.setTimeout(150_000) + + const projectId = await fetchSeededProjectId() + const dir = await mkdtemp(join(tmpdir(), 'promocean-config-sync-')) + const filePath = join(dir, 'config.json') + + // 1. Export the seeded project; the file parses and contains the six seeded slugs. + const exportResult = await runCli(['export', '--url', CMS_URL, '--project', projectId, '--out', filePath]) + expect(exportResult.code, `export stderr: ${exportResult.stderr}`).toBe(0) + + const raw = await readFile(filePath, 'utf8') + const file = configFileSchema.parse(JSON.parse(raw)) as ConfigFile + // Pristine pre-edit snapshot, restored in the `finally` below — this project's config is + // shared with every other spec file (e.g. engagement-loop's/rewards-loop's expected point + // totals assume first_lesson's seeded pointsValue never changes), so this test must leave + // the server exactly as it found it, pass or fail. + const original = structuredClone(file) + + const achievementSlugs = file.achievements.map((a) => a.slug) + const offerSlugs = file.offers.map((o) => o.slug) + const timedEventSlugs = file.timedEvents.map((t) => t.slug) + for (const slug of SEEDED_ACHIEVEMENT_SLUGS) expect(achievementSlugs).toContain(slug) + for (const slug of SEEDED_OFFER_SLUGS) expect(offerSlugs).toContain(slug) + for (const slug of SEEDED_TIMED_EVENT_SLUGS) expect(timedEventSlugs).toContain(slug) + + try { + // 2. Scripted edit: bump first_lesson.pointsValue to 60; append a new "bookworm" achievement. + const firstLesson = file.achievements.find((a) => a.slug === 'first_lesson') + if (!firstLesson) throw new Error('seeded first_lesson achievement missing from the export') + firstLesson.pointsValue = 60 + file.achievements.push({ + slug: 'bookworm', + name: 'Bookworm', + eventType: 'lesson_completed', + targetCount: 25, + pointsValue: 10, + description: null, + artworkUrl: null, + }) + await writeFile(filePath, `${JSON.stringify(file, null, 2)}\n`, 'utf8') + + // 3. --dry-run exits 2, with the plan showing exactly updates:[first_lesson], creates:[bookworm] + // (every other type bucket, and every other achievement, stays unchanged). + const dryRun = await runCli(['import', '--url', CMS_URL, '--project', projectId, '--file', filePath, '--dry-run']) + expect(dryRun.code, `dry-run output: ${dryRun.stdout}${dryRun.stderr}`).toBe(2) + const changeLines = dryRun.stdout + .split('\n') + .map((l) => l.trim()) + .filter((l) => /^(creates|updates|deletes):/.test(l)) + expect(changeLines).toEqual(['creates: bookworm', 'updates: first_lesson']) + + // 4. Import applies the plan: exits 0. + const apply = await runCli(['import', '--url', CMS_URL, '--project', projectId, '--file', filePath]) + expect(apply.code, `import output: ${apply.stdout}${apply.stderr}`).toBe(0) + + // 5. api-side visibility: the api caches config-plane achievements per project for up to 30s + // (packages/adapter-strapi), so the new achievement and the raised pointsValue may not be + // visible immediately after the write above — poll (condition-wait, no sleeps) until they are. + let firstLessonId = '' + await expect(async () => { + const achievements = await fetchAchievements() + const bookworm = achievements.find((a) => a.name === 'Bookworm') + expect(bookworm).toBeTruthy() + expect(bookworm!.target).toBe(25) + + const firstLessonDef = achievements.find((a) => a.name === 'First Lesson') + expect(firstLessonDef).toBeTruthy() + firstLessonId = firstLessonDef!.achievementId + + const bonus = await probeUnlockBonus(firstLessonId) + expect(bonus).toBe(60) + }).toPass({ timeout: 35_000, intervals: [1000, 2000, 3000, 5000] }) + + // 6. Re-import the same (now current) file: a dry-run exits 0 (empty plan — proves + // all-unchanged, the exit-0-vs-2 differentiator being the precise signal for "no diff"), + // and an actual re-apply also exits 0. + const reDryRun = await runCli(['import', '--url', CMS_URL, '--project', projectId, '--file', filePath, '--dry-run']) + expect(reDryRun.code, `re-import dry-run output: ${reDryRun.stdout}${reDryRun.stderr}`).toBe(0) + const reChangeLines = reDryRun.stdout + .split('\n') + .map((l) => l.trim()) + .filter((l) => /^(creates|updates|deletes):/.test(l)) + expect(reChangeLines).toEqual([]) + + const reImport = await runCli(['import', '--url', CMS_URL, '--project', projectId, '--file', filePath]) + expect(reImport.code, `re-import output: ${reImport.stdout}${reImport.stderr}`).toBe(0) + } finally { + // Restore the project to its pristine pre-test state (--prune removes bookworm; the + // update reverts first_lesson.pointsValue to 50) regardless of pass/fail, so a later + // suite run against the same stack sees the original seeded values again. + const restorePath = join(dir, 'restore.json') + await writeFile(restorePath, `${JSON.stringify(original, null, 2)}\n`, 'utf8') + const restore = await runCli(['import', '--url', CMS_URL, '--project', projectId, '--file', restorePath, '--prune']) + if (restore.code !== 0) { + console.error(`config-sync cleanup: restore import failed (exit ${restore.code}): ${restore.stdout}${restore.stderr}`) + } else { + // Close the cache-staleness window itself (not just the DB write): wait until the + // api's config-plane cache actually reflects the revert too, so a suite re-run + // started immediately after this one doesn't race a still-stale cache (this is + // exactly the failure mode a bare DB-level revert doesn't protect against). + try { + await expect(async () => { + const achievements = await fetchAchievements() + expect(achievements.find((a) => a.name === 'Bookworm')).toBeUndefined() + const firstLessonDef = achievements.find((a) => a.name === 'First Lesson') + expect(firstLessonDef).toBeTruthy() + const bonus = await probeUnlockBonus(firstLessonDef!.achievementId) + expect(bonus).toBe(50) + }).toPass({ timeout: 35_000, intervals: [1000, 2000, 3000, 5000] }) + } catch (err) { + console.error(`config-sync cleanup: cache did not revert within 35s: ${(err as Error).message}`) + } + } + } +}) diff --git a/apps/demo/package.json b/apps/demo/package.json index af327c8..912fc1e 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@playwright/test": "^1.53.0", + "@promocean/contracts": "workspace:*", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/apps/demo/playwright.config.ts b/apps/demo/playwright.config.ts index 4914219..6ad874f 100644 --- a/apps/demo/playwright.config.ts +++ b/apps/demo/playwright.config.ts @@ -3,4 +3,16 @@ export default defineConfig({ testDir: './e2e', use: { baseURL: 'http://localhost:3002' }, webServer: { command: 'pnpm dev', url: 'http://localhost:3002', reuseExistingServer: true, timeout: 120_000 }, + // config-sync.spec.ts mutates shared demo-project config (the seeded first_lesson + // achievement's pointsValue, plus a new achievement) via the config-plane import + // endpoint — every other spec's expected point totals assume the seeded values stay + // constant. `dependencies` makes Playwright run the "default" project to completion + // before "config-sync" starts, so the mutation never overlaps with a concurrent + // worker running one of the other specs (config-sync also restores the original + // values itself once done, but that only protects a *later*, separate suite run — + // it can't retroactively fix an in-flight race with concurrent tests). + projects: [ + { name: 'default', testIgnore: '**/config-sync.spec.ts' }, + { name: 'config-sync', testMatch: '**/config-sync.spec.ts', dependencies: ['default'] }, + ], }) diff --git a/docs/superpowers/plans/2026-07-16-sprint-11-config-as-code.md b/docs/superpowers/plans/2026-07-16-sprint-11-config-as-code.md new file mode 100644 index 0000000..6827b04 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-sprint-11-config-as-code.md @@ -0,0 +1,180 @@ +# Promocean Sprint 11: Config-as-Code — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Campaign definitions become git-committable JSON — a slug-keyed portable file, config-plane export/import endpoints (upsert + opt-in prune + dry-run, all lifecycle validations firing), and a thin MIT CLI whose dry-run doubles as a CI drift check. + +**Architecture:** Slugs become the cross-instance identity for achievements/offers/timed events (rewards/placements already have them); files carry no documentIds. Export is a dedicated endpoint emitting the file format exactly (round-trip invariant: export → import = all-unchanged). Import writes through the Document Service in dependency order so the S8–S10 lifecycle checks fire; Strapi stays the single writer; the CLI depends only on the config-plane HTTP contract (the Strapi-exit posture). Runtime surfaces (api, adapters, widgets) are untouched. + +**Spec:** `docs/superpowers/specs/2026-07-16-sprint-11-config-as-code-design.md`. Branch `sprint-11-config-as-code` off main (PR #27 merge). + +## Global Constraints + +(All prior global constraints bind: error envelope, zod contracts single source of truth, TDD per task, per-package gates green before commit, compose-stack e2e in CI. api pnpm filter name is `api`. cms has no unit harness — cms behavior is verified by checked-in live scripts, the S10 `verify-lifecycles.ts` pattern: disposable DB on dev Postgres 5433, localhost blast-radius guard, typed-error assertions, positive controls, `finally` cleanup.) + +Sprint-11 additions (values verbatim from the spec): +- File format `formatVersion: 1`; slug regex `/^[a-z][a-z0-9_-]*$/`; dates `z.iso.datetime()`; cross-references by slug (offers → placement, offers → timedEvent); NO documentIds anywhere in the file; api-keys and webhook-endpoints are NOT covered (never exported, never pruned); `staticCode` IS exported (marketer copy, not credential). +- Import semantics: slug-matched upsert; field-level diff (unchanged definitions skipped — idempotent; `registeredEventTypes` compared order-insensitively); deletes ONLY with `prune: true` and only covered types; `dryRun: true` returns the plan with ZERO writes; apply order: project settings → placements → timed events → achievements → rewards → offers, deletes last in reverse order; mid-run lifecycle rejection → HTTP 422 with `{ applied: true, plan: , error: { stage, message } }`; unknown slug cross-ref (not in existing ∪ file-created) → 400 BEFORE any write. +- Export fails loudly (500 + findings list naming each definition) on any covered row missing a slug — no silent slug synthesis. +- Round-trip invariant is a named test: export output imported back = all-unchanged plan. +- CLI: secret via `PROMOCEAN_CONFIG_SECRET` env var ONLY (never a flag); exit codes 0 success/no-drift, 1 any error, 2 dry-run-found-changes; deps limited to `@promocean/contracts` + `zod`; hand-rolled arg parsing; Node 20+; MIT. +- Runtime untouched: no changes under packages/core, packages/adapter-db, packages/adapter-strapi, packages/sdk, packages/widgets, apps/api (adapters' zod schemas strip the additive `slug` on config-plane reads without error — verified non-strict). +- Prune/runtime caveat (docs must state): update-in-place preserves documentIds; delete + recreate gets a NEW documentId — runtime history continuity across prune/recreate is explicitly not promised. + +--- + +### Task 1: contracts — config file, import request/response schemas + +**Files:** Create `packages/contracts/src/config-file.ts`; modify `src/index.ts` (re-export); test append `packages/contracts/test/contracts.test.ts`. + +**Interfaces — produces:** +```ts +export const configSlugSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/) +export const configFileSchema = z.object({ + formatVersion: z.literal(1), + project: z.object({ + pointRules: z.record(z.string(), z.number().int().min(0)), + registeredEventTypes: z.array(z.string()), + allowedOrigins: z.array(z.string()).nullable(), + }), + placements: z.array(z.object({ slug: configSlugSchema, name: z.string() })), + achievements: z.array(z.object({ + slug: configSlugSchema, name: z.string(), description: z.string().nullable(), + artworkUrl: z.string().nullable(), eventType: z.string(), + targetCount: z.number().int().min(1), pointsValue: z.number().int().min(0), + })), + timedEvents: z.array(z.object({ + slug: configSlugSchema, name: z.string(), description: z.string().nullable(), + startsAt: z.iso.datetime(), endsAt: z.iso.datetime(), + endingSoonMinutes: z.number().int().min(1), multiplier: z.number().int().min(1), + recurrence: z.enum(['none', 'daily', 'weekly', 'monthly']), + recurrenceEndsAt: z.iso.datetime().nullable(), enabled: z.boolean(), + })), + offers: z.array(z.object({ + slug: configSlugSchema, name: z.string(), headline: z.string(), + body: z.string().nullable(), imageUrl: z.string().nullable(), + ctaText: z.string().nullable(), ctaUrl: z.string().nullable(), + startsAt: z.iso.datetime().nullable(), endsAt: z.iso.datetime().nullable(), + priority: z.number().int(), placement: configSlugSchema, + timedEvent: configSlugSchema.nullable(), + })), + rewards: z.array(z.object({ + slug: configSlugSchema, name: z.string(), description: z.string().nullable(), + codeType: z.enum(['generated', 'static']), staticCode: z.string().nullable(), + codePrefix: z.string().nullable(), pointsPrice: z.number().int().min(0), + startsAt: z.iso.datetime().nullable(), endsAt: z.iso.datetime().nullable(), + perUserLimit: z.number().int().min(1), inventory: z.number().int().min(1).nullable(), + enabled: z.boolean(), + })), +}) +export const importRequestSchema = z.object({ + file: configFileSchema, + prune: z.boolean().default(false), + dryRun: z.boolean().default(false), +}) +const typePlanSchema = z.object({ + creates: z.array(z.string()), updates: z.array(z.string()), + deletes: z.array(z.string()), unchanged: z.number().int().min(0), +}) +export const importResponseSchema = z.object({ + applied: z.boolean(), + plan: z.object({ + project: typePlanSchema, placements: typePlanSchema, achievements: typePlanSchema, + timedEvents: typePlanSchema, offers: typePlanSchema, rewards: typePlanSchema, + }), + error: z.object({ stage: z.string(), message: z.string() }).optional(), +}) +export type ConfigFile = z.infer +export type ImportRequest = z.infer +export type ImportResponse = z.infer +``` +(`project` in the plan uses creates/deletes always empty — settings are update-or-unchanged only; keeping one plan shape avoids a special case.) + +Tests (RED first): full-file round-trip with every nullable exercised both ways; `formatVersion: 2` rejected; slug regex boundaries (leading digit, uppercase, hyphen + underscore accepted); offer with `timedEvent: null` accepted; prune/dryRun defaults false when omitted; importResponse with and without `error` parses; negative `unchanged` rejected. Additive — no break. Commit: `feat(contracts): config-as-code file, import request and response schemas` + +--- + +### Task 2: cms — slugs on achievements, offers, timed events + seed backfill + +**Files:** Modify `apps/cms/src/api/achievement/content-types/achievement/schema.json`, `apps/cms/src/api/offer/content-types/offer/schema.json`, `apps/cms/src/api/timed-event/content-types/timed-event/schema.json` (each gains `"slug": { "type": "string", "required": true }`); create `apps/cms/src/api/achievement/content-types/achievement/lifecycles.ts` and `apps/cms/src/api/offer/content-types/offer/lifecycles.ts`; modify `apps/cms/src/api/timed-event/content-types/timed-event/lifecycles.ts` (add slug checks to the existing recurrence validation); config-plane controller read mappers add `slug` (achievements, offers, timedEvents, timedEventsAll); seed `apps/cms/src/index.ts`; regenerate `contentTypes.d.ts`; extend `apps/cms/scripts/verify-lifecycles.ts` probe 1 to cover the three new types' slug checks. + +**Slug lifecycles:** exactly the reward pattern (S8, incl. the populated-project-relation fix and update-path self-exclusion): regex `/^[a-z][a-z0-9_-]*$/`, unique per project. Extract nothing across content types yet unless trivially shared — mirror the reward file's structure per type (the S10 probe infrastructure asserts these fire; duplication across lifecycles files is the established pattern). + +**Seed backfill (exact values):** achievements `first_lesson`, `getting_started`, `profiled`; offer `welcome_offer`; timed events `double_progress_weekend`, `weekly_happy_hour`. (Placement `homepage-banner` and rewards `welcome_coupon`/`demo_discount` already carry slugs.) + +Verification (live, disposable DB via the checked-in script + curls): fresh seed carries all slugs; config-plane reads expose `slug` on all four timed/achievement/offer surfaces; slug lifecycle rejections (bad regex, in-project duplicate, self-update clean) fire for all THREE new types via the extended probe; second-boot idempotence; `pnpm --filter cms typecheck` green; ALSO run `pnpm --filter @promocean/adapter-strapi test` + `pnpm --filter api test` untouched-green (proves the additive field is stripped harmlessly). Commit: `feat(cms): slugs on achievements, offers, timed events with uniqueness lifecycles and seed backfill` + +--- + +### Task 3: cms — export endpoint + +**Files:** Modify `apps/cms/src/api/config-plane/controllers/config-plane.ts` (new `exportProject` handler), `apps/cms/src/api/config-plane/routes/config-plane.ts` (`GET /config-plane/projects/:projectId/export`). + +**Interfaces — produces (Task 4 + CLI consume):** the response body IS a `ConfigFile` (Task 1 schema) — key order per the schema, explicit nulls for absent optionals, ISO datetimes, offers' `placement`/`timedEvent` as slugs resolved from populated relations. + +**Behavior:** configSecretOk guard → 401; missing projectId → 400; unknown project → 404; query all covered types filtered by project (populate offer relations); ANY covered row with a missing/empty slug → 500 `{ error: 'unexported definitions missing slugs', findings: ['achievement "Getting Started" (documentId …)', …] }` listing EVERY offender (not just the first); map to the file shape; respond. `project.pointRules` defaults `{}`, `registeredEventTypes` defaults `[]`, `allowedOrigins` null when absent/malformed (matching the verifyKey mapper's tolerance). + +Verification (live): guard/400/404; happy path parses against `configFileSchema` (run the parse in the verification script — the contract IS the test); slugless-row failure lists all offenders (create two slugless rows, assert both named); offer slug refs match the placement/timed-event slugs. Commit: `feat(cms): project config export endpoint` + +--- + +### Task 4: cms — import endpoint + verify-config-sync script + +**Files:** Modify config-plane controller (+`importProject` handler) and routes (`POST /config-plane/projects/:projectId/import`); create `apps/cms/src/api/config-plane/services/import-plan.ts` (pure-ish plan computation — keep the handler thin); create `apps/cms/scripts/verify-config-sync.ts` (+ npm script `verify:config-sync`) reusing the S10 script's harness conventions (localhost guard, disposable-DB workflow, typed assertions, finally cleanup, positive controls). + +**Interfaces — consumes:** Task 1 schemas (parse body with `importRequestSchema`; respond `satisfies ImportResponse`); Task 3's export (for the round-trip test). + +**Behavior (handler):** +1. Guard → 401; parse → 400 with zod issues. +2. Cross-ref resolution: every `offer.placement` ∈ (existing placement slugs ∪ file placement slugs) and every non-null `offer.timedEvent` ∈ (existing ∪ file timed-event slugs); violation → 400 `{ error: 'unknown reference', details: [{ offer: , ref: , type: 'placement'|'timedEvent' }] }`, zero writes. +3. Plan (in `import-plan.ts`, unit-testable shape even though cms has no harness — the verification script exercises it): per type, slug-match against current rows; `creates` = file-only slugs; `deletes` = cms-only slugs when prune else []; `updates` = matched slugs whose field-level diff is non-empty (normalize before compare: ISO strings vs stored datetimes through `new Date().toISOString()`; `registeredEventTypes` as sets; explicit null vs undefined unified to null); `unchanged` = matched with empty diff. Project settings: update-or-unchanged. +4. `dryRun` → `{ applied: false, plan }`, 200, zero writes. +5. Apply via `strapi.documents()` in the constraint order (project → placements → timedEvents → achievements → rewards → offers; deletes last, reverse order); offers resolve placement/timedEvent slugs to documentIds at write time. Lifecycle rejection mid-run → catch, recompute the actually-applied plan (re-query and re-diff — do NOT trust a partially-executed intended plan), respond 422 `{ applied: true, plan: , error: { stage: '/', message } }`. +6. Full success → 200 `{ applied: true, plan }`. + +**verify-config-sync.ts scenarios (each a named check, non-zero exit on failure):** round-trip invariant (export → import → every plan bucket empty except unchanged); create+update+unchanged in one file (assert exact slug lists); registeredEventTypes order-insensitive (reordered array → unchanged); prune only-with-flag (absent slug survives without prune, deleted with; api-key/webhook rows untouched — seed one webhook endpoint and assert survival); dry-run plan deep-equals the subsequent apply's plan; unknown-ref 400 before writes (count rows before/after); mid-run 422 (import a file whose reward has `codeType: 'static', staticCode: null` — the S8 lifecycle rejects it — assert 422, stage `rewards/`, earlier types genuinely applied, recomputed plan matches DB state); update-in-place preserves documentId (capture before/after). + +Verification: script run recorded (all scenarios green) against a disposable DB; typecheck green. Commit: `feat(cms): config import endpoint with plan, prune, dry-run; config-sync verification script` + +--- + +### Task 5: CLI — @promocean/cli package + +**Files:** Create `packages/cli/` — `package.json` (name `@promocean/cli`, version 0.0.1, MIT license file copied from sdk's, `"bin": { "promocean": "./dist/cli.js" }`, `files: ["dist", "LICENSE", "README.md"]`, deps `@promocean/contracts` `workspace:*` + `zod`; scripts mirroring sdk: build/test/typecheck), `tsconfig.json` (mirror sdk's), `src/cli.ts` (entry: shebang `#!/usr/bin/env node`, arg dispatch), `src/args.ts` (hand-rolled parser: `parseArgs(argv): { command: 'export'|'import', url, project, out?, file?, prune, dryRun }` — throws usage errors naming the missing flag), `src/commands/export.ts`, `src/commands/import.ts`, `src/render.ts` (plan → human table string); `README.md`; tests `packages/cli/test/cli.test.ts` (vitest, mocked fetch injected — commands accept `fetchImpl` for tests, same DI style as the sdk). + +**Interfaces — produces:** +```ts +// export command: GET {url}/api/config-plane/projects/{project}/export +// headers { 'x-config-secret': process.env.PROMOCEAN_CONFIG_SECRET } +// -> parse configFileSchema (defense vs drifted server) -> JSON.stringify(file, null, 2) +// -> writeFile(out) or stdout. Missing env var -> exit 1 naming PROMOCEAN_CONFIG_SECRET. +// import command: read file, configFileSchema.parse (fail fast, zod issue paths listed), +// POST {url}/api/config-plane/projects/{project}/import with { file, prune, dryRun } +// -> parse importResponseSchema -> render plan table (per type: counts + slug lists, +// error.stage/message prominent on 422). +// exit codes (constraint-exact): 0 success / dry-run-no-changes; 1 any error incl. 422; +// 2 dry-run completed with a non-empty creates/updates/deletes anywhere. +``` +Tests: arg parsing (missing --url/--project/--file each named; unknown command usage); env secret required (exit 1, message names the var); export happy path writes validated pretty JSON; export server-drift (invalid body) → exit 1 with zod paths; import dry-run no changes → exit 0; dry-run with one create → exit 2 and the table shows it; apply success → 0; 422 → exit 1 rendering stage+message+applied plan; HTTP 401 → exit 1 with the envelope. Run gates: `pnpm --filter @promocean/cli test` + typecheck; full `pnpm turbo run typecheck` green (new package joins the workspace — check turbo picks it up; add to pnpm-workspace globs if needed, it matches `packages/*`). Commit: `feat(cli): promocean export and import commands` + +--- + +### Task 6: e2e, docs, changeset — sprint DoD + +**Files:** Create `apps/demo/e2e/config-sync.spec.ts`; docs: root README ("Config as code" section: authoring loop, CI drift check via exit code 2, prune semantics, the runtime-history caveat verbatim from the constraint), `packages/cli/README.md` already exists from Task 5 — extend if verification revealed gaps; `RELEASING.md` publish list gains `@promocean/cli` (MIT, non-private — confirm its package.json has NO `private: true` and carries `files`/LICENSE per Task 5); changeset `.changeset/config-as-code.md` (`@promocean/cli` minor — its first release lands it at 0.1.0; `@promocean/contracts` minor). + +**e2e (`config-sync.spec.ts`)** — drives the CLI as a real subprocess (`execFile('node', ['packages/cli/dist/cli.js', ...])` from the repo root with `PROMOCEAN_CONFIG_SECRET` from the compose env) against the compose stack: export seeded project → file parses + contains the six seeded slugs; scripted edit (bump `first_lesson.pointsValue` to 60, append achievement `{ slug: 'bookworm', name: 'Bookworm', eventType: 'lesson_completed', targetCount: 25, pointsValue: 10, description: null, artworkUrl: null }`) → `--dry-run` exits 2 with plan showing exactly `updates: [first_lesson]`, `creates: [bookworm]` → import exits 0 → config-plane achievements read (or `/v1/users/:id/achievements` via api) shows Bookworm and pointsValue 60 within the 30s TTL (poll with condition-waits) → re-import exits 0 all-unchanged. + +**DoD steps (in order):** `pnpm turbo run typecheck build test` fully green; fresh compose stack (`down -v && build && up -d --wait`); `pnpm --filter demo e2e` — ALL specs green incl. config-sync; hand transcript: the cross-instance simulation (create a second empty project via the admin bootstrap method, import the first project's export into it, export the second project, `diff` the two files — identical); stack down; push branch. Commit: `feat(e2e,docs): config-sync loop, config-as-code docs and changeset — sprint 11 wrap` + +PR notes must state: three content types gain a required `slug` (existing dev volumes need reseed or manual slug backfill — the export endpoint's loud failure names offenders); config plane gains its first WRITE endpoint (same x-config-secret trust model, operator-only); new MIT package `@promocean/cli`; runtime surfaces untouched (adapters strip the additive slug); prune/runtime-history caveat; delivers the "config-as-code" v1.x slice — one roadmap item (React Native SDK) remains. + +--- + +## Self-Review Notes + +- **Spec coverage:** §3.1 file format ✓ (T1 verbatim schemas); §3.2 slugs + seed + read exposure + legacy posture ✓ (T2; slugless-export failure in T3); §3.3 export ✓ (T3 incl. all-offenders listing); §3.4 import ✓ (T4: plan service, cross-ref-before-write, dependency order, recomputed 422 plan, prune bounds); §3.5 CLI ✓ (T5: env-only secret, exit codes 0/1/2, DI fetch, zero extra deps); §4 flows = T6 e2e + hand transcript; §5 error handling mapped (loud slugless 500 T3, 400/422 T4, CLI renderings T5); §6 testing 1:1 (round-trip invariant + dry-run-equals-apply + order-insensitivity named in T4's script; existing-suite-untouched gate in T2); §7 DoD = T6. +- **Type consistency:** `ConfigFile`/`ImportRequest`/`ImportResponse` names identical T1/T3/T4/T5; plan bucket keys (`project, placements, achievements, timedEvents, offers, rewards` × `creates/updates/deletes/unchanged`) identical T1/T4/T5/T6; slug regex identical T1/T2; endpoint paths identical T3/T4/T5/T6; exit codes identical T5/T6; seed slugs identical T2/T6 (`first_lesson` etc.). +- **Known-break chain:** none — every task is additive; the only ordering constraints are T1→(T3,T4,T5) for schemas and T2→T3 for slugs existing. cms tasks are verified live (no unit harness), per the established pattern. +- **Deliberate choices encoded:** plan computation isolated in `import-plan.ts` so the diff/normalization logic has one home; 422's plan is RECOMPUTED from the DB, never the intended plan (partial-failure honesty); the e2e drives the CLI as a subprocess (tests the bin contract, not the library); `project` plan bucket kept shape-uniform (empty creates/deletes) to avoid a special case in contracts and the renderer; CLI at 0.0.1 + minor changeset → lands at 0.1.0 alongside its siblings. +- **Compression note:** as with Sprints 2–10, test/verification code specified behaviorally; schemas, endpoint semantics, orderings, exit codes, and seed slugs are exact. diff --git a/docs/superpowers/specs/2026-07-16-sprint-11-config-as-code-design.md b/docs/superpowers/specs/2026-07-16-sprint-11-config-as-code-design.md new file mode 100644 index 0000000..c84560f --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-sprint-11-config-as-code-design.md @@ -0,0 +1,225 @@ +# Sprint 11 Design: Config-as-Code — Export/Import & CLI + +Approved via brainstorming session 2026-07-16. Campaign definitions become +git-committable JSON: a dedicated export endpoint emits a portable, +slug-keyed file; a secret-guarded import endpoint applies it back through the +Document Service so every existing lifecycle validation fires; a thin CLI +(`promocean`) wraps both and doubles as a CI drift-check. This is the design +doc's "config-as-code: JSON export/import of definitions + CLI (also the +Strapi-exit migration path)" v1.x slice — the second-to-last one. + +## 1. Scope + +In scope: + +- **File format** (`formatVersion: 1`, zod-schema'd in contracts): project + settings (pointRules, registeredEventTypes, allowedOrigins) + placements, + achievements, timed events, offers, rewards — slug-keyed, slug + cross-references, ISO dates, no documentIds (instance-portable) +- **Slug fields** added to achievements, offers, and timed events in the cms + (rewards/placements already have them): same regex + per-project uniqueness + lifecycles as rewards; seed backfilled; runtime surfaces UNTOUCHED (api, + adapters, widgets keep keying on documentId) +- **Export**: `GET /config-plane/projects/:projectId/export` (config-secret + guarded) emitting the file format exactly — round-trip guarantee: + export → import always yields an all-unchanged plan +- **Import**: `POST /config-plane/projects/:projectId/import` — body = file + + `{ prune?, dryRun? }`; slug-matched plan (creates/updates/deletes/unchanged) + with field-level diffing (idempotent no-op updates skipped); writes through + the Document Service in dependency order; prune only on request; dry-run + returns the plan without writing +- **CLI** `packages/cli` (`@promocean/cli`, MIT, bin `promocean`): `export` + and `import` commands; `PROMOCEAN_CONFIG_SECRET` env-only auth; client-side + schema validation; exit code 2 on dry-run-found-changes (CI drift-check + primitive); no runtime deps beyond contracts + zod +- Docs (root README section + cli README), changeset (new package 0.1.0, + patch contracts), compose e2e + +Out of scope (explicitly, spec-noted as future): webhook-endpoint/api-key +portability (secret material stays admin-UI-managed), YAML support, +multi-project files, a packaged GitHub Action wrapper, any Strapi-exit +reimplementation itself. + +## 2. Decisions and rationale + +| Decision | Choice | +|---|---| +| Import conflict semantics | Upsert + opt-in `--prune` + `--dry-run` — safe default, declarative on request; a truncated file cannot nuke campaigns unless prune was explicitly passed | +| Identity | Slugs on every covered type — unique per project, the import match key; names stay free-form display text; files carry no instance-specific ids | +| Coverage | Campaign definitions only; api-keys/webhook-endpoints excluded (credential material never lands in a git file). `staticCode` IS included: marketer-authored campaign copy, not a credential | +| Write path | Config-plane write endpoints (not Strapi admin API, not direct DB): reuses the x-config-secret trust model, and all S8–S10 lifecycle validations fire because writes go through the Document Service; Strapi stays the single writer | +| Strapi-exit posture | The CLI depends only on the config-plane HTTP contract — a future non-Strapi config plane implementing the same two endpoints keeps every file and workflow working | +| CLI licensing | MIT (customer-facing tooling, joins contracts/sdk/widgets) | +| Cross-references | By slug (offers → placement, offers → timedEvent), resolved at import; unknown refs that aren't created by the same file fail BEFORE any write | + +## 3. Architecture + +### 3.1 File format (contracts) + +New `packages/contracts/src/config-file.ts`: + +``` +configFileSchema = { + formatVersion: literal 1, + project: { pointRules: Record=0+, registeredEventTypes: string[], + allowedOrigins: string[] | null }, + placements: [{ slug, name }], + achievements: [{ slug, name, description|null, artworkUrl|null, eventType, + targetCount>=1, pointsValue>=0 }], + timedEvents: [{ slug, name, description|null, startsAt, endsAt, + endingSoonMinutes>=1, multiplier>=1, + recurrence: none|daily|weekly|monthly, recurrenceEndsAt|null, + enabled }], + offers: [{ slug, name, headline, body|null, imageUrl|null, ctaText|null, + ctaUrl|null, startsAt|null, endsAt|null, priority, + placement: , timedEvent: | null }], + rewards: [{ slug, name, description|null, codeType, staticCode|null, + codePrefix|null, pointsPrice>=0, startsAt|null, endsAt|null, + perUserLimit>=1, inventory|null, enabled }], +} +``` + +Slug fields validated with the established regex `/^[a-z][a-z0-9_-]*$/`. +Dates `z.iso.datetime()`. Also `importRequestSchema` (file + `prune`/`dryRun` +booleans, default false) and `importResponseSchema`: + +``` +{ applied: boolean, + plan: { : { creates: string[], updates: string[], deletes: string[], + unchanged: number } }, // slug lists + error?: { stage: string, message: string } } +``` + +### 3.2 cms: slugs + +`achievement`, `offer`, and `timed-event` schemas gain +`"slug": { "type": "string", "required": true }`; each content type's +lifecycles gain the reward-pattern checks (regex, per-project uniqueness with +populated project relation and self-exclusion on update — the S8 populate +lesson applies verbatim). Seed backfills deterministic slugs for every seeded +definition (e.g. `first_lesson`, `getting_started`, `welcome_banner`, +`double_progress_weekend`, `weekly_happy_hour`). Config-plane READ endpoints +add `slug` to their per-type responses (additive; the adapters' zod schemas +tolerate unknown keys — they strip them without erroring — so no adapter/api +changes this sprint). + +Pre-existing rows without slugs (dev volumes): the export endpoint fails +loudly on a slugless row, naming the definition and pointing at the admin UI +or a fresh reseed. No automatic slug synthesis — silent generated identity +would haunt later imports. + +### 3.3 cms: export endpoint + +`GET /config-plane/projects/:projectId/export` (configSecretOk guard; 400 +missing projectId; 404 unknown project): queries all covered types, maps to +the file format exactly (slug refs resolved from relations; explicit nulls; +ISO dates), responds with the file. The round-trip invariant — export output +imported back yields all-unchanged — is a named test. + +### 3.4 cms: import endpoint + +`POST /config-plane/projects/:projectId/import` (same guard): + +1. Parse body against `importRequestSchema` → 400 with zod issues. +2. Resolve slug cross-references against (existing ∪ file-created) + placements/timedEvents; unknown → 400 naming the offending offer + ref, + BEFORE any write. +3. Build the plan per type by slug match: create (slug absent), update (slug + present, field-level diff non-empty — order-insensitive for + registeredEventTypes; unchanged definitions skipped for idempotence), + delete (only when `prune: true`, slugs present in cms but absent from the + file), unchanged (count). +4. `dryRun: true` → respond `{ applied: false, plan }`, zero writes. +5. Apply through `strapi.documents()` in dependency order: project settings → + placements → timed events → achievements → rewards → offers; deletes run + last, reverse order. Lifecycle validations fire on every write. On a + mid-run rejection: stop, respond `{ applied: true, plan: , error: { stage, message } }` + with HTTP 422. No cross-document transaction exists in Strapi — partial + application is possible and documented; `--dry-run` first is the + recommended workflow, and idempotent re-import after a fix converges. + +Prune never touches uncovered types (api-keys, webhook-endpoints) and never +cascades into runtime history (unlocks/coupons/ledger live in the runtime DB +keyed by old documentIds; a pruned-then-reimported definition gets a NEW +documentId — runtime continuity across delete/recreate is explicitly not +promised; update-in-place preserves ids, which is why slug-matched upsert is +the default path). + +### 3.5 CLI (`packages/cli`) + +`@promocean/cli`, MIT, `"bin": { "promocean": "dist/cli.js" }`, deps: +`@promocean/contracts` + `zod` only; Node 20+; hand-rolled arg parsing. + +- `promocean export --url --project [--out ]` — GET export, + validate against `configFileSchema` (defense against a drifted server), + pretty-print JSON to `--out` or stdout. +- `promocean import --url --project --file [--prune] + [--dry-run]` — read + client-side validate (fail fast with zod issues), + POST, render the plan as a human-readable per-type table with counts and + slug lists, surface `error.stage/message` prominently on 422. +- Secret: `PROMOCEAN_CONFIG_SECRET` env var ONLY (never a flag — process-list + leakage); missing → loud error naming the variable. +- Exit codes: 0 success/no-drift; 1 any error (validation, HTTP, partial + apply); 2 dry-run completed and found changes — making + `promocean import --dry-run` a CI drift check with no extra tooling. + +## 4. Data flow + +Authoring loop: marketer edits in Strapi → `promocean export` → commit the +file → PR review → merge. Deployment loop: edit the file → `promocean import +--dry-run` (CI shows the plan, exit 2 gates) → `promocean import` → config +plane TTL cache picks the changes up within 30s → api serves them. +Migration loop (Strapi-exit insurance): export from instance A → import into +instance B's empty project → identical definitions (fresh documentIds; +runtime history intentionally does not follow). + +## 5. Error handling + +- Export: slugless legacy row → 500 with a findings list naming each + offending definition (fail loud, no silent synthesis). +- Import: schema 400 (zod issues verbatim) → unknown-ref 400 (named) → + lifecycle rejection 422 mid-run with recomputed applied-plan + stage; + dry-run can never partially apply by construction. +- CLI: network/HTTP failures → exit 1 with the response error envelope + rendered; validation failures list zod issue paths; 422 renders what + applied and what stopped it. +- Config-plane guard failures unchanged (401 via configSecretOk). + +## 6. Testing + +- **contracts**: configFileSchema round-trips incl. every nullable; slug + regex boundaries; cross-ref shape; importResponse plan shape; + formatVersion literal rejects 2. +- **cms (live verification script, extending the S10 harness pattern — + checked in)**: round-trip no-op invariant; create/update/unchanged + field-diff correctness (incl. registeredEventTypes order-insensitivity); + prune only-with-flag + only-covered-types; dry-run plan equals subsequent + apply plan; mid-run lifecycle rejection reports recomputed applied-plan + + 422; unknown-slug-ref fails before writes; slugless-row export failure; + slug lifecycle checks on all three new types (regex, duplicate, + self-update). +- **CLI (vitest, mocked fetch — sdk test style)**: arg parsing incl. missing + required flags; env-secret required; export writes validated file / + stdout; import renders plan; exit codes 0/1/2 each asserted; 422 rendering. +- **e2e (compose)**: CLI exports the seeded project → scripted edit (bump a + pointsValue, add one achievement) → `--dry-run` exits 2 listing exactly + those changes → import applies → `GET /v1/users/:id/achievements` (or + config-plane read) confirms the new definition serves → re-import exits 0 + all-unchanged. +- Existing suite must stay green untouched (slug additions are additive; + adapters' non-strict schemas ignore the new field). + +## 7. Definition of done + +- Full turbo green; fresh compose e2e green (existing specs + the new + config-sync spec) +- Hand transcript: cross-instance simulation — export from the seeded + project, import into a second empty project on the same stack, then export + the second project and diff: the two files must be identical +- Round-trip invariant demonstrated live (export → import → all-unchanged) +- README config-as-code section (authoring loop, CI drift check, prune + semantics, runtime-history caveat); `packages/cli/README.md`; RELEASING.md + gains the cli package in its publish list (MIT, non-private) +- Changeset: `@promocean/cli` 0.1.0 (new), `@promocean/contracts` minor + (new schemas) diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..a5c29bf --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Steve Hynding + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..df09ab5 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,91 @@ +# @promocean/cli + +Config-as-code CLI for Promocean projects: export a project's configuration +(placements, achievements, timed events, offers, rewards, project settings) +to a JSON file, and import a JSON file back into a project with a +plan-before-apply workflow. + +Requires Node 20+. + +## Install + + npm i -g @promocean/cli + +Or run ad hoc with `npx @promocean/cli ...`. + +## Authentication + +Both commands read the config-plane secret from the `PROMOCEAN_CONFIG_SECRET` +environment variable — there is no `--secret` flag, so the secret never shows +up in shell history or process listings. If the variable isn't set, the CLI +exits 1 with an error naming it. + + export PROMOCEAN_CONFIG_SECRET=... + +## Usage + +### Export + +Fetch a project's current configuration and write it as pretty-printed JSON. +The server's response is validated against the config file schema before +anything is written, as a defense against a drifted server. + + promocean export --url https://cms.example.com --project --out project.json + +Omit `--out` to print the JSON to stdout instead. + +### Import + +Validate a config file locally (fail fast, listing every schema issue before +any network call), then upload it. The server computes a plan (creates, +updates, deletes, unchanged counts per content type) and — unless `--dry-run` +is given — applies it. + + promocean import --url https://cms.example.com --project --file project.json [--prune] [--dry-run] + +- `--prune`: delete content that exists on the server but is absent from the + file (per content type). Without it, absent content is left alone. When used, + an import is rejected upfront (HTTP 400, before any write) if a kept offer + references a placement or timed event that the file omits, since that target + would be deleted by the prune, orphaning the offer. +- `--dry-run`: compute and print the plan without applying it. + +The plan is printed as a table: per content type, counts of creates/updates/ +deletes/unchanged, plus the slugs affected. + +Content is matched between the file and the server **by slug**, not by +internal id. Updating a matched row in place preserves its underlying id +(and anything keyed off it, e.g. wallet ledger references to an +achievement); deleting a row (via `--prune`) and later re-adding it under +the same slug creates a brand-new row with a new id — see the root +README's "Config as code" section for the full runtime-history caveat. + +## CI usage (drift check) + +`import --dry-run`'s exit code is the drift signal: `0` means the checked-in +file already matches the server, `2` means it wouldn't (someone changed +content another way, e.g. directly in the CMS admin), `1` means the check +itself failed to run. A CI job that runs this on every push/PR and fails the +build on a non-zero exit catches drift before it compounds: + + PROMOCEAN_CONFIG_SECRET=... promocean import --url https://cms.example.com --project --file config.json --dry-run + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Success, or a dry run that found no changes to make | +| 1 | Any error: bad arguments, missing `PROMOCEAN_CONFIG_SECRET`, a file that fails schema validation, an HTTP error, or a partially-applied import (HTTP 422) | +| 2 | A dry run completed and found at least one create, update, or delete anywhere in the plan | + +A 422 response from the import endpoint means the server started applying +changes and hit an error partway through; the CLI renders which stage failed, +the error message, and the plan actually applied (not the one that was +intended) — always exiting 1. + +## Programmatic use + +`runExport` and `runImport` (from `@promocean/cli/dist/commands/*`) both +accept an injectable `fetchImpl: typeof fetch` and return +`{ exitCode, output }` without ever calling `process.exit`, which is how this +package's own tests drive them. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..f6f922e --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,32 @@ +{ + "name": "@promocean/cli", + "version": "0.0.1", + "license": "MIT", + "type": "module", + "bin": { + "promocean": "./dist/cli.js" + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@promocean/contracts": "workspace:*", + "zod": "^4.0.5" + }, + "devDependencies": { + "@promocean/config": "workspace:*", + "@types/node": "^20.0.0", + "typescript": "^5.8.3", + "vitest": "^3.2.0" + } +} diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts new file mode 100644 index 0000000..c0de230 --- /dev/null +++ b/packages/cli/src/args.ts @@ -0,0 +1,69 @@ +export interface ParsedArgs { + command: 'export' | 'import' + url: string + project: string + out?: string + file?: string + prune: boolean + dryRun: boolean +} + +const USAGE = + 'Usage: promocean --url --project [--out ] [--file ] [--prune] [--dry-run]' + +export class UsageError extends Error {} + +/** + * Hand-rolled arg parser. Throws UsageError naming the missing flag or the + * unrecognized command/flag; never guesses defaults for required values. + */ +export function parseArgs(argv: string[]): ParsedArgs { + const [command, ...rest] = argv + + if (command !== 'export' && command !== 'import') { + throw new UsageError(`Unknown command "${command ?? ''}". ${USAGE}`) + } + + let url: string | undefined + let project: string | undefined + let out: string | undefined + let file: string | undefined + let prune = false + let dryRun = false + + for (let i = 0; i < rest.length; i++) { + const flag = rest[i] + switch (flag) { + case '--url': + url = rest[++i] + break + case '--project': + project = rest[++i] + break + case '--out': + out = rest[++i] + break + case '--file': + file = rest[++i] + break + case '--prune': + prune = true + break + case '--dry-run': + dryRun = true + break + default: + throw new UsageError(`Unknown flag "${flag}". ${USAGE}`) + } + } + + if (!url) throw new UsageError(`Missing required flag --url. ${USAGE}`) + if (!project) throw new UsageError(`Missing required flag --project. ${USAGE}`) + if (command === 'import' && !file) throw new UsageError(`Missing required flag --file. ${USAGE}`) + + // Trim trailing slash(es) so `--url http://host/` and `--url http://host` both + // build `${url}/api/...` without a doubled slash the router would 404 on. + url = url.replace(/\/+$/, '') + + return { command, url, project, out, file, prune, dryRun } +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..aad49c1 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import { realpathSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { parseArgs, UsageError } from './args.js' +import { runExport } from './commands/export.js' +import { runImport } from './commands/import.js' + +/** + * Runs the CLI end-to-end and returns the process exit code. Never calls + * process.exit itself, so it stays testable in-process. + */ +export async function run(argv: string[]): Promise { + let parsed + try { + parsed = parseArgs(argv) + } catch (err) { + if (err instanceof UsageError) { + console.error(err.message) + return 1 + } + throw err + } + + const result = + parsed.command === 'export' + ? await runExport({ url: parsed.url, project: parsed.project, out: parsed.out }) + : await runImport({ + url: parsed.url, + project: parsed.project, + file: parsed.file as string, + prune: parsed.prune, + dryRun: parsed.dryRun, + }) + + if (result.exitCode === 1) console.error(result.output) + else console.log(result.output) + + return result.exitCode +} + +// Node realpath-resolves the ESM main module before setting import.meta.url, but +// leaves process.argv[1] as the invoked path (an npm bin symlink, or a path with +// spaces that a raw `file://` concat would leave un-percent-encoded). Resolve +// argv[1] the same way — realpath then pathToFileURL — so both sides match under +// npm-installed bin symlinks and space-bearing paths alike. +const isMain = + !!process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href +if (isMain) { + run(process.argv.slice(2)).then( + (code) => process.exit(code), + (err) => { + console.error(err instanceof Error ? err.message : String(err)) + process.exit(1) + }, + ) +} diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts new file mode 100644 index 0000000..6d67287 --- /dev/null +++ b/packages/cli/src/commands/export.ts @@ -0,0 +1,43 @@ +import { writeFile } from 'node:fs/promises' +import { configFileSchema } from '@promocean/contracts' +import { readBody, renderErrorBody } from '../http.js' +import type { CommandResult } from '../types.js' + +export interface ExportOptions { + url: string + project: string + out?: string + fetchImpl?: typeof fetch +} + +const ENV_VAR = 'PROMOCEAN_CONFIG_SECRET' + +export async function runExport(opts: ExportOptions): Promise { + const secret = process.env[ENV_VAR] + if (!secret) { + return { exitCode: 1, output: `Error: the ${ENV_VAR} environment variable is required.` } + } + + const fetchImpl = opts.fetchImpl ?? fetch + const url = `${opts.url}/api/config-plane/projects/${encodeURIComponent(opts.project)}/export` + const res = await fetchImpl(url, { headers: { 'x-config-secret': secret } }) + const { json: body, text } = await readBody(res) + + if (!res.ok) { + return { exitCode: 1, output: `Error: export request failed (HTTP ${res.status}): ${renderErrorBody(body, text)}` } + } + + // Defense against a drifted server: validate the response before writing anything. + const parsed = configFileSchema.safeParse(body) + if (!parsed.success) { + const issues = parsed.error.issues.map((issue) => ` ${issue.path.join('.') || '(root)'}: ${issue.message}`).join('\n') + return { exitCode: 1, output: `Error: server export response failed configFileSchema validation:\n${issues}` } + } + + const json = `${JSON.stringify(parsed.data, null, 2)}\n` + if (opts.out) { + await writeFile(opts.out, json, 'utf8') + return { exitCode: 0, output: `Wrote ${opts.out}` } + } + return { exitCode: 0, output: json } +} diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts new file mode 100644 index 0000000..fca623e --- /dev/null +++ b/packages/cli/src/commands/import.ts @@ -0,0 +1,79 @@ +import { readFile } from 'node:fs/promises' +import { configFileSchema, importResponseSchema } from '@promocean/contracts' +import { readBody, renderErrorBody } from '../http.js' +import { planHasChanges, renderPlan } from '../render.js' +import type { CommandResult } from '../types.js' + +export interface ImportOptions { + url: string + project: string + file: string + prune: boolean + dryRun: boolean + fetchImpl?: typeof fetch +} + +const ENV_VAR = 'PROMOCEAN_CONFIG_SECRET' + +export async function runImport(opts: ImportOptions): Promise { + const secret = process.env[ENV_VAR] + if (!secret) { + return { exitCode: 1, output: `Error: the ${ENV_VAR} environment variable is required.` } + } + + let raw: string + try { + raw = await readFile(opts.file, 'utf8') + } catch (err) { + return { exitCode: 1, output: `Error: could not read file "${opts.file}": ${(err as Error).message}` } + } + + let json: unknown + try { + json = JSON.parse(raw) + } catch (err) { + return { exitCode: 1, output: `Error: "${opts.file}" is not valid JSON: ${(err as Error).message}` } + } + + // Validate the file client-side BEFORE upload — fail fast, list zod issue paths. + const parsedFile = configFileSchema.safeParse(json) + if (!parsedFile.success) { + const issues = parsedFile.error.issues.map((issue) => ` ${issue.path.join('.') || '(root)'}: ${issue.message}`).join('\n') + return { exitCode: 1, output: `Error: "${opts.file}" failed configFileSchema validation:\n${issues}` } + } + + const fetchImpl = opts.fetchImpl ?? fetch + const url = `${opts.url}/api/config-plane/projects/${encodeURIComponent(opts.project)}/import` + const res = await fetchImpl(url, { + method: 'POST', + headers: { 'x-config-secret': secret, 'content-type': 'application/json' }, + body: JSON.stringify({ file: parsedFile.data, prune: opts.prune, dryRun: opts.dryRun }), + }) + const { json: body, text } = await readBody(res) + + // 422: partial apply. Still an ImportResponse shape — render the (applied) plan + // with error.stage/message prominent, but this is always a failure exit. + if (res.status === 422) { + const parsed = importResponseSchema.safeParse(body) + if (!parsed.success) { + return { exitCode: 1, output: `Error: import failed (HTTP 422) with an unparseable response: ${renderErrorBody(body, text)}` } + } + return { exitCode: 1, output: renderPlan(parsed.data) } + } + + if (!res.ok) { + return { exitCode: 1, output: `Error: import request failed (HTTP ${res.status}): ${renderErrorBody(body, text)}` } + } + + const parsed = importResponseSchema.safeParse(body) + if (!parsed.success) { + const issues = parsed.error.issues.map((issue) => ` ${issue.path.join('.') || '(root)'}: ${issue.message}`).join('\n') + return { exitCode: 1, output: `Error: server import response failed importResponseSchema validation:\n${issues}` } + } + + const output = renderPlan(parsed.data) + if (opts.dryRun) { + return { exitCode: planHasChanges(parsed.data.plan) ? 2 : 0, output } + } + return { exitCode: 0, output } +} diff --git a/packages/cli/src/http.ts b/packages/cli/src/http.ts new file mode 100644 index 0000000..b7b9b94 --- /dev/null +++ b/packages/cli/src/http.ts @@ -0,0 +1,27 @@ +/** + * Response-body helpers shared by both commands. + * + * A body can only be consumed once, so read it as text and attempt a JSON parse + * in a single pass: success paths validate `json` against a schema; error paths + * render `text`/status so a non-JSON error body (an HTML 502, an empty 401) + * shows its real content instead of the literal "null" a bare `.json().catch(() + * => null)` would print. + */ +export async function readBody(res: Response): Promise<{ json: unknown; text: string }> { + const text = await res.text() + try { + return { json: JSON.parse(text) as unknown, text } + } catch { + return { json: null, text } + } +} + +/** + * Render an HTTP error body: the parsed JSON when present, else the raw non-empty + * text, else a placeholder — never the literal "null". + */ +export function renderErrorBody(json: unknown, text: string): string { + if (json != null) return JSON.stringify(json) + const trimmed = text.trim() + return trimmed.length > 0 ? trimmed : '(empty response body)' +} diff --git a/packages/cli/src/render.ts b/packages/cli/src/render.ts new file mode 100644 index 0000000..73a2fba --- /dev/null +++ b/packages/cli/src/render.ts @@ -0,0 +1,53 @@ +import type { ImportResponse } from '@promocean/contracts' + +type Plan = ImportResponse['plan'] +type TypePlan = Plan[keyof Plan] + +const TYPE_ORDER: Array = ['project', 'placements', 'achievements', 'timedEvents', 'offers', 'rewards'] +const LABELS: Record = { + project: 'project', + placements: 'placements', + achievements: 'achievements', + timedEvents: 'timedEvents', + offers: 'offers', + rewards: 'rewards', +} + +/** True if any type bucket in the plan has a non-empty create/update/delete list. */ +export function planHasChanges(plan: Plan): boolean { + return TYPE_ORDER.some((type) => { + const bucket = plan[type] as TypePlan + return bucket.creates.length > 0 || bucket.updates.length > 0 || bucket.deletes.length > 0 + }) +} + +function renderBucket(label: string, bucket: TypePlan): string[] { + const lines: string[] = [] + lines.push( + ` ${label}: +${bucket.creates.length} created, ~${bucket.updates.length} updated, ` + + `-${bucket.deletes.length} deleted, ${bucket.unchanged} unchanged`, + ) + if (bucket.creates.length > 0) lines.push(` creates: ${bucket.creates.join(', ')}`) + if (bucket.updates.length > 0) lines.push(` updates: ${bucket.updates.join(', ')}`) + if (bucket.deletes.length > 0) lines.push(` deletes: ${bucket.deletes.join(', ')}`) + return lines +} + +/** Render an ImportResponse as a human-readable plan table. Error (422) info is rendered first and prominently. */ +export function renderPlan(response: ImportResponse): string { + const lines: string[] = [] + + if (response.error) { + lines.push('IMPORT FAILED') + lines.push(` stage: ${response.error.stage}`) + lines.push(` message: ${response.error.message}`) + lines.push('') + } + + lines.push(response.applied ? 'Applied plan:' : 'Dry-run plan (no changes applied):') + for (const type of TYPE_ORDER) { + lines.push(...renderBucket(LABELS[type], response.plan[type] as TypePlan)) + } + + return lines.join('\n') +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts new file mode 100644 index 0000000..fa18287 --- /dev/null +++ b/packages/cli/src/types.ts @@ -0,0 +1,5 @@ +/** Shared result shape for both commands: never calls process.exit, so callers (and tests) drive it directly. */ +export interface CommandResult { + exitCode: number + output: string +} diff --git a/packages/cli/test/bin.test.ts b/packages/cli/test/bin.test.ts new file mode 100644 index 0000000..e1340f4 --- /dev/null +++ b/packages/cli/test/bin.test.ts @@ -0,0 +1,58 @@ +import { execFile, execFileSync } from 'node:child_process' +import { mkdtemp, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +// Regression coverage for the npm-bin no-op bug: an npm-installed `promocean` +// runs through a bin *symlink*. Node realpath-resolves the ESM main module +// (import.meta.url) but leaves process.argv[1] as the symlink path, so the old +// `import.meta.url === \`file://${process.argv[1]}\`` check was false and the CLI +// silently exited 0 with no output. These tests exercise the REAL built binary as +// a subprocess (never importing its source) — both directly and through a symlink. + +const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const cliPath = join(pkgRoot, 'dist', 'cli.js') + +let symlinkDir: string + +function runNode(binPath: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolvePromise) => { + execFile('node', [binPath, ...args], (err, stdout, stderr) => { + const code = err ? (typeof (err as NodeJS.ErrnoException).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0 + resolvePromise({ code, stdout, stderr }) + }) + }) +} + +beforeAll(() => { + // Build fresh so the subprocess runs the current source (the pre-existing dist/ + // may be stale). `test` only dependsOn `^build` in turbo, not the package's own + // build, so produce dist/ here. + execFileSync('pnpm', ['exec', 'tsc'], { cwd: pkgRoot, stdio: 'ignore' }) +}, 120_000) + +afterAll(async () => { + if (symlinkDir) await rm(symlinkDir, { recursive: true, force: true }) +}) + +describe('bin entrypoint (real subprocess)', () => { + it('runs its dispatcher when invoked directly (no args -> usage, exit 1)', async () => { + const result = await runNode(cliPath, []) + expect(result.code).toBe(1) + expect(result.stderr).toContain('Usage') + }) + + it('runs its dispatcher when invoked through a symlink (npm-bin regression)', async () => { + symlinkDir = await mkdtemp(join(tmpdir(), 'promocean-cli-bin-')) + const link = join(symlinkDir, 'promocean') + await symlink(cliPath, link) + + const result = await runNode(link, []) + // The pre-fix build would no-op here (exit 0, empty output) because the symlink + // path never equals the realpath'd import.meta.url. + expect(result.code).toBe(1) + expect(result.stderr).toContain('Usage') + }) +}) diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts new file mode 100644 index 0000000..6b5e1d7 --- /dev/null +++ b/packages/cli/test/cli.test.ts @@ -0,0 +1,216 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { parseArgs, UsageError } from '../src/args.js' +import { runExport } from '../src/commands/export.js' +import { runImport } from '../src/commands/import.js' + +const minimalFile = { + formatVersion: 1 as const, + project: { pointRules: {}, registeredEventTypes: [], allowedOrigins: null }, + placements: [], + achievements: [], + timedEvents: [], + offers: [], + rewards: [], +} + +const emptyBucket = { creates: [], updates: [], deletes: [], unchanged: 0 } +const emptyPlan = { + project: emptyBucket, + placements: emptyBucket, + achievements: emptyBucket, + timedEvents: emptyBucket, + offers: emptyBucket, + rewards: emptyBucket, +} + +const ok = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })) + +let tmpDir: string +let ENV_BACKUP: string | undefined + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'promocean-cli-test-')) + ENV_BACKUP = process.env.PROMOCEAN_CONFIG_SECRET + process.env.PROMOCEAN_CONFIG_SECRET = 'sekret' +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + if (ENV_BACKUP === undefined) delete process.env.PROMOCEAN_CONFIG_SECRET + else process.env.PROMOCEAN_CONFIG_SECRET = ENV_BACKUP +}) + +describe('parseArgs', () => { + it('throws naming --url when missing', () => { + expect(() => parseArgs(['export', '--project', 'p1'])).toThrow(/--url/) + }) + it('throws naming --project when missing', () => { + expect(() => parseArgs(['export', '--url', 'http://x'])).toThrow(/--project/) + }) + it('throws naming --file when missing on import', () => { + expect(() => parseArgs(['import', '--url', 'http://x', '--project', 'p1'])).toThrow(/--file/) + }) + it('does not require --file on export', () => { + expect(() => parseArgs(['export', '--url', 'http://x', '--project', 'p1'])).not.toThrow() + }) + it('throws a usage error naming the unknown command', () => { + expect(() => parseArgs(['frobnicate'])).toThrow(UsageError) + expect(() => parseArgs(['frobnicate'])).toThrow(/Unknown command "frobnicate"/) + }) + it('parses prune and dry-run flags', () => { + const parsed = parseArgs(['import', '--url', 'http://x', '--project', 'p1', '--file', 'f.json', '--prune', '--dry-run']) + expect(parsed).toMatchObject({ prune: true, dryRun: true, file: 'f.json' }) + }) + it('trims trailing slash(es) from --url so request paths do not double up', () => { + expect(parseArgs(['export', '--url', 'http://x/', '--project', 'p1']).url).toBe('http://x') + expect(parseArgs(['export', '--url', 'http://x///', '--project', 'p1']).url).toBe('http://x') + expect(parseArgs(['export', '--url', 'http://x', '--project', 'p1']).url).toBe('http://x') + }) +}) + +describe('export command', () => { + it('exits 1 naming PROMOCEAN_CONFIG_SECRET when the env var is missing', async () => { + delete process.env.PROMOCEAN_CONFIG_SECRET + const result = await runExport({ url: 'http://api.test', project: 'p1', fetchImpl: vi.fn() }) + expect(result.exitCode).toBe(1) + expect(result.output).toContain('PROMOCEAN_CONFIG_SECRET') + }) + + it('happy path: validates and writes pretty JSON to --out', async () => { + const fetchImpl = vi.fn().mockImplementation(() => ok(minimalFile)) + const out = join(tmpDir, 'export.json') + const result = await runExport({ url: 'http://api.test', project: 'p1', out, fetchImpl }) + + expect(result.exitCode).toBe(0) + const [url, init] = fetchImpl.mock.calls[0] + expect(String(url)).toBe('http://api.test/api/config-plane/projects/p1/export') + expect((init as RequestInit).headers).toMatchObject({ 'x-config-secret': 'sekret' }) + + const written = await readFile(out, 'utf8') + expect(JSON.parse(written)).toEqual(minimalFile) + expect(written).toBe(`${JSON.stringify(minimalFile, null, 2)}\n`) + }) + + it('happy path without --out prints pretty JSON to output', async () => { + const fetchImpl = vi.fn().mockImplementation(() => ok(minimalFile)) + const result = await runExport({ url: 'http://api.test', project: 'p1', fetchImpl }) + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.output)).toEqual(minimalFile) + }) + + it('server-drift: an invalid response body exits 1 listing zod issue paths', async () => { + const drifted = { ...minimalFile, project: { ...minimalFile.project, pointRules: 'not-an-object' } } + const fetchImpl = vi.fn().mockImplementation(() => ok(drifted)) + const result = await runExport({ url: 'http://api.test', project: 'p1', fetchImpl }) + expect(result.exitCode).toBe(1) + expect(result.output).toContain('project.pointRules') + }) + + it('HTTP error responses exit 1 with the envelope', async () => { + const fetchImpl = vi.fn().mockImplementation(() => ok({ error: { message: 'nope' } }, 401)) + const result = await runExport({ url: 'http://api.test', project: 'p1', fetchImpl }) + expect(result.exitCode).toBe(1) + expect(result.output).toContain('401') + expect(result.output).toContain('nope') + }) + + it('renders a non-JSON error body as its raw text (not the literal "null")', async () => { + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve(new Response('502 Bad Gateway', { status: 502 }))) + const result = await runExport({ url: 'http://api.test', project: 'p1', fetchImpl }) + expect(result.exitCode).toBe(1) + expect(result.output).toContain('502') + expect(result.output).toContain('Bad Gateway') + expect(result.output).not.toContain('null') + }) +}) + +describe('import command', () => { + async function writeConfigFile(contents: unknown): Promise { + const file = join(tmpDir, 'config.json') + await writeFile(file, JSON.stringify(contents), 'utf8') + return file + } + + it('exits 1 naming PROMOCEAN_CONFIG_SECRET when the env var is missing', async () => { + delete process.env.PROMOCEAN_CONFIG_SECRET + const file = await writeConfigFile(minimalFile) + const result = await runImport({ url: 'http://api.test', project: 'p1', file, prune: false, dryRun: false, fetchImpl: vi.fn() }) + expect(result.exitCode).toBe(1) + expect(result.output).toContain('PROMOCEAN_CONFIG_SECRET') + }) + + it('client-side validates the file before upload and lists zod issue paths on failure', async () => { + const file = await writeConfigFile({ ...minimalFile, formatVersion: 2 }) + const fetchImpl = vi.fn() + const result = await runImport({ url: 'http://api.test', project: 'p1', file, prune: false, dryRun: false, fetchImpl }) + expect(result.exitCode).toBe(1) + expect(result.output).toContain('formatVersion') + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('dry-run with no changes exits 0', async () => { + const file = await writeConfigFile(minimalFile) + const fetchImpl = vi.fn().mockImplementation(() => ok({ applied: false, plan: emptyPlan })) + const result = await runImport({ url: 'http://api.test', project: 'p1', file, prune: false, dryRun: true, fetchImpl }) + expect(result.exitCode).toBe(0) + const [url, init] = fetchImpl.mock.calls[0] + expect(String(url)).toBe('http://api.test/api/config-plane/projects/p1/import') + const body = JSON.parse((init as RequestInit).body as string) + expect(body).toEqual({ file: minimalFile, prune: false, dryRun: true }) + }) + + it('dry-run with one create exits 2 and the table shows it', async () => { + const file = await writeConfigFile(minimalFile) + const planWithCreate = { ...emptyPlan, placements: { creates: ['homepage-banner'], updates: [], deletes: [], unchanged: 0 } } + const fetchImpl = vi.fn().mockImplementation(() => ok({ applied: false, plan: planWithCreate })) + const result = await runImport({ url: 'http://api.test', project: 'p1', file, prune: false, dryRun: true, fetchImpl }) + expect(result.exitCode).toBe(2) + expect(result.output).toContain('homepage-banner') + }) + + it('apply success exits 0', async () => { + const file = await writeConfigFile(minimalFile) + const fetchImpl = vi.fn().mockImplementation(() => ok({ applied: true, plan: emptyPlan })) + const result = await runImport({ url: 'http://api.test', project: 'p1', file, prune: false, dryRun: false, fetchImpl }) + expect(result.exitCode).toBe(0) + }) + + it('422 partial apply exits 1 and renders stage, message, and the applied plan', async () => { + const file = await writeConfigFile(minimalFile) + const planAfterFailure = { ...emptyPlan, rewards: { creates: ['free-month'], updates: [], deletes: [], unchanged: 0 } } + const body = { + applied: true, + plan: planAfterFailure, + error: { stage: 'rewards/free-month', message: 'duplicate slug' }, + } + const fetchImpl = vi.fn().mockImplementation(() => ok(body, 422)) + const result = await runImport({ url: 'http://api.test', project: 'p1', file, prune: false, dryRun: false, fetchImpl }) + expect(result.exitCode).toBe(1) + expect(result.output).toContain('rewards/free-month') + expect(result.output).toContain('duplicate slug') + expect(result.output).toContain('Applied plan') + expect(result.output).toContain('free-month') + }) + + it('HTTP 401 exits 1 with the envelope', async () => { + const file = await writeConfigFile(minimalFile) + const fetchImpl = vi.fn().mockImplementation(() => ok({ error: { message: 'Unauthorized' } }, 401)) + const result = await runImport({ url: 'http://api.test', project: 'p1', file, prune: false, dryRun: false, fetchImpl }) + expect(result.exitCode).toBe(1) + expect(result.output).toContain('401') + expect(result.output).toContain('Unauthorized') + }) + + it('renders a non-JSON error body as its raw text (not the literal "null")', async () => { + const file = await writeConfigFile(minimalFile) + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve(new Response('500', { status: 500 }))) + const result = await runImport({ url: 'http://api.test', project: 'p1', file, prune: false, dryRun: false, fetchImpl }) + expect(result.exitCode).toBe(1) + expect(result.output).toContain('500') + expect(result.output).toContain('500') + expect(result.output).not.toContain('null') + }) +}) diff --git a/packages/cli/test/run.test.ts b/packages/cli/test/run.test.ts new file mode 100644 index 0000000..d7ddf22 --- /dev/null +++ b/packages/cli/test/run.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock the two command modules so run()'s dispatch/routing/exit-code logic can be +// tested in isolation (no fetch, no filesystem) — this is the previously-untested +// unit. +vi.mock('../src/commands/export.js', () => ({ runExport: vi.fn() })) +vi.mock('../src/commands/import.js', () => ({ runImport: vi.fn() })) + +import { run } from '../src/cli.js' +import { runExport } from '../src/commands/export.js' +import { runImport } from '../src/commands/import.js' + +let logSpy: ReturnType +let errSpy: ReturnType + +beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) +}) +afterEach(() => { + vi.clearAllMocks() + logSpy.mockRestore() + errSpy.mockRestore() +}) + +describe('run() dispatcher', () => { + it('no args -> usage error on stderr, exit 1', async () => { + const code = await run([]) + expect(code).toBe(1) + expect(errSpy).toHaveBeenCalled() + expect(String(errSpy.mock.calls[0][0])).toContain('Usage') + expect(logSpy).not.toHaveBeenCalled() + expect(runExport).not.toHaveBeenCalled() + expect(runImport).not.toHaveBeenCalled() + }) + + it('a successful export dispatches to runExport, prints to stdout, exits 0', async () => { + vi.mocked(runExport).mockResolvedValue({ exitCode: 0, output: 'EXPORT_OK' }) + const code = await run(['export', '--url', 'http://x/', '--project', 'p1']) + expect(code).toBe(0) + expect(runExport).toHaveBeenCalledWith(expect.objectContaining({ url: 'http://x', project: 'p1' })) + expect(logSpy).toHaveBeenCalledWith('EXPORT_OK') + expect(errSpy).not.toHaveBeenCalled() + }) + + it('a dry-run with changes exits 2 and still routes output to stdout', async () => { + vi.mocked(runImport).mockResolvedValue({ exitCode: 2, output: 'PLAN_WITH_CHANGES' }) + const code = await run(['import', '--url', 'http://x', '--project', 'p1', '--file', 'f.json', '--dry-run']) + expect(code).toBe(2) + expect(runImport).toHaveBeenCalledWith(expect.objectContaining({ dryRun: true, file: 'f.json' })) + expect(logSpy).toHaveBeenCalledWith('PLAN_WITH_CHANGES') + expect(errSpy).not.toHaveBeenCalled() + }) + + it('an exit-1 command result routes output to stderr', async () => { + vi.mocked(runImport).mockResolvedValue({ exitCode: 1, output: 'BOOM' }) + const code = await run(['import', '--url', 'http://x', '--project', 'p1', '--file', 'f.json']) + expect(code).toBe(1) + expect(errSpy).toHaveBeenCalledWith('BOOM') + expect(logSpy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..8245d98 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@promocean/config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "lib": ["ES2022", "DOM"], + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..242d89e --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config' +export default defineConfig({ test: { include: ['test/**/*.test.ts'] } }) diff --git a/packages/contracts/src/config-file.ts b/packages/contracts/src/config-file.ts new file mode 100644 index 0000000..6c62539 --- /dev/null +++ b/packages/contracts/src/config-file.ts @@ -0,0 +1,90 @@ +import { z } from 'zod' +import { EVENT_TYPE_PATTERN } from './events.js' + +export const configSlugSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/) + +// Event-type keys/entries must match the SAME pattern the cms READ mappers filter +// through (mapPointRules / mapRegisteredEventTypes in the config-plane controller, +// mirroring events.ts EVENT_TYPE_PATTERN). Import WRITES raw, so a key the read +// side would silently drop diffs as forever-changed — permanent exit 2 in the CI +// drift check. Reject it at parse time instead. +const configEventTypeSchema = z.string().regex(EVENT_TYPE_PATTERN) + +// A single file must not name the same slug twice within one type: the diff keys +// buckets by slug, so a dup means the dry-run predicts a clean plan the apply then +// 422s on (duplicate-key write). Reject it here, naming the type + offending slug. +function rejectDuplicateSlugs(typeName: string) { + return (items: Array<{ slug: string }>, ctx: z.RefinementCtx) => { + const seen = new Set() + for (let i = 0; i < items.length; i++) { + const slug = items[i].slug + if (seen.has(slug)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `duplicate slug "${slug}" in ${typeName}`, + path: [i, 'slug'], + }) + } + seen.add(slug) + } + } +} + +export const configFileSchema = z.object({ + formatVersion: z.literal(1), + project: z.object({ + pointRules: z.record(configEventTypeSchema, z.number().int().min(0)), + registeredEventTypes: z.array(configEventTypeSchema), + allowedOrigins: z.array(z.string()).nullable(), + }), + placements: z.array(z.object({ slug: configSlugSchema, name: z.string() })) + .superRefine(rejectDuplicateSlugs('placements')), + achievements: z.array(z.object({ + slug: configSlugSchema, name: z.string(), description: z.string().nullable(), + artworkUrl: z.string().nullable(), eventType: z.string(), + targetCount: z.number().int().min(1), pointsValue: z.number().int().min(0), + })).superRefine(rejectDuplicateSlugs('achievements')), + timedEvents: z.array(z.object({ + slug: configSlugSchema, name: z.string(), description: z.string().nullable(), + startsAt: z.iso.datetime(), endsAt: z.iso.datetime(), + endingSoonMinutes: z.number().int().min(1), multiplier: z.number().int().min(1), + recurrence: z.enum(['none', 'daily', 'weekly', 'monthly']), + recurrenceEndsAt: z.iso.datetime().nullable(), enabled: z.boolean(), + })).superRefine(rejectDuplicateSlugs('timedEvents')), + offers: z.array(z.object({ + slug: configSlugSchema, name: z.string(), headline: z.string(), + body: z.string().nullable(), imageUrl: z.string().nullable(), + ctaText: z.string().nullable(), ctaUrl: z.string().nullable(), + startsAt: z.iso.datetime().nullable(), endsAt: z.iso.datetime().nullable(), + priority: z.number().int(), placement: configSlugSchema, + timedEvent: configSlugSchema.nullable(), + })).superRefine(rejectDuplicateSlugs('offers')), + rewards: z.array(z.object({ + slug: configSlugSchema, name: z.string(), description: z.string().nullable(), + codeType: z.enum(['generated', 'static']), staticCode: z.string().nullable(), + codePrefix: z.string().nullable(), pointsPrice: z.number().int().min(0), + startsAt: z.iso.datetime().nullable(), endsAt: z.iso.datetime().nullable(), + perUserLimit: z.number().int().min(1), inventory: z.number().int().min(1).nullable(), + enabled: z.boolean(), + })).superRefine(rejectDuplicateSlugs('rewards')), +}) +export const importRequestSchema = z.object({ + file: configFileSchema, + prune: z.boolean().default(false), + dryRun: z.boolean().default(false), +}) +const typePlanSchema = z.object({ + creates: z.array(z.string()), updates: z.array(z.string()), + deletes: z.array(z.string()), unchanged: z.number().int().min(0), +}) +export const importResponseSchema = z.object({ + applied: z.boolean(), + plan: z.object({ + project: typePlanSchema, placements: typePlanSchema, achievements: typePlanSchema, + timedEvents: typePlanSchema, offers: typePlanSchema, rewards: typePlanSchema, + }), + error: z.object({ stage: z.string(), message: z.string() }).optional(), +}) +export type ConfigFile = z.infer +export type ImportRequest = z.infer +export type ImportResponse = z.infer diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 6c0cd77..b6c7a3c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,3 +10,4 @@ export * from './wallet.js' export * from './streaks.js' export * from './leaderboard.js' export * from './rewards.js' +export * from './config-file.js' diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index 5015558..cd334c5 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -26,6 +26,10 @@ import { recurrenceSchema, liveTimedEventSchema, backfillResponseSchema, + configSlugSchema, + configFileSchema, + importRequestSchema, + importResponseSchema, } from '../src/index.js' describe('trackEventRequestSchema', () => { @@ -605,3 +609,192 @@ describe('backfillResponseSchema', () => { } }) }) + +describe('configSlugSchema', () => { + it('accepts lowercase, digits, hyphen and underscore (not leading)', () => { + for (const slug of ['homepage', 'home2', 'home-page', 'home_page', 'a']) { + expect(configSlugSchema.safeParse(slug).success).toBe(true) + } + }) + it('rejects a leading digit', () => { + expect(configSlugSchema.safeParse('1homepage').success).toBe(false) + }) + it('rejects uppercase letters', () => { + expect(configSlugSchema.safeParse('Homepage').success).toBe(false) + }) + it('rejects a leading hyphen or underscore', () => { + expect(configSlugSchema.safeParse('-homepage').success).toBe(false) + expect(configSlugSchema.safeParse('_homepage').success).toBe(false) + }) + it('rejects an empty string', () => { + expect(configSlugSchema.safeParse('').success).toBe(false) + }) +}) + +describe('configFileSchema', () => { + const baseFile = { + formatVersion: 1 as const, + project: { + pointRules: { click: 1, purchase: 10 }, + registeredEventTypes: ['click', 'purchase'], + allowedOrigins: ['https://example.com'], + }, + placements: [{ slug: 'homepage', name: 'Homepage' }], + achievements: [{ + slug: 'first-purchase', name: 'First Purchase', description: 'Make your first purchase', + artworkUrl: 'https://cdn.example.com/a.png', eventType: 'purchase', + targetCount: 1, pointsValue: 100, + }], + timedEvents: [{ + slug: 'double_points', name: 'Double Points', description: 'Double points weekend', + startsAt: '2026-07-08T00:00:00.000Z', endsAt: '2026-07-09T00:00:00.000Z', + endingSoonMinutes: 30, multiplier: 2, + recurrence: 'weekly' as const, + recurrenceEndsAt: '2026-12-31T00:00:00.000Z', enabled: true, + }], + offers: [{ + slug: 'summer-sale', name: 'Summer Sale', headline: 'Save big', + body: 'Limited time offer', imageUrl: 'https://cdn.example.com/o.png', + ctaText: 'Shop now', ctaUrl: 'https://example.com/shop', + startsAt: '2026-07-01T00:00:00.000Z', endsAt: '2026-07-31T00:00:00.000Z', + priority: 1, placement: 'homepage', + timedEvent: 'double_points', + }], + rewards: [{ + slug: 'free-shipping', name: 'Free Shipping', description: 'Free shipping on next order', + codeType: 'static' as const, staticCode: 'SHIP2026', + codePrefix: 'SHIP', pointsPrice: 500, + startsAt: '2026-07-01T00:00:00.000Z', endsAt: '2026-07-31T00:00:00.000Z', + perUserLimit: 1, inventory: 100, + enabled: true, + }], + } + + it('round-trips a full valid config file with every nullable field set (non-null)', () => { + expect(configFileSchema.parse(baseFile)).toEqual(baseFile) + }) + + it('round-trips a full valid config file with every nullable field set to null', () => { + const nulledFile = { + ...baseFile, + project: { ...baseFile.project, allowedOrigins: null }, + achievements: [{ ...baseFile.achievements[0], description: null, artworkUrl: null }], + timedEvents: [{ ...baseFile.timedEvents[0], description: null, recurrenceEndsAt: null }], + offers: [{ + ...baseFile.offers[0], + body: null, imageUrl: null, ctaText: null, ctaUrl: null, + startsAt: null, endsAt: null, timedEvent: null, + }], + rewards: [{ + ...baseFile.rewards[0], + description: null, staticCode: null, codePrefix: null, startsAt: null, endsAt: null, inventory: null, + }], + } + expect(configFileSchema.parse(nulledFile)).toEqual(nulledFile) + }) + + it('accepts an offer with timedEvent: null', () => { + const file = { + ...baseFile, + offers: [{ ...baseFile.offers[0], timedEvent: null }], + } + expect(configFileSchema.safeParse(file).success).toBe(true) + }) + + it('rejects formatVersion: 2', () => { + const file = { ...baseFile, formatVersion: 2 } + expect(configFileSchema.safeParse(file).success).toBe(false) + }) + + it('rejects a pointRules key that is not a valid event type', () => { + const file = { ...baseFile, project: { ...baseFile.project, pointRules: { 'Bad-Key': 1 } } } + const result = configFileSchema.safeParse(file) + expect(result.success).toBe(false) + }) + + it('rejects a registeredEventTypes entry that is not a valid event type', () => { + const file = { ...baseFile, project: { ...baseFile.project, registeredEventTypes: ['ok_type', 'Bad Type'] } } + const result = configFileSchema.safeParse(file) + expect(result.success).toBe(false) + }) + + it('rejects a duplicate slug within placements, naming the type and slug', () => { + const file = { + ...baseFile, + placements: [{ slug: 'homepage', name: 'Homepage' }, { slug: 'homepage', name: 'Homepage Again' }], + } + const result = configFileSchema.safeParse(file) + expect(result.success).toBe(false) + if (!result.success) { + const messages = result.error.issues.map((i) => i.message).join(' ') + expect(messages).toContain('duplicate slug') + expect(messages).toContain('homepage') + expect(messages).toContain('placements') + } + }) + + it('rejects a duplicate slug within rewards', () => { + const file = { + ...baseFile, + rewards: [baseFile.rewards[0], { ...baseFile.rewards[0], name: 'Dup' }], + } + const result = configFileSchema.safeParse(file) + expect(result.success).toBe(false) + }) +}) + +describe('importRequestSchema', () => { + const file = { + formatVersion: 1 as const, + project: { pointRules: {}, registeredEventTypes: [], allowedOrigins: null }, + placements: [], + achievements: [], + timedEvents: [], + offers: [], + rewards: [], + } + + it('defaults prune and dryRun to false when omitted', () => { + const result = importRequestSchema.parse({ file }) + expect(result.prune).toBe(false) + expect(result.dryRun).toBe(false) + }) + + it('round-trips explicit prune and dryRun values', () => { + const result = importRequestSchema.parse({ file, prune: true, dryRun: true }) + expect(result.prune).toBe(true) + expect(result.dryRun).toBe(true) + }) +}) + +describe('importResponseSchema', () => { + const typePlan = { creates: [], updates: [], deletes: [], unchanged: 0 } + const plan = { + project: typePlan, placements: typePlan, achievements: typePlan, + timedEvents: typePlan, offers: typePlan, rewards: typePlan, + } + + it('parses a response without an error field', () => { + const payload = { applied: true, plan } + expect(importResponseSchema.parse(payload)).toEqual(payload) + }) + + it('parses a response with an error field', () => { + const payload = { + applied: false, + plan, + error: { stage: 'validation', message: 'formatVersion must be 1' }, + } + expect(importResponseSchema.parse(payload)).toEqual(payload) + }) + + it('rejects a negative unchanged count in any plan bucket', () => { + for (const key of Object.keys(plan)) { + const payload = { + applied: true, + plan: { ...plan, [key]: { ...typePlan, unchanged: -1 } }, + } + expect(importResponseSchema.safeParse(payload).success).toBe(false) + } + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a05400b..3293af0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: apps/cms: dependencies: + '@promocean/contracts': + specifier: workspace:* + version: link:../../packages/contracts '@strapi/plugin-cloud': specifier: 5.50.0 version: 5.50.0(01c9908993d0c7697cfcf765da1708e5) @@ -131,6 +134,9 @@ importers: '@playwright/test': specifier: ^1.53.0 version: 1.61.1 + '@promocean/contracts': + specifier: workspace:* + version: link:../../packages/contracts '@types/node': specifier: ^20 version: 20.19.43 @@ -200,6 +206,28 @@ importers: specifier: ^3.2.0 version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0) + packages/cli: + dependencies: + '@promocean/contracts': + specifier: workspace:* + version: link:../contracts + zod: + specifier: ^4.0.5 + version: 4.4.3 + devDependencies: + '@promocean/config': + specifier: workspace:* + version: link:../config + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0) + packages/config: {} packages/contracts: