diff --git a/app/admin-shell/scripts/admin-shell-overrides.js b/app/admin-shell/scripts/admin-shell-overrides.js index 3de7fd013..afcc3ed55 100644 --- a/app/admin-shell/scripts/admin-shell-overrides.js +++ b/app/admin-shell/scripts/admin-shell-overrides.js @@ -85,7 +85,8 @@ module.exports = { 'petoberfest', 'channels', 'channel-collections', - 'channel-topic-map' + 'channel-topic-map', + 'channel-submissions' ], // @@ -171,7 +172,8 @@ module.exports = { petoberfest: 'pb', channels: 'ch', 'channel-collections': 'cc', - 'channel-topic-map': 'ctm' + 'channel-topic-map': 'ctm', + 'channel-submissions': 'csub' }, // diff --git a/app/admin-shell/webapp/controller/Shell.controller.js b/app/admin-shell/webapp/controller/Shell.controller.js index 318cf4e2f..d22d3fb5b 100644 --- a/app/admin-shell/webapp/controller/Shell.controller.js +++ b/app/admin-shell/webapp/controller/Shell.controller.js @@ -65,7 +65,8 @@ sap.ui.define([ topicClusters: "topicClusters", channels: "channels", channelCollections: "channelCollections", - channelTopicMap: "channelTopicMap" + channelTopicMap: "channelTopicMap", + channelSubmissions: "channelSubmissions" }; var NAV_KEY_TO_TITLE = { @@ -127,7 +128,8 @@ sap.ui.define([ topicClusters: "Topic Clusters", channels: "Channels", channelCollections: "Channel Collections", - channelTopicMap: "Channel Topic Map" + channelTopicMap: "Channel Topic Map", + channelSubmissions: "Channel Submissions" }; return Controller.extend("sap.tutorials.admin.shell.controller.Shell", { diff --git a/app/admin-shell/webapp/manifest.json b/app/admin-shell/webapp/manifest.json index b84e8f78a..02a9b84f6 100644 --- a/app/admin-shell/webapp/manifest.json +++ b/app/admin-shell/webapp/manifest.json @@ -98,7 +98,8 @@ "sap.tutorials.admin.petoberfest": "./components/petoberfest", "sap.tutorials.admin.channels": "./components/channels", "sap.tutorials.admin.channelCollections": "./components/channel-collections", - "sap.tutorials.admin.channelTopicMap": "./components/channel-topic-map" + "sap.tutorials.admin.channelTopicMap": "./components/channel-topic-map", + "sap.tutorials.admin.channelSubmissions": "./components/channel-submissions" }, "models": { "admin": { @@ -414,6 +415,12 @@ "settings": {}, "componentData": {}, "lazy": true + }, + "channelSubmissionsComponent": { + "name": "sap.tutorials.admin.channelSubmissions", + "settings": {}, + "componentData": {}, + "lazy": true } }, "services": { @@ -974,6 +981,16 @@ } ] }, + { + "name": "channelSubmissions", + "pattern": "channel-submissions", + "target": [ + { + "name": "channelSubmissionsTarget", + "prefix": "csub" + } + ] + }, { "name": "feedbackDashboard", "pattern": "feedback/dashboard", @@ -1352,6 +1369,13 @@ "viewLevel": 1, "prefix": "ctm" }, + "channelSubmissionsTarget": { + "type": "Component", + "usage": "channelSubmissionsComponent", + "id": "channelSubmissionsTarget", + "viewLevel": 1, + "prefix": "csub" + }, "feedbackDashboardTarget": { "viewName": "TutorialFeedbackDashboard", "viewLevel": 1 diff --git a/app/admin-shell/webapp/model/navigation.json b/app/admin-shell/webapp/model/navigation.json index 3dceaa83d..475ed49ca 100644 --- a/app/admin-shell/webapp/model/navigation.json +++ b/app/admin-shell/webapp/model/navigation.json @@ -25,6 +25,7 @@ { "key": "channels", "title": "Channels" }, { "key": "channelCollections", "title": "Channel Collections" }, { "key": "channelTopicMap", "title": "Channel Topic Map" }, + { "key": "channelSubmissions", "title": "Channel Submissions" }, { "key": "operations", "title": "Featured Tasks" } ] }, diff --git a/app/admin/channel-submissions/package.json b/app/admin/channel-submissions/package.json new file mode 100644 index 000000000..99f6526f2 --- /dev/null +++ b/app/admin/channel-submissions/package.json @@ -0,0 +1,15 @@ +{ + "name": "channel-submissions-admin", + "version": "0.0.1", + "private": true, + "description": "Channel Submissions - Admin Fiori Elements", + "sapux": true, + "scripts": { + "start": "fiori run --open index.html", + "build": "ui5 build --clean-dest" + }, + "devDependencies": { + "@sap/ux-specification": "latest", + "@ui5/cli": "^4.0.0" + } +} diff --git a/app/admin/channel-submissions/ui5.yaml b/app/admin/channel-submissions/ui5.yaml new file mode 100644 index 000000000..3b3302da3 --- /dev/null +++ b/app/admin/channel-submissions/ui5.yaml @@ -0,0 +1,12 @@ +specVersion: "4.0" +metadata: + name: sap.tutorials.admin.channelSubmissions +type: application +framework: + name: SAPUI5 + version: "1.136.0" + libraries: + - name: sap.m + - name: sap.ui.core + - name: sap.ushell + - name: sap.fe.templates diff --git a/app/admin/channel-submissions/webapp/Component.js b/app/admin/channel-submissions/webapp/Component.js new file mode 100644 index 000000000..2dfdac3b5 --- /dev/null +++ b/app/admin/channel-submissions/webapp/Component.js @@ -0,0 +1,4 @@ +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("sap.tutorials.admin.channelSubmissions.Component", { metadata: { manifest: "json" } }); +}); diff --git a/app/admin/channel-submissions/webapp/i18n/i18n.properties b/app/admin/channel-submissions/webapp/i18n/i18n.properties new file mode 100644 index 000000000..4373a8a8d --- /dev/null +++ b/app/admin/channel-submissions/webapp/i18n/i18n.properties @@ -0,0 +1,4 @@ +appTitle=Channel Submissions +appDescription=Moderation queue for community channel submissions +approve=Approve +reject=Reject diff --git a/app/admin/channel-submissions/webapp/manifest.json b/app/admin/channel-submissions/webapp/manifest.json new file mode 100644 index 000000000..f47fbe152 --- /dev/null +++ b/app/admin/channel-submissions/webapp/manifest.json @@ -0,0 +1,79 @@ +{ + "_version": "1.65.0", + "sap.app": { + "id": "sap.tutorials.admin.channelSubmissions", + "type": "application", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "applicationVersion": { "version": "0.0.1" }, + "i18n": "i18n/i18n.properties", + "dataSources": { + "mainService": { + "uri": "/admin/", + "type": "OData", + "settings": { "odataVersion": "4.0" } + } + }, + "crossNavigation": { + "inbounds": { + "ChannelSubmissions-manage": { + "semanticObject": "ChannelSubmissions", + "action": "manage", + "title": "{{appTitle}}", + "signature": { "parameters": {}, "additionalParameters": "allowed" } + } + } + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.136.0", + "libs": { "sap.fe.templates": {} } + }, + "models": { + "": { + "dataSource": "mainService", + "preload": true, + "settings": { + "synchronizationMode": "None", + "operationMode": "Server", + "autoExpandSelect": true, + "earlyRequests": true + } + }, + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { "bundleName": "sap.tutorials.admin.channelSubmissions.i18n.i18n" } + } + }, + "routing": { + "routes": [ + { "name": "ChannelSubmissionsList", "pattern": ":?query:", "target": "ChannelSubmissionsList" }, + { "name": "ChannelSubmissionsObject", "pattern": "ChannelSubmissions({key}):?query:", "target": "ChannelSubmissionsObject" } + ], + "targets": { + "ChannelSubmissionsList": { + "type": "Component", + "id": "ChannelSubmissionsList", + "name": "sap.fe.templates.ListReport", + "options": { + "settings": { + "contextPath": "/ChannelSubmissions", + "initialLoad": "Enabled" + } + } + }, + "ChannelSubmissionsObject": { + "type": "Component", + "id": "ChannelSubmissionsObject", + "name": "sap.fe.templates.ObjectPage", + "options": { + "settings": { + "contextPath": "/ChannelSubmissions" + } + } + } + } + } + } +} diff --git a/approuter/xs-app.json b/approuter/xs-app.json index 363e1ef07..52b08f0a7 100644 --- a/approuter/xs-app.json +++ b/approuter/xs-app.json @@ -371,6 +371,12 @@ "destination": "srv-api", "authenticationType": "xsuaa" }, + { + "source": "^/channel-submissions/(.*)$", + "target": "/channel-submissions/$1", + "destination": "srv-api", + "authenticationType": "xsuaa" + }, { "source": "^/api/(.*)$", "target": "/api/$1", diff --git a/db/channels.cds b/db/channels.cds index bba36de51..11955744d 100644 --- a/db/channels.cds +++ b/db/channels.cds @@ -69,3 +69,18 @@ entity ChannelTopicMap : cuid, managed { relevance : Integer default 50; // 0-100; orders the per-topic band, desc authoringStatus : AuthoringStatus default 'AI_SEEDED' @assert.range; } + +// --- P4: community submission loop (login-required proposals, admin-moderated) --- +type SubmissionKind : String enum { ADD; EDIT; REMOVE; } +type SubmissionStatus : String enum { PENDING; APPROVED; REJECTED; } + +entity ChannelSubmissions : cuid, managed { + kind : SubmissionKind @mandatory @assert.range; + targetChannel : Association to Channels; // null for ADD; required for EDIT/REMOVE (enforced in handler) + proposed : LargeString; // JSON of proposed curated fields (ADD/EDIT); ignored for REMOVE + rationale : String(1000); // submitter's free-text reason + submitterId : String(120); // set server-side from req.user.id + status : SubmissionStatus default 'PENDING' @assert.range; + reviewerId : String(120); // set on approve/reject from req.user.id + reviewNote : String(800); // moderator note +} diff --git a/db/last-dev/csn.json b/db/last-dev/csn.json index 60c1d07b7..4b895b9aa 100644 --- a/db/last-dev/csn.json +++ b/db/last-dev/csn.json @@ -3733,6 +3733,80 @@ }, "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_UIEVENT" }, + "com.sap.developers.ims.ChannelSubmissions": { + "kind": "entity", + "@cds.persistence.journal": true, + "elements": { + "ID": { + "key": true, + "type": "cds.String", + "length": 36, + "@cds.persistence.name": "ID" + }, + "createdAt": { + "type": "cds.Timestamp", + "@cds.persistence.name": "CREATEDAT" + }, + "createdBy": { + "type": "cds.String", + "length": 255, + "@cds.persistence.name": "CREATEDBY" + }, + "modifiedAt": { + "type": "cds.Timestamp", + "@cds.persistence.name": "MODIFIEDAT" + }, + "modifiedBy": { + "type": "cds.String", + "length": 255, + "@cds.persistence.name": "MODIFIEDBY" + }, + "kind": { + "type": "cds.String", + "length": 5000, + "@cds.persistence.name": "KIND" + }, + "targetChannel_ID": { + "type": "cds.String", + "length": 36, + "@odata.foreignKey4": "targetChannel", + "@cds.persistence.name": "TARGETCHANNEL_ID" + }, + "proposed": { + "type": "cds.LargeString", + "@cds.persistence.name": "PROPOSED" + }, + "rationale": { + "type": "cds.String", + "length": 1000, + "@cds.persistence.name": "RATIONALE" + }, + "submitterId": { + "type": "cds.String", + "length": 120, + "@cds.persistence.name": "SUBMITTERID" + }, + "status": { + "type": "cds.String", + "default": { + "val": "PENDING" + }, + "length": 5000, + "@cds.persistence.name": "STATUS" + }, + "reviewerId": { + "type": "cds.String", + "length": 120, + "@cds.persistence.name": "REVIEWERID" + }, + "reviewNote": { + "type": "cds.String", + "length": 800, + "@cds.persistence.name": "REVIEWNOTE" + } + }, + "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_CHANNELSUBMISSIONS" + }, "com.sap.developers.ims.CatGameAwards": { "kind": "entity", "doc": "\"Hit the Cat\" mini-game daily point ledger (issue #2042).\n\nOne row = one calendar day on which a signed-in player earned the daily\ncat-game award during a given Devtoberfest event. The award endpoint\n(POST /api/devtoberfest/cat-game/award) writes exactly one row per\n(user, event, day): 5 points per day, once per day, capped at 100 points\ntotal per event, and only while that event is active.\n\nThe natural (user, event, awardDate) tuple is the PRIMARY KEY — no surrogate\nID — so HANA enforces the once-per-day rule at the DB layer: a second insert\nfor the same day collides on the PK and the handler maps it to\n\"already-today\" (race-safe, unlike a CAP-only @assert.unique which this\nrepo's raw db.run() inserts bypass — see srv/lib/resolve-db-user.js).\n\n`createdAt` (from `managed`) is the actual award instant and is what the\ncross-container GAMEBOARD_BONUS_V1 view exposes as AWARD_DATE, so the\ngameboard's event-window filter always sees an in-window timestamp (the\nendpoint only ever inserts while the event is live).", diff --git a/db/persistence.cds b/db/persistence.cds index d2f6e13eb..bf4a0619a 100644 --- a/db/persistence.cds +++ b/db/persistence.cds @@ -55,3 +55,4 @@ annotate ims.Channels with @cds.persistence.journal; annotate ims.ChannelCollections with @cds.persistence.journal; annotate ims.ChannelCollectionItems with @cds.persistence.journal; annotate ims.ChannelTopicMap with @cds.persistence.journal; +annotate ims.ChannelSubmissions with @cds.persistence.journal; diff --git a/db/src/com.sap.developers.ims.ChannelSubmissions.hdbmigrationtable b/db/src/com.sap.developers.ims.ChannelSubmissions.hdbmigrationtable new file mode 100644 index 000000000..f21024d27 --- /dev/null +++ b/db/src/com.sap.developers.ims.ChannelSubmissions.hdbmigrationtable @@ -0,0 +1,17 @@ +== version=1 +COLUMN TABLE com_sap_developers_ims_ChannelSubmissions ( + ID NVARCHAR(36) NOT NULL, + createdAt TIMESTAMP, + createdBy NVARCHAR(255), + modifiedAt TIMESTAMP, + modifiedBy NVARCHAR(255), + kind NVARCHAR(5000), + targetChannel_ID NVARCHAR(36), + proposed NCLOB, + rationale NVARCHAR(1000), + submitterId NVARCHAR(120), + status NVARCHAR(5000) DEFAULT 'PENDING', + reviewerId NVARCHAR(120), + reviewNote NVARCHAR(800), + PRIMARY KEY(ID) +) diff --git a/docs/superpowers/plans/2026-09-04-external-channels-p4-submissions.md b/docs/superpowers/plans/2026-09-04-external-channels-p4-submissions.md new file mode 100644 index 000000000..3a67b984f --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-external-channels-p4-submissions.md @@ -0,0 +1,947 @@ +# External Channels P4 — Community Submission Loop 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:** Let logged-in developers propose channel additions/edits/removals through a login-required form, land them as `ChannelSubmissions` rows, and give admins a moderation queue whose approve/reject actions apply the change to `Channels` (or record a rejection) — submissions never mutate `Channels` directly. + +**Architecture:** A new `ChannelSubmissions` entity (kind ADD/EDIT/REMOVE, status PENDING/APPROVED/REJECTED, `proposed` JSON payload) persists proposals. A dedicated XSUAA-protected CAP service (`ChannelSubmissionService`, `@requires:'authenticated-user'`, `@insertonly`) accepts creates from a `csrfFetch` Vue island on the `/channels` page; a `before('CREATE')` handler stamps `submitterId`/`PENDING` server-side so the client cannot forge them. The admin moderation queue is an `AdminService.ChannelSubmissions` projection with bound `approve(note)`/`reject(note)` actions; the approve handler applies a whitelisted mutation to `Channels` (ADD → insert, EDIT → patch curated fields, REMOVE → `isPublished=false`) and both actions stamp `reviewerId`/`reviewNote`/status. A Fiori Elements admin app (cloned from P3's `channel-topic-map`) renders the queue. + +**Tech Stack:** SAP CAP (Node.js, CAP 10) + CDS + SAP HANA (SQLite in unit tests) · Fiori Elements (SAPUI5 1.136) inside the `admin-shell` ToolPage · Hugo + Vue 3 island (`hugo-apps`) with `csrfFetch` · vitest (`cds.test('serve', …, '--in-memory')`). + +**Spec:** `docs/superpowers/specs/2026-09-04-external-channels-integration-design.md` (§5.4, §12, §13, §15, §16) + +## Global Constraints + +- **CAP namespace** `com.sap.developers.ims` (alias `ims`); folder `tutorials-poc` = GitHub `sap-tutorials/tutorials-ims`. +- **PRs target `DEV`, never `main`.** Do NOT deploy — deploy is a separate step the maintainer controls. +- **Never hand-author or hand-edit `.hdbmigrationtable` files.** Regenerate ONLY via `cds build --production` (validated by `scripts/check-cds-build-staging.ts`). A new persisted entity produces NO HANA table unless annotated `@cds.persistence.journal` in `db/persistence.cds`. +- **Never write raw SQL** — use `cds.ql`/CQL. **Never use `req.user` without a `@requires` annotation** on the service. **Never store secrets in source** — use service bindings/env vars. +- **HANA stores columns UPPERCASE; unqualified entity names resolve on SQLite but FAIL on HANA.** In runtime handlers reference target entities through the resolved db model (`const { Channels, ChannelSubmissions } = db.entities('com.sap.developers.ims')`), never bare `SELECT.from('Channels')`. +- **`INSERT.into(dbEntity)` on `cds.db` does NOT auto-fill a UUID key** — set `ID: cds.utils.uuid()` explicitly (service-layer generic CREATE does fill `cuid` keys; only raw `db.run(INSERT…)` needs it). +- **Changes under `srv/lib/`** require re-walking transitive `./` imports and confirming each is in `.deploy/mta.yaml`'s `srv-qa` `cp` list (`scripts/check-srv-qa-cp-list.ts`). This plan adds no `srv/lib/` files, but new top-level `srv/*.js`/`*.cds` are loaded by CAP automatically — no cp-list entry needed for service roots. +- **A new authenticated CAP service needs an approuter route** (`approuter/xs-app.json`, `authenticationType:'xsuaa'`) placed BEFORE the catch-all, and may need gate-fixture updates (`scripts/check-public-endpoints.ts`, `scripts/check-srv-qa-route-drift.ts`). A new `csrfFetch` POST client must be registered for `scripts/check-csrf-clients.ts`. +- **Submissions never mutate `Channels` directly**; anon writes to the submit service MUST be rejected (401). The `proposed` payload is applied only through the admin approve action, and only for the whitelisted curated fields below. +- **Proposable curated fields** (the ONLY keys an ADD/EDIT may write to `Channels`): `name, url, purpose, ownerName, ownerType, category, subcategory, platform, tags, focusAreas, aliases, relatedUrls, notes, updateFrequency`. ADD also sets `sourceId` (generated `community-…`), `status:'Active'`, `isPublished:true`. REMOVE sets only `isPublished:false`. +- **Address the user as Tom.** Admin-UI changes ship only via a FULL `mbt build` (no `--skip-build`, no `-m` scoping) — relevant to deploy, not to this plan's tests. + +--- + +## File Structure + +- `db/channels.cds` — add `SubmissionKind`/`SubmissionStatus` types + `ChannelSubmissions` entity (alongside the other `Channel*` entities). +- `db/persistence.cds` — add `@cds.persistence.journal` annotation for `ChannelSubmissions`. +- `db/src/com.sap.developers.ims.ChannelSubmissions.hdbmigrationtable` — GENERATED by `cds build --production`; committed, never hand-edited. +- `srv/channel-submission-service.cds` + `.js` — login-required insert-only submit service. +- `srv/admin-service.cds` + `.js` — add `ChannelSubmissions` moderation projection + `approve`/`reject` actions and handlers. +- `approuter/xs-app.json` — add the `/channel-submissions/*` xsuaa route. +- `hugo-apps/src/channel-submit/` — new Vue island (submit form) + registration. +- `hugo/…/channels` page — mount point for the island (exact wiring from Task 5). +- `app/admin/channel-submissions/` (5 files) + `admin-shell` registration — moderation-queue FE app. +- `test/channel-submissions-model.test.js`, `test/channel-submission-service.test.js`, `test/admin-channel-submissions.test.js`, `test/admin-channel-submissions-app.test.js`, `test/e2e/channel-submit.e2e.test.js` — tests. + +--- + +### Task 1: `ChannelSubmissions` model + persistence journal + migration table + +**Files:** +- Modify: `db/channels.cds` (append after `ChannelTopicMap`, currently ends line 71) +- Modify: `db/persistence.cds` (journal block, currently lines 54-57) +- Create (GENERATED): `db/src/com.sap.developers.ims.ChannelSubmissions.hdbmigrationtable` +- Test: `test/channel-submissions-model.test.js` + +**Interfaces:** +- Produces: entity `com.sap.developers.ims.ChannelSubmissions` with elements `kind` (enum `ADD`/`EDIT`/`REMOVE`), `status` (enum `PENDING`/`APPROVED`/`REJECTED`, default `PENDING`), `targetChannel` (Association to `Channels`, nullable), `proposed` (LargeString JSON), `rationale` (String 1000), `submitterId` (String 120), `reviewerId` (String 120), `reviewNote` (String 800), plus `cuid`+`managed`. Later tasks read/write these exact names; the flattened FK column is `targetChannel_ID`. + +- [ ] **Step 1: Write the failing model test** + +Create `test/channel-submissions-model.test.js`: + +```js +import cds from '@sap/cds'; +const project = cds.test('serve', '--project', '.', '--in-memory'); +const NS = 'com.sap.developers.ims'; +const linked = () => cds.linked(cds.model).entities(NS); + +describe('ChannelSubmissions model', () => { + test('entity exists with cuid + managed + review fields', () => { + const e = linked().ChannelSubmissions; + expect(e).toBeTruthy(); + for (const c of ['ID', 'kind', 'status', 'proposed', 'rationale', + 'submitterId', 'reviewerId', 'reviewNote', + 'targetChannel_ID', 'createdAt', 'modifiedAt']) { + expect(e.elements[c]).toBeTruthy(); + } + }); + + test('kind enum is ADD/EDIT/REMOVE, status enum PENDING/APPROVED/REJECTED default PENDING', () => { + const e = linked().ChannelSubmissions; + expect(Object.keys(e.elements.kind.enum)).toEqual(['ADD', 'EDIT', 'REMOVE']); + expect(Object.keys(e.elements.status.enum)).toEqual(['PENDING', 'APPROVED', 'REJECTED']); + expect(e.elements.status.default.val).toBe('PENDING'); + }); + + test('targetChannel associates to Channels and is nullable', () => { + const e = linked().ChannelSubmissions; + expect(e.elements.targetChannel.target).toBe(`${NS}.Channels`); + expect(e.elements.targetChannel.notNull).not.toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/channel-submissions-model.test.js` +Expected: FAIL — `linked().ChannelSubmissions` is `undefined`. + +- [ ] **Step 3: Add types + entity to `db/channels.cds`** + +Append after line 71 (after the `ChannelTopicMap` entity): + +```cds +// --- P4: community submission loop (login-required proposals, admin-moderated) --- +type SubmissionKind : String enum { ADD; EDIT; REMOVE; } +type SubmissionStatus : String enum { PENDING; APPROVED; REJECTED; } + +entity ChannelSubmissions : cuid, managed { + kind : SubmissionKind @mandatory @assert.range; + targetChannel : Association to Channels; // null for ADD; required for EDIT/REMOVE (enforced in handler) + proposed : LargeString; // JSON of proposed curated fields (ADD/EDIT); ignored for REMOVE + rationale : String(1000); // submitter's free-text reason + submitterId : String(120); // set server-side from req.user.id + status : SubmissionStatus default 'PENDING' @assert.range; + reviewerId : String(120); // set on approve/reject from req.user.id + reviewNote : String(800); // moderator note +} +``` + +- [ ] **Step 4: Add the persistence journal annotation to `db/persistence.cds`** + +In the journal block (currently ends at line 57 with `annotate ims.ChannelTopicMap …`), add: + +```cds +annotate ims.ChannelSubmissions with @cds.persistence.journal; +``` + +- [ ] **Step 5: Run the model test to verify it passes** + +Run: `npx vitest run --project unit test/channel-submissions-model.test.js` +Expected: PASS. + +- [ ] **Step 6: Generate the migration table via production build** + +Run: `cds build --production` +Then verify staging is clean: `npx tsx scripts/check-cds-build-staging.ts` +Expected: exit 0; a new `db/src/com.sap.developers.ims.ChannelSubmissions.hdbmigrationtable` (version=1, `COLUMN TABLE`) appears. Do NOT hand-edit it. + +- [ ] **Step 7: Sanity-deploy the model to in-memory SQLite** + +Run: `npx cds deploy --to sqlite::memory:` +Expected: no compile/deploy error (proves the model is coherent end-to-end). + +- [ ] **Step 8: Commit** + +```bash +git add db/channels.cds db/persistence.cds db/src/com.sap.developers.ims.ChannelSubmissions.hdbmigrationtable db/last-dev/csn.json test/channel-submissions-model.test.js +git commit -m "feat(channels): P4 ChannelSubmissions model + persistence journal + migration table" +``` + +--- + +### Task 2: Login-required submit service (`ChannelSubmissionService`) + +**Files:** +- Create: `srv/channel-submission-service.cds` +- Create: `srv/channel-submission-service.js` +- Test: `test/channel-submission-service.test.js` + +**Interfaces:** +- Consumes: `ims.ChannelSubmissions` (Task 1). +- Produces: service at `@path:'/channel-submissions'`, `@requires:'authenticated-user'`, exposing `@insertonly entity Submissions as projection on ims.ChannelSubmissions`. On CREATE the handler forces `submitterId = req.user.id`, `status = 'PENDING'`, and nulls `reviewerId`/`reviewNote`. Anon CREATE → 401. Later: the island (Task 5) POSTs `{ kind, targetChannel_ID?, proposed, rationale }` to `/channel-submissions/Submissions`; the approuter route (Task 6) fronts it. + +- [ ] **Step 1: Write the failing service test** + +Create `test/channel-submission-service.test.js`: + +```js +import cds from '@sap/cds'; +const project = cds.test('serve', '--project', '.', '--in-memory'); +const authUser = { auth: { username: 'alice', password: 'alice' } }; + +describe('ChannelSubmissionService', () => { + test('anonymous CREATE is rejected 401', async () => { + await expect( + project.post('/channel-submissions/Submissions', { kind: 'ADD', proposed: '{}' }), + ).rejects.toMatchObject({ response: { status: 401 } }); + }); + + test('authenticated CREATE stamps submitterId + forces PENDING, ignores client-sent status/reviewer', async () => { + const { data } = await project.post( + '/channel-submissions/Submissions', + { kind: 'ADD', proposed: '{"name":"X","url":"https://x"}', rationale: 'please add', + status: 'APPROVED', submitterId: 'spoofed', reviewerId: 'spoofed', reviewNote: 'spoofed' }, + authUser, + ); + expect(data.status).toBe('PENDING'); + expect(data.submitterId).toBe('alice'); + expect(data.reviewerId).toBeNull(); + expect(data.reviewNote).toBeNull(); + }); + + test('service is insert-only — READ is not allowed', async () => { + await expect( + project.get('/channel-submissions/Submissions', authUser), + ).rejects.toMatchObject({ response: { status: expect.any(Number) } }); + }); +}); +``` + +> Note for implementer: the mock auth users (`alice`, `admin`) come from the project's `cds.requires.auth` mocked-users config used by existing tests (see `test/admin-channels.test.js`). If `alice` is not a configured mocked user, use whichever authenticated mocked user those tests use; the assertion is that `submitterId` equals the caller's `req.user.id`, not the literal `alice`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/channel-submission-service.test.js` +Expected: FAIL — route `/channel-submissions/Submissions` does not exist (404), so the 401 assertion fails. + +- [ ] **Step 3: Create the service definition** + +Create `srv/channel-submission-service.cds`: + +```cds +using { com.sap.developers.ims as ims } from '../db/channels'; + +@path : '/channel-submissions' +@requires: 'authenticated-user' +service ChannelSubmissionService { + + // Insert-only: logged-in developers propose changes; they cannot read others' proposals. + @insertonly + entity Submissions as projection on ims.ChannelSubmissions; +} +``` + +- [ ] **Step 4: Create the CREATE handler** + +Create `srv/channel-submission-service.js`: + +```js +const cds = require('@sap/cds'); + +module.exports = class ChannelSubmissionService extends cds.ApplicationService { + async init() { + // Stamp server-controlled fields; never trust client-sent status / reviewer / submitter. + this.before('CREATE', 'Submissions', (req) => { + req.data.submitterId = req.user.id; + req.data.status = 'PENDING'; + req.data.reviewerId = null; + req.data.reviewNote = null; + }); + await super.init(); + } +}; +``` + +- [ ] **Step 5: Run the service test to verify it passes** + +Run: `npx vitest run --project unit test/channel-submission-service.test.js` +Expected: PASS (all three cases). + +- [ ] **Step 6: Commit** + +```bash +git add srv/channel-submission-service.cds srv/channel-submission-service.js test/channel-submission-service.test.js +git commit -m "feat(channels): P4 login-required ChannelSubmissionService (insert-only, server-stamped)" +``` + +--- + +### Task 3: Admin moderation projection + `approve`/`reject` actions (definitions) + +**Files:** +- Modify: `srv/admin-service.cds` (add projection + actions near the other channel projections, ~lines 303-311) +- Test: extended in Task 4 (`test/admin-channel-submissions.test.js`) + +**Interfaces:** +- Consumes: `ims.ChannelSubmissions` (Task 1). +- Produces: `AdminService.ChannelSubmissions` projection with bound actions `approve(note: String(500))` and `reject(note: String(500))`. Service is already `@requires:'Admin'` (service level), so both actions inherit Admin auth. Handlers land in Task 4. FE annotations expose the two actions as `UI.DataFieldForAction` buttons. + +- [ ] **Step 1: Add the projection + actions to `srv/admin-service.cds`** + +Near the existing channel projections (`Channels`, `ChannelCollections`, `ChannelTopicMap`), add: + +```cds + @odata.draft.enabled: false + entity ChannelSubmissions as projection on ims.ChannelSubmissions + actions { + action approve(note : String(500)); + action reject (note : String(500)); + }; +``` + +- [ ] **Step 2: Add FE action annotations to `srv/admin-service.cds`** + +Follow the `content-moderation-service.cds` pattern (its `UI.DataFieldForAction` block at ~:77-81). Add, in the annotate section for this service (or inline via `annotate AdminService.ChannelSubmissions with @UI …`): + +```cds +annotate AdminService.ChannelSubmissions with @(UI: { + LineItem: [ + { $Type: 'UI.DataField', Value: kind }, + { $Type: 'UI.DataField', Value: status }, + { $Type: 'UI.DataField', Value: submitterId }, + { $Type: 'UI.DataField', Value: rationale }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.approve', Label: '{i18n>approve}' }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.reject', Label: '{i18n>reject}' } + ] +}); +``` + +> Implementer: match the exact annotation idiom already used for `ChannelTopicMap`/`Channels` in this file (inline `@(UI…)` vs separate `annotate` statement); keep whichever the surrounding projections use. The load-bearing requirement is that `approve` and `reject` appear as bound-action line items. + +- [ ] **Step 3: Verify the model compiles** + +Run: `npx cds compile srv/admin-service.cds > /dev/null` (or `npx cds deploy --to sqlite::memory:`) +Expected: no compile error; the two actions are bound to `AdminService.ChannelSubmissions`. + +- [ ] **Step 4: Commit** + +```bash +git add srv/admin-service.cds +git commit -m "feat(channels): P4 AdminService ChannelSubmissions projection + approve/reject actions" +``` + +--- + +### Task 4: Admin approve/reject handlers (apply to `Channels`) + anon-write test + +**Files:** +- Modify: `srv/admin-service.js` (add handlers in `init()`, following the PetSubmissions handler at ~:2602-2639 and the cross-entity mutation idiom in `srv/content-moderation-service.js:23-49`) +- Test: `test/admin-channel-submissions.test.js` + +**Interfaces:** +- Consumes: `AdminService.ChannelSubmissions` + actions (Task 3); `ims.Channels` curated fields (Task 1 / Global Constraints whitelist). +- Produces: `approve` applies the proposal to `Channels` (ADD → `INSERT` with generated `sourceId` + `ID`; EDIT → `UPDATE` whitelisted fields on `targetChannel_ID`; REMOVE → set `isPublished:false`) then marks the submission `APPROVED` with `reviewerId`/`reviewNote`. `reject` marks it `REJECTED` with `reviewerId`/`reviewNote` and touches no `Channels` row. Both reject non-PENDING submissions (400) and missing IDs (404). + +- [ ] **Step 1: Write the failing handler test** + +Create `test/admin-channel-submissions.test.js`: + +```js +import cds from '@sap/cds'; +const project = cds.test('serve', '--project', '.', '--in-memory'); +const NS = 'com.sap.developers.ims'; +const adminAuth = { auth: { username: 'admin', password: 'admin' } }; + +async function seedSubmission(row) { + const db = await cds.connect.to('db'); + const { ChannelSubmissions } = db.entities(NS); + const ID = cds.utils.uuid(); + await db.run(INSERT.into(ChannelSubmissions).entries({ ID, status: 'PENDING', ...row })); + return ID; +} +async function seedChannel(row) { + const db = await cds.connect.to('db'); + const { Channels } = db.entities(NS); + const ID = cds.utils.uuid(); + await db.run(INSERT.into(Channels).entries({ ID, sourceId: `seed-${ID.slice(0, 8)}`, name: 'orig', url: 'https://orig', isPublished: true, status: 'Active', ...row })); + return ID; +} + +describe('AdminService ChannelSubmissions moderation', () => { + test('anonymous access to the queue is rejected 401', async () => { + await expect(project.get('/admin/ChannelSubmissions')).rejects.toMatchObject({ response: { status: 401 } }); + }); + + test('approve ADD inserts a published Channel and marks submission APPROVED', async () => { + const id = await seedSubmission({ kind: 'ADD', proposed: JSON.stringify({ name: 'New Ch', url: 'https://new', ownerType: 'Community_Member', status: 'Archived', isPublished: false }) }); + await project.post(`/admin/ChannelSubmissions(${id})/AdminService.approve`, { note: 'ok' }, adminAuth); + const db = await cds.connect.to('db'); + const { Channels, ChannelSubmissions } = db.entities(NS); + const ch = await db.run(SELECT.one.from(Channels).where({ name: 'New Ch' })); + expect(ch).toBeTruthy(); + expect(ch.isPublished).toBe(true); // forced true, client "isPublished:false" ignored + expect(ch.status).toBe('Active'); // forced Active, client "status:Archived" ignored + expect(ch.sourceId).toMatch(/^community-/); + const sub = await db.run(SELECT.one.from(ChannelSubmissions).where({ ID: id })); + expect(sub.status).toBe('APPROVED'); + expect(sub.reviewNote).toBe('ok'); + }); + + test('approve EDIT patches only whitelisted fields on the target channel', async () => { + const chId = await seedChannel({ name: 'orig', purpose: 'old' }); + const id = await seedSubmission({ kind: 'EDIT', targetChannel_ID: chId, proposed: JSON.stringify({ purpose: 'new purpose', contentHash: 'HACK', sourceId: 'HACK' }) }); + await project.post(`/admin/ChannelSubmissions(${id})/AdminService.approve`, { note: '' }, adminAuth); + const db = await cds.connect.to('db'); + const { Channels } = db.entities(NS); + const ch = await db.run(SELECT.one.from(Channels).where({ ID: chId })); + expect(ch.purpose).toBe('new purpose'); + expect(ch.contentHash).not.toBe('HACK'); // non-whitelisted key ignored + expect(ch.sourceId).not.toBe('HACK'); + }); + + test('approve REMOVE unpublishes the target channel', async () => { + const chId = await seedChannel({ name: 'togo' }); + const id = await seedSubmission({ kind: 'REMOVE', targetChannel_ID: chId }); + await project.post(`/admin/ChannelSubmissions(${id})/AdminService.approve`, { note: 'spam' }, adminAuth); + const db = await cds.connect.to('db'); + const { Channels } = db.entities(NS); + const ch = await db.run(SELECT.one.from(Channels).where({ ID: chId })); + expect(ch.isPublished).toBe(false); + }); + + test('reject records REJECTED + note without touching Channels', async () => { + const id = await seedSubmission({ kind: 'ADD', proposed: '{"name":"nope","url":"https://nope"}' }); + await project.post(`/admin/ChannelSubmissions(${id})/AdminService.reject`, { note: 'off-topic' }, adminAuth); + const db = await cds.connect.to('db'); + const { Channels, ChannelSubmissions } = db.entities(NS); + const sub = await db.run(SELECT.one.from(ChannelSubmissions).where({ ID: id })); + expect(sub.status).toBe('REJECTED'); + expect(sub.reviewNote).toBe('off-topic'); + const ch = await db.run(SELECT.one.from(Channels).where({ name: 'nope' })); + expect(ch).toBeUndefined(); + }); + + test('approving an already-reviewed submission is rejected 400', async () => { + const id = await seedSubmission({ kind: 'REMOVE', targetChannel_ID: await seedChannel({ name: 'x' }), status: 'APPROVED' }); + await expect( + project.post(`/admin/ChannelSubmissions(${id})/AdminService.approve`, { note: '' }, adminAuth), + ).rejects.toMatchObject({ response: { status: 400 } }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/admin-channel-submissions.test.js` +Expected: FAIL — actions have no handler, so `approve`/`reject` do nothing and assertions fail (or the action is unhandled). + +- [ ] **Step 3: Implement the handlers in `srv/admin-service.js`** + +Inside the service `init()` (matching the file's existing `this.on('approve', , …)` idiom used for PetSubmissions), add: + +```js + const PROPOSABLE = [ + 'name', 'url', 'purpose', 'ownerName', 'ownerType', 'category', + 'subcategory', 'platform', 'tags', 'focusAreas', 'aliases', + 'relatedUrls', 'notes', 'updateFrequency', + ]; + const pickProposed = (json) => { + let obj = {}; + try { obj = json ? JSON.parse(json) : {}; } catch { obj = {}; } + return Object.fromEntries(Object.entries(obj).filter(([k]) => PROPOSABLE.includes(k))); + }; + + this.on('approve', 'ChannelSubmissions', async (req) => { + const id = req.params?.[0]?.ID ?? req.params?.[0]; + const db = await cds.connect.to('db'); + const { Channels, ChannelSubmissions } = db.entities('com.sap.developers.ims'); + const sub = await db.run(SELECT.one.from(ChannelSubmissions).where({ ID: id })); + if (!sub) return req.reject(404, 'Submission not found'); + if (sub.status !== 'PENDING') return req.reject(400, 'Submission already reviewed'); + + const clean = pickProposed(sub.proposed); + if (sub.kind === 'ADD') { + await db.run(INSERT.into(Channels).entries({ + ID: cds.utils.uuid(), + sourceId: `community-${cds.utils.uuid().slice(0, 8)}`, + status: 'Active', + isPublished: true, + ...clean, + })); + } else if (sub.kind === 'EDIT') { + if (!sub.targetChannel_ID) return req.reject(400, 'EDIT requires a target channel'); + if (Object.keys(clean).length) { + await db.run(UPDATE(Channels).set(clean).where({ ID: sub.targetChannel_ID })); + } + } else if (sub.kind === 'REMOVE') { + if (!sub.targetChannel_ID) return req.reject(400, 'REMOVE requires a target channel'); + await db.run(UPDATE(Channels).set({ isPublished: false }).where({ ID: sub.targetChannel_ID })); + } + + await db.run(UPDATE(ChannelSubmissions).set({ + status: 'APPROVED', reviewerId: req.user.id, reviewNote: req.data.note, + }).where({ ID: id })); + return req.reply(); + }); + + this.on('reject', 'ChannelSubmissions', async (req) => { + const id = req.params?.[0]?.ID ?? req.params?.[0]; + const db = await cds.connect.to('db'); + const { ChannelSubmissions } = db.entities('com.sap.developers.ims'); + const sub = await db.run(SELECT.one.from(ChannelSubmissions).where({ ID: id })); + if (!sub) return req.reject(404, 'Submission not found'); + if (sub.status !== 'PENDING') return req.reject(400, 'Submission already reviewed'); + await db.run(UPDATE(ChannelSubmissions).set({ + status: 'REJECTED', reviewerId: req.user.id, reviewNote: req.data.note, + }).where({ ID: id })); + return req.reply(); + }); +``` + +> Implementer: place these next to the other `this.on('approve', …)` handlers in the file. Use the file's already-imported `cds`; do not add new `require`s. `SELECT`/`INSERT`/`UPDATE` are CQL globals already used throughout this file. + +- [ ] **Step 4: Run the handler test to verify it passes** + +Run: `npx vitest run --project unit test/admin-channel-submissions.test.js` +Expected: PASS (all cases). + +- [ ] **Step 5: Commit** + +```bash +git add srv/admin-service.js test/admin-channel-submissions.test.js +git commit -m "feat(channels): P4 admin approve/reject applies proposals to Channels (whitelisted)" +``` + +--- + +### Task 5: Approuter route for the submit service + +**Files:** +- Modify: `approuter/xs-app.json` (add an xsuaa route in the `srv-api` API block — model on the petoberfest upload route at ~:345-373, place it right after the petoberfest block and BEFORE the `^/api/(.*)$` catch-all and the Hugo-page/static catch-alls) +- Test: `test/channel-submit-approuter.test.js` + +**Interfaces:** +- Consumes: `ChannelSubmissionService` at `/channel-submissions` (Task 2). +- Produces: an approuter route `^/channel-submissions/(.*)$` → `srv-api`, `authenticationType:'xsuaa'`, **no `csrfProtection` key** (AppRouter default CSRF-on is required by `check-csrf-clients.ts`). This lets the island (Task 6) reach the service through the router. + +**Ruling — gate scope (from recon):** an authenticated-user CAP service needs NO `check-public-endpoints.ts` route (it is not fully-public, so it is excluded — and must NOT get an `authenticationType:'none'` route), NO `check-srv-qa-route-drift.ts` change (that gate only covers `/content/*` Express routes), and NO `check-xs-app-mta.ts` change (reuses existing `srv-api` destination; `authenticated-user` is not a scope). The only invariant is: reuse `destination:'srv-api'`, `authenticationType:'xsuaa'`, no `csrfProtection`. + +- [ ] **Step 1: Write the failing route test** + +Create `test/channel-submit-approuter.test.js`: + +```js +import fs from 'node:fs'; +import path from 'node:path'; + +const xsApp = JSON.parse(fs.readFileSync(path.resolve('approuter/xs-app.json'), 'utf8')); + +describe('channel-submissions approuter route', () => { + const route = xsApp.routes.find((r) => r.source === '^/channel-submissions/(.*)$'); + + test('route exists, targets srv-api under xsuaa', () => { + expect(route).toBeTruthy(); + expect(route.destination).toBe('srv-api'); + expect(route.authenticationType).toBe('xsuaa'); + }); + + test('route does not set csrfProtection (AppRouter default CSRF-on required)', () => { + expect(route).not.toHaveProperty('csrfProtection'); + }); + + test('route sits before the /api catch-all and the final static catch-all', () => { + const idx = xsApp.routes.indexOf(route); + const apiCatchAll = xsApp.routes.findIndex((r) => r.source === '^/api/(.*)$'); + const staticCatchAll = xsApp.routes.findIndex((r) => r.source === '^(.*)$'); + if (apiCatchAll !== -1) expect(idx).toBeLessThan(apiCatchAll); + expect(idx).toBeLessThan(staticCatchAll); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/channel-submit-approuter.test.js` +Expected: FAIL — route not found (`route` is undefined). + +- [ ] **Step 3: Add the route to `approuter/xs-app.json`** + +Insert immediately after the last petoberfest route block (the `^/petoberfest-api/(.*)$` xsuaa route at ~:369-373), before `^/api/(.*)$`: + +```json + { + "source": "^/channel-submissions/(.*)$", + "target": "/channel-submissions/$1", + "destination": "srv-api", + "authenticationType": "xsuaa" + }, +``` + +- [ ] **Step 4: Run the route test to verify it passes** + +Run: `npx vitest run --project unit test/channel-submit-approuter.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add approuter/xs-app.json test/channel-submit-approuter.test.js +git commit -m "feat(channels): P4 approuter xsuaa route for /channel-submissions" +``` + +--- + +### Task 6: Submit-form island (`channel-submit`) + +**Files:** +- Create: `hugo-apps/src/channel-submit/lib/submit.ts` (auth probe + POST helper — the testable core) +- Create: `hugo-apps/src/channel-submit/App.vue` (the form UI) +- Create: `hugo-apps/src/channel-submit/main.ts` (mount entry) +- Modify: `hugo-apps/vite.config.ts` (add input entry, ~:316 near `channels-directory`) +- Modify: `hugo/layouts/channels/list.html` (add mount `
` + island-src ` + + +``` + +- [ ] **Step 6: Create the mount entry `hugo-apps/src/channel-submit/main.ts`** + +```ts +import { createApp } from 'vue'; +import App from './App.vue'; + +function boot() { + document.querySelectorAll('[data-island="channel-submit"]').forEach((el) => { + createApp(App).mount(el); + }); +} +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); +else boot(); +``` + +- [ ] **Step 7: Register the vite entry** + +In `hugo-apps/vite.config.ts` `build.rollupOptions.input`, next to `'channels-directory'` (~:316), add: + +```ts + 'channel-submit': resolve(__dirname, 'src/channel-submit/main.ts'), +``` + +- [ ] **Step 8: Add the mount point + script to `hugo/layouts/channels/list.html`** + +Inside the `
`, after the existing `
` block, add: + +```html +
+``` + +And after the existing `channels-directory` module script at the bottom, add: + +```html + +``` + +- [ ] **Step 9: Verify the csrf-clients gate is satisfied** + +Run: `npx tsx scripts/check-csrf-clients.ts` +Expected: exit 0 (the new POST client imports `csrfFetch`, so it passes; the authenticated route needs no allowlist entry). + +- [ ] **Step 10: Commit** + +```bash +git add hugo-apps/src/channel-submit hugo-apps/vite.config.ts hugo/layouts/channels/list.html +git commit -m "feat(channels): P4 login-gated channel-submit island (csrfFetch + auth probe)" +``` + +--- + +### Task 7: Admin moderation-queue FE app + shell registration + +**Files:** +- Create: `app/admin/channel-submissions/` (5 files — clone P3's `app/admin/channel-topic-map/`) + - `package.json` (name `channel-submissions-admin`) + - `ui5.yaml` (`sap.tutorials.admin.channelSubmissions`, SAPUI5 1.136.0) + - `webapp/Component.js` + - `webapp/i18n/i18n.properties` + - `webapp/manifest.json` (`dataSources.mainService.uri:"/admin/"`, ListReport+ObjectPage, `contextPath:/ChannelSubmissions`) +- Modify (shell registration): + - `app/admin-shell/scripts/admin-shell-overrides.js` (folder list ~:88; prefix map ~:174 — add `'channel-submissions':'csub'`) + - `app/admin-shell/webapp/manifest.json` (resourceRoots ~:101; componentUsages ~:412-417; routes ~:967-976; targets ~:1348-1354) + - `app/admin-shell/webapp/controller/Shell.controller.js` (`NAV_KEY_TO_ROUTE` ~:68; `NAV_KEY_TO_TITLE` ~:130) + - `app/admin-shell/webapp/model/navigation.json` (~:27 — add nav entry) +- Test: `test/admin-channel-submissions-app.test.js` (clone `test/admin-channel-topic-map-app.test.js`) + +**Interfaces:** +- Consumes: `AdminService.ChannelSubmissions` (Tasks 3-4) at OData path `/admin/`. +- Produces: a headless `componentUsage` `sap.tutorials.admin.channelSubmissions` mounted in the `admin-shell` ToolPage under nav key `channel-submissions`, prefix `csub`. `copy-components.js` auto-mirrors `app/admin/channel-submissions/webapp` → dist at build; `discover-admin-components.js` auto-discovers — do NOT hand-create dist dirs. + +**Ruling — prefix choice:** P3 recon flagged `cs`/`csu`/`csb` as apparently free but noted the full map must be verified. Use **`csub`** for `channel-submissions` (distinct from P2's `channel-collections` prefix and from any `cs*` already taken). The implementer MUST grep the full prefix map in `admin-shell-overrides.js` first and pick a genuinely unused 3-4 char prefix if `csub` collides; record the chosen prefix in the report. + +- [ ] **Step 1: Write the failing app-registration test** + +Clone `test/admin-channel-topic-map-app.test.js` → `test/admin-channel-submissions-app.test.js`, retargeting every `channel-topic-map`/`channelTopicMap`/`ctm`/`ChannelTopicMap` token to the submissions equivalents (`channel-submissions`/`channelSubmissions`/``/`ChannelSubmissions`). It asserts: (a) the FE app's `manifest.json` `contextPath` is `/ChannelSubmissions` and `dataSources.mainService.uri` is `/admin/`; (b) `admin-shell` `manifest.json` has the componentUsage, route, and target; (c) `navigation.json` + `Shell.controller.js` maps contain the nav key. Reproduce the sibling test's exact assertion shapes. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/admin-channel-submissions-app.test.js` +Expected: FAIL — the app dir and shell registrations don't exist yet. + +- [ ] **Step 3: Clone the FE app** + +Copy `app/admin/channel-topic-map/` → `app/admin/channel-submissions/`. In every copied file replace: `channel-topic-map`→`channel-submissions`, `channelTopicMap`→`channelSubmissions`, `ChannelTopicMap`→`ChannelSubmissions`, `channel-topic-map-admin`→`channel-submissions-admin`. In `webapp/manifest.json` set `contextPath` to `/ChannelSubmissions` and keep `dataSources.mainService.uri` `"/admin/"`. Set the ListReport columns to `kind`, `status`, `submitterId`, `rationale` and expose the `approve`/`reject` actions (they surface from the OData metadata via the Task 3 annotations). + +- [ ] **Step 4: Register in the shell** + +Apply the same edits P3 made for `channel-topic-map`, using nav key `channel-submissions`, prefix ``, title e.g. `Channel Submissions`, across: `admin-shell-overrides.js` (folder list + prefix map), `admin-shell/webapp/manifest.json` (resourceRoots, componentUsages, routes, targets), `Shell.controller.js` (`NAV_KEY_TO_ROUTE`, `NAV_KEY_TO_TITLE`), `navigation.json`. + +- [ ] **Step 5: Run the app-registration test to verify it passes** + +Run: `npx vitest run --project unit test/admin-channel-submissions-app.test.js` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add app/admin/channel-submissions app/admin-shell test/admin-channel-submissions-app.test.js +git commit -m "feat(channels): P4 admin moderation-queue FE app + shell registration" +``` + +--- + +### Task 8: e2e nudge + whole-suite gate + +**Files:** +- Create: `test/e2e/channel-submit.e2e.test.js` (tolerant post-deploy nudge; self-skips without `SMOKE_BASE_URL`) + +**Interfaces:** +- Consumes: `test/e2e/e2e.config.js` (`BASE_URL`, `hasBaseUrl()`), `test/e2e/_browser.js` (`launchBrowser`, `newPage({ authenticated })`). +- Produces: a `describe.skipIf(!hasBaseUrl())` spec that loads `/channels` and asserts the submit island mount is present, and (when authenticated creds exist) that the form renders. No hard assertions that would flake on sparse data. + +- [ ] **Step 1: Write the e2e nudge** + +Create `test/e2e/channel-submit.e2e.test.js` (mirror an existing e2e spec's header — e.g. `test/e2e/README.md` reference and a sibling `*.e2e.test.js`): + +```js +import { describe, test, expect, beforeAll, afterAll } from 'vitest'; +import { hasBaseUrl, BASE_URL } from './e2e.config.js'; +import { launchBrowser, newPage } from './_browser.js'; + +describe.skipIf(!hasBaseUrl())('channel submit form (post-deploy)', () => { + let browser; + beforeAll(async () => { browser = await launchBrowser(); }); + afterAll(async () => { await browser?.close(); }); + + test('/channels renders the submit island mount', async () => { + const page = await newPage({ browser, authenticated: false }); + await page.goto(`${BASE_URL}/channels/`, { waitUntil: 'domcontentloaded' }); + const mount = await page.$('[data-island="channel-submit"]'); + expect(mount).not.toBeNull(); + await page.close(); + }); +}); +``` + +> Implementer: match the exact `newPage`/`launchBrowser` signatures in `test/e2e/_browser.js` (the call shape above is indicative). Keep the spec tolerant — it is a nudge, not a gate. + +- [ ] **Step 2: Confirm it self-skips locally** + +Run: `npx vitest run --project e2e test/e2e/channel-submit.e2e.test.js` +Expected: SKIPPED (no `SMOKE_BASE_URL` set) — 0 failures. + +- [ ] **Step 3: Run the CDS + gate checks** + +```bash +npx tsx scripts/check-cds-build-staging.ts # 0 — migration table committed, no drift +npx tsx scripts/check-srv-qa-cp-list.ts # 0 — no new srv/lib deps +npx tsx scripts/check-public-endpoints.ts # 0 — authenticated service needs no anon route +npx tsx scripts/check-csrf-clients.ts # 0 — island imports csrfFetch +``` +Expected: all exit 0. + +- [ ] **Step 4: Run the full unit suite** + +Run: `npm test` +Expected: all P4 tests green; no NEW failures vs the branch's pre-P4 baseline. (Pre-existing `hugo-apps/selfie` missing-optional-dep failures are unrelated — confirm the failing-file set is unchanged from before P4.) + +- [ ] **Step 5: Commit** + +```bash +git add test/e2e/channel-submit.e2e.test.js +git commit -m "test(channels): P4 e2e submit-form nudge (self-skips without SMOKE_BASE_URL)" +``` + +--- + +## Self-Review + +**1. Spec coverage** (§5.4, §12, §13, §15, §16): +- §5.4 `ChannelSubmissions` entity (kind/status/targetChannel/proposed/rationale/submitterId/reviewerId/reviewNote) → Task 1. ✅ +- §12 login-required submit form → Task 2 (service) + Task 5 (route) + Task 6 (island, auth-gated). ✅ +- §13 admin moderation queue applying approve/reject to `Channels` (ADD/EDIT/REMOVE) → Tasks 3, 4, 7. ✅ +- §15/§16 (persistence/migration, deploy-safety, phase boundary) → Task 1 journal+migration; gates in Tasks 5/8; no `srv/lib` changes. ✅ + +**2. Placeholder scan:** no TBD/TODO/"handle edge cases"/"similar to Task N" — every code step carries real code. The only judgment deferred to the implementer is the FE-app prefix (ruled: `csub`, verify-and-adjust) and matching the exact `newPage`/annotation idiom of sibling files, both with explicit rulings. ✅ + +**3. Type consistency:** `ChannelProposal` shape (`kind`, `targetChannel_ID?`, `proposed` string, `rationale`) is identical in Task 6's lib, test, and App.vue; the POST target `/channel-submissions/Submissions` matches the service `@path` (Task 2) + route (Task 5). The PROPOSABLE whitelist is identical in the plan constraints and Task 4. Enum member lists (`ADD`/`EDIT`/`REMOVE`, `PENDING`/`APPROVED`/`REJECTED`) match across Tasks 1, 4, 6. `db.entities('com.sap.developers.ims')` destructuring is used consistently in the handler and tests. ✅ + +No gaps found. diff --git a/hugo-apps/src/channel-submit/App.vue b/hugo-apps/src/channel-submit/App.vue new file mode 100644 index 000000000..6578b5c8d --- /dev/null +++ b/hugo-apps/src/channel-submit/App.vue @@ -0,0 +1,71 @@ + + + diff --git a/hugo-apps/src/channel-submit/lib/submit.test.ts b/hugo-apps/src/channel-submit/lib/submit.test.ts new file mode 100644 index 000000000..8cc6b2b7b --- /dev/null +++ b/hugo-apps/src/channel-submit/lib/submit.test.ts @@ -0,0 +1,48 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@shared/csrf-fetch', () => ({ + csrfFetch: vi.fn(), + CsrfFetchError: class extends Error {}, +})); +import { csrfFetch } from '@shared/csrf-fetch'; +import { probeAuth, submitChannelProposal } from './submit'; + +beforeEach(() => vi.resetAllMocks()); + +describe('probeAuth', () => { + test('true only when JSON body.authenticated is truthy', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ authenticated: true }), + }) as unknown as typeof fetch; + expect(await probeAuth()).toBe(true); + }); + + test('false for a 200 HTML login page (anon)', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'text/html' }, + json: async () => { throw new Error('not json'); }, + }) as unknown as typeof fetch; + expect(await probeAuth()).toBe(false); + }); +}); + +describe('submitChannelProposal', () => { + test('POSTs the payload to the submissions route via csrfFetch', async () => { + (csrfFetch as unknown as ReturnType).mockResolvedValue({ ok: true, status: 201 }); + await submitChannelProposal({ kind: 'ADD', proposed: '{"name":"X","url":"https://x"}', rationale: 'add it' }); + expect(csrfFetch).toHaveBeenCalledTimes(1); + const [url, opts] = (csrfFetch as unknown as ReturnType).mock.calls[0]; + expect(url).toBe('/channel-submissions/Submissions'); + expect(opts.method).toBe('POST'); + expect(JSON.parse(opts.body)).toMatchObject({ kind: 'ADD', rationale: 'add it' }); + }); + + test('throws on a non-2xx response', async () => { + (csrfFetch as unknown as ReturnType).mockResolvedValue({ ok: false, status: 401 }); + await expect(submitChannelProposal({ kind: 'REMOVE', targetChannel_ID: 'c1', proposed: '', rationale: '' })) + .rejects.toThrow(); + }); +}); diff --git a/hugo-apps/src/channel-submit/lib/submit.ts b/hugo-apps/src/channel-submit/lib/submit.ts new file mode 100644 index 000000000..0c99217eb --- /dev/null +++ b/hugo-apps/src/channel-submit/lib/submit.ts @@ -0,0 +1,31 @@ +import { csrfFetch } from '@shared/csrf-fetch'; + +export type ChannelProposal = { + kind: 'ADD' | 'EDIT' | 'REMOVE'; + targetChannel_ID?: string; + proposed: string; // JSON string of curated fields + rationale: string; +}; + +// Do NOT trust res.ok: approuter returns 200 + HTML login page for anon on the xsuaa /auth/user route. +export async function probeAuth(): Promise { + try { + const res = await fetch('/auth/user', { credentials: 'include' }); + if (!res.ok) return false; + if (!/application\/json/i.test(res.headers.get('content-type') || '')) return false; + const body = await res.json(); + return Boolean(body && body.authenticated); + } catch { + return false; + } +} + +export async function submitChannelProposal(payload: ChannelProposal): Promise { + const res = await csrfFetch('/channel-submissions/Submissions', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(`Submission failed: ${res.status}`); +} diff --git a/hugo-apps/src/channel-submit/main.ts b/hugo-apps/src/channel-submit/main.ts new file mode 100644 index 000000000..ddaa1e7f2 --- /dev/null +++ b/hugo-apps/src/channel-submit/main.ts @@ -0,0 +1,10 @@ +import { createApp } from 'vue'; +import App from './App.vue'; + +function boot() { + document.querySelectorAll('[data-island="channel-submit"]').forEach((el) => { + createApp(App).mount(el); + }); +} +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); +else boot(); diff --git a/hugo-apps/vite.config.ts b/hugo-apps/vite.config.ts index e89b33628..068bb6650 100644 --- a/hugo-apps/vite.config.ts +++ b/hugo-apps/vite.config.ts @@ -314,6 +314,7 @@ export default defineConfig({ 'ui5-me': resolve(__dirname, 'src/ui5/ui5-me.ts'), 'ui5-illustrations': resolve(__dirname, 'src/ui5/ui5-illustrations.ts'), 'channels-directory': resolve(__dirname, 'src/channels-directory/index.ts'), + 'channel-submit': resolve(__dirname, 'src/channel-submit/main.ts'), }, output: { // Content-hash entry bundles so a changed bundle gets a new URL the diff --git a/hugo/layouts/channels/list.html b/hugo/layouts/channels/list.html index 19f0dbed6..7597ce95d 100644 --- a/hugo/layouts/channels/list.html +++ b/hugo/layouts/channels/list.html @@ -17,6 +17,8 @@

{{ .Title }}

{{- end }} +
+ {{ end }} diff --git a/srv/admin-service.cds b/srv/admin-service.cds index 1ff1cd9b9..41d5f134a 100644 --- a/srv/admin-service.cds +++ b/srv/admin-service.cds @@ -310,6 +310,19 @@ service AdminService { @odata.draft.enabled entity ChannelTopicMap as projection on ims.ChannelTopicMap; + // P4: community submission moderation queue. + // AdminService is already @requires:'Admin' at service level — the projection + // and its bound actions inherit that gate. No per-action @requires needed: + // any Admin-scoped user may approve or reject submissions. + // @odata.draft.enabled: false — submissions are immediate-save (no draft round-trip). + // Handlers for approve/reject land in Task 4. + @odata.draft.enabled: false + entity ChannelSubmissions as projection on ims.ChannelSubmissions + actions { + action approve(note : String(500)); + action reject (note : String(500)); + }; + @cds.redirection.target: true @Capabilities.ChangeTracking : { Supported: true } @odata.draft.enabled @@ -1431,3 +1444,17 @@ extend entity AdminService.FreshnessFinding with actions { @(requires: 'Tutorial.Author') action setDisposition(disposition: String, note: String) returns { status: String }; }; + +// ── P4: ChannelSubmissions moderation queue FE annotations ────────────────── +// Action FQN form follows ContentModerationService.NewsItems (lines 77-81 of +// content-moderation-service.cds): ServiceName.EntityName/actionName. +annotate AdminService.ChannelSubmissions with @(UI: { + LineItem: [ + { $Type: 'UI.DataField', Value: kind }, + { $Type: 'UI.DataField', Value: status }, + { $Type: 'UI.DataField', Value: submitterId }, + { $Type: 'UI.DataField', Value: rationale }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.ChannelSubmissions/approve', Label: '{i18n>approve}' }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.ChannelSubmissions/reject', Label: '{i18n>reject}' } + ] +}); diff --git a/srv/admin-service.js b/srv/admin-service.js index b2ecdeb66..a9d72c7aa 100644 --- a/srv/admin-service.js +++ b/srv/admin-service.js @@ -2638,6 +2638,70 @@ export default class AdminService extends cds.ApplicationService { return req.reply(); }); + // P4: ChannelSubmissions moderation — approve / reject bound actions. + // AdminService is @requires:'Admin' at service level; no per-action gate needed. + // approve: applies the whitelisted proposal fields to Channels (ADD→INSERT, + // EDIT→UPDATE, REMOVE→isPublished:false) then stamps the submission APPROVED. + // reject: stamps REJECTED only; never touches Channels. + // Both guard: non-PENDING → 400, unknown ID → 404. + // PROPOSABLE whitelist: only these fields may be written from the proposed JSON. + const CHANNEL_PROPOSABLE = [ + 'name', 'url', 'purpose', 'ownerName', 'ownerType', 'category', + 'subcategory', 'platform', 'tags', 'focusAreas', 'aliases', + 'relatedUrls', 'notes', 'updateFrequency', + ]; + const pickProposed = (json) => { + let obj = {}; + try { obj = json ? JSON.parse(json) : {}; } catch { obj = {}; } + return Object.fromEntries(Object.entries(obj).filter(([k]) => CHANNEL_PROPOSABLE.includes(k))); + }; + + this.on('approve', 'ChannelSubmissions', async (req) => { + const id = req.params?.[0]?.ID ?? req.params?.[0]; + if (!id) return req.reject(400, 'approve: missing entity key'); + const { Channels, ChannelSubmissions } = cds.entities('com.sap.developers.ims'); + const sub = await SELECT.one.from(ChannelSubmissions).where({ ID: id }); + if (!sub) return req.reject(404, 'Submission not found'); + if (sub.status !== 'PENDING') return req.reject(400, 'Submission already reviewed'); + + const clean = pickProposed(sub.proposed); + if (sub.kind === 'ADD') { + await INSERT.into(Channels).entries({ + ID: cds.utils.uuid(), + sourceId: `community-${cds.utils.uuid().slice(0, 8)}`, + ...clean, + status: 'Active', + isPublished: true, + }); + } else if (sub.kind === 'EDIT') { + if (!sub.targetChannel_ID) return req.reject(400, 'EDIT requires a target channel'); + if (Object.keys(clean).length) { + await UPDATE(Channels).set(clean).where({ ID: sub.targetChannel_ID }); + } + } else if (sub.kind === 'REMOVE') { + if (!sub.targetChannel_ID) return req.reject(400, 'REMOVE requires a target channel'); + await UPDATE(Channels).set({ isPublished: false }).where({ ID: sub.targetChannel_ID }); + } + + await UPDATE(ChannelSubmissions).set({ + status: 'APPROVED', reviewerId: req.user.id, reviewNote: req.data.note, + }).where({ ID: id }); + return req.reply(); + }); + + this.on('reject', 'ChannelSubmissions', async (req) => { + const id = req.params?.[0]?.ID ?? req.params?.[0]; + if (!id) return req.reject(400, 'reject: missing entity key'); + const { ChannelSubmissions } = cds.entities('com.sap.developers.ims'); + const sub = await SELECT.one.from(ChannelSubmissions).where({ ID: id }); + if (!sub) return req.reject(404, 'Submission not found'); + if (sub.status !== 'PENDING') return req.reject(400, 'Submission already reviewed'); + await UPDATE(ChannelSubmissions).set({ + status: 'REJECTED', reviewerId: req.user.id, reviewNote: req.data.note, + }).where({ ID: id }); + return req.reply(); + }); + // Phase 2-B (#464): Severity-classified expiry warnings for the // admin-shell notifications popover. Read-only — no DB writes. diff --git a/srv/channel-submission-service.cds b/srv/channel-submission-service.cds new file mode 100644 index 000000000..7c7c8c37c --- /dev/null +++ b/srv/channel-submission-service.cds @@ -0,0 +1,10 @@ +using { com.sap.developers.ims as ims } from '../db/channels'; + +@path : '/channel-submissions' +@requires: 'authenticated-user' +service ChannelSubmissionService { + + // Insert-only: logged-in developers propose changes; they cannot read others' proposals. + @insertonly + entity Submissions as projection on ims.ChannelSubmissions; +} diff --git a/srv/channel-submission-service.js b/srv/channel-submission-service.js new file mode 100644 index 000000000..32adf80de --- /dev/null +++ b/srv/channel-submission-service.js @@ -0,0 +1,14 @@ +import cds from '@sap/cds'; + +export default class ChannelSubmissionService extends cds.ApplicationService { + async init() { + // Stamp server-controlled fields; never trust client-sent status / reviewer / submitter. + this.before('CREATE', 'Submissions', (req) => { + req.data.submitterId = req.user.id; + req.data.status = 'PENDING'; + req.data.reviewerId = null; + req.data.reviewNote = null; + }); + await super.init(); + } +} diff --git a/test/admin-channel-submissions-app.test.js b/test/admin-channel-submissions-app.test.js new file mode 100644 index 000000000..19b191ec7 --- /dev/null +++ b/test/admin-channel-submissions-app.test.js @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (p) => readFileSync(resolve(p), 'utf8'); + +describe('Channel Submissions admin app scaffold', () => { + it('manifest points at /admin/ and the ChannelSubmissions contextPath', () => { + const m = JSON.parse(read('app/admin/channel-submissions/webapp/manifest.json')); + expect(m['sap.app'].id).toBe('sap.tutorials.admin.channelSubmissions'); + expect(m['sap.app'].dataSources.mainService.uri).toBe('/admin/'); + const list = m['sap.ui5'].routing.targets.ChannelSubmissionsList; + expect(list.options.settings.contextPath).toBe('/ChannelSubmissions'); + }); + + it('is registered in admin-shell overrides + navigation', () => { + const overrides = read('app/admin-shell/scripts/admin-shell-overrides.js'); + expect(overrides).toContain('channel-submissions'); + expect(overrides).toContain("'csub'"); + const nav = read('app/admin-shell/webapp/model/navigation.json'); + expect(nav).toContain('channelSubmissions'); + }); + + it('admin-shell manifest.json has the componentUsage, route, and target', () => { + const shellManifest = JSON.parse(read('app/admin-shell/webapp/manifest.json')); + expect(shellManifest['sap.ui5'].componentUsages.channelSubmissionsComponent).toBeDefined(); + const routes = shellManifest['sap.ui5'].routing.routes; + const route = routes.find(r => r.name === 'channelSubmissions'); + expect(route).toBeDefined(); + expect(route.pattern).toBe('channel-submissions'); + const targets = shellManifest['sap.ui5'].routing.targets; + expect(targets.channelSubmissionsTarget).toBeDefined(); + }); + + it('Shell.controller.js contains the channelSubmissions nav key', () => { + const controller = read('app/admin-shell/webapp/controller/Shell.controller.js'); + expect(controller).toContain('channelSubmissions'); + }); +}); diff --git a/test/admin-channel-submissions.test.js b/test/admin-channel-submissions.test.js new file mode 100644 index 000000000..107083088 --- /dev/null +++ b/test/admin-channel-submissions.test.js @@ -0,0 +1,98 @@ +// test/admin-channel-submissions.test.js +import cds from '@sap/cds'; +const project = cds.test('serve', '--project', '.', '--in-memory'); +const NS = 'com.sap.developers.ims'; +const adminAuth = { auth: { username: 'admin', password: 'admin' } }; + +async function seedSubmission(row) { + const { ChannelSubmissions } = cds.entities(NS); + const ID = cds.utils.uuid(); + await INSERT.into(ChannelSubmissions).entries({ ID, status: 'PENDING', ...row }); + return ID; +} +async function seedChannel(row) { + const { Channels } = cds.entities(NS); + const ID = cds.utils.uuid(); + await INSERT.into(Channels).entries({ ID, sourceId: `seed-${ID.slice(0, 8)}`, name: 'orig', url: 'https://orig', isPublished: true, status: 'Active', ...row }); + return ID; +} + +describe('AdminService ChannelSubmissions moderation', () => { + test('anonymous access to the queue is rejected 401', async () => { + await expect(project.get('/admin/ChannelSubmissions')).rejects.toMatchObject({ response: { status: 401 } }); + }); + + test('approve ADD inserts a published Channel and marks submission APPROVED', async () => { + const id = await seedSubmission({ kind: 'ADD', proposed: JSON.stringify({ name: 'New Ch', url: 'https://new', ownerType: 'Community_Member', status: 'Archived', isPublished: false }) }); + await project.post(`/admin/ChannelSubmissions(${id})/AdminService.approve`, { note: 'ok' }, adminAuth); + const { Channels, ChannelSubmissions } = cds.entities(NS); + const ch = await SELECT.one.from(Channels).where({ name: 'New Ch' }); + expect(ch).toBeTruthy(); + expect(ch.isPublished).toBe(true); // forced true, client "isPublished:false" ignored + expect(ch.status).toBe('Active'); // forced Active, client "status:Archived" ignored + expect(ch.sourceId).toMatch(/^community-/); + const sub = await SELECT.one.from(ChannelSubmissions).where({ ID: id }); + expect(sub.status).toBe('APPROVED'); + expect(sub.reviewNote).toBe('ok'); + expect(sub.reviewerId).toBe('admin'); // req.user.id stamped from auth + }); + + test('approve EDIT patches only whitelisted fields on the target channel', async () => { + const chId = await seedChannel({ name: 'orig', purpose: 'old' }); + const id = await seedSubmission({ kind: 'EDIT', targetChannel_ID: chId, proposed: JSON.stringify({ purpose: 'new purpose', contentHash: 'HACK', sourceId: 'HACK' }) }); + await project.post(`/admin/ChannelSubmissions(${id})/AdminService.approve`, { note: '' }, adminAuth); + const { Channels } = cds.entities(NS); + const ch = await SELECT.one.from(Channels).where({ ID: chId }); + expect(ch.purpose).toBe('new purpose'); + expect(ch.contentHash).not.toBe('HACK'); // non-whitelisted key ignored + expect(ch.sourceId).not.toBe('HACK'); + }); + + test('approve REMOVE unpublishes the target channel', async () => { + const chId = await seedChannel({ name: 'togo' }); + const id = await seedSubmission({ kind: 'REMOVE', targetChannel_ID: chId }); + await project.post(`/admin/ChannelSubmissions(${id})/AdminService.approve`, { note: 'spam' }, adminAuth); + const { Channels } = cds.entities(NS); + const ch = await SELECT.one.from(Channels).where({ ID: chId }); + expect(ch.isPublished).toBe(false); + }); + + test('reject records REJECTED + note without touching Channels', async () => { + const id = await seedSubmission({ kind: 'ADD', proposed: '{"name":"nope","url":"https://nope"}' }); + await project.post(`/admin/ChannelSubmissions(${id})/AdminService.reject`, { note: 'off-topic' }, adminAuth); + const { Channels, ChannelSubmissions } = cds.entities(NS); + const sub = await SELECT.one.from(ChannelSubmissions).where({ ID: id }); + expect(sub.status).toBe('REJECTED'); + expect(sub.reviewNote).toBe('off-topic'); + const ch = await SELECT.one.from(Channels).where({ name: 'nope' }); + expect(ch).toBeUndefined(); + }); + + test('approving an already-reviewed submission is rejected 400', async () => { + const id = await seedSubmission({ kind: 'REMOVE', targetChannel_ID: await seedChannel({ name: 'x' }), status: 'APPROVED' }); + await expect( + project.post(`/admin/ChannelSubmissions(${id})/AdminService.approve`, { note: '' }, adminAuth), + ).rejects.toMatchObject({ response: { status: 400 } }); + }); + + test('rejecting an already-reviewed submission is rejected 400', async () => { + const id = await seedSubmission({ kind: 'ADD', proposed: '{}', status: 'REJECTED' }); + await expect( + project.post(`/admin/ChannelSubmissions(${id})/AdminService.reject`, { note: '' }, adminAuth), + ).rejects.toMatchObject({ response: { status: 400 } }); + }); + + test('approve unknown submission ID returns 404', async () => { + const fakeId = cds.utils.uuid(); + await expect( + project.post(`/admin/ChannelSubmissions(${fakeId})/AdminService.approve`, { note: '' }, adminAuth), + ).rejects.toMatchObject({ response: { status: 404 } }); + }); + + test('reject unknown submission ID returns 404', async () => { + const fakeId = cds.utils.uuid(); + await expect( + project.post(`/admin/ChannelSubmissions(${fakeId})/AdminService.reject`, { note: '' }, adminAuth), + ).rejects.toMatchObject({ response: { status: 404 } }); + }); +}); diff --git a/test/channel-submission-service.test.js b/test/channel-submission-service.test.js new file mode 100644 index 000000000..025c634b3 --- /dev/null +++ b/test/channel-submission-service.test.js @@ -0,0 +1,45 @@ +// test/channel-submission-service.test.js +import cds from '@sap/cds'; +import { describe, test, expect } from 'vitest'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +// 'developer' is a mocked user with the 'authenticated-user' role (see .cdsrc.json) +const authUser = { auth: { username: 'developer', password: 'developer' } }; +const NS = 'com.sap.developers.ims'; +const linked = () => cds.linked(cds.model).entities(NS); + +describe('ChannelSubmissionService', () => { + test('anonymous CREATE is rejected 401', async () => { + await expect( + project.post('/channel-submissions/Submissions', { kind: 'ADD', proposed: '{}' }), + ).rejects.toMatchObject({ response: { status: 401 } }); + }); + + test('authenticated CREATE stamps submitterId + forces PENDING, ignores client-sent status/reviewer', async () => { + // Use a unique rationale so we can find this specific row in the DB after INSERT. + // @insertonly entities return a minimal/empty response body in CAP 10; we verify + // server-stamped fields by selecting the persisted row directly (matches model-test + // patterns in this repo, e.g. channel-collections-model.test.js). + const unique = `test-submit-${Date.now()}`; + const { status: httpStatus } = await project.post( + '/channel-submissions/Submissions', + { kind: 'ADD', proposed: '{"name":"X","url":"https://x"}', rationale: unique, + status: 'APPROVED', submitterId: 'spoofed', reviewerId: 'spoofed', reviewNote: 'spoofed' }, + authUser, + ); + // CAP 10 returns 204 No Content for @insertonly entities (no SELECT round-trip) + expect([201, 204]).toContain(httpStatus); + const row = await SELECT.one.from(linked().ChannelSubmissions).where({ rationale: unique }); + expect(row).toBeTruthy(); + expect(row.status).toBe('PENDING'); + expect(row.submitterId).toBe('developer'); + expect(row.reviewerId).toBeNull(); + expect(row.reviewNote).toBeNull(); + }); + + test('service is insert-only — READ is not allowed', async () => { + await expect( + project.get('/channel-submissions/Submissions', authUser), + ).rejects.toMatchObject({ response: { status: expect.any(Number) } }); + }); +}); diff --git a/test/channel-submissions-model.test.js b/test/channel-submissions-model.test.js new file mode 100644 index 000000000..bf6739c12 --- /dev/null +++ b/test/channel-submissions-model.test.js @@ -0,0 +1,29 @@ +import cds from '@sap/cds'; +const project = cds.test('serve', '--project', '.', '--in-memory'); +const NS = 'com.sap.developers.ims'; +const linked = () => cds.linked(cds.model).entities(NS); + +describe('ChannelSubmissions model', () => { + test('entity exists with cuid + managed + review fields', () => { + const e = linked().ChannelSubmissions; + expect(e).toBeTruthy(); + for (const c of ['ID', 'kind', 'status', 'proposed', 'rationale', + 'submitterId', 'reviewerId', 'reviewNote', + 'targetChannel_ID', 'createdAt', 'modifiedAt']) { + expect(e.elements[c]).toBeTruthy(); + } + }); + + test('kind enum is ADD/EDIT/REMOVE, status enum PENDING/APPROVED/REJECTED default PENDING', () => { + const e = linked().ChannelSubmissions; + expect(Object.keys(e.elements.kind.enum)).toEqual(['ADD', 'EDIT', 'REMOVE']); + expect(Object.keys(e.elements.status.enum)).toEqual(['PENDING', 'APPROVED', 'REJECTED']); + expect(e.elements.status.default.val).toBe('PENDING'); + }); + + test('targetChannel associates to Channels and is nullable', () => { + const e = linked().ChannelSubmissions; + expect(e.elements.targetChannel.target).toBe(`${NS}.Channels`); + expect(e.elements.targetChannel.notNull).not.toBe(true); + }); +}); diff --git a/test/channel-submit-approuter.test.js b/test/channel-submit-approuter.test.js new file mode 100644 index 000000000..4a794a304 --- /dev/null +++ b/test/channel-submit-approuter.test.js @@ -0,0 +1,26 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const xsApp = JSON.parse(fs.readFileSync(path.resolve('approuter/xs-app.json'), 'utf8')); + +describe('channel-submissions approuter route', () => { + const route = xsApp.routes.find((r) => r.source === '^/channel-submissions/(.*)$'); + + test('route exists, targets srv-api under xsuaa', () => { + expect(route).toBeTruthy(); + expect(route.destination).toBe('srv-api'); + expect(route.authenticationType).toBe('xsuaa'); + }); + + test('route does not set csrfProtection (AppRouter default CSRF-on required)', () => { + expect(route).not.toHaveProperty('csrfProtection'); + }); + + test('route sits before the /api catch-all and the final static catch-all', () => { + const idx = xsApp.routes.indexOf(route); + const apiCatchAll = xsApp.routes.findIndex((r) => r.source === '^/api/(.*)$'); + const staticCatchAll = xsApp.routes.findIndex((r) => r.source === '^(.*)$'); + if (apiCatchAll !== -1) expect(idx).toBeLessThan(apiCatchAll); + expect(idx).toBeLessThan(staticCatchAll); + }); +}); diff --git a/test/e2e/channel-submit.e2e.test.js b/test/e2e/channel-submit.e2e.test.js new file mode 100644 index 000000000..2baaa40bc --- /dev/null +++ b/test/e2e/channel-submit.e2e.test.js @@ -0,0 +1,48 @@ +// e2e: channel-submit island mount nudge (P4 submissions). +// Path: browser → approuter /channels → baked static page + channel-submit island mount. +// No auth required to verify the island mount point is present in the HTML; the +// form rendering behind the login gate is a separate concern. +// +// TOLERANT by design: this is a post-deploy nudge, not a hard gate. The only +// assertion is that the island mount element exists in the DOM — it does not +// require any particular form state, login flow, or network call to succeed. +// +// Self-skips when SMOKE_BASE_URL / PLAYWRIGHT_BASE_URL is absent so +// `npm test` (unit tier) and credential-less local runs stay green. +// +// Run against a deployed approuter: +// SMOKE_BASE_URL=https://… npx vitest run --project e2e test/e2e/channel-submit.e2e.test.js +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { hasBaseUrl } from './e2e.config.js'; +import { launchBrowser, newPage } from './_browser.js'; + +describe.skipIf(!hasBaseUrl())('e2e: channel submit form (post-deploy nudge)', () => { + let browser; + beforeAll(async () => { + browser = await launchBrowser(); + }); + afterAll(async () => { + await browser?.close(); + }); + + it('/channels renders the channel-submit island mount point', async () => { + const { context, page } = await newPage(browser, { authenticated: false }); + try { + const response = await page.goto('/channels/', { waitUntil: 'domcontentloaded' }); + expect(response, 'no response received').not.toBeNull(); + expect(response.status(), `unexpected status ${response.status()} for /channels/`).toBe(200); + // Verified repo fact: served pages render
, NOT
. + await page.locator('main').first().waitFor({ state: 'visible', timeout: 15_000 }); + // The submit island mount must be present in the baked HTML — this element + // is the hydration target for the channel-submit Vue island. Its presence + // confirms the island was included in the Hugo template at build time. + const mountCount = await page.locator('[data-island="channel-submit"]').count(); + expect( + mountCount, + '/channels must render a [data-island="channel-submit"] mount point' + ).toBeGreaterThan(0); + } finally { + await context.close(); + } + }); +});