diff --git a/CLAUDE.md b/CLAUDE.md index f9197520d..f9281be8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,3 +133,4 @@ The load-bearing few. **Full detail for every relocated item → [tutorials-ims- - **User-facing UI changes want a committed e2e spec** — advisory PR nudge on `app/**`/`hugo/**` changes; real coverage runs in the post-DEV-deploy `e2e` job. Ref: [e2e-coverage-pattern.md](docs/developers/reference/e2e-coverage-pattern.md). - **`test:e2e` is post-deploy only, not on PRs** — self-skips without `SMOKE_BASE_URL`. Served tutorials render `
`+`

`, NOT `
`. Runbook: `test/e2e/README.md`. - **Freshness detector grounding needs the corpus-embedding backfill** — until `srv/jobs/freshness-corpus-embedding-job.js` runs, every API-obsolescence claim degrades to `confidence: Low`. Tutorial source from `ContentFiles.sourceContent` via `getTutorialSource(slug)`, NOT `Steps.description`. → gotchas.md "Freshness detector". +- **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). diff --git a/app/admin-shell/scripts/admin-shell-overrides.js b/app/admin-shell/scripts/admin-shell-overrides.js index fcd565304..1e69e401d 100644 --- a/app/admin-shell/scripts/admin-shell-overrides.js +++ b/app/admin-shell/scripts/admin-shell-overrides.js @@ -80,7 +80,8 @@ module.exports = { 'video-rotation', 'pats', 'featureFlags', - 'petoberfest' + 'petoberfest', + 'channels' ], // @@ -161,7 +162,8 @@ module.exports = { 'content-moderation': 'cm', pats: 'pt', featureFlags: 'ffl', - petoberfest: 'pb' + petoberfest: 'pb', + channels: 'ch' }, // diff --git a/app/admin-shell/webapp/controller/Shell.controller.js b/app/admin-shell/webapp/controller/Shell.controller.js index 570de98ae..f3a17963a 100644 --- a/app/admin-shell/webapp/controller/Shell.controller.js +++ b/app/admin-shell/webapp/controller/Shell.controller.js @@ -60,7 +60,8 @@ sap.ui.define([ pats: "pats", petoberfest: "petoberfest", petoberfestContests: "petoberfestContests", - topicClusters: "topicClusters" + topicClusters: "topicClusters", + channels: "channels" }; var NAV_KEY_TO_TITLE = { @@ -117,7 +118,8 @@ sap.ui.define([ pats: "Personal Access Tokens", petoberfest: "Pet Photo Moderation", petoberfestContests: "Petoberfest Contests", - topicClusters: "Topic Clusters" + topicClusters: "Topic Clusters", + channels: "Channels" }; 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 b126c0e4b..121be54d5 100644 --- a/app/admin-shell/webapp/manifest.json +++ b/app/admin-shell/webapp/manifest.json @@ -93,7 +93,8 @@ "sap.tutorials.admin.videoRotation": "./components/video-rotation", "sap.tutorials.admin.pats": "./components/pats", "sap.tutorials.admin.featureFlags": "./components/featureFlags", - "sap.tutorials.admin.petoberfest": "./components/petoberfest" + "sap.tutorials.admin.petoberfest": "./components/petoberfest", + "sap.tutorials.admin.channels": "./components/channels" }, "models": { "admin": { @@ -379,6 +380,12 @@ "settings": {}, "componentData": {}, "lazy": true + }, + "channelsComponent": { + "name": "sap.tutorials.admin.channels", + "settings": {}, + "componentData": {}, + "lazy": true } }, "services": { @@ -889,6 +896,16 @@ } ] }, + { + "name": "channels", + "pattern": "channels", + "target": [ + { + "name": "channelsTarget", + "prefix": "ch" + } + ] + }, { "name": "feedbackDashboard", "pattern": "feedback/dashboard", @@ -1232,6 +1249,13 @@ "viewLevel": 1, "prefix": "pb" }, + "channelsTarget": { + "type": "Component", + "usage": "channelsComponent", + "id": "channelsTarget", + "viewLevel": 1, + "prefix": "ch" + }, "feedbackDashboardTarget": { "viewName": "TutorialFeedbackDashboard", "viewLevel": 1 diff --git a/app/admin-shell/webapp/model/navigation.json b/app/admin-shell/webapp/model/navigation.json index 94b926da4..80b83f456 100644 --- a/app/admin-shell/webapp/model/navigation.json +++ b/app/admin-shell/webapp/model/navigation.json @@ -22,6 +22,7 @@ { "key": "concepts", "title": "Concepts" }, { "key": "advocates", "title": "Advocates" }, { "key": "alerts", "title": "Alerts" }, + { "key": "channels", "title": "Channels" }, { "key": "operations", "title": "Featured Tasks" } ] }, diff --git a/app/admin/channels/package.json b/app/admin/channels/package.json new file mode 100644 index 000000000..1daa19704 --- /dev/null +++ b/app/admin/channels/package.json @@ -0,0 +1,15 @@ +{ + "name": "sap.tutorials.admin.channels", + "version": "0.0.1", + "private": true, + "description": "Channels - 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/channels/ui5.yaml b/app/admin/channels/ui5.yaml new file mode 100644 index 000000000..c7e9834b1 --- /dev/null +++ b/app/admin/channels/ui5.yaml @@ -0,0 +1,12 @@ +specVersion: "4.0" +metadata: + name: sap.tutorials.admin.channels +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/channels/webapp/Component.js b/app/admin/channels/webapp/Component.js new file mode 100644 index 000000000..26ef6f26b --- /dev/null +++ b/app/admin/channels/webapp/Component.js @@ -0,0 +1,4 @@ +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("sap.tutorials.admin.channels.Component", { metadata: { manifest: "json" } }); +}); diff --git a/app/admin/channels/webapp/i18n/i18n.properties b/app/admin/channels/webapp/i18n/i18n.properties new file mode 100644 index 000000000..62e62595a --- /dev/null +++ b/app/admin/channels/webapp/i18n/i18n.properties @@ -0,0 +1,2 @@ +appTitle=Channels +appDescription=Curate external SAP developer channels diff --git a/app/admin/channels/webapp/manifest.json b/app/admin/channels/webapp/manifest.json new file mode 100644 index 000000000..ed4fa47e7 --- /dev/null +++ b/app/admin/channels/webapp/manifest.json @@ -0,0 +1,79 @@ +{ + "_version": "1.65.0", + "sap.app": { + "id": "sap.tutorials.admin.channels", + "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": { + "Channels-manage": { + "semanticObject": "Channels", + "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.channels.i18n.i18n" } + } + }, + "routing": { + "routes": [ + { "name": "ChannelsList", "pattern": ":?query:", "target": "ChannelsList" }, + { "name": "ChannelsObject", "pattern": "Channels({key}):?query:", "target": "ChannelsObject" } + ], + "targets": { + "ChannelsList": { + "type": "Component", + "id": "ChannelsList", + "name": "sap.fe.templates.ListReport", + "options": { + "settings": { + "contextPath": "/Channels", + "initialLoad": "Enabled" + } + } + }, + "ChannelsObject": { + "type": "Component", + "id": "ChannelsObject", + "name": "sap.fe.templates.ObjectPage", + "options": { + "settings": { + "contextPath": "/Channels" + } + } + } + } + } + } +} diff --git a/db/channels.cds b/db/channels.cds new file mode 100644 index 000000000..094737819 --- /dev/null +++ b/db/channels.cds @@ -0,0 +1,43 @@ +namespace com.sap.developers.ims; + +using { managed, cuid } from '@sap/cds/common'; + +type ChannelOwnerType : String enum { + SAP_Official; SAP_Developer_Advocate; SAP_Executive; + Community_Member; Community_Organization; User_Group; + Third_party_Training; Third_party_Media; Third_party_Platform; +} +type ChannelStatus : String enum { Active; Archived; Closed; Discontinued; EOL; } + +@assert.unique.sourceId: [sourceId] +entity Channels : cuid, managed { + sourceId : String(40) @mandatory; // "portal-001" — dedup / re-ingest key + name : String(200) @mandatory; + url : String(500) @mandatory; + relatedUrls : array of String(500); + aliases : array of String(120); + purpose : String(1000); // cleaned of [cite:] markers at ingest + notes : String(1000); + ownerName : String(120); + ownerType : ChannelOwnerType @assert.range; + isSapOwned : Boolean default false; + category : String(60); + subcategory : String(80); + platform : String(40); + status : ChannelStatus default 'Active' @assert.range; + focusAreas : array of String(60); + tags : array of String(40); + updateFrequency: String(40); + githubStars : Integer; + subscribers : Integer; + + // ── curation / lifecycle (admin-editable; absent from ingest so re-seed never wipes) ── + isPublished : Boolean default true; + isFeatured : Boolean default false; + editorialNote : String(800); + contentHash : String(64); + ingestBatch : String(40); + linkStatus : String(20) default 'UNKNOWN'; + linkStatusOverride : String(20); + lastChecked : Timestamp; +} diff --git a/db/last-dev/csn.json b/db/last-dev/csn.json index d3b580812..1539cd15b 100644 --- a/db/last-dev/csn.json +++ b/db/last-dev/csn.json @@ -2050,6 +2050,197 @@ }, "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_FEATUREDTASKS" }, + "com.sap.developers.ims.Channels": { + "kind": "entity", + "@assert.unique.sourceId": [ + { + "=": "sourceId" + } + ], + "@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" + }, + "sourceId": { + "type": "cds.String", + "length": 40, + "@cds.persistence.name": "SOURCEID" + }, + "name": { + "type": "cds.String", + "length": 200, + "@cds.persistence.name": "NAME" + }, + "url": { + "type": "cds.String", + "length": 500, + "@cds.persistence.name": "URL" + }, + "relatedUrls": { + "type": "cds.LargeString", + "@cds.persistence.name": "RELATEDURLS" + }, + "aliases": { + "type": "cds.LargeString", + "@cds.persistence.name": "ALIASES" + }, + "purpose": { + "type": "cds.String", + "length": 1000, + "@cds.persistence.name": "PURPOSE" + }, + "notes": { + "type": "cds.String", + "length": 1000, + "@cds.persistence.name": "NOTES" + }, + "ownerName": { + "type": "cds.String", + "length": 120, + "@cds.persistence.name": "OWNERNAME" + }, + "ownerType": { + "type": "cds.String", + "length": 5000, + "@cds.persistence.name": "OWNERTYPE" + }, + "isSapOwned": { + "type": "cds.Boolean", + "default": { + "val": false + }, + "@cds.persistence.name": "ISSAPOWNED" + }, + "category": { + "type": "cds.String", + "length": 60, + "@cds.persistence.name": "CATEGORY" + }, + "subcategory": { + "type": "cds.String", + "length": 80, + "@cds.persistence.name": "SUBCATEGORY" + }, + "platform": { + "type": "cds.String", + "length": 40, + "@cds.persistence.name": "PLATFORM" + }, + "status": { + "type": "cds.String", + "default": { + "val": "Active" + }, + "length": 5000, + "@cds.persistence.name": "STATUS" + }, + "focusAreas": { + "type": "cds.LargeString", + "@cds.persistence.name": "FOCUSAREAS" + }, + "tags": { + "type": "cds.LargeString", + "@cds.persistence.name": "TAGS" + }, + "updateFrequency": { + "type": "cds.String", + "length": 40, + "@cds.persistence.name": "UPDATEFREQUENCY" + }, + "githubStars": { + "type": "cds.Integer", + "@cds.persistence.name": "GITHUBSTARS" + }, + "subscribers": { + "type": "cds.Integer", + "@cds.persistence.name": "SUBSCRIBERS" + }, + "isPublished": { + "type": "cds.Boolean", + "default": { + "val": true + }, + "@cds.persistence.name": "ISPUBLISHED" + }, + "isFeatured": { + "type": "cds.Boolean", + "default": { + "val": false + }, + "@cds.persistence.name": "ISFEATURED" + }, + "editorialNote": { + "type": "cds.String", + "length": 800, + "@cds.persistence.name": "EDITORIALNOTE" + }, + "contentHash": { + "type": "cds.String", + "length": 64, + "@cds.persistence.name": "CONTENTHASH" + }, + "ingestBatch": { + "type": "cds.String", + "length": 40, + "@cds.persistence.name": "INGESTBATCH" + }, + "linkStatus": { + "type": "cds.String", + "length": 20, + "default": { + "val": "UNKNOWN" + }, + "@cds.persistence.name": "LINKSTATUS" + }, + "linkStatusOverride": { + "type": "cds.String", + "length": 20, + "@cds.persistence.name": "LINKSTATUSOVERRIDE" + }, + "lastChecked": { + "type": "cds.Timestamp", + "@cds.persistence.name": "LASTCHECKED" + } + }, + "$tableConstraints": { + "unique": { + "sourceId": { + "paths": [ + { + "ref": [ + "sourceId" + ], + "isChecked": true + } + ], + "parentTable": "com.sap.developers.ims.Channels" + } + } + }, + "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_CHANNELS" + }, "com.sap.developers.ims.FailedEmails": { "kind": "entity", "@cds.persistence.journal": true, diff --git a/db/persistence.cds b/db/persistence.cds index 1ba6e23f8..0d2e2fc98 100644 --- a/db/persistence.cds +++ b/db/persistence.cds @@ -51,3 +51,4 @@ annotate ims.CatGameAwards with @cds.persistence.journal; // #2042 Hit-the-Cat annotate ims.MetricSnapshots with @cds.persistence.journal; annotate ims.PublishTimings with @cds.persistence.journal; annotate ims.TutorialValidationRules with @cds.persistence.journal; +annotate ims.Channels with @cds.persistence.journal; diff --git a/db/schema.cds b/db/schema.cds index a5ffcfcab..2c123afe3 100644 --- a/db/schema.cds +++ b/db/schema.cds @@ -5,6 +5,7 @@ using { com.sap.developers.ims.shared } from './_content-shape'; using from './advocates'; using from './devtoberfest'; using from './homepage'; +using from './channels'; // Sequence-backed business ID for backward compatibility with legacy integer IDs aspect LegacyKeyed { diff --git a/db/src/com.sap.developers.ims.Channels.hdbmigrationtable b/db/src/com.sap.developers.ims.Channels.hdbmigrationtable new file mode 100644 index 000000000..0b718851c --- /dev/null +++ b/db/src/com.sap.developers.ims.Channels.hdbmigrationtable @@ -0,0 +1,36 @@ +== version=1 +COLUMN TABLE com_sap_developers_ims_Channels ( + ID NVARCHAR(36) NOT NULL, + createdAt TIMESTAMP, + createdBy NVARCHAR(255), + modifiedAt TIMESTAMP, + modifiedBy NVARCHAR(255), + sourceId NVARCHAR(40), + name NVARCHAR(200), + url NVARCHAR(500), + relatedUrls NCLOB, + aliases NCLOB, + purpose NVARCHAR(1000), + notes NVARCHAR(1000), + ownerName NVARCHAR(120), + ownerType NVARCHAR(5000), + isSapOwned BOOLEAN DEFAULT FALSE, + category NVARCHAR(60), + subcategory NVARCHAR(80), + platform NVARCHAR(40), + status NVARCHAR(5000) DEFAULT 'Active', + focusAreas NCLOB, + tags NCLOB, + updateFrequency NVARCHAR(40), + githubStars INTEGER, + subscribers INTEGER, + isPublished BOOLEAN DEFAULT TRUE, + isFeatured BOOLEAN DEFAULT FALSE, + editorialNote NVARCHAR(800), + contentHash NVARCHAR(64), + ingestBatch NVARCHAR(40), + linkStatus NVARCHAR(20) DEFAULT 'UNKNOWN', + linkStatusOverride NVARCHAR(20), + lastChecked TIMESTAMP, + PRIMARY KEY(ID) +) diff --git a/docs/developers/reference/channels.md b/docs/developers/reference/channels.md new file mode 100644 index 000000000..2777345b0 --- /dev/null +++ b/docs/developers/reference/channels.md @@ -0,0 +1,140 @@ +# External Channels Subsystem + +This document covers the **P1 foundation** of the external-channels subsystem: the `Channels` source-of-truth entity, re-ingest CLI, `/build/channels` feed, `/channels` Hugo directory page, and `promote-channels` verb-lane fill. P2–P4 work (editorial collections, topic crosswalk, community submissions) are out of scope here and tracked separately. + +--- + +## Data model + +### `Channels` entity + +- **File:** `db/channels.cds` +- **Namespace:** `com.sap.developers.ims` +- **Persistence:** annotated `@cds.persistence.journal` in `db/persistence.cds` — deploys as `.hdbmigrationtable` so schema evolution uses `ALTER TABLE` rather than drop-and-recreate. +- **Aggregation:** pulled into the global model via `using from './channels'` in `db/schema.cds`. + +Key design points: + +- **Unique dedup key:** `sourceId` (String 40) — the `id` field from the raw research dataset; `@assert.unique.sourceId` enforces it at DB level. +- **Array columns:** `relatedUrls`, `aliases`, `focusAreas`, `tags` are declared as `array of String(...)`. On SQLite these come back as native arrays; on HANA they are stored as JSON NCLOBs. The `/build/channels` feed handler (`srv/server.js`) applies `JSON.parse` for the HANA case; `seed-channels` calls `cds.linked(cds.model ?? ...).entities('com.sap.developers.ims')` to resolve the entity through CAP's linked model — the `cds.linked()` / `entities(NS)` pattern is required for correct array round-tripping. +- **Admin-curated columns** (never touched by re-ingest): `isPublished`, `isFeatured`, `editorialNote`, `linkStatus`, `linkStatusOverride`, `lastChecked`. These are preserved across every re-seed so editorial decisions survive data refreshes. +- **Enum columns:** `ownerType` (`ChannelOwnerType`) and `status` (`ChannelStatus`) both carry `@assert.range` — invalid values are rejected at the service layer. + +--- + +## Re-ingest CLI (`seed-channels`) + +```bash +npm run seed-channels -- --file --commit +``` + +- **Script:** `scripts/seed-channels.cjs` (npm script: `cds bind --exec -- node scripts/seed-channels.cjs`) +- **Normalizer:** `srv/lib/channels/normalize.js` — `normalizeChannel(raw, ingestBatch)` cleans citation markers, maps free-text `ownerType`/`status` to enum values, and computes a `contentHash` (SHA-256 of source-owned fields in sorted-key canonical JSON). + +### Behaviour + +| Situation | Action | +|---|---| +| Row not in DB | `INSERT` with a new `cds.utils.uuid()` as `ID` | +| Row in DB, hash unchanged (no `--force`) | Skip (`skipped++`) | +| Row in DB, hash changed (or `--force`) | `UPDATE` source-owned fields only; curated columns are deleted from the patch before writing | +| Row in DB but absent from this ingest batch | Soft-retire: `status = 'Archived'`; curated columns untouched | + +### Flags + +| Flag | Effect | +|---|---| +| `--file ` | Path to JSON dataset (default: `d:/tmp/External-SAP-Channels-Complete.json`) | +| `--commit` | Write to DB; omit for dry-run | +| `--force` | Re-process all rows regardless of `contentHash` match | + +The script requires a live DB binding (`cds bind --exec`). Use `npm run seed-channels` rather than invoking the script directly. + +--- + +## Directory data path + +``` +/build/channels (CAP Express feed) + ↓ +scripts/fetch-channels.ts → hugo/data/channels.json + ↓ +/channels Hugo page → channels-directory Vue island +``` + +### `/build/channels` feed + +- **Location:** `srv/server.js` Express middleware (around line 418) +- **Auth:** public, unauthenticated; `Cache-Control: public, max-age=60` +- **Filtering:** returns only rows where `isPublished = true`; then excludes any where the effective `linkStatus` is `'BROKEN'` (override wins: `linkStatusOverride || linkStatus`) +- **Array parsing:** `focusAreas`, `tags`, `relatedUrls`, `aliases` are passed through a `parseArr` helper that calls `JSON.parse` for HANA string values and passes through native arrays from SQLite +- **Response shape:** `{ channels: Channel[], buildAt: string }` + +### `fetch-channels.ts` + +- **File:** `scripts/fetch-channels.ts` +- **npm script:** `fetch-channels` (`tsx scripts/fetch-channels.ts`) +- **Wired into `build:all`:** yes — `npm run fetch-channels` is one of the steps in the `build:all` script in `package.json` +- **Output:** `hugo/data/channels.json` (created with `mkdirSync` if missing) +- **Fail-open:** if the CAP feed is unreachable (e.g., during a cold build before `cds watch` starts), the script writes an empty-channels payload with `error` set and a warning to stdout — the build continues; the `/channels` page renders with zero items + +### `/channels` Hugo page + +- **Content directory:** `hugo/content/channels/` (`_index.md` sets title + description) +- **Layout:** `hugo/layouts/channels/list.html` — renders the channel list JSON into a `` — uses the `island-src.html` partial (hashed path from the island manifest); **never hardcode `/js/channels-directory.js`** + +### `channels-directory` Vue island + +- **Location:** `hugo-apps/src/channels-directory/` +- **Entry:** `index.ts` +- **Component:** `ChannelsDirectory.vue` +- **Filter logic:** `filter.ts` exports `filterChannels(channels, state)` where `state` is `{ query?, category?, platform?, ownerScope? }`. Facets: + - **category** — exact match on `channel.category` + - **platform** — exact match on `channel.platform` + - **ownerScope** — `'sap'` (only `isSapOwned === true`), `'community'` (only `isSapOwned !== true`), or `'all'` + - **query** — case-insensitive substring match across `name`, `purpose`, and `tags` + +--- + +## Verb-lane fill (`promote-channels`) + +```bash +npm run promote-channels +``` + +- **CLI wrapper:** `scripts/promote-channels-to-shelves.cjs` +- **npm script:** `cds bind --exec -- node scripts/promote-channels-to-shelves.cjs` +- **Core logic:** `srv/lib/channels/promote-to-shelves.js` — exports `promoteFeatured(db)`, `mapChannelToShelf(channel)`, `CATEGORY_TO_SHELF`, `FOCUS_TO_VERB` + +### Mapping rules + +`mapChannelToShelf` converts a channel row to `{ verb, shelf }`: + +1. **shelf** from `CATEGORY_TO_SHELF[channel.category]` (default `'REFERENCE'`) +2. **community / third-party guard:** if the computed shelf is `'START_HERE'` but `channel.isSapOwned !== true`, the shelf is downgraded to `'REFERENCE'` — community items **never** land in `START_HERE` +3. **verb** from `pickVerb(channel.focusAreas)` which walks `FOCUS_TO_VERB` (ordered priority list of keyword arrays → `INTEGRATE / OPERATE / AI / MODEL / BUILD / LEARN`); default is `'BUILD'` + +`promoteFeatured(db)` selects all rows with `isFeatured = true, isPublished = true`, maps each to a shelf/verb pair, and inserts into `HomepageShelves` with: +- `badge: 'THIRD_PARTY'` when `!isSapOwned` +- `authoringStatus: 'AI_SEEDED'`, `isExternal: true`, `isActive: true`, `sortOrder: 500` +- **Idempotent:** upserts on `(verb, url)` — existing rows are skipped, not overwritten + +--- + +## Admin surface + +- **OData projection:** `AdminService.Channels` in `srv/admin-service.cds` (line 296–297), annotated `@odata.draft.enabled` +- **FE app:** `app/admin/channels/` (UI5 Fiori Elements; bootstrapped from `package.json` + `ui5.yaml`) +- **Shell wiring:** `app/admin-shell/scripts/admin-shell-overrides.js` registers `'channels'` in the component list (explicit ordering) with router prefix `'ch'`. The shell manifest is **generated** by `app/admin-shell/scripts/generate-manifest.js`; do NOT hand-edit the generated `manifest.json` — run the generator (triggered automatically at `npm run prebuild`) + +--- + +## P1 scope / deferred to P2–P4 + +Items explicitly **not** in this subsystem yet: + +- **P2 — Editorial `ChannelCollections`:** curated groupings (e.g., "Getting Started", "CAP ecosystem") with their own Hugo/island surface +- **P3 — `ChannelTopicMap` crosswalk:** per-topic bands on `/topics/` pages wiring channels relevant to each topic +- **P4 — `ChannelSubmissions`:** community submission form + moderation loop +- **Nightly link-health extension:** the existing link-health job already knows how to check URLs; wiring it to `Channels.url` / `linkStatus` is a follow-up, not yet implemented. P1 already filters `BROKEN` channels out of the feed so stale data is not surfaced to users. diff --git a/docs/superpowers/plans/2026-09-04-external-channels-p1-foundation.md b/docs/superpowers/plans/2026-09-04-external-channels-p1-foundation.md new file mode 100644 index 000000000..b39ac76db --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-external-channels-p1-foundation.md @@ -0,0 +1,1145 @@ +# External SAP Channels — P1 Foundation 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:** Stand up the `Channels` source-of-truth entity, a re-ingestable seed pipeline from the research dataset, a `/channels` browsable directory, and fill verb-lane shelf gaps — the foundation every later phase builds on. + +**Architecture:** A new journaled CAP entity `Channels` (namespace `com.sap.developers.ims`) is the single source of truth. A CLI seed script normalizes the research JSON and idempotently upserts it, preserving admin-curated columns. A `/build/channels` Express feed bakes to `hugo/data/channels.json`; a Hugo section page + Vue facet island render the directory client-side over that baked JSON. A promotion module maps featured channels into the existing `HomepageShelves` entity to fill verb lanes. An admin Fiori Elements app exposes curation. + +**Tech Stack:** SAP CAP (Node.js) · CDS · SAP HANA (HDI) · Hugo · Vue 3 (Vite islands) · SAPUI5 Fiori Elements · Vitest. + +**Spec:** `docs/superpowers/specs/2026-09-04-external-channels-integration-design.md` + +## Global Constraints + +- **Namespace:** all new persisted entities live under `com.sap.developers.ims` (the `ims` namespace, same as `HomepageShelves`), NOT `.external`. Verbatim: `namespace com.sap.developers.ims;`. +- **Journal required:** every new persisted entity MUST get an explicit `annotate ims. with @cds.persistence.journal;` line in `db/persistence.cds`, or it deploys as a DROP+CREATE `.hdbtable` and loses curated data on redeploy. +- **Array columns need reflection:** entity handles for INSERT/SELECT of `array of String` columns MUST be obtained via `cds.linked(cds.model ?? await cds.load('*')).entities('com.sap.developers.ims')` — a fully-qualified string entity name is not type-aware and fails on HANA for array columns (serializes to JSON NCLOB). Pattern documented in `srv/lib/homepage/seed-homepage-shelves.js:26-31`. +- **HANA table/column names are UPPERCASE, underscore-joined** (`COM_SAP_DEVELOPERS_IMS_CHANNELS`); never SELECT a BLOB alongside metadata (N/A here — no BLOBs). +- **Upsert on natural key:** all write paths SELECT-then-UPDATE-or-INSERT on the natural key (`sourceId`), never blind INSERT. +- **Community channels never land in `START_HERE`** — third-party/community items map only to `REFERENCE`/`TOOLS`/`KEEP_CURRENT`. +- **Validate before commit:** run `npx cds deploy --to sqlite::memory:` after any `db/**/*.cds` change; run relevant tests before every commit. +- **Test bootstrap:** service tests use one top-level `const project = cds.test('serve', '--project', '.', '--in-memory');` per file (per-describe bootstrap races the port). Admin service is `@requires`-gated → read/write over HTTP with `{ auth: { username:'admin', password:'admin' } }`. +- **CDS-MCP:** before landing any CDS-model or CAP-API change, validate the exact syntax with `cds-mcp` per repo rules. + +--- + +### Task 1: `Channels` entity + persistence journal + +**Files:** +- Create: `db/channels.cds` +- Modify: `db/persistence.cds` (append one journal line) +- Test: `test/channels-model.test.js` + +**Interfaces:** +- Produces: entity `com.sap.developers.ims.Channels` with fields `sourceId, name, url, relatedUrls[], aliases[], purpose, notes, ownerName, ownerType, isSapOwned, category, subcategory, platform, status, focusAreas[], tags[], updateFrequency, githubStars, subscribers, isPublished, isFeatured, editorialNote, contentHash, ingestBatch, linkStatus, linkStatusOverride, lastChecked`. Enums `ChannelOwnerType`, `ChannelStatus`. + +- [ ] **Step 1: Write `db/channels.cds`** + +```cds +namespace com.sap.developers.ims; + +using { managed, cuid } from '@sap/cds/common'; + +type ChannelOwnerType : String enum { + SAP_Official; SAP_Developer_Advocate; SAP_Executive; + Community_Member; Community_Organization; User_Group; + Third_party_Training; Third_party_Media; Third_party_Platform; +} +type ChannelStatus : String enum { Active; Archived; Closed; Discontinued; EOL; } + +@assert.unique.sourceId: [sourceId] +entity Channels : cuid, managed { + sourceId : String(40) @mandatory; // "portal-001" — dedup / re-ingest key + name : String(200) @mandatory; + url : String(500) @mandatory; + relatedUrls : array of String(500); + aliases : array of String(120); + purpose : String(1000); // cleaned of [cite:] markers at ingest + notes : String(1000); + ownerName : String(120); + ownerType : ChannelOwnerType; + isSapOwned : Boolean default false; + category : String(60); + subcategory : String(80); + platform : String(40); + status : ChannelStatus default 'Active'; + focusAreas : array of String(60); + tags : array of String(40); + updateFrequency: String(40); + githubStars : Integer; + subscribers : Integer; + + // ── curation / lifecycle (admin-editable; absent from ingest so re-seed never wipes) ── + isPublished : Boolean default true; + isFeatured : Boolean default false; + editorialNote : String(800); + contentHash : String(64); + ingestBatch : String(40); + linkStatus : String(20) default 'UNKNOWN'; + linkStatusOverride : String(20); + lastChecked : Timestamp; +} +``` + +- [ ] **Step 2: Append journal annotation to `db/persistence.cds`** + +Add this line alongside the existing `annotate ims.* with @cds.persistence.journal;` block: + +```cds +annotate ims.Channels with @cds.persistence.journal; +``` + +- [ ] **Step 3: Verify the model compiles** + +Run: `npx cds deploy --to sqlite::memory:` +Expected: exits 0, no compile error (confirms enums/arrays/annotation are valid and the new file loads). + +- [ ] **Step 4: Write the failing model test** + +```js +// test/channels-model.test.js +import cds from '@sap/cds'; +import { describe, it, expect, afterAll } from 'vitest'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); + +describe('Channels entity', () => { + const NS = 'com.sap.developers.ims'; + const linked = () => cds.linked(cds.model).entities(NS); + + afterAll(async () => { + const { Channels } = linked(); + await DELETE.from(Channels).where({ sourceId: 'test-001' }); + }); + + it('round-trips array columns', async () => { + const { Channels } = linked(); + await INSERT.into(Channels).entries({ + ID: cds.utils.uuid(), sourceId: 'test-001', name: 'Test', url: 'https://x.test', + focusAreas: ['abap', 'cap'], tags: ['t1'], relatedUrls: ['https://y.test'], + isSapOwned: true, isPublished: true, + }); + const row = await SELECT.one.from(Channels).where({ sourceId: 'test-001' }); + expect(row.focusAreas).toEqual(['abap', 'cap']); + expect(row.tags).toEqual(['t1']); + expect(row.isPublished).toBe(true); + }); +}); +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run test/channels-model.test.js` +Expected: PASS (entity exists, arrays round-trip). + +- [ ] **Step 6: Commit** + +```bash +git add db/channels.cds db/persistence.cds test/channels-model.test.js +git commit -m "feat(channels): add Channels source-of-truth entity + persistence journal" +``` + +--- + +### Task 2: Ingestion — normalize module + seed CLI + +Split pure normalization (unit-testable, no DB) from the thin DB-writing CLI. + +**Files:** +- Create: `srv/lib/channels/normalize.js` +- Create: `scripts/seed-channels.cjs` +- Modify: `package.json` (add `seed-channels` script entry) +- Test: `test/channels-normalize.test.js`, `test/channels-seed.test.js` + +**Interfaces:** +- Consumes: `com.sap.developers.ims.Channels` (Task 1). +- Produces: `srv/lib/channels/normalize.js` exports `cleanCitations(text) -> string`, `normalizeOwnerType(raw) -> enumString|null`, `normalizeStatus(raw) -> {status, note}`, `computeContentHash(sourceFields) -> string`, `normalizeChannel(rawJson) -> channelRow`. CLI `scripts/seed-channels.cjs` reads `--file ` (default `d:/tmp/External-SAP-Channels-Complete.json`), flags `--commit` (default dry-run) and `--force`. + +- [ ] **Step 1: Write the failing normalize test** + +```js +// test/channels-normalize.test.js +import { describe, it, expect } from 'vitest'; +import { + cleanCitations, normalizeOwnerType, normalizeStatus, + computeContentHash, normalizeChannel, +} from '../srv/lib/channels/normalize.js'; + +describe('channels normalize', () => { + it('strips [cite:] markers and trailing space', () => { + expect(cleanCitations('The BTP portal. [cite: 12]')).toBe('The BTP portal.'); + expect(cleanCitations('No marker')).toBe('No marker'); + }); + + it('maps owner_type strings to the enum', () => { + expect(normalizeOwnerType('SAP Official')).toBe('SAP_Official'); + expect(normalizeOwnerType('Community Member')).toBe('Community_Member'); + expect(normalizeOwnerType('unknown junk')).toBeNull(); + }); + + it('normalizes status with a carry-over note', () => { + expect(normalizeStatus('Active')).toEqual({ status: 'Active', note: null }); + expect(normalizeStatus('Entering EOL')).toEqual({ status: 'EOL', note: 'Entering EOL' }); + expect(normalizeStatus('Active (Canonical source)')) + .toEqual({ status: 'Active', note: 'Canonical source' }); + }); + + it('content hash is stable across key order and changes with content', () => { + const a = computeContentHash({ name: 'X', url: 'u', purpose: 'p' }); + const b = computeContentHash({ url: 'u', purpose: 'p', name: 'X' }); + const c = computeContentHash({ name: 'X', url: 'u', purpose: 'q' }); + expect(a).toBe(b); + expect(a).not.toBe(c); + }); + + it('normalizeChannel produces an upsert-ready row', () => { + const row = normalizeChannel({ + id: 'portal-001', name: 'BTP Portal', url: 'https://x', + owner_type: 'SAP Official', isSapOwned: true, status: 'Active', + focus_areas: ['btp'], tags: ['btp'], purpose: 'Portal. [cite: 1]', + }, '2026-09-03'); + expect(row.sourceId).toBe('portal-001'); + expect(row.purpose).toBe('Portal.'); + expect(row.ownerType).toBe('SAP_Official'); + expect(row.focusAreas).toEqual(['btp']); + expect(row.ingestBatch).toBe('2026-09-03'); + expect(typeof row.contentHash).toBe('string'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/channels-normalize.test.js` +Expected: FAIL — cannot resolve `../srv/lib/channels/normalize.js`. + +- [ ] **Step 3: Write `srv/lib/channels/normalize.js`** + +```js +'use strict'; +const crypto = require('node:crypto'); + +// Strip trailing "[cite: N]" style markers (and any trailing whitespace). +function cleanCitations(text) { + if (!text) return text; + return String(text).split('[cite')[0].replace(/\s+$/, ''); +} + +const OWNER_TYPE_MAP = { + 'sap official': 'SAP_Official', + 'sap developer advocate': 'SAP_Developer_Advocate', + 'sap executive': 'SAP_Executive', + 'community member': 'Community_Member', + 'community organization': 'Community_Organization', + 'user group': 'User_Group', + 'third-party training': 'Third_party_Training', + 'third-party media': 'Third_party_Media', + 'third-party platform': 'Third_party_Platform', +}; +function normalizeOwnerType(raw) { + if (!raw) return null; + return OWNER_TYPE_MAP[String(raw).trim().toLowerCase()] ?? null; +} + +// Map free-text status → enum, carrying any parenthetical / qualifier as a note. +function normalizeStatus(raw) { + if (!raw) return { status: 'Active', note: null }; + const s = String(raw).trim(); + const lower = s.toLowerCase(); + if (lower.startsWith('entering eol') || lower === 'eol') return { status: 'EOL', note: s === 'EOL' ? null : s }; + if (lower.startsWith('active')) { + const m = s.match(/\((.+)\)/); + return { status: 'Active', note: m ? m[1].trim() : null }; + } + if (lower.startsWith('archiv')) return { status: 'Archived', note: null }; + if (lower.startsWith('closed')) return { status: 'Closed', note: null }; + if (lower.startsWith('discontinu')) return { status: 'Discontinued', note: null }; + return { status: 'Active', note: s }; +} + +// Hash only the source (dataset-owned) fields, order-independent. +function computeContentHash(sourceFields) { + const canonical = JSON.stringify(sourceFields, Object.keys(sourceFields).sort()); + return crypto.createHash('sha256').update(canonical).digest('hex'); +} + +function normalizeChannel(raw, ingestBatch) { + const { status, note } = normalizeStatus(raw.status); + const purpose = cleanCitations(raw.purpose); + const notesParts = [cleanCitations(raw.notes), note].filter(Boolean); + const source = { + name: raw.name, url: raw.url, + relatedUrls: raw.related_urls ?? [], + aliases: raw.aliases ?? [], + purpose, notes: notesParts.join(' — ') || null, + ownerName: raw.owner ?? raw.owner_name ?? null, + ownerType: normalizeOwnerType(raw.owner_type), + isSapOwned: raw.isSapOwned === true, + category: raw.category ?? null, + subcategory: raw.subcategory ?? null, + platform: raw.platform ?? null, + status, + focusAreas: raw.focus_areas ?? [], + tags: raw.tags ?? [], + updateFrequency: raw.update_frequency ?? null, + githubStars: raw.github_stars ?? null, + subscribers: raw.subscribers ?? null, + }; + return { sourceId: raw.id, ...source, contentHash: computeContentHash(source), ingestBatch }; +} + +module.exports = { cleanCitations, normalizeOwnerType, normalizeStatus, computeContentHash, normalizeChannel }; +``` + +- [ ] **Step 4: Run normalize test to verify it passes** + +Run: `npx vitest run test/channels-normalize.test.js` +Expected: PASS. + +- [ ] **Step 5: Write `scripts/seed-channels.cjs`** + +```js +'use strict'; +// Idempotent re-ingest of the external-channels research dataset into Channels. +// Preserves admin-curated columns; retires-on-absence (soft). Run: +// npx cds bind --exec -- node scripts/seed-channels.cjs --file d:/tmp/External-SAP-Channels-Complete.json --commit +const cds = require('@sap/cds'); +const { readFileSync } = require('node:fs'); +const { normalizeChannel } = require('../srv/lib/channels/normalize.js'); + +const CURATED = ['isPublished', 'isFeatured', 'editorialNote', 'linkStatus', 'linkStatusOverride', 'lastChecked']; + +async function main() { + const args = process.argv.slice(2); + const commit = args.includes('--commit'); + const force = args.includes('--force'); + const fileIdx = args.indexOf('--file'); + const file = fileIdx >= 0 ? args[fileIdx + 1] : 'd:/tmp/External-SAP-Channels-Complete.json'; + + const doc = JSON.parse(readFileSync(file, 'utf8')); + const batch = doc.metadata?.generated ?? new Date().toISOString().slice(0, 10); + const rawChannels = doc.channels ?? doc; + + const db = await cds.connect.to('db'); + const linked = cds.linked(cds.model ?? (await cds.load('*'))); + const { Channels } = linked.entities('com.sap.developers.ims'); + + let inserted = 0, updated = 0, skipped = 0; + const seen = new Set(); + for (const raw of rawChannels) { + const row = normalizeChannel(raw, batch); + seen.add(row.sourceId); + const existing = await SELECT.one.from(Channels).where({ sourceId: row.sourceId }); + if (existing && existing.contentHash === row.contentHash && !force) { skipped++; continue; } + if (existing) { + // update source-owned fields only; never touch curated columns + const patch = { ...row }; + for (const k of CURATED) delete patch[k]; + if (commit) await UPDATE(Channels).set(patch).where({ ID: existing.ID }); + updated++; + } else { + if (commit) await INSERT.into(Channels).entries({ ID: cds.utils.uuid(), ...row }); + inserted++; + } + } + + // retire-on-absence (soft): rows never seen in this batch → Archived, curation untouched + const all = await SELECT.from(Channels).columns('ID', 'sourceId', 'status'); + let retired = 0; + for (const r of all) { + if (!seen.has(r.sourceId) && r.status !== 'Archived') { + if (commit) await UPDATE(Channels).set({ status: 'Archived' }).where({ ID: r.ID }); + retired++; + } + } + + console.log(`[seed-channels] batch=${batch} ${commit ? 'COMMIT' : 'DRY-RUN'} ` + + `inserted=${inserted} updated=${updated} skipped=${skipped} retired=${retired}`); +} +main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); }); +``` + +- [ ] **Step 6: Add the package.json script entry** + +In `package.json` `scripts`, add: + +```json +"seed-channels": "cds bind --exec -- node scripts/seed-channels.cjs" +``` + +- [ ] **Step 7: Write the failing seed idempotency test** + +```js +// test/channels-seed.test.js +import cds from '@sap/cds'; +import { describe, it, expect, afterAll } from 'vitest'; +import { normalizeChannel } from '../srv/lib/channels/normalize.js'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +const NS = 'com.sap.developers.ims'; +const linked = () => cds.linked(cds.model).entities(NS); + +// Mirror the seed's upsert semantics (curated-column preservation) directly against the DB. +async function upsert(raw, batch, { commit = true } = {}) { + const { Channels } = linked(); + const row = normalizeChannel(raw, batch); + const existing = await SELECT.one.from(Channels).where({ sourceId: row.sourceId }); + const CURATED = ['isPublished', 'isFeatured', 'editorialNote', 'linkStatus', 'linkStatusOverride', 'lastChecked']; + if (existing && existing.contentHash === row.contentHash) return 'skipped'; + if (existing) { + const patch = { ...row }; for (const k of CURATED) delete patch[k]; + if (commit) await UPDATE(Channels).set(patch).where({ ID: existing.ID }); + return 'updated'; + } + if (commit) await INSERT.into(Channels).entries({ ID: cds.utils.uuid(), ...row }); + return 'inserted'; +} + +describe('channels seed upsert', () => { + const base = { id: 'seed-001', name: 'Portal', url: 'https://p', owner_type: 'SAP Official', status: 'Active', purpose: 'A. [cite: 1]' }; + afterAll(async () => { await DELETE.from(linked().Channels).where({ sourceId: 'seed-001' }); }); + + it('inserts, then skips unchanged, and preserves curated columns on change', async () => { + expect(await upsert(base, '2026-09-03')).toBe('inserted'); + // curator flips isFeatured + const { Channels } = linked(); + await UPDATE(Channels).set({ isFeatured: true }).where({ sourceId: 'seed-001' }); + // same content → skip + expect(await upsert(base, '2026-09-03')).toBe('skipped'); + // changed purpose → update source col, keep isFeatured + expect(await upsert({ ...base, purpose: 'B.' }, '2026-09-10')).toBe('updated'); + const row = await SELECT.one.from(Channels).where({ sourceId: 'seed-001' }); + expect(row.purpose).toBe('B.'); + expect(row.isFeatured).toBe(true); + }); +}); +``` + +- [ ] **Step 8: Run seed test to verify it passes** + +Run: `npx vitest run test/channels-seed.test.js` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add srv/lib/channels/normalize.js scripts/seed-channels.cjs package.json test/channels-normalize.test.js test/channels-seed.test.js +git commit -m "feat(channels): normalize module + idempotent re-ingestable seed CLI" +``` + +--- + +### Task 3: `/build/channels` read feed + +**Files:** +- Modify: `srv/server.js` (add route in the `/build/*` block, ~line 337) +- Test: `test/build-channels-feed.test.js` + +**Interfaces:** +- Consumes: `com.sap.developers.ims.Channels` (Task 1). +- Produces: `GET /build/channels` → `{ channels: [...], buildAt: ISOString }`. Each channel includes parsed array columns and coalesced `linkStatus` (override wins); `isPublished: false` and `linkStatus === 'BROKEN'` rows are excluded. + +- [ ] **Step 1: Write the failing feed test** + +```js +// test/build-channels-feed.test.js +import cds from '@sap/cds'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +const NS = 'com.sap.developers.ims'; +const linked = () => cds.linked(cds.model).entities(NS); + +describe('GET /build/channels', () => { + beforeAll(async () => { + const { Channels } = linked(); + await INSERT.into(Channels).entries([ + { ID: cds.utils.uuid(), sourceId: 'feed-pub', name: 'Pub', url: 'https://pub', isPublished: true, linkStatus: 'OK', focusAreas: ['btp'] }, + { ID: cds.utils.uuid(), sourceId: 'feed-unpub', name: 'Unpub', url: 'https://unpub', isPublished: false, linkStatus: 'OK' }, + { ID: cds.utils.uuid(), sourceId: 'feed-broken', name: 'Broken', url: 'https://broken', isPublished: true, linkStatus: 'BROKEN' }, + ]); + }); + afterAll(async () => { + await DELETE.from(linked().Channels).where({ sourceId: { in: ['feed-pub', 'feed-unpub', 'feed-broken'] } }); + }); + + it('returns only published, non-broken channels with parsed arrays', async () => { + const { status, data } = await project.get('/build/channels'); + expect(status).toBe(200); + const ids = data.channels.map((c) => c.sourceId); + expect(ids).toContain('feed-pub'); + expect(ids).not.toContain('feed-unpub'); + expect(ids).not.toContain('feed-broken'); + const pub = data.channels.find((c) => c.sourceId === 'feed-pub'); + expect(pub.focusAreas).toEqual(['btp']); + expect(typeof data.buildAt).toBe('string'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/build-channels-feed.test.js` +Expected: FAIL — 404 on `/build/channels`. + +- [ ] **Step 3: Add the route in `srv/server.js`** + +Insert next to `GET /build/homepage-shelves` (~line 337). Note the array-column parse guard — HANA returns `array of String` columns as JSON strings; SQLite returns arrays. + +```js +app.get('/build/channels', async (_req, res) => { + const db = await cds.connect.to('db'); + const rows = await db.run( + SELECT.from('com.sap.developers.ims.Channels') + .where({ isPublished: true }) + .orderBy('category', 'name'), + ); + const parseArr = (v) => (Array.isArray(v) ? v : (typeof v === 'string' && v ? JSON.parse(v) : [])); + const channels = rows + .map((r) => ({ + ...r, + linkStatus: r.linkStatusOverride || r.linkStatus, + focusAreas: parseArr(r.focusAreas), + tags: parseArr(r.tags), + relatedUrls: parseArr(r.relatedUrls), + aliases: parseArr(r.aliases), + })) + .filter((r) => r.linkStatus !== 'BROKEN'); + res.set('Cache-Control', 'public, max-age=60'); + res.json({ channels, buildAt: new Date().toISOString() }); +}); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/build-channels-feed.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add srv/server.js test/build-channels-feed.test.js +git commit -m "feat(channels): /build/channels read feed (published, non-broken, parsed arrays)" +``` + +--- + +### Task 4: Hugo bake — `scripts/fetch-channels.ts` + build wiring + +**Files:** +- Create: `scripts/fetch-channels.ts` +- Modify: `package.json` (add `fetch-channels` script + insert into the `build:all` chain, line ~90) +- Test: manual bake verification (build script; no unit test — mirrors sibling fetchers which have none) + +**Interfaces:** +- Consumes: `GET /build/channels` (Task 3). +- Produces: `hugo/data/channels.json` shaped `{ channels: [...], buildAt, error }`. Consumed by Task 5 via `.Site.Data.channels`. + +- [ ] **Step 1: Write `scripts/fetch-channels.ts`** (mirror `scripts/fetch-homepage-shelves.ts`) + +```ts +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const CAP_BASE = process.env.CAP_BASE_URL || 'http://localhost:4004'; +const OUT_PATH = join('hugo', 'data', 'channels.json'); + +let payload: { channels: unknown[]; buildAt: string; error: string | null } = { + channels: [], buildAt: new Date().toISOString(), error: null, +}; +try { + const res = await fetch(`${CAP_BASE}/build/channels`); + if (!res.ok) throw new Error(`status ${res.status}`); + payload = { ...payload, ...(await res.json()) }; +} catch (err) { + payload.error = err instanceof Error ? err.message : String(err); + console.warn(`[fetch-channels] warn: ${payload.error} — writing empty payload`); +} +mkdirSync(join('hugo', 'data'), { recursive: true }); +writeFileSync(OUT_PATH, JSON.stringify(payload, null, 2), 'utf-8'); +console.log(`[fetch-channels] wrote ${payload.channels.length} channels → ${OUT_PATH}`); +``` + +- [ ] **Step 2: Add the package.json script entry** + +In `scripts`, next to `fetch-homepage-shelves`: + +```json +"fetch-channels": "tsx scripts/fetch-channels.ts" +``` + +- [ ] **Step 3: Insert into the `build:all` chain** + +In the `build:all` script value, add `&& npm run fetch-channels` immediately after `npm run fetch-homepage-shelves`, before `npm run build:hugo`. + +- [ ] **Step 4: Verify the bake against a running CAP** + +Run (with `cds watch` up and the seed applied): +```bash +npm run fetch-channels && npx jq '.channels | length' hugo/data/channels.json +``` +Expected: prints a positive count; `hugo/data/channels.json` exists with a `channels` array. (With CAP down, it writes an empty payload with `error` set — the deliberate warn-and-continue convention.) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/fetch-channels.ts package.json +git commit -m "feat(channels): bake /build/channels into hugo/data/channels.json" +``` + +--- + +### Task 5: `/channels` directory — Hugo page + Vue facet island + +Client-side facet/search over the baked JSON embedded in the page (no runtime API call — mirrors the offline-capable island pattern). + +**Files:** +- Create: `hugo/content/channels/_index.md` +- Create: `hugo/layouts/channels/list.html` +- Create: `hugo-apps/src/channels-directory/index.ts` +- Create: `hugo-apps/src/channels-directory/ChannelsDirectory.vue` +- Create: `hugo-apps/src/channels-directory/filter.ts` +- Modify: `hugo-apps/vite.config.ts` (add rollup input) +- Test: `hugo-apps/src/channels-directory/filter.test.ts` + +**Interfaces:** +- Consumes: `hugo/data/channels.json` (Task 4) via `.Site.Data.channels.channels`; island manifest via `island-src.html`. +- Produces: `filter.ts` exports `filterChannels(channels, { query, category, ownerScope, platform }) -> Channel[]` where `ownerScope ∈ 'all'|'sap'|'community'`. + +- [ ] **Step 1: Write the failing filter test** + +```ts +// hugo-apps/src/channels-directory/filter.test.ts +import { describe, it, expect } from 'vitest'; +import { filterChannels } from './filter'; + +const data = [ + { name: 'BTP Docs', category: 'Portal', platform: 'Web', isSapOwned: true, purpose: 'docs', tags: ['btp'] }, + { name: 'Reddit SAP', category: 'Community', platform: 'Web', isSapOwned: false, purpose: 'forum', tags: ['community'] }, +]; + +describe('filterChannels', () => { + it('matches query across name/purpose/tags', () => { + expect(filterChannels(data, { query: 'reddit' }).map((c) => c.name)).toEqual(['Reddit SAP']); + expect(filterChannels(data, { query: 'btp' }).map((c) => c.name)).toEqual(['BTP Docs']); + }); + it('filters by owner scope', () => { + expect(filterChannels(data, { ownerScope: 'sap' }).map((c) => c.name)).toEqual(['BTP Docs']); + expect(filterChannels(data, { ownerScope: 'community' }).map((c) => c.name)).toEqual(['Reddit SAP']); + }); + it('filters by category and platform', () => { + expect(filterChannels(data, { category: 'Portal' })).toHaveLength(1); + expect(filterChannels(data, { platform: 'Web' })).toHaveLength(2); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit hugo-apps/src/channels-directory/filter.test.ts` +Expected: FAIL — cannot resolve `./filter`. + +- [ ] **Step 3: Write `hugo-apps/src/channels-directory/filter.ts`** + +```ts +export interface Channel { + name: string; url?: string; purpose?: string; category?: string; + platform?: string; isSapOwned?: boolean; tags?: string[]; ownerType?: string; +} +export interface FilterState { + query?: string; category?: string; platform?: string; + ownerScope?: 'all' | 'sap' | 'community'; +} +export function filterChannels(channels: Channel[], state: FilterState): Channel[] { + const q = (state.query || '').trim().toLowerCase(); + return channels.filter((c) => { + if (state.category && c.category !== state.category) return false; + if (state.platform && c.platform !== state.platform) return false; + if (state.ownerScope === 'sap' && !c.isSapOwned) return false; + if (state.ownerScope === 'community' && c.isSapOwned) return false; + if (q) { + const hay = `${c.name} ${c.purpose || ''} ${(c.tags || []).join(' ')}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --project unit hugo-apps/src/channels-directory/filter.test.ts` +Expected: PASS. + +- [ ] **Step 5: Write the Vue component `ChannelsDirectory.vue`** + +```vue + + + +``` + +- [ ] **Step 6: Write the island entry `index.ts`** + +```ts +import { createApp } from 'vue'; +import ChannelsDirectory from './ChannelsDirectory.vue'; + +function boot() { + document.querySelectorAll('[data-island="channels-directory"]').forEach((el) => { + const dataEl = document.getElementById('channels-data'); + let channels: unknown[] = []; + try { channels = JSON.parse(dataEl?.textContent || '[]'); } catch { channels = []; } + createApp(ChannelsDirectory, { channels }).mount(el); + }); +} +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); +else boot(); +``` + +- [ ] **Step 7: Register the Vite input** + +In `hugo-apps/vite.config.ts` `rollupOptions.input`, add: + +```ts +'channels-directory': resolve(__dirname, 'src/channels-directory/index.ts'), +``` + +- [ ] **Step 8: Write the Hugo section + layout** + +`hugo/content/channels/_index.md`: + +```markdown +--- +title: "SAP Developer Channels" +description: "The portals, docs, repos, communities, and voices SAP developers use every day." +layout: "list" +--- +``` + +`hugo/layouts/channels/list.html`: + +```go-html-template +{{ define "main" }} +{{- $channels := (.Site.Data.channels.channels) | default slice -}} +
+
+

{{ .Title }}

+

{{ .Description }}

+
+ +
+ +
+ +{{ end }} +``` + +- [ ] **Step 9: Run the filter test again + build the islands** + +Run: `npx vitest run --project unit hugo-apps/src/channels-directory/filter.test.ts && npm --prefix hugo-apps run build` +Expected: test PASS; Vite build emits `channels-directory-.js` into `hugo/static/js/`. + +- [ ] **Step 10: Commit** + +```bash +git add hugo/content/channels/_index.md hugo/layouts/channels/list.html hugo-apps/src/channels-directory/ hugo-apps/vite.config.ts +git commit -m "feat(channels): /channels directory page + Vue facet/search island" +``` + +--- + +### Task 6: Surface A — promote featured channels into `HomepageShelves` + +**Files:** +- Create: `srv/lib/channels/promote-to-shelves.js` +- Create: `scripts/promote-channels-to-shelves.cjs` +- Modify: `package.json` (add `promote-channels` script) +- Test: `test/channels-promote.test.js` + +**Interfaces:** +- Consumes: `com.sap.developers.ims.Channels` (Task 1), `com.sap.developers.ims.HomepageShelves` (`db/homepage.cds`). +- Produces: `promote-to-shelves.js` exports `mapChannelToShelf(channel) -> { verb, shelf } | null` and `promoteFeatured(db) -> { upserted, skipped }`. Upserts `HomepageShelves` on `(verb, url)` (honors `@assert.unique.verbUrl`). Community/third-party (`isSapOwned === false`) is never mapped to `START_HERE`. + +- [ ] **Step 1: Write the failing mapping test** + +```js +// test/channels-promote.test.js +import cds from '@sap/cds'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mapChannelToShelf, promoteFeatured } from '../srv/lib/channels/promote-to-shelves.js'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +const NS = 'com.sap.developers.ims'; +const linked = () => cds.linked(cds.model).entities(NS); + +describe('mapChannelToShelf', () => { + it('maps an SAP learning portal to START_HERE/learn', () => { + expect(mapChannelToShelf({ isSapOwned: true, category: 'Learning', focusAreas: ['onboarding'] })) + .toEqual({ verb: 'learn', shelf: 'START_HERE' }); + }); + it('never puts a community channel in START_HERE', () => { + const m = mapChannelToShelf({ isSapOwned: false, category: 'Learning', focusAreas: ['onboarding'] }); + expect(m?.shelf).not.toBe('START_HERE'); + }); + it('maps a GitHub repo to TOOLS', () => { + expect(mapChannelToShelf({ isSapOwned: true, category: 'GitHub Repository', focusAreas: ['cap'] }).shelf).toBe('TOOLS'); + }); +}); + +describe('promoteFeatured', () => { + beforeAll(async () => { + const { Channels } = linked(); + await INSERT.into(Channels).entries([ + { ID: cds.utils.uuid(), sourceId: 'promo-sap', name: 'CAP Docs', url: 'https://promo-cap', isSapOwned: true, isFeatured: true, isPublished: true, category: 'Portal', focusAreas: ['cap'] }, + { ID: cds.utils.uuid(), sourceId: 'promo-comm', name: 'Reddit', url: 'https://promo-reddit', isSapOwned: false, isFeatured: true, isPublished: true, category: 'Community', focusAreas: ['abap'] }, + ]); + }); + afterAll(async () => { + await DELETE.from(linked().Channels).where({ sourceId: { in: ['promo-sap', 'promo-comm'] } }); + await DELETE.from(linked().HomepageShelves).where({ url: { in: ['https://promo-cap', 'https://promo-reddit'] } }); + }); + + it('upserts featured channels into HomepageShelves and is idempotent', async () => { + const db = await cds.connect.to('db'); + const first = await promoteFeatured(db); + expect(first.upserted).toBeGreaterThan(0); + const second = await promoteFeatured(db); + expect(second.upserted).toBe(0); // already present → skipped on second run + const { HomepageShelves } = linked(); + const reddit = await SELECT.one.from(HomepageShelves).where({ url: 'https://promo-reddit' }); + expect(reddit.badge).toBe('THIRD_PARTY'); + expect(reddit.shelf).not.toBe('START_HERE'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/channels-promote.test.js` +Expected: FAIL — cannot resolve `../srv/lib/channels/promote-to-shelves.js`. + +- [ ] **Step 3: Write `srv/lib/channels/promote-to-shelves.js`** + +```js +'use strict'; +const cds = require('@sap/cds'); + +// Deterministic category → shelf and focus → verb defaults (admin-overridable later). +const CATEGORY_TO_SHELF = { + 'Portal': 'REFERENCE', 'Documentation': 'REFERENCE', 'Docs': 'REFERENCE', + 'GitHub Repository': 'TOOLS', 'Package Registry': 'TOOLS', 'Tool': 'TOOLS', + 'YouTube': 'KEEP_CURRENT', 'Podcast': 'KEEP_CURRENT', 'Blog': 'KEEP_CURRENT', 'News': 'KEEP_CURRENT', + 'Learning': 'START_HERE', 'Community': 'REFERENCE', +}; +const FOCUS_TO_VERB = [ + [['integration'], 'integrate'], [['ops', 'admin', 'operations'], 'operate'], + [['ai', 'genai'], 'AI'], [['rap', 'data-model', 'cds'], 'model'], + [['abap', 'cap', 'sdk', 'build'], 'build'], [['onboarding', 'tutorial', 'learn'], 'learn'], +]; + +function pickVerb(focusAreas = []) { + const lower = focusAreas.map((f) => String(f).toLowerCase()); + for (const [keys, verb] of FOCUS_TO_VERB) if (keys.some((k) => lower.includes(k))) return verb; + return 'build'; +} + +function mapChannelToShelf(channel) { + let shelf = CATEGORY_TO_SHELF[channel.category] || 'REFERENCE'; + // community / third-party may never land in START_HERE + if (shelf === 'START_HERE' && channel.isSapOwned !== true) shelf = 'REFERENCE'; + return { verb: pickVerb(channel.focusAreas), shelf }; +} + +async function promoteFeatured(db) { + const linked = cds.linked(cds.model ?? (await cds.load('*'))); + const { Channels, HomepageShelves } = linked.entities('com.sap.developers.ims'); + const featured = await db.run(SELECT.from(Channels).where({ isFeatured: true, isPublished: true })); + let upserted = 0, skipped = 0; + for (const ch of featured) { + const { verb, shelf } = mapChannelToShelf(ch); + const existing = await db.run(SELECT.one.from(HomepageShelves).where({ verb, url: ch.url })); + if (existing) { skipped++; continue; } + await db.run(INSERT.into(HomepageShelves).entries({ + ID: cds.utils.uuid(), verb, shelf, url: ch.url, title: ch.name, + description: ch.editorialNote || ch.purpose, whyItMatters: ch.editorialNote || null, + isExternal: true, isActive: true, badge: ch.isSapOwned ? null : 'THIRD_PARTY', + authoringStatus: 'AI_SEEDED', sortOrder: 500, + })); + upserted++; + } + return { upserted, skipped }; +} + +module.exports = { mapChannelToShelf, promoteFeatured, CATEGORY_TO_SHELF, FOCUS_TO_VERB }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/channels-promote.test.js` +Expected: PASS. + +- [ ] **Step 5: Write the CLI wrapper `scripts/promote-channels-to-shelves.cjs`** + +```js +'use strict'; +const cds = require('@sap/cds'); +const { promoteFeatured } = require('../srv/lib/channels/promote-to-shelves.js'); + +(async () => { + await cds.load('*'); + const db = await cds.connect.to('db'); + const { upserted, skipped } = await promoteFeatured(db); + console.log(`[promote-channels] upserted=${upserted} skipped=${skipped}`); + process.exit(0); +})().catch((e) => { console.error(e); process.exit(1); }); +``` + +- [ ] **Step 6: Add the package.json script entry** + +```json +"promote-channels": "cds bind --exec -- node scripts/promote-channels-to-shelves.cjs" +``` + +- [ ] **Step 7: Commit** + +```bash +git add srv/lib/channels/promote-to-shelves.js scripts/promote-channels-to-shelves.cjs package.json test/channels-promote.test.js +git commit -m "feat(channels): promote featured channels into HomepageShelves (verb-lane fill)" +``` + +--- + +### Task 7: Admin — `Channels` CRUD (service projection + Fiori Elements app + shell wiring) + +**Files:** +- Modify: `srv/admin-service.cds` (add `Channels` projection) +- Create: `app/admin/channels/package.json`, `ui5.yaml` +- Create: `app/admin/channels/webapp/Component.js`, `webapp/manifest.json`, `webapp/i18n/i18n.properties` +- Modify: `app/admin-shell/webapp/manifest.json` (resourceRoot + componentUsage + route + target) +- Test: `test/admin-channels.test.js` + +**Interfaces:** +- Consumes: `com.sap.developers.ims.Channels` (Task 1), `AdminService` (`@path:'/admin'`, `db/admin-service.cds`). +- Produces: `GET /admin/Channels` (admin-auth) list; draft-enabled ObjectPage for editing `isPublished`, `isFeatured`, `editorialNote`, `linkStatusOverride`. + +- [ ] **Step 1: Write the failing admin-service test** + +```js +// test/admin-channels.test.js +import cds from '@sap/cds'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +const adminAuth = { auth: { username: 'admin', password: 'admin' } }; +const NS = 'com.sap.developers.ims'; +const linked = () => cds.linked(cds.model).entities(NS); + +describe('AdminService.Channels', () => { + beforeAll(async () => { + await INSERT.into(linked().Channels).entries({ + ID: cds.utils.uuid(), sourceId: 'admin-001', name: 'Admin Test', url: 'https://admin-test', isPublished: true, + }); + }); + afterAll(async () => { await DELETE.from(linked().Channels).where({ sourceId: 'admin-001' }); }); + + it('is exposed at /admin/Channels and requires admin auth', async () => { + await expect(project.get('/admin/Channels')).rejects.toMatchObject({ response: { status: 401 } }); + const { status, data } = await project.get('/admin/Channels', adminAuth); + expect(status).toBe(200); + expect(data.value.some((c) => c.sourceId === 'admin-001')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/admin-channels.test.js` +Expected: FAIL — `/admin/Channels` 404/not found. + +- [ ] **Step 3: Add the projection to `srv/admin-service.cds`** + +Next to the `HomepageShelves` projection (~line 291): + +```cds +@odata.draft.enabled +entity Channels as projection on ims.Channels; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/admin-channels.test.js` +Expected: PASS. + +- [ ] **Step 5: Create the Fiori Elements app** (mirror `app/admin/homepage/`) + +`app/admin/channels/webapp/Component.js`: + +```js +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("sap.tutorials.admin.channels.Component", { metadata: { manifest: "json" } }); +}); +``` + +`app/admin/channels/webapp/manifest.json`: + +```json +{ + "_version": "1.65.0", + "sap.app": { + "id": "sap.tutorials.admin.channels", + "type": "application", + "title": "Channels", + "dataSources": { + "mainService": { "uri": "/admin/", "type": "OData", "settings": { "odataVersion": "4.0" } } + }, + "crossNavigation": { "inbounds": { "Channels-manage": { "semanticObject": "Channels", "action": "manage", "signature": { "parameters": {}, "additionalParameters": "allowed" } } } } + }, + "sap.ui5": { + "dependencies": { "libs": { "sap.fe.templates": {} } }, + "models": { "": { "dataSource": "mainService", "settings": { "operationMode": "Server", "autoExpandSelect": true, "earlyRequests": true } } }, + "routing": { + "routes": [ + { "name": "ChannelsList", "pattern": ":?query:", "target": "ChannelsList" }, + { "name": "ChannelsObject", "pattern": "Channels({key}):?query:", "target": "ChannelsObject" } + ], + "targets": { + "ChannelsList": { "type": "Component", "id": "ChannelsList", "name": "sap.fe.templates.ListReport", "options": { "settings": { "contextPath": "/Channels", "initialLoad": "Enabled" } } }, + "ChannelsObject": { "type": "Component", "id": "ChannelsObject", "name": "sap.fe.templates.ObjectPage", "options": { "settings": { "contextPath": "/Channels" } } } + } + } + } +} +``` + +`app/admin/channels/webapp/i18n/i18n.properties`: + +```properties +appTitle=Channels +appDescription=Curate external SAP developer channels +``` + +`app/admin/channels/package.json` and `ui5.yaml`: copy verbatim from `app/admin/homepage/` and rename `id`/`name` fields to `sap.tutorials.admin.channels`. + +- [ ] **Step 6: Wire the app into the admin shell** + +In `app/admin-shell/webapp/manifest.json`, add the four entries (mirror the `homepage` quartet): +- `sap.ui5.resourceRoots`: `"sap.tutorials.admin.channels": "./components/channels"` +- `sap.ui5.componentUsages`: `"channelsComponent": { "name": "sap.tutorials.admin.channels", "lazy": true }` +- `sap.ui5.routing.routes`: `{ "name": "channels", "pattern": "channels", "target": [{ "name": "channelsTarget", "prefix": "ch" }] }` +- `sap.ui5.routing.targets`: `"channelsTarget": { "type": "Component", "usage": "channelsComponent", "id": "channelsTarget", "viewLevel": 1, "prefix": "ch" }` + +(If the shell uses `manifest.template.json` + `generate-manifest.js`, edit the template and re-run `npm --prefix app/admin-shell run build`.) + +- [ ] **Step 7: Add a UI-nav entry** (if the shell has a side-nav list — mirror the `homepage` `sap.tnt.NavigationListItem`): add a "Channels" item pointing to the `channels` route. Locate via the existing `homepage`/`homepageShelves` nav item in the shell's `ToolPage` view/controller and add a sibling. + +- [ ] **Step 8: Build the shell and verify no manifest error** + +Run: `npm --prefix app/admin-shell run build` +Expected: build succeeds; `components/channels/` present in the shell output. + +- [ ] **Step 9: Commit** + +```bash +git add srv/admin-service.cds app/admin/channels/ app/admin-shell/webapp/manifest.json test/admin-channels.test.js +git commit -m "feat(channels): admin CRUD app + AdminService.Channels projection + shell wiring" +``` + +--- + +### Task 8: Full-suite gate + docs pointer + +**Files:** +- Modify: `docs/developers/reference/tutorials-ims-gotchas.md` (or a new `docs/developers/reference/channels.md`) — one section documenting the channels subsystem +- Modify: `CLAUDE.md` (one Top-Gotchas bullet pointing to the doc) + +- [ ] **Step 1: Run the full unit suite** + +Run: `npm test` +Expected: all channels tests green; no pre-existing test regressed. If a pre-existing anon-write test breaks because of the new admin projection, update that test (service-guard rule). + +- [ ] **Step 2: Compile-check the model one more time** + +Run: `npx cds deploy --to sqlite::memory:` +Expected: exits 0. + +- [ ] **Step 3: Write the reference doc section** + +Document: the `Channels` entity + namespace/journal requirement; `seed-channels` re-ingest CLI (idempotent, preserves curated columns, retire-on-absence); `/build/channels` → `hugo/data/channels.json` → `/channels` directory island; `promote-channels` verb-lane fill (community never START_HERE); admin app location; that `fetch-channels` is wired into `build:all`. + +- [ ] **Step 4: Add the CLAUDE.md gotcha bullet** + +One bullet under Top Gotchas linking to the doc, e.g.: +`- **External channels subsystem** — `Channels` entity is the source of truth; re-ingest via `npm run seed-channels`; directory at `/channels`; verb-lane fill via `npm run promote-channels`. → channels.md.` + +- [ ] **Step 5: Commit** + +```bash +git add docs/ CLAUDE.md +git commit -m "docs(channels): document channels subsystem + gotcha pointer" +``` + +--- + +## Self-Review + +**1. Spec coverage (P1 scope):** +- §5.1 `Channels` entity → Task 1 ✓ +- §6 ingestion (clean/normalize/hash/idempotent upsert/preserve curated/retire) → Task 2 ✓ +- §7 Surface A verb-lane fill (category→shelf, focus→verb, community-never-START_HERE, THIRD_PARTY badge) → Task 6 ✓ +- §8 Surface B directory (facets: category/platform/SAP-vs-community, search) → Tasks 3–5 ✓ +- §10 Tier-1 deterministic facets → Task 5 ✓ (Tier-2 editorial collections = P2, out of P1 scope) +- §13 admin (Channels app) → Task 7 ✓ +- §14 link-health: P1 filters `BROKEN` in the feed (Task 3) + directory `noscript`/island; the nightly job *extension* to `Channels.url` is deferred to a follow-up (spec §14 reuses the existing job) — noted, not silently dropped. +- §9 Surface C, §5.2–5.4 collections/crosswalk/submissions → P2–P4, explicitly out of P1 scope. + +**2. Placeholder scan:** No TBD/TODO; every code step has real code. Task 5 Step 8 and Task 7 Step 5 reference "copy verbatim from `app/admin/homepage/`" for boilerplate (`package.json`/`ui5.yaml`) — acceptable because those files are pure scaffolding with a single renamed id, and the exact rename is stated. + +**3. Type consistency:** `normalizeChannel(raw, ingestBatch)` signature consistent across Tasks 2 tests + impl. `mapChannelToShelf`/`promoteFeatured` signatures consistent across Task 6 test + impl. Entity reflection via `cds.linked(...).entities('com.sap.developers.ims')` used identically in every task. `filterChannels(channels, state)` consistent across Task 5 test + impl + component. Feed shape `{ channels, buildAt }` consistent Task 3 → Task 4 → Task 5. + +**Follow-up plans (not this plan):** P2 editorial `ChannelCollections`; P3 `ChannelTopicMap` crosswalk + per-topic bands; P4 `ChannelSubmissions` moderation loop. Each gets its own spec-derived plan. diff --git a/docs/superpowers/specs/2026-09-04-external-channels-integration-design.md b/docs/superpowers/specs/2026-09-04-external-channels-integration-design.md new file mode 100644 index 000000000..e89845020 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-external-channels-integration-design.md @@ -0,0 +1,264 @@ +# External SAP Channels — Site Integration Design + +*Design spec for incorporating the consolidated 238-channel SAP developer-channels dataset into developers.sap.com as a living, curated, navigable part of the site.* + +- **Date:** 2026-09-04 +- **Status:** Draft for review +- **Source dataset:** `External-SAP-Channels-Complete.json` (238 channels; schema_version 2.1.0) +- **Related surfaces:** verb-lane shelves (`db/homepage.cds` → `HomepageShelves`), topic pages (`/topics/*` Hugo taxonomy, `topic_clusters.json`), Knowledge Graph external content (`db/external-content.cds`) + +> **CDS note:** All entity shapes below are proposals grounded in the existing `db/homepage.cds` conventions (`cuid`, `managed`, `authoringStatus`, `badge`, link-health fields). Actual CDS authoring in the implementation phase must be validated with `cds-mcp` per project rules before landing. + +--- + +## 1. Context & problem + +developers.sap.com currently surfaces **internal content** (tutorials, missions, blogs, videos, events) plus a **verb-scoped set of curated external links** (`HomepageShelves`, badged `THIRD_PARTY`). The wider world a developer actually lives in — 238 channels spanning portals, docs, GitHub, package registries, YouTube, podcasts, community Q&A, user groups, and independent trainers — is not represented on the site as a first-class, navigable thing. + +Two gaps: + +1. **Coverage gaps on verb-lane shelves.** The four shelves (`START_HERE`/`REFERENCE`/`TOOLS`/`KEEP_CURRENT`) have thin spots the dataset can fill with best-in-class links. +2. **No home for the breadth.** ~1/3 of the trusted developer surface is community-run (Stack Overflow, Reddit, Slack, user groups, open-source projects, independent trainers) and the site surfaces none of it. There is no per-topic "related resources," no browsable directory, and no way for the community to propose additions. + +**A raw list of 238 links is not the goal.** The value is in *curation, clustering, and navigation* — guiding a developer to the right channel at the right moment. + +## 2. Goals + +- A **single living source of truth** for external channels, re-ingestable as the dataset evolves. +- **Fill verb-shelf gaps** with a curated subset (Surface A). +- A **`/channels` destination** — clustered, explained, faceted, browsable — as the deep home for the full set (Surface B). +- **Per-topic "related channels"** woven onto `/topics/*` and tutorial pages via a topic crosswalk (Surface C). +- **Editorial clustering + navigation** so the breadth reads as guided, not dumped. +- A **community submission & moderation loop** (propose add/change/remove → SAP review → publish). +- **Admin-UI/DB-driven curation** consistent with the existing shelf admin apps and Tom's DB-over-env preference. + +## 3. Non-goals (YAGNI) + +- Full Knowledge-Graph ingestion of channels (PageRank/community detection over channels). Deferred; revisit only if per-topic relevance proves insufficient. +- Auto-crawling channel content (feeds, subscriber counts live). Metadata is refreshed by re-ingesting an updated dataset, not by live scraping. +- Personalized channel recommendations. The `personaTags` primitive exists on shelves; we do not build channel-level personalization in this scope. +- Replacing the existing `HomepageShelves` third-party mechanism. We *feed* it, not replace it. + +## 4. Architecture overview + +One ingestion pipeline produces normalized rows in a new `Channels` entity. Three surfaces derive from it; two supporting subsystems (collections, crosswalk) and one workflow (submissions) hang off it. + +```text +External-SAP-Channels-Complete.json + → scripts/seed-channels.cjs (normalize + idempotent upsert + status reconcile) + → Channels (source of truth, HANA) + ├── Surface A: curated subset → HomepageShelves (verb lanes) [reuse existing] + ├── Surface B: /channels feed → Vue island directory [new] + │ └── ChannelCollections (+ items) — editorial clusters + ├── Surface C: ChannelTopicMap crosswalk → /topics/* + tutorials [new] + └── ChannelSubmissions → admin moderation queue → mutate Channels [new] +``` + +## 5. Data model + +### 5.1 `Channels` (source of truth) + +Mirrors the source dataset fields plus lifecycle/curation columns. Dedup key is the source `id` (e.g. `portal-001`); `contentHash` drives idempotent re-ingest. + +```cds +type ChannelOwnerType : String enum { + SAP_Official; SAP_Developer_Advocate; SAP_Executive; + Community_Member; Community_Organization; User_Group; + Third_party_Training; Third_party_Media; Third_party_Platform; +} +type ChannelStatus : String enum { Active; Archived; Closed; Discontinued; EOL; } + +@assert.unique.sourceId: [sourceId] +entity Channels : cuid, managed { + sourceId : String(40) @mandatory; // "portal-001" — dedup/re-ingest key + name : String(200) @mandatory; + url : String(500) @mandatory; + relatedUrls : array of String(500); + aliases : array of String(120); + purpose : String(1000); // cleaned of [cite:] markers at ingest + notes : String(1000); + ownerName : String(120); + ownerType : ChannelOwnerType @assert.range; + isSapOwned : Boolean default false; + category : String(60); // "Portal", "GitHub Repository", ... + subcategory : String(80); + platform : String(40); // "Web", "YouTube", "GitHub", ... + status : ChannelStatus default 'Active' @assert.range; + focusAreas : array of String(60); + tags : array of String(40); + updateFrequency: String(40); + githubStars : Integer; + subscribers : Integer; + + // ── curation / lifecycle (admin-editable; absent from ingest so re-seed never wipes) ── + isPublished : Boolean default true; // show in directory + isFeatured : Boolean default false; // eligible for verb shelves / topic bands + editorialNote : String(800); // curator prose overriding purpose on cards + contentHash : String(64); // hash of source fields → skip unchanged on re-ingest + ingestBatch : String(40); // dataset generated-date; drives retire-on-absence + linkStatus : String(20) default 'UNKNOWN'; + linkStatusOverride : String(20); + lastChecked : Timestamp; +} +``` + +### 5.2 `ChannelCollections` + `ChannelCollectionItems` (editorial clusters) + +The "intelligent grouping + explanations" layer. A collection is a named, ordered, explained set of channels — LLM-drafted, human-reviewed (reuse the `AuthoringStatus` enum already in `homepage.cds`). + +```cds +entity ChannelCollections : cuid, managed { + slug : String(80) @mandatory; // "getting-started-abap-cloud" + title : String(140) @mandatory; + intro : String(1200); // narrative: what this cluster is, how to navigate it + sortOrder : Integer default 100; + isPublished : Boolean default false; + authoringStatus : AuthoringStatus default 'BLANK'; // BLANK | AI_SEEDED | REVIEWED + items : Composition of many ChannelCollectionItems on items.collection = $self; +} +entity ChannelCollectionItems : cuid { + collection : Association to ChannelCollections; + channel : Association to Channels; + sortOrder : Integer default 100; + blurb : String(280); // optional per-item "why it's in this collection / read this first" +} +``` + +### 5.3 `ChannelTopicMap` (Surface C crosswalk) + +Maps a channel to a site topic tag (the hierarchical `software-product>…` vocabulary in `hugo/data/tags.json`). LLM-drafted, human-reviewed. + +```cds +@assert.unique.pair: [channel_ID, topicTag] +entity ChannelTopicMap : cuid, managed { + channel : Association to Channels @mandatory; + topicTag : String(140) @mandatory; // "software-product>sap-business-technology-platform" + relevance : Integer default 50; // 0-100, orders the per-topic band + authoringStatus : AuthoringStatus default 'AI_SEEDED'; +} +``` + +### 5.4 `ChannelSubmissions` (community moderation queue) + +```cds +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 + proposed : LargeString; // JSON payload of proposed fields + rationale : String(1000); // submitter's "why" + submitterId : String(120); // XSUAA user id + status : SubmissionStatus default 'PENDING' @assert.range; + reviewerId : String(120); + reviewNote : String(800); +} +``` + +## 6. Ingestion pipeline + +`scripts/seed-channels.cjs` (follows the established `seed-*.cjs` convention): + +1. Read the dataset JSON. **Clean** each `purpose`/`notes` of `[cite: …]` markers. +2. Normalize enums (`owner_type` → `ChannelOwnerType`, `status` → `ChannelStatus`; map `"Entering EOL"`→`EOL`, `"Active (Canonical …)"`→`Active` + note). +3. Compute `contentHash` per row from source fields. +4. **Idempotent upsert on `sourceId`** (SELECT-then-UPDATE-or-INSERT, per the project's slug-upsert rule): unchanged hash → skip; changed → update source fields only, never the admin-curated columns (§5.1); new → insert. +5. **Retire on absence:** rows whose `sourceId` is absent from the newest `ingestBatch` are set `status` per the dataset's correction notes (or flagged for review), never hard-deleted. +6. Honor the dataset's `corrections_and_historical_notes` to auto-mark retired channels (openSAP, HANA Academy YT, ONE Support Launchpad). + +Re-running with an updated dataset is safe and non-destructive to curation. + +## 7. Surface A — fill verb-lane shelves + +- A curated subset (`isFeatured = true`) is promoted into `HomepageShelves`. +- **Category → shelf** default mapping (admin-overridable): Docs/Portal→`REFERENCE`; GitHub/registries/tools→`TOOLS`; YouTube/podcast/news/blogs→`KEEP_CURRENT`; Learning/entry portals→`START_HERE` **for SAP-official only** (third-party never lands in `START_HERE`, preserving the existing rule). +- **Focus_areas/tags → verb** mapping via a small lookup (abap/rap→`build`/`model`; integration→`integrate`; ops/admin→`operate`; ai→`AI`; onboarding/tutorials→`learn`). +- Promotion generates `HomepageShelves` rows carrying `badge=THIRD_PARTY` for community items, `isExternal=true`, and reuses existing link-health + `whyItMatters`. Community channels honor the §11 governance bar. + +## 8. Surface B — `/channels` directory + +- **Route:** top-level `/channels` (see Open Questions for verb-lane nesting alternative). Served via the HANA-BLOB content-page pattern (`page-channels`) consistent with the Phase-2 flip, or as a Hugo page hosting a Vue island fed by a new CAP feed `/build/channels` — decide in the plan; both are established patterns. +- **Landing structure (top → bottom):** + 1. Short intro (what this is, how developers use these channels). + 2. **Editorial collections** (`ChannelCollections`) — the lead navigation: a handful of explained clusters ("Get started with ABAP Cloud", "Stay current on AI", "Best community voices"), each with its intro and ordered items. + 3. **Faceted full list** — filter by category, focus area, SAP-official vs community, platform, status; text search over name/purpose/tags. + 4. **Per-channel detail** — name, link, purpose/`editorialNote`, owner, badges (SAP-official / community / third-party), related links, link-health. +- Community items clearly badged throughout. + +## 9. Surface C — per-topic "related channels" + +- A "Go deeper / follow" band renders on `/topics/*` term pages and (optionally) tutorial pages, sourced by joining `ChannelTopicMap` on the page's primary topic tag, ordered by `relevance`, capped (e.g. top 5), community items badged. +- The crosswalk (`ChannelTopicMap`) is **LLM-drafted then human-reviewed**: a generation pass proposes `(channel → topicTag, relevance)` rows as `AI_SEEDED`; a curator promotes to `REVIEWED` in the admin UI before they go live. Only `REVIEWED` (or a config-gated `AI_SEEDED`) rows render. +- `/topics/*` is currently a Hugo taxonomy; the band needs either a baked `channels_by_topic.json` data file (build-time) or a small island calling the feed. Prefer the baked-data approach to match existing `/topics/` rendering. + +## 10. Clustering & navigation + +Two tiers, so the page is guided not dumped: + +- **Tier 1 — deterministic facets** (free from `Channels` fields): category, focus area, SAP-vs-community, platform, status. The escape hatch for power users. +- **Tier 2 — editorial collections** (`ChannelCollections`): curated, ordered, *explained*. This is the primary navigation and where "good explanations around navigating the content" live. Seeded by an LLM clustering pass over `focus_areas`/`tags`/`purpose`, then human-reviewed. Each collection carries a narrative `intro` and optional per-item `blurb`. + +## 11. Governance — community channels + +Per Tom's decision: **include community-owned channels, clearly badged, with a stated inclusion bar** — and a community submission path (§12). + +- **Inclusion bar (documented, applied at review):** active (not dormant/dead), reputable (recognizable community standing or substantive following), on-topic (SAP developer relevance), and safe (no policy-violating content). +- **Labeling:** `owner_type`-derived badges — "SAP", "SAP Advocate", "Community", "User Group", "Third-party". Community items never appear in `START_HERE`; they appear in `REFERENCE`/`TOOLS`/`KEEP_CURRENT`, the directory, and (if `REVIEWED`) topic bands. +- Individuals (advocates, community voices) are represented primarily via the existing Developer Advocates page and a compact "Community voices" collection, not as a sprawl of individual rows. + +## 12. Community submission & moderation + +- **Submit:** a lightweight form (add a channel / propose an edit / flag for removal) writing a `ChannelSubmissions` row. **Login-required (XSUAA)** by default to deter spam (see Open Questions). +- **Review:** a moderation queue in the admin shell — approve/reject with a note. Approve applies the change to `Channels` (ADD inserts, EDIT patches curated fields, REMOVE sets `isPublished=false`/`status`). Reject closes with a reason. +- Submissions never mutate `Channels` directly; every change is an auditable review action (reuses `managed` + reviewer fields). + +## 13. Admin UI + +New Fiori Elements components in the existing admin shell (matches `app/admin/shelf-definitions/`, `app/admin/homepage/`): + +- **Channels** — browse/edit the source of truth; toggle `isPublished`/`isFeatured`; edit `editorialNote`, shelf/verb overrides. +- **Channel Collections** — CRUD collections + ordered items; edit intros/blurbs; flip `authoringStatus` to `REVIEWED`. +- **Channel Topic Map** — review/correct the crosswalk; promote `AI_SEEDED`→`REVIEWED`. +- **Channel Submissions** — moderation queue. + +All under XSUAA, consistent with existing admin scopes. + +## 14. Link health & lifecycle + +- Reuse the nightly link-health pattern (`srv/jobs/homepage-link-health.js`) extended to `Channels.url`; `linkStatusOverride` silences false-BROKEN on auth/bot-gated URLs (same as shelves). `BROKEN` links are filtered from the directory/bands but retained in admin for triage. +- Retirement is soft (§6.5), honoring dataset correction notes. + +## 15. Testing + +- **Ingest:** unit tests on `seed-channels.cjs` — cite-marker stripping, enum normalization, idempotent re-run (unchanged hash skips; curated columns preserved), retire-on-absence. Run `cds deploy --to sqlite::memory:` before committing model changes; validate HANA-qualified names (avoid the unqualified-entity-name HANA trap noted in project memory). +- **Surface A:** shelf-promotion mapping tests; assert community items never land in `START_HERE`. +- **Surface B/C:** feed shape tests; facet filtering; crosswalk join renders only `REVIEWED` rows. +- **Submissions:** approve/reject applies/rejects correctly; anon-write is rejected (update any pre-existing anon-POST tests per the service-guard rule). +- **e2e:** a committed spec for `/channels` (advisory nudge; runs post-DEV-deploy). + +## 16. Phasing + +- **P0 — CEO overview report** ✅ *(delivered: `SAP-Developer-Channels-Overview.md`; feeds a leadership PowerPoint; independent of the build).* +- **P1 — Foundation + Surface A + directory core:** `Channels` entity + `seed-channels.cjs` ingest; `/channels` directory with facets + per-channel detail; fill verb shelves. Admin: Channels app. +- **P2 — Clustering & navigation:** `ChannelCollections` + LLM-seed/review; collections lead the directory landing. Admin: Collections app. +- **P3 — Per-topic bands (Surface C):** `ChannelTopicMap` + LLM-draft/review crosswalk; `/topics/*` + tutorial bands. Admin: Topic Map app. +- **P4 — Community submission loop:** `ChannelSubmissions` + submit form + moderation queue. + +Each phase is independently shippable; P1 delivers standalone value. + +## 17. Open questions + +1. **Directory route placement:** top-level `/channels` (recommended) vs nested under a verb lane vs under `/explore/`. +2. **Submission access:** login-required (recommended, less spam) vs open with heavier moderation. +3. **Directory serving mechanism:** HANA-BLOB `page-channels` (matches Phase-2 content-page flip) vs Hugo page + island + live feed. Decide in P1 plan. +4. **`AI_SEEDED` visibility:** do we ever render un-reviewed collections/crosswalk rows behind a config flag, or hard-gate on `REVIEWED`? + +## 18. Reused vs new + +| Reused (existing) | New (this design) | +|---|---| +| `HomepageShelves` (`isExternal`, `badge=THIRD_PARTY`, `whyItMatters`, link-health, `AuthoringStatus`) | `Channels`, `ChannelCollections`(+items), `ChannelTopicMap`, `ChannelSubmissions` | +| `seed-*.cjs` convention | `scripts/seed-channels.cjs` | +| Admin shell + Fiori Elements pattern | 4 admin components | +| Nightly link-health job | Extended to `Channels.url` | +| `/topics/*` baked-data rendering | `channels_by_topic.json` + band partial | diff --git a/hugo-apps/src/channels-directory/ChannelsDirectory.vue b/hugo-apps/src/channels-directory/ChannelsDirectory.vue new file mode 100644 index 000000000..1e1c0f7ed --- /dev/null +++ b/hugo-apps/src/channels-directory/ChannelsDirectory.vue @@ -0,0 +1,40 @@ + + + diff --git a/hugo-apps/src/channels-directory/filter.test.ts b/hugo-apps/src/channels-directory/filter.test.ts new file mode 100644 index 000000000..424c30578 --- /dev/null +++ b/hugo-apps/src/channels-directory/filter.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { filterChannels } from './filter'; + +const data = [ + { name: 'BTP Docs', category: 'Portal', platform: 'Web', isSapOwned: true, purpose: 'docs', tags: ['btp'] }, + { name: 'Reddit SAP', category: 'Community', platform: 'Web', isSapOwned: false, purpose: 'forum', tags: ['community'] }, + { name: 'SAP YouTube', category: 'Video', platform: 'YouTube', isSapOwned: true, purpose: 'tutorials', tags: ['video'] }, +]; + +describe('filterChannels', () => { + it('matches query across name/purpose/tags', () => { + expect(filterChannels(data, { query: 'reddit' }).map((c) => c.name)).toEqual(['Reddit SAP']); + expect(filterChannels(data, { query: 'btp' }).map((c) => c.name)).toEqual(['BTP Docs']); + }); + it('filters by owner scope', () => { + expect(filterChannels(data, { ownerScope: 'sap' }).map((c) => c.name)).toEqual(['BTP Docs', 'SAP YouTube']); + expect(filterChannels(data, { ownerScope: 'community' }).map((c) => c.name)).toEqual(['Reddit SAP']); + }); + it('filters by category and platform', () => { + expect(filterChannels(data, { category: 'Portal' })).toHaveLength(1); + // Web filter excludes the YouTube entry — proves exclusion + expect(filterChannels(data, { platform: 'Web' })).toHaveLength(2); + expect(filterChannels(data, { platform: 'YouTube' })).toHaveLength(1); + }); + it('applies multiple facets together (only rows matching ALL survive)', () => { + // community + query: only Reddit SAP matches both + expect(filterChannels(data, { query: 'forum', ownerScope: 'community' }).map((c) => c.name)).toEqual(['Reddit SAP']); + // sap + category Portal: only BTP Docs matches both + expect(filterChannels(data, { category: 'Portal', ownerScope: 'sap' }).map((c) => c.name)).toEqual(['BTP Docs']); + // sap + Web: BTP Docs only (SAP YouTube is sap but not Web) + expect(filterChannels(data, { ownerScope: 'sap', platform: 'Web' }).map((c) => c.name)).toEqual(['BTP Docs']); + }); +}); diff --git a/hugo-apps/src/channels-directory/filter.ts b/hugo-apps/src/channels-directory/filter.ts new file mode 100644 index 000000000..8bfb8433a --- /dev/null +++ b/hugo-apps/src/channels-directory/filter.ts @@ -0,0 +1,22 @@ +export interface Channel { + name: string; url?: string; purpose?: string; category?: string; + platform?: string; isSapOwned?: boolean; tags?: string[]; ownerType?: string; +} +export interface FilterState { + query?: string; category?: string; platform?: string; + ownerScope?: 'all' | 'sap' | 'community'; +} +export function filterChannels(channels: Channel[], state: FilterState): Channel[] { + const q = (state.query || '').trim().toLowerCase(); + return channels.filter((c) => { + if (state.category && c.category !== state.category) return false; + if (state.platform && c.platform !== state.platform) return false; + if (state.ownerScope === 'sap' && !c.isSapOwned) return false; + if (state.ownerScope === 'community' && c.isSapOwned) return false; + if (q) { + const hay = `${c.name} ${c.purpose || ''} ${(c.tags || []).join(' ')}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }); +} diff --git a/hugo-apps/src/channels-directory/index.ts b/hugo-apps/src/channels-directory/index.ts new file mode 100644 index 000000000..c3f39e205 --- /dev/null +++ b/hugo-apps/src/channels-directory/index.ts @@ -0,0 +1,13 @@ +import { createApp } from 'vue'; +import ChannelsDirectory from './ChannelsDirectory.vue'; + +function boot() { + document.querySelectorAll('[data-island="channels-directory"]').forEach((el) => { + const dataEl = document.getElementById('channels-data'); + let channels: unknown[] = []; + try { channels = JSON.parse(dataEl?.textContent || '[]'); } catch { channels = []; } + createApp(ChannelsDirectory, { channels }).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 4e6c9b592..e89b33628 100644 --- a/hugo-apps/vite.config.ts +++ b/hugo-apps/vite.config.ts @@ -313,6 +313,7 @@ export default defineConfig({ 'ui5-tutorial': resolve(__dirname, 'src/ui5/ui5-tutorial.ts'), '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'), }, output: { // Content-hash entry bundles so a changed bundle gets a new URL the diff --git a/hugo/content/channels/_index.md b/hugo/content/channels/_index.md new file mode 100644 index 000000000..61a045d65 --- /dev/null +++ b/hugo/content/channels/_index.md @@ -0,0 +1,5 @@ +--- +title: "SAP Developer Channels" +description: "The portals, docs, repos, communities, and voices SAP developers use every day." +layout: "list" +--- diff --git a/hugo/layouts/channels/list.html b/hugo/layouts/channels/list.html new file mode 100644 index 000000000..0f5ae1b12 --- /dev/null +++ b/hugo/layouts/channels/list.html @@ -0,0 +1,19 @@ +{{ define "main" }} +{{- $channels := (.Site.Data.channels.channels) | default slice -}} +
+
+

{{ .Title }}

+

{{ .Description }}

+
+ +
+ +
+ +{{ end }} diff --git a/package.json b/package.json index 1009283c5..5606a7195 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build:cds": "cds build --production", "fetch-tutorials": "tsx scripts/fetch-tutorials.ts --target hugo", "fetch-homepage-shelves": "tsx scripts/fetch-homepage-shelves.ts", + "fetch-channels": "tsx scripts/fetch-channels.ts", "fetch-verb-definitions": "tsx scripts/fetch-verb-definitions.ts", "fetch-tags": "tsx scripts/fetch-tags.ts", "fetch-shelf-definitions": "tsx scripts/fetch-shelf-definitions.ts", @@ -50,6 +51,8 @@ "import:advocates": "cds bind --exec -- node scripts/import-advocates.cjs", "backfill-categories": "node scripts/backfill-categories.cjs", "kg:reextract": "cross-env KG_EXTRACT_BUILD_CAP=10000 cds bind --exec -- node scripts/kg-reextract.cjs", + "seed-channels": "cds bind --exec -- node scripts/seed-channels.cjs", + "promote-channels": "cds bind --exec -- node scripts/promote-channels-to-shelves.cjs", "seed-tag-labels": "tsx scripts/seed-tag-labels.ts", "migrate:reference": "node scripts/migrate-reference-data.js", "migrate:users": "node scripts/migrate-user-progress.js", @@ -88,7 +91,7 @@ "build:display": "cd app/display-app && npm install && npm run build", "copy-joule-vendor": "node scripts/copy-joule-vendor.mjs", "check-deploy-cap-target": "node scripts/check-deploy-cap-target.cjs", - "build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-verb-definitions && npm run fetch-tags && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run build:icon-subset && npm run build:css && npm run build:apps && npm run build:island-manifest && npm run check:ui5-single-copy && npx tsx scripts/check-ui5-entry-coverage.ts && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:hugo && npm run build:page-fallback && npm run build:whats-new-snapshot && npm run retain:assets && npm run build:publish-island-manifest && npm run build:highlight && npm run build:display && npm run build:sdl", + "build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-channels && npm run fetch-verb-definitions && npm run fetch-tags && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run build:icon-subset && npm run build:css && npm run build:apps && npm run build:island-manifest && npm run check:ui5-single-copy && npx tsx scripts/check-ui5-entry-coverage.ts && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:hugo && npm run build:page-fallback && npm run build:whats-new-snapshot && npm run retain:assets && npm run build:publish-island-manifest && npm run build:highlight && npm run build:display && npm run build:sdl", "build:deploy": "npm run check-deploy-cap-target && npm run build:all", "deploy": "node scripts/deploy-mta.cjs", "build:admin": "npm --prefix app/admin-shell run build", diff --git a/scripts/fetch-channels.ts b/scripts/fetch-channels.ts new file mode 100644 index 000000000..748db05a4 --- /dev/null +++ b/scripts/fetch-channels.ts @@ -0,0 +1,20 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const CAP_BASE = process.env.CAP_BASE_URL || 'http://localhost:4004'; +const OUT_PATH = join('hugo', 'data', 'channels.json'); + +let payload: { channels: unknown[]; buildAt: string; error: string | null } = { + channels: [], buildAt: new Date().toISOString(), error: null, +}; +try { + const res = await fetch(`${CAP_BASE}/build/channels`); + if (!res.ok) throw new Error(`status ${res.status}`); + payload = { ...payload, ...(await res.json()) }; +} catch (err) { + payload.error = err instanceof Error ? err.message : String(err); + console.warn(`[fetch-channels] warn: ${payload.error} — writing empty payload`); +} +mkdirSync(join('hugo', 'data'), { recursive: true }); +writeFileSync(OUT_PATH, JSON.stringify(payload, null, 2), 'utf-8'); +console.log(`[fetch-channels] wrote ${payload.channels.length} channels → ${OUT_PATH}`); diff --git a/scripts/promote-channels-to-shelves.cjs b/scripts/promote-channels-to-shelves.cjs new file mode 100644 index 000000000..deb16536e --- /dev/null +++ b/scripts/promote-channels-to-shelves.cjs @@ -0,0 +1,11 @@ +'use strict'; +const cds = require('@sap/cds'); +const { promoteFeatured } = require('../srv/lib/channels/promote-to-shelves.js'); + +(async () => { + await cds.load('*'); + const db = await cds.connect.to('db'); + const { upserted, skipped } = await promoteFeatured(db); + console.log(`[promote-channels] upserted=${upserted} skipped=${skipped}`); + process.exit(0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/scripts/seed-channels.cjs b/scripts/seed-channels.cjs new file mode 100644 index 000000000..1701bde1c --- /dev/null +++ b/scripts/seed-channels.cjs @@ -0,0 +1,58 @@ +'use strict'; +// Idempotent re-ingest of the external-channels research dataset into Channels. +// Preserves admin-curated columns; retires-on-absence (soft). Run: +// npx cds bind --exec -- node scripts/seed-channels.cjs --file d:/tmp/External-SAP-Channels-Complete.json --commit +const cds = require('@sap/cds'); +const { readFileSync } = require('node:fs'); +const { normalizeChannel } = require('../srv/lib/channels/normalize.js'); + +const CURATED = ['isPublished', 'isFeatured', 'editorialNote', 'linkStatus', 'linkStatusOverride', 'lastChecked']; + +async function main() { + const args = process.argv.slice(2); + const commit = args.includes('--commit'); + const force = args.includes('--force'); + const fileIdx = args.indexOf('--file'); + const file = fileIdx >= 0 ? args[fileIdx + 1] : 'd:/tmp/External-SAP-Channels-Complete.json'; + + const doc = JSON.parse(readFileSync(file, 'utf8')); + const batch = doc.metadata?.generated ?? new Date().toISOString().slice(0, 10); + const rawChannels = doc.channels ?? doc; + + const db = await cds.connect.to('db'); + const linked = cds.linked(cds.model ?? (await cds.load('*'))); + const { Channels } = linked.entities('com.sap.developers.ims'); + + let inserted = 0, updated = 0, skipped = 0; + const seen = new Set(); + for (const raw of rawChannels) { + const row = normalizeChannel(raw, batch); + seen.add(row.sourceId); + const existing = await SELECT.one.from(Channels).where({ sourceId: row.sourceId }); + if (existing && existing.contentHash === row.contentHash && !force) { skipped++; continue; } + if (existing) { + // update source-owned fields only; never touch curated columns + const patch = { ...row }; + for (const k of CURATED) delete patch[k]; + if (commit) await UPDATE(Channels).set(patch).where({ ID: existing.ID }); + updated++; + } else { + if (commit) await INSERT.into(Channels).entries({ ID: cds.utils.uuid(), ...row }); + inserted++; + } + } + + // retire-on-absence (soft): rows never seen in this batch → Archived, curation untouched + const all = await SELECT.from(Channels).columns('ID', 'sourceId', 'status'); + let retired = 0; + for (const r of all) { + if (!seen.has(r.sourceId) && r.status !== 'Archived') { + if (commit) await UPDATE(Channels).set({ status: 'Archived' }).where({ ID: r.ID }); + retired++; + } + } + + console.log(`[seed-channels] batch=${batch} ${commit ? 'COMMIT' : 'DRY-RUN'} ` + + `inserted=${inserted} updated=${updated} skipped=${skipped} retired=${retired}`); +} +main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); }); diff --git a/srv/admin-service.cds b/srv/admin-service.cds index 0ceba3e38..81f754097 100644 --- a/srv/admin-service.cds +++ b/srv/admin-service.cds @@ -300,6 +300,9 @@ service AdminService { action regenerate() returns { processed : Integer; skipped : Integer; cost : String }; }; + @odata.draft.enabled + entity Channels as projection on ims.Channels; + @cds.redirection.target: true @Capabilities.ChangeTracking : { Supported: true } @odata.draft.enabled diff --git a/srv/lib/channels/normalize.js b/srv/lib/channels/normalize.js new file mode 100644 index 000000000..b8674ebba --- /dev/null +++ b/srv/lib/channels/normalize.js @@ -0,0 +1,73 @@ +'use strict'; +const crypto = require('node:crypto'); + +// Strip trailing "[cite: N]" style markers (and any trailing whitespace). +function cleanCitations(text) { + if (!text) return text; + return String(text).split('[cite')[0].replace(/\s+$/, ''); +} + +const OWNER_TYPE_MAP = { + 'sap official': 'SAP_Official', + 'sap developer advocate': 'SAP_Developer_Advocate', + 'sap executive': 'SAP_Executive', + 'community member': 'Community_Member', + 'community organization': 'Community_Organization', + 'user group': 'User_Group', + 'third-party training': 'Third_party_Training', + 'third-party media': 'Third_party_Media', + 'third-party platform': 'Third_party_Platform', +}; +function normalizeOwnerType(raw) { + if (!raw) return null; + return OWNER_TYPE_MAP[String(raw).trim().toLowerCase()] ?? null; +} + +// Map free-text status → enum, carrying any parenthetical / qualifier as a note. +function normalizeStatus(raw) { + if (!raw) return { status: 'Active', note: null }; + const s = String(raw).trim(); + const lower = s.toLowerCase(); + if (lower.startsWith('entering eol') || lower === 'eol') return { status: 'EOL', note: s === 'EOL' ? null : s }; + if (lower.startsWith('active')) { + const m = s.match(/\((.+)\)/); + return { status: 'Active', note: m ? m[1].trim() : null }; + } + if (lower.startsWith('archiv')) return { status: 'Archived', note: null }; + if (lower.startsWith('closed')) return { status: 'Closed', note: null }; + if (lower.startsWith('discontinu')) return { status: 'Discontinued', note: null }; + return { status: 'Active', note: s }; +} + +// Hash only the source (dataset-owned) fields, order-independent. +function computeContentHash(sourceFields) { + const canonical = JSON.stringify(sourceFields, Object.keys(sourceFields).sort()); + return crypto.createHash('sha256').update(canonical).digest('hex'); +} + +function normalizeChannel(raw, ingestBatch) { + const { status, note } = normalizeStatus(raw.status); + const purpose = cleanCitations(raw.purpose); + const notesParts = [cleanCitations(raw.notes), note].filter(Boolean); + const source = { + name: raw.name, url: raw.url, + relatedUrls: raw.related_urls ?? [], + aliases: raw.aliases ?? [], + purpose, notes: notesParts.join(' — ') || null, + ownerName: raw.owner ?? raw.owner_name ?? null, + ownerType: normalizeOwnerType(raw.owner_type), + isSapOwned: raw.isSapOwned === true, + category: raw.category ?? null, + subcategory: raw.subcategory ?? null, + platform: raw.platform ?? null, + status, + focusAreas: raw.focus_areas ?? [], + tags: raw.tags ?? [], + updateFrequency: raw.update_frequency ?? null, + githubStars: raw.github_stars ?? null, + subscribers: raw.subscribers ?? null, + }; + return { sourceId: raw.id, ...source, contentHash: computeContentHash(source), ingestBatch }; +} + +module.exports = { cleanCitations, normalizeOwnerType, normalizeStatus, computeContentHash, normalizeChannel }; diff --git a/srv/lib/channels/promote-to-shelves.js b/srv/lib/channels/promote-to-shelves.js new file mode 100644 index 000000000..f70cb7392 --- /dev/null +++ b/srv/lib/channels/promote-to-shelves.js @@ -0,0 +1,50 @@ +'use strict'; +const cds = require('@sap/cds'); + +// Deterministic category → shelf and focus → verb defaults (admin-overridable later). +const CATEGORY_TO_SHELF = { + 'Portal': 'REFERENCE', 'Documentation': 'REFERENCE', 'Docs': 'REFERENCE', + 'GitHub Repository': 'TOOLS', 'Package Registry': 'TOOLS', 'Tool': 'TOOLS', + 'YouTube': 'KEEP_CURRENT', 'Podcast': 'KEEP_CURRENT', 'Blog': 'KEEP_CURRENT', 'News': 'KEEP_CURRENT', + 'Learning': 'START_HERE', 'Community': 'REFERENCE', +}; +const FOCUS_TO_VERB = [ + [['integration'], 'INTEGRATE'], [['ops', 'admin', 'operations'], 'OPERATE'], + [['ai', 'genai'], 'AI'], [['rap', 'data-model', 'cds'], 'MODEL'], + [['abap', 'cap', 'sdk', 'build'], 'BUILD'], [['onboarding', 'tutorial', 'learn'], 'LEARN'], +]; + +function pickVerb(focusAreas = []) { + const lower = focusAreas.map((f) => String(f).toLowerCase()); + for (const [keys, verb] of FOCUS_TO_VERB) if (keys.some((k) => lower.includes(k))) return verb; + return 'BUILD'; +} + +function mapChannelToShelf(channel) { + let shelf = CATEGORY_TO_SHELF[channel.category] || 'REFERENCE'; + // community / third-party may never land in START_HERE + if (shelf === 'START_HERE' && channel.isSapOwned !== true) shelf = 'REFERENCE'; + return { verb: pickVerb(channel.focusAreas), shelf }; +} + +async function promoteFeatured(db) { + const linked = cds.linked(cds.model ?? (await cds.load('*'))); + const { Channels, HomepageShelves } = linked.entities('com.sap.developers.ims'); + const featured = await db.run(SELECT.from(Channels).where({ isFeatured: true, isPublished: true })); + let upserted = 0, skipped = 0; + for (const ch of featured) { + const { verb, shelf } = mapChannelToShelf(ch); + const existing = await db.run(SELECT.one.from(HomepageShelves).where({ verb, url: ch.url })); + if (existing) { skipped++; continue; } + await db.run(INSERT.into(HomepageShelves).entries({ + ID: cds.utils.uuid(), verb, shelf, url: ch.url, title: ch.name, + description: ch.editorialNote || ch.purpose, whyItMatters: ch.editorialNote || null, + isExternal: true, isActive: true, badge: ch.isSapOwned ? null : 'THIRD_PARTY', + authoringStatus: 'AI_SEEDED', sortOrder: 500, + })); + upserted++; + } + return { upserted, skipped }; +} + +module.exports = { mapChannelToShelf, promoteFeatured, CATEGORY_TO_SHELF, FOCUS_TO_VERB }; diff --git a/srv/server.js b/srv/server.js index 2d19682b3..fbb91bc95 100644 --- a/srv/server.js +++ b/srv/server.js @@ -416,6 +416,38 @@ cds.on('bootstrap', (app) => { } }); + // Build-time data for Hugo /channels directory — consumed by + // scripts/fetch-channels.ts at build time. Public, unauthenticated. + // Cache-Control 60s. Filters to isPublished=true + linkStatus!='BROKEN' + // (override wins). Array columns (focusAreas, tags, relatedUrls, aliases) + // are parsed from JSON strings on HANA; SQLite returns them as arrays already. + app.get('/build/channels', async (_req, res) => { + try { + const db = await cds.connect.to('db'); + const rows = await db.run( + SELECT.from('com.sap.developers.ims.Channels') + .where({ isPublished: true }) + .orderBy('category', 'name'), + ); + const parseArr = (v) => (Array.isArray(v) ? v : (typeof v === 'string' && v ? JSON.parse(v) : [])); + const channels = rows + .map((r) => ({ + ...r, + linkStatus: r.linkStatusOverride || r.linkStatus, + focusAreas: parseArr(r.focusAreas), + tags: parseArr(r.tags), + relatedUrls: parseArr(r.relatedUrls), + aliases: parseArr(r.aliases), + })) + .filter((r) => r.linkStatus !== 'BROKEN'); + res.set('Cache-Control', 'public, max-age=60'); + res.json({ channels, buildAt: new Date().toISOString() }); + } catch (err) { + console.error('[build/channels]', err.message); + res.status(500).json({ error: err.message }); + } + }); + // (#1032) Build-time data for Hugo featured topics carousel — consumed by // scripts/fetch-tutorials.ts at build time. Public, unauthenticated. // Cache-Control 60s (Hugo fetches once per build, not per request). diff --git a/test/admin-channels.test.js b/test/admin-channels.test.js new file mode 100644 index 000000000..3dc6f713b --- /dev/null +++ b/test/admin-channels.test.js @@ -0,0 +1,24 @@ +// test/admin-channels.test.js +import cds from '@sap/cds'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +const adminAuth = { auth: { username: 'admin', password: 'admin' } }; +const NS = 'com.sap.developers.ims'; +const linked = () => cds.linked(cds.model).entities(NS); + +describe('AdminService.Channels', () => { + beforeAll(async () => { + await INSERT.into(linked().Channels).entries({ + ID: cds.utils.uuid(), sourceId: 'admin-001', name: 'Admin Test', url: 'https://admin-test', isPublished: true, + }); + }); + afterAll(async () => { await DELETE.from(linked().Channels).where({ sourceId: 'admin-001' }); }); + + it('is exposed at /admin/Channels and requires admin auth', async () => { + await expect(project.get('/admin/Channels')).rejects.toMatchObject({ response: { status: 401 } }); + const { status, data } = await project.get('/admin/Channels', adminAuth); + expect(status).toBe(200); + expect(data.value.some((c) => c.sourceId === 'admin-001')).toBe(true); + }); +}); diff --git a/test/build-channels-feed.test.js b/test/build-channels-feed.test.js new file mode 100644 index 000000000..08245c456 --- /dev/null +++ b/test/build-channels-feed.test.js @@ -0,0 +1,33 @@ +// test/build-channels-feed.test.js +import cds from '@sap/cds'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +const NS = 'com.sap.developers.ims'; +const linked = () => cds.linked(cds.model).entities(NS); + +describe('GET /build/channels', () => { + beforeAll(async () => { + const { Channels } = linked(); + await INSERT.into(Channels).entries([ + { ID: cds.utils.uuid(), sourceId: 'feed-pub', name: 'Pub', url: 'https://pub', isPublished: true, linkStatus: 'OK', focusAreas: ['btp'] }, + { ID: cds.utils.uuid(), sourceId: 'feed-unpub', name: 'Unpub', url: 'https://unpub', isPublished: false, linkStatus: 'OK' }, + { ID: cds.utils.uuid(), sourceId: 'feed-broken', name: 'Broken', url: 'https://broken', isPublished: true, linkStatus: 'BROKEN' }, + ]); + }); + afterAll(async () => { + await DELETE.from(linked().Channels).where({ sourceId: { in: ['feed-pub', 'feed-unpub', 'feed-broken'] } }); + }); + + it('returns only published, non-broken channels with parsed arrays', async () => { + const { status, data } = await project.get('/build/channels'); + expect(status).toBe(200); + const ids = data.channels.map((c) => c.sourceId); + expect(ids).toContain('feed-pub'); + expect(ids).not.toContain('feed-unpub'); + expect(ids).not.toContain('feed-broken'); + const pub = data.channels.find((c) => c.sourceId === 'feed-pub'); + expect(pub.focusAreas).toEqual(['btp']); + expect(typeof data.buildAt).toBe('string'); + }); +}); diff --git a/test/channels-model.test.js b/test/channels-model.test.js new file mode 100644 index 000000000..884d59d62 --- /dev/null +++ b/test/channels-model.test.js @@ -0,0 +1,29 @@ +// test/channels-model.test.js +import cds from '@sap/cds'; +import { describe, it, expect, afterAll } from 'vitest'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); + +describe('Channels entity', () => { + const NS = 'com.sap.developers.ims'; + const linked = () => cds.linked(cds.model).entities(NS); + + afterAll(async () => { + const { Channels } = linked(); + await DELETE.from(Channels).where({ sourceId: 'test-001' }); + }); + + it('round-trips array columns', async () => { + const { Channels } = linked(); + await INSERT.into(Channels).entries({ + ID: cds.utils.uuid(), sourceId: 'test-001', name: 'Test', url: 'https://x.test', + focusAreas: ['abap', 'cap'], tags: ['t1'], relatedUrls: ['https://y.test'], + isSapOwned: true, isPublished: true, + }); + const row = await SELECT.one.from(Channels).where({ sourceId: 'test-001' }); + expect(row.focusAreas).toEqual(['abap', 'cap']); + expect(row.tags).toEqual(['t1']); + expect(row.relatedUrls).toEqual(['https://y.test']); + expect(row.isPublished).toBe(true); + }); +}); diff --git a/test/channels-normalize.test.js b/test/channels-normalize.test.js new file mode 100644 index 000000000..666f72bdc --- /dev/null +++ b/test/channels-normalize.test.js @@ -0,0 +1,48 @@ +// test/channels-normalize.test.js +import { describe, it, expect } from 'vitest'; +import { + cleanCitations, normalizeOwnerType, normalizeStatus, + computeContentHash, normalizeChannel, +} from '../srv/lib/channels/normalize.js'; + +describe('channels normalize', () => { + it('strips [cite:] markers and trailing space', () => { + expect(cleanCitations('The BTP portal. [cite: 12]')).toBe('The BTP portal.'); + expect(cleanCitations('No marker')).toBe('No marker'); + }); + + it('maps owner_type strings to the enum', () => { + expect(normalizeOwnerType('SAP Official')).toBe('SAP_Official'); + expect(normalizeOwnerType('Community Member')).toBe('Community_Member'); + expect(normalizeOwnerType('unknown junk')).toBeNull(); + }); + + it('normalizes status with a carry-over note', () => { + expect(normalizeStatus('Active')).toEqual({ status: 'Active', note: null }); + expect(normalizeStatus('Entering EOL')).toEqual({ status: 'EOL', note: 'Entering EOL' }); + expect(normalizeStatus('Active (Canonical source)')) + .toEqual({ status: 'Active', note: 'Canonical source' }); + }); + + it('content hash is stable across key order and changes with content', () => { + const a = computeContentHash({ name: 'X', url: 'u', purpose: 'p' }); + const b = computeContentHash({ url: 'u', purpose: 'p', name: 'X' }); + const c = computeContentHash({ name: 'X', url: 'u', purpose: 'q' }); + expect(a).toBe(b); + expect(a).not.toBe(c); + }); + + it('normalizeChannel produces an upsert-ready row', () => { + const row = normalizeChannel({ + id: 'portal-001', name: 'BTP Portal', url: 'https://x', + owner_type: 'SAP Official', isSapOwned: true, status: 'Active', + focus_areas: ['btp'], tags: ['btp'], purpose: 'Portal. [cite: 1]', + }, '2026-09-03'); + expect(row.sourceId).toBe('portal-001'); + expect(row.purpose).toBe('Portal.'); + expect(row.ownerType).toBe('SAP_Official'); + expect(row.focusAreas).toEqual(['btp']); + expect(row.ingestBatch).toBe('2026-09-03'); + expect(typeof row.contentHash).toBe('string'); + }); +}); diff --git a/test/channels-promote.test.js b/test/channels-promote.test.js new file mode 100644 index 000000000..4c86edf75 --- /dev/null +++ b/test/channels-promote.test.js @@ -0,0 +1,48 @@ +// test/channels-promote.test.js +import cds from '@sap/cds'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mapChannelToShelf, promoteFeatured } from '../srv/lib/channels/promote-to-shelves.js'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +const NS = 'com.sap.developers.ims'; +const linked = () => cds.linked(cds.model).entities(NS); + +describe('mapChannelToShelf', () => { + it('maps an SAP learning portal to START_HERE/learn', () => { + expect(mapChannelToShelf({ isSapOwned: true, category: 'Learning', focusAreas: ['onboarding'] })) + .toEqual({ verb: 'LEARN', shelf: 'START_HERE' }); + }); + it('never puts a community channel in START_HERE', () => { + const m = mapChannelToShelf({ isSapOwned: false, category: 'Learning', focusAreas: ['onboarding'] }); + expect(m?.shelf).not.toBe('START_HERE'); + }); + it('maps a GitHub repo to TOOLS', () => { + expect(mapChannelToShelf({ isSapOwned: true, category: 'GitHub Repository', focusAreas: ['cap'] }).shelf).toBe('TOOLS'); + }); +}); + +describe('promoteFeatured', () => { + beforeAll(async () => { + const { Channels } = linked(); + await INSERT.into(Channels).entries([ + { ID: cds.utils.uuid(), sourceId: 'promo-sap', name: 'CAP Docs', url: 'https://promo-cap', isSapOwned: true, isFeatured: true, isPublished: true, category: 'Portal', focusAreas: ['cap'] }, + { ID: cds.utils.uuid(), sourceId: 'promo-comm', name: 'Reddit', url: 'https://promo-reddit', isSapOwned: false, isFeatured: true, isPublished: true, category: 'Community', focusAreas: ['abap'] }, + ]); + }); + afterAll(async () => { + await DELETE.from(linked().Channels).where({ sourceId: { in: ['promo-sap', 'promo-comm'] } }); + await DELETE.from(linked().HomepageShelves).where({ url: { in: ['https://promo-cap', 'https://promo-reddit'] } }); + }); + + it('upserts featured channels into HomepageShelves and is idempotent', async () => { + const db = await cds.connect.to('db'); + const first = await promoteFeatured(db); + expect(first.upserted).toBeGreaterThan(0); + const second = await promoteFeatured(db); + expect(second.upserted).toBe(0); // already present → skipped on second run + const { HomepageShelves } = linked(); + const reddit = await SELECT.one.from(HomepageShelves).where({ url: 'https://promo-reddit' }); + expect(reddit.badge).toBe('THIRD_PARTY'); + expect(reddit.shelf).not.toBe('START_HERE'); + }); +}); diff --git a/test/channels-seed.test.js b/test/channels-seed.test.js new file mode 100644 index 000000000..4edc43fe4 --- /dev/null +++ b/test/channels-seed.test.js @@ -0,0 +1,43 @@ +// test/channels-seed.test.js +import cds from '@sap/cds'; +import { describe, it, expect, afterAll } from 'vitest'; +import { normalizeChannel } from '../srv/lib/channels/normalize.js'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +const NS = 'com.sap.developers.ims'; +const linked = () => cds.linked(cds.model).entities(NS); + +// Mirror the seed's upsert semantics (curated-column preservation) directly against the DB. +async function upsert(raw, batch, { commit = true } = {}) { + const { Channels } = linked(); + const row = normalizeChannel(raw, batch); + const existing = await SELECT.one.from(Channels).where({ sourceId: row.sourceId }); + const CURATED = ['isPublished', 'isFeatured', 'editorialNote', 'linkStatus', 'linkStatusOverride', 'lastChecked']; + if (existing && existing.contentHash === row.contentHash) return 'skipped'; + if (existing) { + const patch = { ...row }; for (const k of CURATED) delete patch[k]; + if (commit) await UPDATE(Channels).set(patch).where({ ID: existing.ID }); + return 'updated'; + } + if (commit) await INSERT.into(Channels).entries({ ID: cds.utils.uuid(), ...row }); + return 'inserted'; +} + +describe('channels seed upsert', () => { + const base = { id: 'seed-001', name: 'Portal', url: 'https://p', owner_type: 'SAP Official', status: 'Active', purpose: 'A. [cite: 1]' }; + afterAll(async () => { await DELETE.from(linked().Channels).where({ sourceId: 'seed-001' }); }); + + it('inserts, then skips unchanged, and preserves curated columns on change', async () => { + expect(await upsert(base, '2026-09-03')).toBe('inserted'); + // curator flips isFeatured + const { Channels } = linked(); + await UPDATE(Channels).set({ isFeatured: true }).where({ sourceId: 'seed-001' }); + // same content → skip + expect(await upsert(base, '2026-09-03')).toBe('skipped'); + // changed purpose → update source col, keep isFeatured + expect(await upsert({ ...base, purpose: 'B.' }, '2026-09-10')).toBe('updated'); + const row = await SELECT.one.from(Channels).where({ sourceId: 'seed-001' }); + expect(row.purpose).toBe('B.'); + expect(row.isFeatured).toBe(true); + }); +});