diff --git a/CLAUDE.md b/CLAUDE.md index 24f83be8c..ab3678775 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,3 +159,4 @@ The load-bearing few. **Full detail for every relocated item → [tutorials-ims- - **External channels subsystem** — `Channels` entity (`db/channels.cds`) is the source of truth; re-ingest via `npm run seed-channels`; directory at `/channels` (baked by `fetch-channels` in `build:all`); verb-lane fill via `npm run promote-channels`; community items never land in `START_HERE`. → [channels.md](docs/developers/reference/channels.md). - **TechEd sessions subsystem (#2312)** — new `TechEd*` entities (`db/external/teched.cds`, NOT reused `CommunityEvents`), loaded via a **`using` side-effect** in `db/external-content.cds` (don't delete the import); RainFocus ingest (`POST https://events.rainfocus.com/api/sessions`, headers `rfapiprofileid`/`rfwidgetid` per venue from `RAINFOCUS_{TE26,TEV26}_PROFILE`/`_WIDGET` env — live-confirmed defaults baked in, IDs may rotate; speakers embedded in `participants[]`, tracks in `attributevalues[attribute=="Track"]`, offset-paginated); idempotent seed via `scripts/seed-teched.cjs`; public `/build/teched` feed; corrected fetcher + delta cron jobs shipped in #2332; `/teched/` page + island, KG projection, embeddings, and Devtoberfest cross-linking are later units. → [teched.md](docs/developers/reference/teched.md). - **Signed provenance envelope (#2245)** — `PROVENANCE_ENVELOPE_ENABLED` (DB config, default OFF, DEV-first); two anonymous Express endpoints (`/content/tutorials/:slug/provenance` and `/.well-known/tutorial-provenance/jwks.json`); signing key = `PROVENANCE_SIGNING_KEY` credstore secret (Ed25519 PKCS8 PEM); rotate via `/admin-ui/#secrets`. Fail-open. → gotchas.md "Signed provenance envelope". +- **Optimized draft deletion `draft_admin_integrity` ON (#2909, CAP 10.2)** — `cds.fiori.draft_admin_integrity: true` in package.json; draft discard cascade-deletes draft `CompletionPathItems`/`GroupPathItems` at the DB level, so rollup reconcile now fires via `after('DISCARD','Missions.drafts'/'Groups.drafts')` (the `.drafts` target is required), NOT the per-row child `after('DELETE')` hooks (those stay for direct programmatic deletes). Deleting an active entity right after `draftActivate` can 403 ("a draft exists") until the residual draft is discarded. Constraint migration deploys to HANA (`.hdbconstraint`, `NOT VALIDATED`) BEFORE the flag serves. → gotchas.md "Optimized draft deletion". diff --git a/docs/developers/reference/tutorials-ims-gotchas.md b/docs/developers/reference/tutorials-ims-gotchas.md index cfcae3bd0..58f267874 100644 --- a/docs/developers/reference/tutorials-ims-gotchas.md +++ b/docs/developers/reference/tutorials-ims-gotchas.md @@ -203,3 +203,17 @@ All default OFF and DEV-only unless noted. Toggles fail-open on every fault path - **`sourceCommit` plumbing** — `ContentCurrent.sourceCommit` (String(64)) is populated by the publish pipeline via the `source_commit` field in the publish payload; `scripts/fetch-tutorials.ts` threads the HEAD commit SHA through to the publish client. On older published content the column is NULL; the provenance JWS payload will carry `sourceCommit: null` in that case, which is valid. - **Quarantine + publisher are status-aware for DELETED/INACTIVE (#2585 follow-up)** — a tutorial with `Tutorials.status IN ('DELETED','INACTIVE')` is excluded from the whole publish/quarantine path, so a retired tutorial whose source `.md` still exists upstream no longer gets re-discovered, re-quarantined, or shown in the Admin active-quarantine facet. Mechanism: `GET /content/excluded-slugs` (public-read on prod srv, `hashesAuth` on srv-qa) returns those lowercased slugs; `scripts/fetch-tutorials.ts` (`applyExclusion` after discovery, both main and `--regenerate` paths) and `scripts/validate-tutorials.ts` (skip at the top of the file loop) fetch it and skip those slugs — **fail-open**, an unreachable endpoint means no filtering; and `quarantineIngestHandler` (`packages/content/content-store.js`) drops events for those slugs before persisting, so the slug-only Admin facet join never surfaces them. One-off cleanup of a live snapshot without a full rebuild: `scripts/clear-deleted-from-quarantine.cjs` (`cds bind --exec … -- node scripts/clear-deleted-from-quarantine.cjs --commit`; dry-run by default). Note: the pre-existing `/content/source-hashes` filter excludes only `INACTIVE` — the new `/content/excluded-slugs` query covers **both** statuses. + +## Optimized draft deletion — `draft_admin_integrity` (CAP 10.2, #2909) + +- **`cds.fiori.draft_admin_integrity: true` is ON in `package.json`** — CAP 10.2 optimized draft deletion. `cds build` generates `ON DELETE CASCADE` constraints on each draft table's `DraftAdministrativeData_DraftUUID` FK; draft activation and root cancellation then delete only the `DraftAdministrativeData` record and let the DB cascade the child draft rows, instead of the runtime issuing a per-table DELETE for each. All 19 draft-enabled entities live in `AdminService`. + +- **Draft DISCARD now reconciles rollups via root handlers, NOT child DELETE events** — because the cascade removes draft `CompletionPathItems`/`GroupPathItems` at the DB level, the per-row `before/after('DELETE')` hooks (`srv/admin-service.js:1197-1242`) do **not** fire on a draft discard. `srv/admin-service.js` registers `this.after('DISCARD', 'Missions.drafts', …)` and `…('Groups.drafts', …)` (the `.drafts` target is required — DISCARD on the active entity does not fire) which re-fire `reconcileEntityForUsers` for the active entity's `legacyId`. The existing child DELETE handlers stay — they still fire for **direct programmatic deletes** (e.g. `service.tx()` row deletes), which the cascade does not touch. Fail-open like the SAVE reconcile hooks. + +- **Delete-active-immediately-after-activate now 403s until the draft is cleared** — under the flag, `DELETE …(IsActiveEntity=true)` right after `draftActivate` can return `403 — Entity cannot be deleted because a draft exists` (observed on SQLite in tests). Any code or test that deletes an active entity shortly after activation must first discard the residual draft (`DELETE …(IsActiveEntity=false)`), then delete the active. This is the documented 10.2 behavior change, not a bug. + +- **Constraint-deploy migration ordering (HANA)** — constraints generate at `cds build` once the flag is on and deploy to HANA as `.hdbconstraint` with `NOT VALIDATED` (so existing orphaned draft rows don't block the deploy). Deploy the constraints to a target env and let them land **before** the flag serves there — once the runtime serves with the flag on, it stops issuing per-table draft deletes and relies on the cascade. Order DEV → test → PROD per the no-main-hotfix model; confirm scope with the maintainer before any PROD step. + +- **Optional orphan purge** — set `cds.fiori.draft_orphan_cleanup: true` to purge pre-existing orphaned draft rows as background cleanup during draft create/edit, then turn it back off once orphans are cleared. Independent of `draft_admin_integrity`; default OFF. Not enabled in `package.json`. + +- **Rollback** — the constraints are additive (`ON DELETE CASCADE` on draft tables only). To revert: flip `draft_admin_integrity` back off (the runtime resumes per-table draft deletes, and the DISCARD reconcile handlers become harmless no-ops relative to the restored child-DELETE path) and optionally drop the generated constraints on the next deploy. diff --git a/docs/superpowers/plans/2026-11-01-draft-admin-integrity-2909.md b/docs/superpowers/plans/2026-11-01-draft-admin-integrity-2909.md new file mode 100644 index 000000000..fbdd266b4 --- /dev/null +++ b/docs/superpowers/plans/2026-11-01-draft-admin-integrity-2909.md @@ -0,0 +1,208 @@ +# CAP 10.2 Optimized Draft Deletion (`draft_admin_integrity`) 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:** Adopt CAP 10.2's optimized draft deletion (`cds.fiori.draft_admin_integrity: true`) safely — closing the one reconcile-correctness gap it introduces and shipping the DB-constraint migration in the correct order. + +**Architecture:** Enabling `draft_admin_integrity: true` makes `cds build` generate `ON DELETE CASCADE` constraints on each draft table's `DraftAdministrativeData_DraftUUID` FK, and changes draft activation/root-cancellation to delete only the `DraftAdministrativeData` record — the DB cascades the child rows. That cascade bypasses CAP's per-row `before/after('DELETE')` handlers. In this project the only correctness-sensitive handlers are the `CompletionPathItems`/`GroupPathItems` FK-capture-and-reconcile pair in `AdminService` (`srv/admin-service.js:1197-1242`), which keep Mission/Group completion rollups consistent. The fix is **additive**: add root-level `DISCARD` handlers on `Missions.drafts`/`Groups.drafts` that fire the same reconcile — DISCARD fires at the draft root before the cascade, independent of per-child-row delete events. The existing child `before/after('DELETE')` handlers stay (they still fire for direct programmatic deletes, which the cascade does not touch). The flag + constraint deployment is sequenced as a DB migration: deploy constraints first, flip the serving flag after. + +**Tech Stack:** CAP Node.js `@sap/cds` 10.2, `@cap-js/hana` (prod/hybrid), `@cap-js/sqlite` (dev/test), Fiori draft model, vitest (`cds.test('serve', ...)`), HANA Cloud. + +**Spec:** https://cap.cloud.sap/docs/releases/2026/oct26#optimized-draft-deletion + +## Global Constraints + +- CAP Node.js only — the Java `cds.drafts.deleteStrategy` property does NOT apply; the Node.js lever is `cds.fiori.draft_admin_integrity` (+ companion `cds.fiori.draft_orphan_cleanup`) +- Node.js pinned 22.23.4; `@sap/cds` ^10.2.0 (already on DEV) +- Never write raw SQL — use `cds.ql` / CQL (the existing reconcile helpers already do) +- PR targets `DEV` (never `main`); open a PR, never direct-merge +- Reconcile is fire-and-forget in a detached `cds.tx` and MUST remain fail-open — a reconcile fault must never fail the user's draft operation (see `fireReconcile`, admin-service.js:1159-1165) +- Draft-enabled entities in scope are all in `AdminService`; only `Missions`/`Groups` (via composition children `CompletionPathItems`/`GroupPathItems`) carry reconcile risk — the other 17 draft entities have only cache-bust/snapshot `after('DELETE')` handlers (stale-cache at worst, not data loss) +- The constraint-generation + HANA deploy (Task 3) is a schema migration: constraints deploy FIRST (`NOT VALIDATED`, so existing orphans don't block), the serving flag flips AFTER. Confirm deploy scope + ordering with the maintainer before any PROD step — this plan delivers the code + DEV verification; PROD rollout is a separate authorized deploy. +- `srv-qa` cp-list audit: this plan touches only `srv/admin-service.js` (already a `srv-qa` dep's sibling, not a new `srv/lib/` import) — no new transitive `srv/lib/` dependency is introduced, so the `srv-qa` cp list does not change. Confirm during Task 1 that no new `./lib/*` import is added. + +## Review Focus + +- **Draft DISCARD bypasses reconcile (the whole point):** under `draft_admin_integrity: true`, discarding a Mission/Group draft that removed a `CompletionPathItems`/`GroupPathItems` child must still reconcile the parent rollup. Task 2's test must prove the DISCARD handler fires the reconcile even though the child `after('DELETE')` does not. +- **Direct programmatic delete still reconciles:** the existing child `before/after('DELETE')` handlers must keep working for non-draft-cascade deletes (e.g. `service.tx()` row deletes, active-entity edits). Task 2 must prove enabling the flag + adding DISCARD handlers did not break the existing CREATE/UPDATE/DELETE child reconcile path. +- **Reconcile stays fail-open:** a thrown error inside the new DISCARD handler must not reject the user's discard. Task 2 asserts the discard still returns success when reconcile internals throw. +- **DISCARD on a draft with no child items is a no-op, not a crash:** discarding a freshly-created Mission draft with zero CompletionPathItems must not throw (undefined FK → skip, matching the existing `legacyId == null` guard). +- **SQLite cascade-constraint parity:** enabling `draft_admin_integrity` generates FK constraints; the dev/test SQLite deploy must still boot. Task 1 must confirm `cds.test('serve', '--in-memory')` starts clean with the flag on (SQLite supports the constraints, but verify no deploy error). + +--- + +## Task 1: Enable the flag and confirm the app still boots (dev/test) + +**Files:** +- Modify: `package.json` (`cds.fiori` section — create it) +- Test: `test/admin-drafts.test.js` (existing — used only as a boot smoke here) + +**Interfaces:** +- Produces: `cds.fiori.draft_admin_integrity: true` in package.json; a confirmed-booting SQLite test harness that Task 2 builds its new tests on. + +- [ ] **Step 1: Add the flag to `package.json`** + +In the `cds` block, add a `fiori` section (it does not exist yet): + +```json +"fiori": { + "draft_admin_integrity": true +} +``` + +- [ ] **Step 2: Confirm no new `srv/lib/*` import is implied** + +Run: `grep -n "require('./lib/\|from './lib/" srv/admin-service.js` +Expected: the only reconcile import is the pre-existing `./lib/completion-reconcile.js` (admin-service.js:68). No new lib dep → `srv-qa` cp list unchanged. + +- [ ] **Step 3: Run the existing draft test as a boot smoke** + +Run: `npx vitest run test/admin-drafts.test.js` +Expected: PASS — the SQLite in-memory deploy boots with the cascade constraints generated, and existing draft create/activate tests still pass. If the deploy errors on constraint generation, STOP and investigate (do not proceed to Task 2). + +- [ ] **Step 4: Commit** + +```bash +git add package.json +git commit -m "feat(cap): enable cds.fiori.draft_admin_integrity for optimized draft deletion" +``` + +--- + +## Task 2: Add root DISCARD reconcile handlers for Missions and Groups + +**Files:** +- Modify: `srv/admin-service.js` (add handlers next to the existing SAVE reconcile hooks, after line 1276) +- Test: `test/admin-drafts-discard-reconcile.test.js` (new) + +**Interfaces:** +- Consumes: `fireReconcile(kind, legacyId)` (admin-service.js:1159), `resolveGroupLegacy(groupId)` (:1175), `resolveMissionsNestingGroup(groupId)` (:1181), `reconcileEntityForUsers` (imported :68). These are all in scope inside the `init()` closure where the new handlers are registered. +- Produces: `this.after('DISCARD', 'Missions', onMissionDiscard)` and `this.after('DISCARD', 'Groups', onGroupDiscard)` — reconcile the parent rollup on draft discard. + +**Behavior the handlers must implement.** On DISCARD of a draft root, the draft's child `CompletionPathItems`/`GroupPathItems` rows are about to be cascade-deleted; the reconcile needs the parent legacyId(s). On a Missions discard, resolve the Mission's own `legacyId` from the draft/active row and `fireReconcile('MISSION', legacyId)`. On a Groups discard, resolve the Group's `legacyId` → `fireReconcile('GROUP', legacyId)`, then `resolveMissionsNestingGroup(groupId)` → `fireReconcile('MISSION', mLegacy)` for each (mirroring `onGroupSave`, admin-service.js:1261-1273). Wrap in try/catch, fail-open. DISCARD refers to the active entity per CAP's event model; the handler reads the ID from `req.data?.ID ?? req.params?.[0]?.ID` and resolves legacyId via the existing resolvers. + +- [ ] **Step 1: Write the failing test** + +Create `test/admin-drafts-discard-reconcile.test.js`. Model the harness on `test/admin-drafts.test.js` (`cds.test('serve', '--project', '.', '--in-memory')`, `adminAuth`, a seeded Tag). Spy on the reconcile by stubbing `reconcileEntityForUsers` is not possible across the HTTP boundary; instead assert on observable effect: after discarding a Mission draft that had a CompletionPathItems child, the parent Mission's rollup records are reconciled. If asserting the rollup side effect is too heavy for a unit test, assert the handler path instead by spying on `cds.log('rollup')` or by registering a test-only `after('DISCARD','Missions')` probe. Write these three tests: + +```js +it('fires MISSION reconcile when a Mission draft is discarded', async () => { + // create Mission draft (POST /admin/Missions, lenient) → get ID + // DELETE the draft: project.delete(`/admin/Missions(ID=${id},IsActiveEntity=false)`, adminAuth) + // assert reconcile fired for the mission's legacyId (via probe/log spy) +}); + +it('DISCARD of an empty Mission draft does not throw', async () => { + // create Mission draft with no CompletionPathItems, discard it + // assert discard returns 204/200 and no error logged +}); + +it('Group draft discard reconciles the group and nesting missions', async () => { + // create Group draft, discard, assert GROUP reconcile fired +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run test/admin-drafts-discard-reconcile.test.js` +Expected: FAIL — no DISCARD handler exists yet, so the reconcile probe/log is never hit. + +- [ ] **Step 3: Implement `onMissionDiscard` and `onGroupDiscard` in `srv/admin-service.js`** + +Add after line 1276 (right after `this.after('SAVE', 'Groups', onGroupSave)`). Reuse `fireReconcile`, `resolveGroupLegacy`, `resolveMissionsNestingGroup`, and the `cds.entities('com.sap.developers.ims')` access pattern. Register with `this.after('DISCARD', 'Missions', onMissionDiscard)` and `this.after('DISCARD', 'Groups', onGroupDiscard)`. Follow the fail-open try/catch shape of `onGroupSave`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run test/admin-drafts-discard-reconcile.test.js` +Expected: PASS — all three tests green. + +- [ ] **Step 5: Run the existing draft + reconcile tests for regression** + +Run: `npx vitest run test/admin-drafts.test.js test/admin-service-reconcile.test.js` +Expected: PASS — the existing child-delete and SAVE reconcile paths are unaffected. + +- [ ] **Step 6: Commit** + +```bash +git add srv/admin-service.js test/admin-drafts-discard-reconcile.test.js +git commit -m "fix(admin): reconcile Mission/Group rollups on draft DISCARD (draft_admin_integrity)" +``` + +--- + +## Task 3: Document the constraint-deploy migration runbook + +**Files:** +- Modify: `docs/developers/reference/tutorials-ims-gotchas.md` (add a "Draft delete — draft_admin_integrity" entry) OR create `docs/developers/operations/draft-admin-integrity-migration.md` if the gotchas file is the wrong home — pick the gotchas file to match the project's "one doc per subsystem, gotchas for the rest" convention. +- Modify: `CLAUDE.md` Top Gotchas (one bullet linking to the above, matching the existing bullet style) + +**Interfaces:** +- Consumes: Tasks 1-2 (flag enabled, handlers in place) +- Produces: a runbook a deployer follows to roll the cascade constraints to HANA in the correct order. + +The runbook must capture: (1) constraints generate at `cds build` once the flag is on and deploy to HANA as `.hdbconstraint` with `NOT VALIDATED` (existing orphaned draft rows do not block); (2) deploy order — ship the constraints to a target env and let them land BEFORE relying on cascade behavior, since the Node.js runtime stops issuing per-table draft deletes once the flag serves; (3) optional `cds.fiori.draft_orphan_cleanup: true` to purge pre-existing orphaned draft rows around the migration, then turn back off; (4) rollback — the constraints are additive (`ON DELETE CASCADE` on draft tables only); reverting means flipping the flag off (runtime resumes per-table deletes) and optionally dropping the constraints; (5) DEV→test→PROD ordering per the project's no-main-hotfix model. + +- [ ] **Step 1: Write the runbook entry** + +Add the migration steps + rollback to the gotchas doc (or new ops doc). Use the spec's exact flag names (`cds.fiori.draft_admin_integrity`, `cds.fiori.draft_orphan_cleanup`). Keep it to the five points above. + +- [ ] **Step 2: Add the CLAUDE.md Top Gotchas bullet** + +One bullet, matching the existing terse style, linking to the doc. Note the flag is ON as of this PR and that draft discard now reconciles via root DISCARD handlers (not child DELETE events). + +- [ ] **Step 3: Verify the doc links resolve** + +Run: `grep -n "draft-admin-integrity\|draft_admin_integrity" CLAUDE.md docs/developers/reference/tutorials-ims-gotchas.md` +Expected: the CLAUDE.md bullet references the doc section that exists. + +- [ ] **Step 4: Commit** + +```bash +git add CLAUDE.md docs/ +git commit -m "docs: draft_admin_integrity migration runbook + gotcha" +``` + +--- + +## Task 4: Open PR to DEV + +**Files:** none (git/PR only) + +**Interfaces:** +- Consumes: Tasks 1-3 complete, tests green + +- [ ] **Step 1: Run the scoped verification suite one final time** + +Run: `npx vitest run test/admin-drafts.test.js test/admin-drafts-discard-reconcile.test.js test/admin-service-reconcile.test.js` +Expected: all PASS. (Full suite runs in CI — the repo blocks the full local suite.) + +- [ ] **Step 2: Run the static guards (they run in CI; catch drift early)** + +Run: `npm run static-guards` +Expected: 17/17 pass (confirms no `srv-qa` dep-parity or cp-list drift from the admin-service change). + +- [ ] **Step 3: Push and open the PR** + +```bash +git push -u origin +gh pr create --base DEV --title "feat(cap): adopt draft_admin_integrity (optimized draft deletion) #2909" --body "$(cat <<'EOF' +Closes #2909 + +## Summary +- Enables `cds.fiori.draft_admin_integrity: true` — CAP 10.2 optimized draft deletion (DB `ON DELETE CASCADE` on draft tables; activation/root-cancel delete only the admin record). +- Adds root `after('DISCARD')` reconcile handlers for `Missions`/`Groups` in AdminService. Under the flag, discarding a draft cascade-deletes child `CompletionPathItems`/`GroupPathItems` at the DB level, bypassing the per-row `after('DELETE')` reconcile. The DISCARD handlers restore rollup correctness; the existing child DELETE handlers stay (they still fire for direct programmatic deletes). +- Adds a constraint-deploy migration runbook. + +## Migration ordering (NOT done in this PR — separate authorized deploy) +Constraints generate at `cds build` and deploy to HANA as `.hdbconstraint` (`NOT VALIDATED`). Deploy the constraints to a target env BEFORE the flag serves there. See the runbook. Confirm scope + ordering with the maintainer. + +## Test plan +- [x] `test/admin-drafts-discard-reconcile.test.js` — new; DISCARD reconcile + empty-draft no-op + group-nesting +- [x] `test/admin-drafts.test.js` + `admin-service-reconcile.test.js` — no regression (SQLite) +- [x] `npm run static-guards` 17/17 +- [ ] Hybrid HANA draft-discard verification on DEV (reviewer / post-deploy) +EOF +)" +``` + +result: PR opened to DEV adopting draft_admin_integrity with DISCARD-reconcile fix and migration runbook diff --git a/package.json b/package.json index bcd25e0c2..864400628 100644 --- a/package.json +++ b/package.json @@ -457,6 +457,9 @@ "hana": { "fuzzy": false }, + "fiori": { + "draft_admin_integrity": true + }, "websocket": { "kind": "socket.io", "adapter": { diff --git a/srv/admin-service.js b/srv/admin-service.js index d01f9c22c..51b01f8d2 100644 --- a/srv/admin-service.js +++ b/srv/admin-service.js @@ -1275,6 +1275,40 @@ export default class AdminService extends cds.ApplicationService { this.after('SAVE', 'Missions', onMissionSave); this.after('SAVE', 'Groups', onGroupSave); + // #2909 — draft DISCARD reconcile. With cds.fiori.draft_admin_integrity on, + // discarding a Mission/Group draft cascade-deletes its draft CompletionPathItems/ + // GroupPathItems at the DB level, bypassing the per-row after('DELETE') reconcile + // hooks above. These root DISCARD handlers restore rollup correctness by firing + // reconcile for the ACTIVE entity's legacyId (the discard reverts the draft to + // the active state, so we reconcile against that). DISCARD fires at the draft + // root regardless of the child-row cascade. Fail-open like the SAVE hooks. + const onMissionDiscard = async (_data, req) => { + try { + const { Missions } = cds.entities('com.sap.developers.ims'); + const id = req.data?.ID ?? req.params?.[0]?.ID; + const legacyId = id + ? (await SELECT.one.from(Missions).columns('legacyId').where({ ID: id }))?.legacyId + : null; + fireReconcile('MISSION', legacyId); + } catch (_) { /* fail-open */ } + }; + const onGroupDiscard = async (_data, req) => { + try { + const { Groups } = cds.entities('com.sap.developers.ims'); + const groupId = req.data?.ID ?? req.params?.[0]?.ID; + const legacyId = groupId + ? (await SELECT.one.from(Groups).columns('legacyId').where({ ID: groupId }))?.legacyId + : null; + fireReconcile('GROUP', legacyId); + if (groupId) { + const missionLegacyIds = await resolveMissionsNestingGroup(groupId).catch(() => []); + for (const mLegacy of missionLegacyIds) fireReconcile('MISSION', mLegacy); + } + } catch (_) { /* fail-open */ } + }; + this.after('DISCARD', 'Missions.drafts', onMissionDiscard); + this.after('DISCARD', 'Groups.drafts', onGroupDiscard); + // Advocates: auto-derive slug from firstName + lastName on CREATE. advocateHandlers.register(this); devtoberfestBannerHandlers.register(this); diff --git a/test/admin-drafts-discard-reconcile.test.js b/test/admin-drafts-discard-reconcile.test.js new file mode 100644 index 000000000..909dc220c --- /dev/null +++ b/test/admin-drafts-discard-reconcile.test.js @@ -0,0 +1,138 @@ +// test/admin-drafts-discard-reconcile.test.js +// +// Regression for #2909: with cds.fiori.draft_admin_integrity enabled, discarding +// a draft of a Mission/Group cascade-deletes its draft composition children +// (CompletionPathItems / GroupPathItems) at the DB level, bypassing the per-row +// after('DELETE') reconcile hooks. The root after('DISCARD','Missions'/'Groups') +// handlers restore rollup correctness by firing reconcileEntityForUsers for the +// active entity's legacyId on discard. +// +// Assertion strategy mirrors admin-service-reconcile.test.js: seed a user whose +// active-entity rollup is out of sync, perform the draft action, and vi.waitFor +// the observable TaskRecords heal. Reconcile is a static ESM import in +// admin-service.js (not spyable across the module boundary), so we assert the +// side effect, not the call. +// +// Harness: cds.test('serve', '--project', '.', '--in-memory') + adminAuth HTTP, +// matching test/admin-drafts.test.js and admin-service-reconcile.test.js. +import { describe, it, expect, vi } from 'vitest'; +import cds from '@sap/cds'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +const adminAuth = { auth: { username: 'admin', password: 'admin' } }; +const NS = 'com.sap.developers.ims'; + +describe('draft DISCARD reconciles rollups (#2909 draft_admin_integrity)', () => { + // ─── A) Discarding an edit of an active Mission fires MISSION reconcile ─── + // Active Mission has one CPI→tutorial. User completed the tutorial but their + // MISSION TaskRecord is stale IN_PROGRESS. Admin draftEdits the Mission then + // DISCARDs. The after('DISCARD','Missions') handler resolves the active + // Mission's legacyId and fires reconcile → user heals to COMPLETED. + it('fires MISSION reconcile when a Mission draft (edit) is discarded', async () => { + const db = await cds.connect.to('db'); + const { Users, Tutorials, Missions, CompletionPaths, CompletionPathItems, TaskRecords, Tags } = cds.entities(NS); + + const USER_ID = 'aaaaaaaa-2909-0001-0000-000000000001'; + const TUT_ID = 'bbbbbbbb-2909-0001-0000-000000000001'; + const MISS_ID = 'cccccccc-2909-0001-0000-000000000001'; + const CP_ID = 'dddddddd-2909-0001-0000-000000000001'; + const CPI_ID = 'eeeeeeee-2909-0001-0000-000000000001'; + const TAG_ID = 'tttttttt-2909-0001-0000-000000000001'; + const TUT_LEGACY = 92001; + const MISS_LEGACY = 92501; + + await db.run(INSERT.into(Users).entries({ ID: USER_ID, legacyId: 92101, uuid: 'u-2909-01' })); + await db.run(INSERT.into(Tutorials).entries({ + ID: TUT_ID, legacyId: TUT_LEGACY, slug: 'disc-reconcile-tut-01', title: 'Disc Tut 01', status: 'ACTIVE', + })); + await db.run(INSERT.into(Tags).entries({ ID: TAG_ID, legacyId: 92201, name: '__TEST__ disc-01' })); + await db.run(INSERT.into(Missions).entries({ + ID: MISS_ID, legacyId: MISS_LEGACY, title: 'Disc Reconcile Mission', published: true, + })); + await db.run(INSERT.into(CompletionPaths).entries({ + ID: CP_ID, mission_ID: MISS_ID, legacyId: 92601, name: 'Disc Path', + })); + await db.run(INSERT.into(CompletionPathItems).entries({ + ID: CPI_ID, path_ID: CP_ID, taskType: 'TUTORIAL', taskLegacyId: TUT_LEGACY, itemOrder: 10, legacyId: 92701, + })); + // User completed the tutorial but MISSION record is stale IN_PROGRESS. + await db.run(INSERT.into(TaskRecords).entries([ + { ID: 'ffffffff-2909-0001-0000-000000000001', user_ID: USER_ID, taskLegacyId: TUT_LEGACY, taskType: 'TUTORIAL', status: 'COMPLETED', progress: 100, legacyId: 92801, attemptNumber: 1 }, + { ID: 'ffffffff-2909-0001-0000-000000000002', user_ID: USER_ID, taskLegacyId: MISS_LEGACY, taskType: 'MISSION', status: 'IN_PROGRESS', progress: 50, legacyId: 92802, attemptNumber: 1 }, + ])); + + // draftEdit the active Mission, then DISCARD the draft. + const { status: sEdit } = await project.post( + `/admin/Missions(ID=${MISS_ID},IsActiveEntity=true)/AdminService.draftEdit`, {}, adminAuth + ); + expect([200, 201]).toContain(sEdit); + const { status: sDisc } = await project.delete( + `/admin/Missions(ID=${MISS_ID},IsActiveEntity=false)`, adminAuth + ); + expect([200, 204]).toContain(sDisc); + + // DISCARD handler fires MISSION reconcile for the active legacyId → heal. + await vi.waitFor(async () => { + const [row] = await db.run( + SELECT.from(TaskRecords).where({ user_ID: USER_ID, taskType: 'MISSION', taskLegacyId: MISS_LEGACY }) + ); + expect(row?.status).toBe('COMPLETED'); + }, { timeout: 2000, interval: 40 }); + }); + + // ─── B) Discarding an empty freshly-created Mission draft does not throw ── + it('DISCARD of an empty new Mission draft does not throw', async () => { + const { status: sNew, data: draft } = await project.post('/admin/Missions', { + title: '__TEST__ Disc Empty Mission', slug: 'disc-empty-mission-2909', + }, { ...adminAuth, headers: { Prefer: 'handling=lenient' } }); + expect(sNew).toBe(201); + const { status: sDisc } = await project.delete( + `/admin/Missions(ID=${draft.ID},IsActiveEntity=false)`, adminAuth + ); + expect([200, 204]).toContain(sDisc); + }); + + // ─── C) Discarding an edit of an active Group fires GROUP reconcile ─────── + it('fires GROUP reconcile when a Group draft (edit) is discarded', async () => { + const db = await cds.connect.to('db'); + const { Users, Tutorials, Groups, GroupPathItems, TaskRecords } = cds.entities(NS); + + const USER_ID = 'aaaaaaaa-2909-0003-0000-000000000001'; + const TUT_ID = 'bbbbbbbb-2909-0003-0000-000000000001'; + const GRP_ID = 'cccccccc-2909-0003-0000-000000000001'; + const GPI_ID = 'eeeeeeee-2909-0003-0000-000000000001'; + const TUT_LEGACY = 92003; + const GRP_LEGACY = 92503; + + await db.run(INSERT.into(Users).entries({ ID: USER_ID, legacyId: 92103, uuid: 'u-2909-03' })); + await db.run(INSERT.into(Tutorials).entries({ + ID: TUT_ID, legacyId: TUT_LEGACY, slug: 'disc-reconcile-tut-03', title: 'Disc Tut 03', status: 'ACTIVE', + })); + await db.run(INSERT.into(Groups).entries({ + ID: GRP_ID, legacyId: GRP_LEGACY, title: 'Disc Reconcile Group', published: true, + })); + await db.run(INSERT.into(GroupPathItems).entries({ + ID: GPI_ID, group_ID: GRP_ID, tutorial_ID: TUT_ID, itemOrder: 10, legacyId: 92703, + })); + await db.run(INSERT.into(TaskRecords).entries([ + { ID: 'ffffffff-2909-0003-0000-000000000001', user_ID: USER_ID, taskLegacyId: TUT_LEGACY, taskType: 'TUTORIAL', status: 'COMPLETED', progress: 100, legacyId: 92803, attemptNumber: 1 }, + { ID: 'ffffffff-2909-0003-0000-000000000002', user_ID: USER_ID, taskLegacyId: GRP_LEGACY, taskType: 'GROUP', status: 'IN_PROGRESS', progress: 50, legacyId: 92804, attemptNumber: 1 }, + ])); + + const { status: sEdit } = await project.post( + `/admin/Groups(ID=${GRP_ID},IsActiveEntity=true)/AdminService.draftEdit`, {}, adminAuth + ); + expect([200, 201]).toContain(sEdit); + const { status: sDisc } = await project.delete( + `/admin/Groups(ID=${GRP_ID},IsActiveEntity=false)`, adminAuth + ); + expect([200, 204]).toContain(sDisc); + + await vi.waitFor(async () => { + const [row] = await db.run( + SELECT.from(TaskRecords).where({ user_ID: USER_ID, taskType: 'GROUP', taskLegacyId: GRP_LEGACY }) + ); + expect(row?.status).toBe('COMPLETED'); + }, { timeout: 2000, interval: 40 }); + }); +}); diff --git a/test/admin-drafts.test.js b/test/admin-drafts.test.js index f0440ebbc..af3b13441 100644 --- a/test/admin-drafts.test.js +++ b/test/admin-drafts.test.js @@ -107,8 +107,12 @@ describe('Draft Enablement', () => { ); expect(activateStatus).toBe(201); - // Cleanup - await project.delete(`/admin/Missions(ID=${mission.ID},IsActiveEntity=true)`, adminAuth); + // Cleanup. Under cds.fiori.draft_admin_integrity (CAP 10.2) a residual + // draft can linger after activation on SQLite and blocks the active delete + // with "a draft exists" — discard any leftover draft first, then delete the + // active entity. + await project.delete(`/admin/Missions(ID=${mission.ID},IsActiveEntity=false)`, adminAuth).catch(() => {}); + await project.delete(`/admin/Missions(ID=${mission.ID},IsActiveEntity=true)`, adminAuth).catch(() => {}); }); }); });