From 44d70793881ec928d82a6d5426a64d5eb2af4681 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 3 Sep 2026 11:12:36 -0400 Subject: [PATCH 001/138] fix(sitemap): guard catalog-only rebuilds from wiping /tutorials/ URLs /sitemap.xml is served from the HANA page-sitemap.xml blob and republished by publish-content.ts on every non-slug rebuild (discoverPageFiles, gated `if (!opts.slug)`). catalog-only skips "Fetch tutorials", so Hugo bakes a sitemap with NO /tutorials/ URLs and republishing it WIPES the live sitemap down to ~180 links (reported 2026-09-03 by the Intelligent Search Data Crawling team: crawled docs ~1700 -> ~1500). Same wipe class as the /browse/ and /authors/ catalog-only wipes. - scripts/seed-sitemap-from-deployed.ts: catalog-only preserve step. Runs AFTER the Hugo build (the sitemap is a build output, not an input) and overwrites the tutorial-less sitemap with the one the approuter is currently serving. Fails the run if the deployed sitemap is unreadable or names zero tutorials (ALLOW_EMPTY_SITEMAP=1 escape hatch for seed envs). - scripts/check-sitemap-tutorials.cjs: fail-closed guard. Runs in full + catalog-only before publish; fails if the baked sitemap has zero /tutorials/ (the exact wipe signature). - rebuild-content.yml: wire both steps in after "Build Hugo site". - seo-files.test.js: smoke assertion that /sitemap.xml contains /tutorials/. - unit tests cover both tutorial- counters (shared logic, run through both impls so they can't diverge). --- .github/workflows/rebuild-content.yml | 30 ++++ package.json | 1 + .../__tests__/check-sitemap-tutorials.test.ts | 72 ++++++++++ scripts/check-sitemap-tutorials.cjs | 129 ++++++++++++++++++ scripts/seed-sitemap-from-deployed.ts | 100 ++++++++++++++ test/smoke/seo-files.test.js | 6 + 6 files changed, 338 insertions(+) create mode 100644 scripts/__tests__/check-sitemap-tutorials.test.ts create mode 100644 scripts/check-sitemap-tutorials.cjs create mode 100644 scripts/seed-sitemap-from-deployed.ts diff --git a/.github/workflows/rebuild-content.yml b/.github/workflows/rebuild-content.yml index e841a9ad6..108cd223e 100644 --- a/.github/workflows/rebuild-content.yml +++ b/.github/workflows/rebuild-content.yml @@ -650,6 +650,36 @@ jobs: - name: Build Hugo site run: /tmp/hugo --source hugo --minify + # Preserve /sitemap.xml across catalog-only rebuilds (same wipe class as + # /browse/ + /authors/ above; reported 2026-09-03 by the Intelligent Search + # Data Crawling team). /sitemap.xml is served from the HANA `page-sitemap.xml` + # blob and publish-content.ts republishes it on every non-slug rebuild. + # catalog-only skips "Fetch tutorials", so the Hugo build just above bakes a + # sitemap with NO /tutorials/ URLs — publishing that would wipe the live + # sitemap to ~180 links. The sitemap can't be regenerated here (it needs the + # ~1.4k tutorial pages the skipped GitHub fetch provides), so — UNLIKE the + # browse/author preserve steps, which re-hydrate build INPUTS before the + # build — this runs AFTER the build and overwrites the freshly-baked sitemap + # with the one the approuter is CURRENTLY serving. Fails the run (non-zero) + # rather than ship a wiped sitemap if the deployed one is unreadable or names + # zero tutorials. Not needed in `full` (regenerates it) or `slug-targeted` + # (page-sitemap.xml is not republished). + - name: Preserve sitemap.xml for catalog-only rebuild + if: ${{ steps.mode.outputs.effective_mode == 'catalog-only' }} + env: + APPROUTER_URL: ${{ steps.url.outputs.approuter_url }} + run: npx tsx scripts/seed-sitemap-from-deployed.ts + + # Fail-closed net for the sitemap wipe class. Runs in the modes that + # republish the page-sitemap.xml blob (full + catalog-only; slug-targeted + # does not touch it). Fails BEFORE the HANA publish if the sitemap about to + # be published names zero /tutorials/ URLs — whatever the cause (broken + # preserve step, a future fetch-skipping mode, a bad Hugo build). A red run + # is recoverable (re-run mode=full); a silently wiped sitemap is the incident. + - name: Guard - sitemap includes tutorial URLs + if: ${{ steps.mode.outputs.effective_mode != 'slug-targeted' }} + run: node scripts/check-sitemap-tutorials.cjs --hugo-dir hugo/public + # [#1657] Restore CDS syntax highlighting to the runtime-push path. Hugo's # render-codeblock.html maps ```cds → the Chroma SQL lexer as a PLACEHOLDER # (Chroma has no CDS lexer) and tags the wrapper `data-lang=cds`; the SQL diff --git a/package.json b/package.json index 835766644..1009283c5 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "build:analytics-explorer": "npm --prefix app/analytics-explorer install && npm --prefix app/analytics-explorer run build", "check-explore-bundle-manifest": "node scripts/check-explore-bundle-manifest.cjs", "check-verb-shelves": "node scripts/check-verb-shelves.cjs", + "check-sitemap-tutorials": "node scripts/check-sitemap-tutorials.cjs", "build:hugo": "npm run check-explore-bundle-manifest && npm run check-verb-shelves && hugo --source hugo --minify", "build:page-fallback": "node scripts/build-page-fallback.cjs", "build:whats-new-snapshot": "node scripts/build-whats-new-snapshot.cjs", diff --git a/scripts/__tests__/check-sitemap-tutorials.test.ts b/scripts/__tests__/check-sitemap-tutorials.test.ts new file mode 100644 index 000000000..04d5b4c86 --- /dev/null +++ b/scripts/__tests__/check-sitemap-tutorials.test.ts @@ -0,0 +1,72 @@ +// scripts/__tests__/check-sitemap-tutorials.test.ts +// +// Unit tests for the build-time sitemap guard (scripts/check-sitemap-tutorials.cjs) +// and the shared tutorial- counter used by the catalog-only preserve step +// (scripts/seed-sitemap-from-deployed.ts). Both defend the 2026-09-03 sitemap-wipe +// class: a catalog-only rebuild republished the page-sitemap.xml blob with zero +// /tutorials/ URLs, dropping the live sitemap from ~1.6k links to ~180. +// +// We import the pure cores and assert against synthetic sitemap XML — same +// approach as check-verb-shelves.test.ts. The CLI wiring (file read + exit code) +// is exercised by the live rebuild-content.yml guard step. + +import { describe, it, expect } from 'vitest'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { countTutorialLocs } = require('../check-sitemap-tutorials.cjs'); +import { countTutorialLocs as countTutorialLocsTs } from '../seed-sitemap-from-deployed'; + +const urlset = (locs: string[]) => + `\n\n` + + locs.map((l) => ` ${l}2026-09-03T13:31:00Z`).join('\n') + + `\n\n`; + +const HOST = 'https://developers.sap.com'; + +// The two implementations are deliberately duplicated (a .cjs guard + a .ts +// preserve script that can't cleanly import the .cjs under tsx). Run every case +// through BOTH so they can never silently diverge. +const impls: Array<[string, (xml: string) => number]> = [ + ['check-sitemap-tutorials.cjs', countTutorialLocs], + ['seed-sitemap-from-deployed.ts', countTutorialLocsTs], +]; + +for (const [name, count] of impls) { + describe(`countTutorialLocs (${name})`, () => { + it('counts absolute tutorial URLs', () => { + const xml = urlset([ + `${HOST}/`, + `${HOST}/tutorials/abap-custom-ui-trust-cf/`, + `${HOST}/tutorials/hana-cloud-mission/`, + `${HOST}/topics/cap/`, + ]); + expect(count(xml)).toBe(2); + }); + + it('returns 0 for the wipe signature: no tutorial URLs', () => { + const xml = urlset([`${HOST}/`, `${HOST}/browse/`, `${HOST}/authors/tomjung/`, `${HOST}/topics/`]); + expect(count(xml)).toBe(0); + }); + + it('does NOT count the bare /tutorials/ section index', () => { + const xml = urlset([`${HOST}/tutorials/`, `${HOST}/tutorials/real-slug/`]); + expect(count(xml)).toBe(1); + }); + + it('does NOT count /tutorials- paths', () => { + const xml = urlset([`${HOST}/tutorial-navigator/`, `${HOST}/tutorials-qa/foo/`]); + expect(count(xml)).toBe(0); + }); + + it('matches root-relative tutorial locs defensively', () => { + const xml = urlset([`/tutorials/some-slug/`, `/browse/`]); + expect(count(xml)).toBe(1); + }); + + it('handles empty / non-string input', () => { + expect(count('')).toBe(0); + // @ts-expect-error deliberate wrong type + expect(count(null)).toBe(0); + expect(count('')).toBe(0); + }); + }); +} diff --git a/scripts/check-sitemap-tutorials.cjs b/scripts/check-sitemap-tutorials.cjs new file mode 100644 index 000000000..612c08afe --- /dev/null +++ b/scripts/check-sitemap-tutorials.cjs @@ -0,0 +1,129 @@ +// scripts/check-sitemap-tutorials.cjs +// +// Build-time guard for the runtime-push (rebuild-content.yml) path. Fails the +// run if the baked hugo/public/sitemap.xml contains ZERO /tutorials/ +// entries — the exact signature of a sitemap wiped down to only its +// non-tutorial pages. +// +// Root cause it prevents (reported 2026-09-03 by the Intelligent Search Data +// Crawling team — crawled docs dropped ~1700 → ~1500, live sitemap held only +// ~180 links): +// /sitemap.xml is served from HANA as the `page-sitemap.xml` blob (see +// srv/lib/page-key-map.js IN_SCOPE_PAGES). publish-content.ts merges that +// page-* blob into the publish set via discoverPageFiles() — but ONLY when +// NOT slug-scoped (`if (!opts.slug)`). So: +// - slug-targeted → page-sitemap.xml NOT republished (server carries it +// forward). SAFE. +// - full → "Fetch tutorials" runs, Hugo bakes the full ~1.4k +// tutorial , republishes the good sitemap. SAFE. +// - catalog-only → "Fetch tutorials" is SKIPPED, so hugo/content/tutorials +// is EMPTY, Hugo bakes a sitemap with NO /tutorials/ URLs, +// and that tutorial-less blob is republished — WIPING the +// live sitemap to ~180 URLs. THIS is the wipe class. +// Same family as the /browse/ (2026-08-07) and /authors/ (#1659 Phase C) +// catalog-only wipes. The durable fix pairs this guard with +// scripts/seed-sitemap-from-deployed.ts, which re-hydrates the deployed +// sitemap in catalog-only BEFORE this guard runs so a legit catalog-only +// rebuild carries the tutorial s forward and passes. +// +// This guard is the fail-closed net: whatever the cause (broken preserve step, +// a future mode that skips fetch, a bad Hugo build), if the sitemap about to be +// published names zero tutorials it FAILS the run rather than let publish-content +// clobber the good page-sitemap.xml blob. A hard red run is visible and +// recoverable (re-run mode=full); a silently wiped sitemap is the incident. +// +// Why a standalone .cjs (not a prebuild hook): the project's global npm config +// sets ignore-scripts=true, which blocks all pre/post lifecycle hooks. Invoked +// as an explicit workflow step. Mirrors scripts/check-verb-shelves.cjs. +// +// Exit codes: +// 0 sitemap has >= MIN_SITEMAP_TUTORIALS tutorial entries. +// 1 sitemap file missing/unreadable, OR fewer than the minimum tutorial URLs +// (default 1). Set ALLOW_EMPTY_SITEMAP=1 to allow zero (genuinely empty +// seed envs), mirroring seed-browse's ALLOW_EMPTY_BROWSE escape hatch. +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +// --------------------------------------------------------------------------- +// Pure core (unit-tested in scripts/__tests__/check-sitemap-tutorials.test.ts). +// Count entries whose URL path is under /tutorials/. Host-agnostic: matches +// both absolute (https://developers.sap.com/tutorials//) and, defensively, +// root-relative (/tutorials//) forms. The bare section index /tutorials/ +// (no trailing slug) is excluded from the sitemap by the template, so any match +// here is a real tutorial page. +// --------------------------------------------------------------------------- +function countTutorialLocs(xml) { + if (typeof xml !== 'string' || xml.length === 0) return 0; + let count = 0; + const re = /\s*([^<]+?)\s*<\/loc>/gi; + let m; + while ((m = re.exec(xml)) !== null) { + let loc = m[1]; + // Strip scheme+host so we compare on the path only. + const pathOnly = loc.replace(/^https?:\/\/[^/]+/i, ''); + // A real tutorial URL has a slug after /tutorials/ — require a non-empty + // path segment so the bare /tutorials/ section index (if ever emitted) and + // /tutorials- siblings don't count. + if (/^\/tutorials\/[^/][^<]*$/i.test(pathOnly)) count += 1; + } + return count; +} + +module.exports = { countTutorialLocs }; + +// --------------------------------------------------------------------------- +// CLI (only when run directly, so the pure core imports cleanly under Vitest). +// --------------------------------------------------------------------------- +function fail(lines) { + console.error(''); + console.error('[rebuild] sitemap-tutorials guard FAILED:'); + for (const l of lines) console.error(` ${l}`); + console.error(''); + console.error(' /sitemap.xml is served from the HANA `page-sitemap.xml` blob and is'); + console.error(' republished on every full/catalog-only rebuild. A sitemap with no'); + console.error(' /tutorials/ URLs means the Hugo build had no tutorial content — a'); + console.error(' catalog-only rebuild that skipped "Fetch tutorials" and whose'); + console.error(' seed-sitemap-from-deployed.ts preserve step did not carry the'); + console.error(' deployed sitemap forward. Fix: re-run the rebuild with mode=full to'); + console.error(' regenerate the full sitemap, then catalog-only will preserve it.'); + console.error(''); + process.exit(1); +} + +function argOf(flag, fallback) { + const i = process.argv.indexOf(flag); + return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback; +} + +function main() { + const hugoDir = argOf('--hugo-dir', 'hugo/public'); + const sitemapPath = path.resolve(process.cwd(), hugoDir, 'sitemap.xml'); + const allowEmpty = process.env.ALLOW_EMPTY_SITEMAP === '1'; + const min = allowEmpty ? 0 : Math.max(1, parseInt(process.env.MIN_SITEMAP_TUTORIALS || '1', 10)); + + if (!fs.existsSync(sitemapPath)) { + fail([`sitemap not found at ${path.relative(process.cwd(), sitemapPath)}.`, + 'The Hugo build did not emit a sitemap — check hugo.toml `home` outputs include \'sitemap\'.']); + } + + let xml; + try { + xml = fs.readFileSync(sitemapPath, 'utf-8'); + } catch (err) { + fail([`could not read ${sitemapPath}: ${err.message}`]); + } + + const tutorialCount = countTutorialLocs(xml); + const totalLocs = (xml.match(//gi) || []).length; + + if (tutorialCount < min) { + fail([`sitemap has ${tutorialCount} /tutorials/ URL(s) (require >= ${min}); ${totalLocs} total.`, + 'This is the wipe signature — the live sitemap would drop to non-tutorial pages only.']); + } + + console.log(`[rebuild] sitemap-tutorials guard OK — ${tutorialCount} /tutorials/ URLs of ${totalLocs} total .`); +} + +if (require.main === module) main(); diff --git a/scripts/seed-sitemap-from-deployed.ts b/scripts/seed-sitemap-from-deployed.ts new file mode 100644 index 000000000..65e1b0f3f --- /dev/null +++ b/scripts/seed-sitemap-from-deployed.ts @@ -0,0 +1,100 @@ +import { writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// scripts/seed-sitemap-from-deployed.ts +// +// Preserves /sitemap.xml across catalog-only rebuilds — same wipe class as the +// /browse/ (seed-browse-from-deployed.ts) and /authors/ (seed-authors-from- +// deployed.ts) preserve steps. Reported 2026-09-03 by the Intelligent Search +// Data Crawling team (crawled docs ~1700 → ~1500; live sitemap held ~180 links). +// +// THE BUG +// /sitemap.xml is served from HANA as the `page-sitemap.xml` blob (srv/lib/ +// page-key-map.js IN_SCOPE_PAGES). scripts/publish-content.ts merges that +// page-* blob into the publish set via discoverPageFiles() whenever the run is +// NOT slug-scoped. The rebuild-content workflow skips "Fetch tutorials" in +// catalog-only mode, so hugo/content/tutorials is empty, the Hugo build bakes a +// sitemap with NO /tutorials/ URLs, and publishing that tutorial-less blob +// WIPES the live sitemap down to its non-tutorial pages (~180 URLs). +// +// WHY NOT just regenerate the sitemap in catalog-only? +// The sitemap is generated by Hugo iterating site.Pages — it needs the ~1.4k +// tutorial content pages present at build time. Those come ONLY from the +// GitHub tutorial-markdown fetch that catalog-only deliberately skips (it's the +// fast admin-edit path). Running the fetch would defeat the mode. +// +// THE FIX (this script) +// In catalog-only mode, AFTER the Hugo build (unlike seed-browse/seed-authors, +// which re-hydrate build INPUTS before the build), overwrite the freshly-baked +// (tutorial-less) hugo/public/sitemap.xml with the sitemap the approuter is +// CURRENTLY serving at /sitemap.xml. That deployed sitemap already carries the +// full tutorial from the last full rebuild, so the publish step +// carries it forward verbatim instead of shipping a wiped one. The +// check-sitemap-tutorials.cjs guard then runs as the fail-closed net. +// +// FAIL-SAFE +// If the deployed sitemap can't be fetched, isn't a , or names zero +// /tutorials/ URLs, this script EXITS NON-ZERO without writing — deliberately +// FAILING the catalog-only rebuild rather than shipping a wiped sitemap. A hard +// failure is visible and recoverable (re-run mode=full); a silent wipe is the +// exact incident this fixes. The one acceptable "empty" case (a fresh/seed env +// with no tutorials yet) is handled by ALLOW_EMPTY_SITEMAP=1. + +const APPROUTER_URL = (process.env.APPROUTER_URL || '').replace(/\/+$/, ''); +const OUT_PATH = join('hugo', 'public', 'sitemap.xml'); +const ALLOW_EMPTY = process.env.ALLOW_EMPTY_SITEMAP === '1'; + +// Count entries under /tutorials/. Kept in sync with +// scripts/check-sitemap-tutorials.cjs countTutorialLocs (this file can't import +// the .cjs cleanly under tsx without pulling its CLI, so the tiny matcher is +// duplicated; both are covered by unit tests). Exported for the unit test. +export function countTutorialLocs(xml: string): number { + if (typeof xml !== 'string' || xml.length === 0) return 0; + let count = 0; + const re = /\s*([^<]+?)\s*<\/loc>/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(xml)) !== null) { + const pathOnly = m[1].replace(/^https?:\/\/[^/]+/i, ''); + if (/^\/tutorials\/[^/][^<]*$/i.test(pathOnly)) count += 1; + } + return count; +} + +function die(msg: string): never { + console.error(`[seed-sitemap] FAILED: ${msg}`); + console.error('[seed-sitemap] Refusing to proceed — a catalog-only rebuild must not ship a sitemap with no /tutorials/ URLs.'); + console.error('[seed-sitemap] Recover by re-running the rebuild with mode=full, or set ALLOW_EMPTY_SITEMAP=1 if the catalog is genuinely empty.'); + process.exit(1); +} + +async function main() { + if (!APPROUTER_URL) die('APPROUTER_URL is not set'); + + let xml: string; + try { + const res = await fetch(`${APPROUTER_URL}/sitemap.xml`, { redirect: 'follow' }); + if (!res.ok) die(`GET ${APPROUTER_URL}/sitemap.xml returned ${res.status}`); + xml = await res.text(); + } catch (err) { + die(`could not fetch ${APPROUTER_URL}/sitemap.xml — ${err instanceof Error ? err.message : err}`); + } + + if (!xml.includes(' document (got an error page or redirect body?)'); + } + + const tutorialCount = countTutorialLocs(xml); + if (tutorialCount === 0 && !ALLOW_EMPTY) { + die('deployed /sitemap.xml has zero /tutorials/ URLs. This env has no populated sitemap to preserve — a previous catalog-only rebuild may have already wiped it. Re-run with mode=full to regenerate.'); + } + + if (!existsSync(dirname(OUT_PATH))) mkdirSync(dirname(OUT_PATH), { recursive: true }); + writeFileSync(OUT_PATH, xml, 'utf-8'); + console.log(`[seed-sitemap] wrote ${xml.length} bytes to ${OUT_PATH} (preserved from deployed ${APPROUTER_URL}/sitemap.xml, ${tutorialCount} /tutorials/ URLs).`); +} + +// Only run main() when invoked directly (not when imported by the unit test). +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(e => { console.error(e); process.exit(1); }); +} diff --git a/test/smoke/seo-files.test.js b/test/smoke/seo-files.test.js index 26200dbdf..d8219f319 100644 --- a/test/smoke/seo-files.test.js +++ b/test/smoke/seo-files.test.js @@ -19,6 +19,12 @@ describe('SEO files', () => { expect(text).toContain('https:\/\/developers\.sap\.com\//); expect(text).toMatch(//); + // Guards the 2026-09-03 sitemap-wipe class (reported by the Intelligent Search + // Data Crawling team): a catalog-only rebuild that skipped "Fetch tutorials" + // republished the page-sitemap.xml blob with NO /tutorials/ URLs, dropping the + // live sitemap from ~1.6k links to ~180. The sitemap MUST name tutorial pages. + expect(text, 'sitemap must contain /tutorials/ URLs — a sitemap without them is the wipe signature') + .toMatch(/https:\/\/developers\.sap\.com\/tutorials\/[^<]+<\/loc>/); }); it('301-redirects legacy AEM sitemap URLs to /sitemap.xml', async () => { From 5e62fab0d57b6931c1b3d687d14b9776b1d10028 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 3 Sep 2026 13:33:40 -0400 Subject: [PATCH 002/138] fix(tutorials): support no-step + commented-out-step validation tutorials (#2127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devtoberfest "validation" tutorials are published with zero real steps on purpose: the page must be live (visible in the mission) but NOT completable — no Done button, no completion, no Devtoberfest points — until real questions are added. Two defects blocked this. 1. Commented-out steps broke rendering. parseV2Steps was only fence-aware, so a `### Question 1` wrapped in a multi-line `` comment was lifted as a phantom step and its `` markers stranded across the split, breaking Hugo rendering of the whole page. parseV2Steps now consults commentLineFlags() (already used by frontmatter/intro extraction): H3s inside a spanning comment no longer delimit a step, and commented lines are dropped from step content. 2. No-step tutorials were quarantined. validate-tutorials.ts required stepCount > 0 for every tutorial. A new repo-scoped exception (stepCountReason + NO_STEP_ALLOWED_REPOS) permits exactly 0 steps ONLY for the developer-advocates repo family (looked up via the fetch discovery cache); every other repo still requires >=1 step. Missing/corrupt discovery cache degrades safely (still requires a step). The Hugo layout already omits Done buttons and completion when stepCount == 0 (step shortcodes gone, `total > 0` guards short-circuit). Additionally suppress the now-meaningless progress ring, progress-bar island, PiP launcher, and expand/collapse-all controls for step-less tutorials so the page renders cleanly. Verified: parser + validate unit suites green (adds v2-commented-h3.test.ts and validate-tutorials-stepcount.test.ts); local Hugo build of a 0-step tutorial renders the intro with no Done button and no progress affordances. --- hugo/layouts/tutorials/u1-object-page.html | 8 ++ .../parsers/__tests__/v2-commented-h3.test.ts | 117 ++++++++++++++++++ scripts/parsers/v2.ts | 13 +- scripts/validate-tutorials.ts | 64 +++++++++- test/validate-tutorials-stepcount.test.ts | 44 +++++++ 5 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 scripts/parsers/__tests__/v2-commented-h3.test.ts create mode 100644 test/validate-tutorials-stepcount.test.ts diff --git a/hugo/layouts/tutorials/u1-object-page.html b/hugo/layouts/tutorials/u1-object-page.html index 01d49d4e1..39833c3f1 100644 --- a/hugo/layouts/tutorials/u1-object-page.html +++ b/hugo/layouts/tutorials/u1-object-page.html @@ -275,11 +275,15 @@

{{ .Title }}{{ if in .Params.ta {{ else if .Params.primaryTag }} {{ .Params.primaryTag }} {{ end }} + {{/* No progress ring for step-less tutorials (e.g. Devtoberfest validation + tutorials, #2127) — nothing to complete, so "0/0" would be misleading. */}} + {{ if gt (int .Params.stepCount) 0 }} 0/{{ .Params.stepCount }} + {{ end }}
@@ -356,12 +360,16 @@

Steps

{{ end }} + {{/* Step-oriented affordances (progress bar, PiP step navigator, + expand/collapse-all) are suppressed for step-less tutorials (#2127). */}} + {{ if gt (int .Params.stepCount) 0 }} {{ if and (not site.Params.qa) (not site.Params.previewMode) }}
{{ end }} {{ if and (not site.Params.qa) (not site.Params.previewMode) }}
{{ end }}
+ {{ end }} {{ partial "tutorial-video.html" . }}
{{ .Content }}
diff --git a/scripts/parsers/__tests__/v2-commented-h3.test.ts b/scripts/parsers/__tests__/v2-commented-h3.test.ts new file mode 100644 index 000000000..2f46b38ad --- /dev/null +++ b/scripts/parsers/__tests__/v2-commented-h3.test.ts @@ -0,0 +1,117 @@ +// Regression tests for v2 step-splitter HTML-comment awareness (issue #2127). +// +// The v2 parser splits steps on `### ` H3 lines. Like fence-awareness, it must +// also ignore H3s that sit inside a *multi-line* `` HTML comment — +// an author disabling a not-yet-ready step by commenting it out. Without this, +// the commented `### Question 1` is lifted as a phantom step and its `` markers strand across the split, breaking Hugo rendering of the whole +// page. Surfaced by the Devtoberfest validation tutorials (developer-advocates +// repo), where the ONLY H3 is commented out — the tutorial must parse to zero +// steps, not one broken phantom step. + +import { describe, it, expect } from 'vitest' +import { parseV2Steps } from '../v2.js' +import { composeTutorial } from '../compose.js' + +describe('parseV2Steps HTML-comment awareness', () => { + it('does not treat an H3 inside a multi-line HTML comment as a step', () => { + // Shape of the Devtoberfest validation tutorial: intro prose, then the only + // `### Question 1` is commented out until real questions are ready. + const body = [ + 'This tutorial will be updated at the end of this day.', + '', + '', + '', + ].join('\n') + + const steps = parseV2Steps(body) + expect(steps).toHaveLength(0) + }) + + it('keeps real steps and ignores a commented-out H3 between them', () => { + const body = [ + '### Real Step One', + 'Body of step one.', + '', + '', + '', + '### Real Step Two', + 'Body of step two.', + ].join('\n') + + const steps = parseV2Steps(body) + expect(steps).toHaveLength(2) + expect(steps[0].title).toBe('Real Step One') + expect(steps[1].title).toBe('Real Step Two') + // The commented-out heading must not appear as a step title. + expect(steps.map(s => s.title)).not.toContain('Commented Out Step') + // No stranded comment markers leak into step content. + expect(steps[0].content).not.toContain('') + }) + + it('still splits on a self-contained single-line comment line (not a spanning comment)', () => { + // A single-line `` does not open a multi-line comment, so a real + // H3 on the following line must still delimit a step. + const body = [ + '', + '### Real Step One', + 'Body.', + ].join('\n') + + const steps = parseV2Steps(body) + expect(steps).toHaveLength(1) + expect(steps[0].title).toBe('Real Step One') + }) +}) + +describe('composeTutorial with a commented-out-only step (Devtoberfest validation shape, #2127)', () => { + it('composes to zero steps without stranding comment markers into content', () => { + const md = [ + '---', + 'auto_validation: true', + 'time: 10', + 'author_name: Daniel Wroblewski', + 'author_profile: https://github.com/thecodester', + 'tags: [ tutorial>beginner, topic>cloud ]', + 'primary_tag: topic>cloud', + 'parser: v2', + '---', + '', + '# Devtoberfest 2026 - Week 1 - AI - Validation', + '', + ' Validation tutorial for Devtoberfest points.', + '', + '## You will learn', + '- A lot about technology', + '', + '## Intro', + 'This tutorial will be updated at the end of this day.', + '', + '', + '', + ].join('\n') + + const result = composeTutorial(md, { + repo: 'developer-advocates', branch: 'main', slug: 'devtoberfest2026-ai-week1-validation', + target: 'hugo', rewriteImages: false, + }) + expect(result.steps).toHaveLength(0) + }) +}) + diff --git a/scripts/parsers/v2.ts b/scripts/parsers/v2.ts index c226ca269..0a65aa627 100644 --- a/scripts/parsers/v2.ts +++ b/scripts/parsers/v2.ts @@ -1,5 +1,6 @@ import type { TutorialStep } from './types.js' import { createFenceTracker } from './fence-tracker.js' +import { commentLineFlags } from './html-comment-lines.js' const VALIDATE_LINE = /^\s*\[VALIDATE_\d+\]\s*$/ const DONE_LINE = /^\s*\[DONE\]\s*$/ @@ -15,13 +16,23 @@ export function parseV2Steps(body: string): TutorialStep[] { // literal content, not a step delimiter. Root cause of the cookbook // tutorial's phantom-step bug. const fence = createFenceTracker() + // Per-line mask for lines inside a *multi-line* `` comment. An + // author disabling a not-yet-ready step by commenting it out must not have + // that `### ` lifted as a phantom step, nor its `` markers stranded + // across the split (which breaks Hugo rendering of the whole page). Root cause + // of the Devtoberfest validation-tutorial break (#2127). Commented lines are + // both non-delimiting and dropped from step content, mirroring intro.ts. + const commented = commentLineFlags(lines) - for (const line of lines) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i] if (fence(line)) { if (inStep) currentLines.push(line) continue } + if (commented[i]) continue + const h3Match = line.match(/^### (.+)$/) if (h3Match) { if (inStep) { diff --git a/scripts/validate-tutorials.ts b/scripts/validate-tutorials.ts index 8ed404630..188b7b073 100644 --- a/scripts/validate-tutorials.ts +++ b/scripts/validate-tutorials.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs' +import { readdirSync, readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import matter from 'gray-matter' @@ -10,6 +10,60 @@ const QUARANTINE_DIR = join(ROOT, '.tutorial-cache', 'quarantine') const REQUIRED_FIELDS = ['type', 'slug', 'title', 'time', 'stepCount'] as const +/** + * Source repos permitted to publish step-less tutorials (issue #2127). + * + * Devtoberfest "validation" tutorials are published with zero steps on purpose: + * authors want the page live (so it appears in the mission) but *not* completable + * — no Done button, no completion, no Devtoberfest points — until real questions + * are added. That render-without-completion behaviour already falls out of the + * Hugo layout when `stepCount === 0` (no step shortcodes → no Done buttons; the + * `total > 0` guards in tutorial.ts short-circuit). The only thing blocking it is + * this pre-validation gate, so the exception is scoped narrowly to the + * developer-advocates repo family. Every other repo still requires ≥1 step. + */ +export const NO_STEP_ALLOWED_REPOS = new Set([ + 'developer-advocates', + 'developer-advocates-Contribution', +]) + +/** + * Validate a tutorial's `stepCount`. Returns a quarantine reason, or `null` if OK. + * + * Normal tutorials require a positive integer step count. A tutorial sourced from + * a {@link NO_STEP_ALLOWED_REPOS} repo may also have exactly 0 steps (see #2127) — + * but a missing/NaN/negative count is still invalid everywhere. + * + * @param stepCount the frontmatter `stepCount` value (untrusted) + * @param repo the tutorial's source repo (from the discovery map), or undefined + */ +export function stepCountReason(stepCount: unknown, repo: string | undefined): string | null { + if (Number.isInteger(stepCount) && (stepCount as number) > 0) return null + if (stepCount === 0 && repo !== undefined && NO_STEP_ALLOWED_REPOS.has(repo)) return null + return `Invalid 'stepCount' value: ${stepCount}` +} + +/** + * Build a `slug → source repo` map from the fetch discovery cache + * (`.tutorial-cache/_discovery.json`, written by fetch-tutorials.ts). Used to + * scope the no-step exception to specific source repos. Missing/corrupt cache + * degrades safely to an empty map (→ every tutorial still requires a step). + */ +function loadRepoBySlug(): Record { + const path = join(ROOT, '.tutorial-cache', '_discovery.json') + if (!existsSync(path)) return {} + try { + const map = JSON.parse(readFileSync(path, 'utf-8')) as Record + const out: Record = {} + for (const [slug, entry] of Object.entries(map)) { + if (entry?.repo) out[slug] = entry.repo + } + return out + } catch { + return {} + } +} + /** * Counts Hugo shortcode opens vs closes in a tutorial body. * @@ -64,6 +118,7 @@ const files = readdirSync(TUTORIALS_DIR).filter(f => f.endsWith('.md') && !f.sta console.log(`Pre-validating ${files.length} tutorials (Hugo frontmatter)...\n`) const quarantined: Array<{ file: string; reason: string }> = [] +const repoBySlug = loadRepoBySlug() for (const file of files) { const content = readFileSync(join(TUTORIALS_DIR, file), 'utf-8') @@ -86,9 +141,10 @@ for (const file of files) { reason = `Invalid 'time' value: ${fm.time}` } - // Validate stepCount is a positive integer - if (!reason && (!Number.isInteger(fm.stepCount) || fm.stepCount <= 0)) { - reason = `Invalid 'stepCount' value: ${fm.stepCount}` + // Validate stepCount. Normal tutorials require ≥1 step; the + // developer-advocates repo family may publish 0-step tutorials (#2127). + if (!reason) { + reason = stepCountReason(fm.stepCount, repoBySlug[fm.slug]) } // Check for unclosed shortcode blocks (Hugo-specific). Helper is exported diff --git a/test/validate-tutorials-stepcount.test.ts b/test/validate-tutorials-stepcount.test.ts new file mode 100644 index 000000000..bf9c8288b --- /dev/null +++ b/test/validate-tutorials-stepcount.test.ts @@ -0,0 +1,44 @@ +// Regression tests for the repo-scoped no-step tutorial exception (#2127). +// +// Devtoberfest "validation" tutorials publish with zero steps on purpose so the +// page is live but not completable (no Done button, no points) until real +// questions are added. That exception is scoped narrowly to the +// developer-advocates repo family — every other repo still requires ≥1 step. + +import { describe, it, expect } from 'vitest'; +import { stepCountReason, NO_STEP_ALLOWED_REPOS } from '../scripts/validate-tutorials.js'; + +describe('stepCountReason', () => { + it('accepts a positive integer step count from any repo', () => { + expect(stepCountReason(3, 'tutorials')).toBeNull(); + expect(stepCountReason(1, 'developer-advocates')).toBeNull(); + expect(stepCountReason(1, undefined)).toBeNull(); + }); + + it('rejects a zero step count for a normal repo', () => { + expect(stepCountReason(0, 'tutorials')).toMatch(/Invalid 'stepCount'/); + }); + + it('rejects a zero step count when the source repo is unknown', () => { + expect(stepCountReason(0, undefined)).toMatch(/Invalid 'stepCount'/); + }); + + it('accepts a zero step count from the developer-advocates repo family', () => { + expect(stepCountReason(0, 'developer-advocates')).toBeNull(); + expect(stepCountReason(0, 'developer-advocates-Contribution')).toBeNull(); + }); + + it('still rejects a non-integer / negative count even for the allowed repos', () => { + expect(stepCountReason(undefined, 'developer-advocates')).toMatch(/Invalid 'stepCount'/); + expect(stepCountReason(NaN, 'developer-advocates')).toMatch(/Invalid 'stepCount'/); + expect(stepCountReason(-1, 'developer-advocates')).toMatch(/Invalid 'stepCount'/); + expect(stepCountReason(2.5, 'developer-advocates')).toMatch(/Invalid 'stepCount'/); + }); + + it('scopes the exception to exactly the two developer-advocates repos', () => { + expect(NO_STEP_ALLOWED_REPOS.has('developer-advocates')).toBe(true); + expect(NO_STEP_ALLOWED_REPOS.has('developer-advocates-Contribution')).toBe(true); + expect(NO_STEP_ALLOWED_REPOS.has('tutorials')).toBe(false); + expect(NO_STEP_ALLOWED_REPOS.size).toBe(2); + }); +}); From c919eda41a5aef841d2594dba7bb79d69ee518ba Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 3 Sep 2026 13:56:59 -0400 Subject: [PATCH 003/138] fix(api-docs): sap-devs CLI install no longer on npm (#2123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm i -g sap-devs instruction 404s — the CLI is no longer published to npm. Replace with the Homebrew/Scoop/GitHub Releases binary install methods and fix the project link to the sap-devs-cli repo. Closes #2123 --- hugo/content/api-docs/_index.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/hugo/content/api-docs/_index.md b/hugo/content/api-docs/_index.md index 799620b36..89f39e20d 100644 --- a/hugo/content/api-docs/_index.md +++ b/hugo/content/api-docs/_index.md @@ -34,10 +34,25 @@ These exist and are documented, but require elevated XSUAA scopes (`Tutorial.Aut `sap-devs` is a companion CLI that bundles the same SAP developer content and context this site consumes — CAP / BTP / ABAP tips, canonical code samples, error lookups, tutorial search, event listings, and more — so you can get to it without leaving your terminal. -**Install** +**Install** — the CLI ships as a self-contained binary (it's no longer published to npm). Pick your platform: + +```bash +# macOS (Homebrew) +brew tap SAP-samples/sap-devs-cli https://github.com/SAP-samples/sap-devs-cli.git +brew install --cask sap-devs + +# Windows (Scoop) +scoop bucket add sap-devs https://github.com/SAP-samples/sap-devs-cli.git +scoop install sap-devs + +# Linux / manual — download the archive for your platform from GitHub Releases, +# extract it, and put the binary on your PATH: +# https://github.com/SAP-samples/sap-devs-cli/releases +``` + +Then run the first-time setup: ```bash -npm i -g sap-devs sap-devs init # first-time setup wizard sap-devs sync --force # pull latest content ``` @@ -57,7 +72,7 @@ sap-devs doctor # tool + project health check sap-devs help # full command list ``` -The full command reference lives with the CLI itself: run `sap-devs help` or see the [sap-devs project repository](https://github.com/SAP-samples/sap-devs). +The full command reference lives with the CLI itself: run `sap-devs help` or see the [sap-devs project repository](https://github.com/SAP-samples/sap-devs-cli). ## MCP server From ce4a537878eae185fa16fd18b7ec25636fb1c608 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 02:14:42 -0400 Subject: [PATCH 004/138] chore(scavenger-hunt): update Ajay + Shilpa clue captions (#2130) --- hugo/data/scavenger_hunt.json | 4 ++-- test/e2e/scavenger-hunt.test.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/hugo/data/scavenger_hunt.json b/hugo/data/scavenger_hunt.json index f31babfe3..ba9b882f1 100644 --- a/hugo/data/scavenger_hunt.json +++ b/hugo/data/scavenger_hunt.json @@ -4,14 +4,14 @@ "homepage": { "slug": "ajay-soreng", "name": "Ajay Soreng", - "caption": "1st letter of first name", + "caption": "lettersYouNeed <- substr(firstName, 1, 1)", "showMoreInfo": true }, "api-docs": { "slug": "shilpa-shankar", "name": "Shilpa Shankar", "heading": "Devtoberfest Scavenger Hunt", - "caption": "4th letter of first name", + "caption": "func main() { lettersYouNeed := string(myString[3]) }", "showMoreInfo": true }, "ai": { diff --git a/test/e2e/scavenger-hunt.test.js b/test/e2e/scavenger-hunt.test.js index d1bc895d9..d3b6a25d8 100644 --- a/test/e2e/scavenger-hunt.test.js +++ b/test/e2e/scavenger-hunt.test.js @@ -51,7 +51,7 @@ describe.skipIf(!hasBaseUrl())('e2e: Devtoberfest scavenger hunt (unauthenticate { timeout: 5_000 } ); - expect(await page.locator('.sh--home .sh-clue-text').textContent()).toContain('1st letter of first name'); + expect(await page.locator('.sh--home .sh-clue-text').textContent()).toContain('lettersYouNeed <- substr(firstName, 1, 1)'); expect(await page.locator('.sh--home .sh-moreinfo a').getAttribute('href')).toBe('https://url.sap/7afji2'); // Advocate hero actually loaded from /api/advocates/:slug/photo. const imgOk = await page.locator('.sh--home .sh-img').evaluate((el) => el.complete && el.naturalWidth > 0); @@ -84,7 +84,7 @@ describe.skipIf(!hasBaseUrl())('e2e: Devtoberfest scavenger hunt (unauthenticate const block = page.locator('.sh--embed'); await block.waitFor({ state: 'visible', timeout: 15_000 }); expect(await block.locator('.sh-heading').textContent()).toContain('Devtoberfest Scavenger Hunt'); - expect(await block.locator('.sh-clue-text').textContent()).toContain('4th letter of first name'); + expect(await block.locator('.sh-clue-text').textContent()).toContain('func main() { lettersYouNeed := string(myString[3]) }'); const imgOk = await block.locator('.sh-img').evaluate((el) => el.complete && el.naturalWidth > 0); expect(imgOk, '/api-docs/ advocate hero image failed to load').toBe(true); } finally { From 608bc76838ceb5c16d046e6018331c3cdb3997b6 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 02:34:24 -0400 Subject: [PATCH 005/138] feat(homepage): Devtoberfest banner with live countdown (#2131) Add a prominent top-of-homepage banner linking to /devtoberfest/. Before the event it shows a live countdown to the start; during the event it shows a live-now message with the contest date range. Hidden off-season, after the event ends, or on error -- no layout shift. The event window is read at runtime from the existing anonymous GET /api/devtoberfest/status (admin-managed via /admin-ui/#/devtoberfest), so there are no hardcoded dates. Reuses the tested countdown.ts phase/label logic; new phase->view logic in view.ts is unit-tested (9 cases). --- hugo-apps/src/devtoberfest-banner/main.ts | 64 ++++++++++++++++ .../src/devtoberfest-banner/view.test.ts | 75 +++++++++++++++++++ hugo-apps/src/devtoberfest-banner/view.ts | 65 ++++++++++++++++ hugo-apps/vite.config.ts | 1 + hugo/assets/css/homepage.css | 64 ++++++++++++++++ hugo/layouts/index.html | 2 + .../homepage/devtoberfest-banner.html | 20 +++++ 7 files changed, 291 insertions(+) create mode 100644 hugo-apps/src/devtoberfest-banner/main.ts create mode 100644 hugo-apps/src/devtoberfest-banner/view.test.ts create mode 100644 hugo-apps/src/devtoberfest-banner/view.ts create mode 100644 hugo/layouts/partials/homepage/devtoberfest-banner.html diff --git a/hugo-apps/src/devtoberfest-banner/main.ts b/hugo-apps/src/devtoberfest-banner/main.ts new file mode 100644 index 000000000..ed4bd7cf4 --- /dev/null +++ b/hugo-apps/src/devtoberfest-banner/main.ts @@ -0,0 +1,64 @@ +// Homepage Devtoberfest banner island (#2131). +// +// Vanilla hydrator (mirrors topic-clusters-band): fetches the public event +// window from /api/devtoberfest/status, then renders a live countdown before +// the event and a "live now" + date range during it. The phase/label logic is +// the pure bannerView() in ./view.ts; this file only owns the fetch, the DOM, +// and the once-a-second tick. When there is no active/upcoming event (503, +// ended, error) the SSR shell stays hidden — no layout shift, no crash. + +import { bannerView } from './view' +import type { StatusResponse } from '../devtoberfest/types' + +const TICK_MS = 1000 + +function hydrate(root: HTMLElement): void { + const api = root.dataset.api || '/api/devtoberfest/status' + const href = root.dataset.href || '/devtoberfest/' + const msgEl = root.querySelector('[data-role="msg"]') + const winEl = root.querySelector('[data-role="window"]') + const linkEl = root.querySelector('a.hp-dtf-banner__link') + if (!msgEl || !winEl || !linkEl) return + + linkEl.href = href + + let timer: ReturnType | undefined + + const hide = (): void => { + root.hidden = true + root.setAttribute('aria-hidden', 'true') + if (timer) { + clearInterval(timer) + timer = undefined + } + } + + const render = (status: StatusResponse | null): void => { + const v = bannerView(status, Date.now()) + if (!v.show) { + hide() + return + } + msgEl.textContent = v.message + winEl.textContent = v.window + root.dataset.phase = v.phase + root.hidden = false + root.setAttribute('aria-hidden', 'false') + } + + fetch(api, { headers: { Accept: 'application/json' } }) + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .then((status) => { + render(status) + // Only tick while something is visible; render() self-hides once the + // window closes (crossing endDate), clearing the interval then. + if (!root.hidden) { + timer = setInterval(() => render(status), TICK_MS) + } + }) + .catch(() => hide()) +} + +document + .querySelectorAll('[data-app="devtoberfest-banner"]') + .forEach(hydrate) diff --git a/hugo-apps/src/devtoberfest-banner/view.test.ts b/hugo-apps/src/devtoberfest-banner/view.test.ts new file mode 100644 index 000000000..439adc415 --- /dev/null +++ b/hugo-apps/src/devtoberfest-banner/view.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest' +import { bannerView } from './view' +import type { StatusResponse } from '../devtoberfest/types' + +function status(startDate: string, endDate: string): StatusResponse { + return { + event: { name: 'Devtoberfest', startDate, endDate }, + joined: false, + termsVersion: 1, + termsRequired: true, + contentRulesUrl: '', + faqUrl: '', + gameboardUrl: '', + activitiesUrl: '', + bannerUrl: '', + } +} + +const START = '2026-10-01T00:00:00Z' +const END = '2026-10-31T23:59:59Z' + +describe('bannerView', () => { + it('hides when status is null', () => { + expect(bannerView(null, Date.now()).show).toBe(false) + }) + + it('hides when there is no active event', () => { + const s = status(START, END) + s.event = null + expect(bannerView(s, Date.now()).show).toBe(false) + }) + + it('hides when dates are missing', () => { + expect(bannerView(status('', ''), Date.now()).show).toBe(false) + }) + + it('hides when dates are unparseable', () => { + expect(bannerView(status('not-a-date', 'nope'), Date.now()).show).toBe(false) + }) + + it('before the event: shows a live countdown to the start', () => { + // 2 days, 3 hours, 4 minutes before start + const now = Date.parse(START) - (2 * 86400 + 3 * 3600 + 4 * 60) * 1000 + const v = bannerView(status(START, END), now) + expect(v.show).toBe(true) + expect(v.phase).toBe('before') + expect(v.message).toBe('Starts in 2d 3h 4m') + expect(v.window).toBe('Oct 1 – Oct 31') + }) + + it('during the event: shows the live-now message with the window', () => { + const now = Date.parse(START) + 5 * 86400 * 1000 + const v = bannerView(status(START, END), now) + expect(v.show).toBe(true) + expect(v.phase).toBe('during') + expect(v.message).toBe('Live now') + expect(v.window).toBe('Oct 1 – Oct 31') + }) + + it('at the exact start instant: already "during"', () => { + const v = bannerView(status(START, END), Date.parse(START)) + expect(v.phase).toBe('during') + }) + + it('after the event ends: hidden', () => { + const now = Date.parse(END) + 1000 + expect(bannerView(status(START, END), now).show).toBe(false) + }) + + it('formats the window in UTC regardless of local tz', () => { + // A start at 23:30 UTC must still label as the UTC calendar day. + const v = bannerView(status('2026-10-01T23:30:00Z', '2026-10-31T23:59:59Z'), Date.parse('2026-10-01T23:30:00Z')) + expect(v.window).toBe('Oct 1 – Oct 31') + }) +}) diff --git a/hugo-apps/src/devtoberfest-banner/view.ts b/hugo-apps/src/devtoberfest-banner/view.ts new file mode 100644 index 000000000..e37c147da --- /dev/null +++ b/hugo-apps/src/devtoberfest-banner/view.ts @@ -0,0 +1,65 @@ +// Pure, unit-testable view logic for the homepage Devtoberfest banner (#2131). +// +// The banner reads the public `/api/devtoberfest/status` window (admin-managed +// via /admin-ui/#/devtoberfest — no hardcoded dates) and shows one of two +// states, computed live in the visitor's browser so it never goes stale: +// +// before → a live countdown to the start ("Starts in 5d 12h 30m") +// during → the live-now message with the contest date range +// +// Any other case (event ended, no active event, unparseable/absent dates) +// hides the banner entirely. Keeping this a pure function of (status, now) +// means the phase/label logic is testable without a live clock or the DOM. + +import { formatCountdown, formatDuration } from '../devtoberfest/countdown' +import type { StatusResponse } from '../devtoberfest/types' + +export interface BannerView { + /** Whether the banner should be visible at all. */ + show: boolean + /** 'before' | 'during' when shown; '' when hidden. */ + phase: '' | 'before' | 'during' + /** The primary dynamic line, e.g. "Starts in 5d 12h 30m" or "Live now". */ + message: string + /** Contest window "Oct 1 – Oct 31" (rendered during both phases). */ + window: string +} + +const HIDDEN: BannerView = { show: false, phase: '', message: '', window: '' } + +/** + * Format an ISO date as a short "Mon D" label in UTC so the contest window is + * deterministic regardless of the visitor's timezone (the window is a date + * range, not an instant — a UTC calendar day is the right unit here). + */ +function fmtDay(iso: string): string { + const t = Date.parse(iso) + if (isNaN(t)) return '' + return new Date(t).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }) +} + +/** + * Compute the banner's visible state from the status payload and the current + * time (epoch ms). Returns HIDDEN unless the event is upcoming or running. + */ +export function bannerView(status: StatusResponse | null, nowMs: number): BannerView { + const ev = status?.event + if (!ev || !ev.startDate || !ev.endDate) return HIDDEN + + const cd = formatCountdown(nowMs, ev.startDate, ev.endDate) + if (cd.phase !== 'before' && cd.phase !== 'during') return HIDDEN + + const start = Date.parse(ev.startDate) + const s = fmtDay(ev.startDate) + const e = fmtDay(ev.endDate) + const window = s && e ? `${s} – ${e}` : '' + + const message = + cd.phase === 'before' ? `Starts in ${formatDuration(start - nowMs)}` : 'Live now' + + return { show: true, phase: cd.phase, message, window } +} diff --git a/hugo-apps/vite.config.ts b/hugo-apps/vite.config.ts index 4d95ce18f..4e6c9b592 100644 --- a/hugo-apps/vite.config.ts +++ b/hugo-apps/vite.config.ts @@ -308,6 +308,7 @@ export default defineConfig({ 'devtoberfest-sessions-calendar': resolve(__dirname, 'src/devtoberfest-sessions-calendar/main.ts'), 'devtoberfest-rules': resolve(__dirname, 'src/devtoberfest-rules/main.ts'), 'devtoberfest-faq': resolve(__dirname, 'src/devtoberfest-faq/main.ts'), + 'devtoberfest-banner': resolve(__dirname, 'src/devtoberfest-banner/main.ts'), 'ui5-core': resolve(__dirname, 'src/ui5/ui5-core.ts'), 'ui5-tutorial': resolve(__dirname, 'src/ui5/ui5-tutorial.ts'), 'ui5-me': resolve(__dirname, 'src/ui5/ui5-me.ts'), diff --git a/hugo/assets/css/homepage.css b/hugo/assets/css/homepage.css index 0cbd091e3..4bcec561c 100644 --- a/hugo/assets/css/homepage.css +++ b/hugo/assets/css/homepage.css @@ -793,3 +793,67 @@ .hp-tc-badge--mission, .hp-tc-badge--group { background: #e6f6ea; } .hp-tc-badge--community-event { background: #f0e6fb; } + +/* ===== Devtoberfest banner (#2131) ===== + Prominent top-of-homepage promo. SSR ships it hidden; the + devtoberfest-banner island reveals it only when the event is upcoming + (live countdown) or running ("Live now" + date range). Autumn-orange + Devtoberfest branding, distinct from the neutral hp-band rows. */ +.hp-dtf-banner { margin: 0; } +.hp-dtf-banner[hidden] { display: none; } + +.hp-dtf-banner__link { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem 1rem; + padding: 0.9rem 1.5rem; + border-radius: 10px; + text-decoration: none; + color: #fff; + background: linear-gradient(90deg, #c74806 0%, #e9730c 55%, #f0ab00 100%); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.hp-dtf-banner__link:hover, +.hp-dtf-banner__link:focus-visible { + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22); +} +.hp-dtf-banner__link:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} + +.hp-dtf-banner__brand { + font-size: 1.35rem; + font-weight: 700; + letter-spacing: 0.01em; +} +.hp-dtf-banner__msg { + font-size: 1.1rem; + font-weight: 600; + /* Tabular figures keep the ticking countdown from jittering in width. */ + font-variant-numeric: tabular-nums; +} +.hp-dtf-banner__window { + font-size: 1rem; + opacity: 0.95; +} +.hp-dtf-banner__cta { + margin-left: auto; + font-size: 0.95rem; + font-weight: 600; + white-space: nowrap; +} + +@media (max-width: 600px) { + .hp-dtf-banner__link { flex-direction: column; align-items: flex-start; } + .hp-dtf-banner__cta { margin-left: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .hp-dtf-banner__link { transition: none; } + .hp-dtf-banner__link:hover, + .hp-dtf-banner__link:focus-visible { transform: none; } +} diff --git a/hugo/layouts/index.html b/hugo/layouts/index.html index 9d1ad25bd..79dca07dc 100644 --- a/hugo/layouts/index.html +++ b/hugo/layouts/index.html @@ -5,6 +5,7 @@ {{- $shelves := (.Site.Data.homepage_shelves.shelves) | default slice -}}
+ {{ partial "homepage/devtoberfest-banner.html" . }} {{ partial "homepage/hero.html" . }} {{ partial "homepage/verb-spine.html" (dict "shelves" $shelves) }} @@ -20,6 +21,7 @@ {{ $css := resources.Get "css/homepage.css" | minify | fingerprint }} + diff --git a/hugo/layouts/partials/homepage/devtoberfest-banner.html b/hugo/layouts/partials/homepage/devtoberfest-banner.html new file mode 100644 index 000000000..6295b6fc6 --- /dev/null +++ b/hugo/layouts/partials/homepage/devtoberfest-banner.html @@ -0,0 +1,20 @@ +{{- /* Devtoberfest homepage banner — issue #2131. + Prominent top-of-page strip that links to the Devtoberfest page. + SSR emits a hidden shell; the devtoberfest-banner island fetches the + admin-managed event window from /api/devtoberfest/status and reveals it + only when the event is upcoming (live countdown) or running ("Live now" + + date range). Hidden when no active event / ended / on error — so there + is no layout shift in the common off-season case. */ -}} + From 17a153195e6299dd61327ffb8172990289e10f5e Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 03:26:01 -0400 Subject: [PATCH 006/138] feat(events): per-event name, description & logo on event-display + app-space (#2133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop hardcoding "TechEd" and pull real event identity from the related event. Schema: - Events gains description (app-space hero), hasLogo, logoUpdatedAt. - New EventLogo entity: 1:1 composition child holding a WebP BLOB (mirrors DevtoberfestBanner — plain hdbtable, no journal annotation). - Events hdbmigrationtable regenerated (version=4). Backend: - srv/lib/event-logo-store.js — sharp -> WebP q82 pipeline, sha256, upsert + hasLogo flip, HANA-raw / SQLite-CDS-QL fetch (LOB locator rule). - AdminService bound actions uploadEventLogo / clearEventLogo + handlers. - Anonymous GET /api/event-logo?eventLegacyId=N (ETag + 1d cache), with matching approuter authenticationType:none route. - getEventBuckets now returns { eventName, eventType, hasLogo, buckets }; getAppSpaceProgress adds eventDescription + hasLogo. Frontend: - event-display: real event name in the hero label + logo lockup. - app-space: event name, description, and logo from the related event. - Admin Events object page: Upload/Clear Logo header actions. Tests: test/unit/events/event-logo-store.test.js (pipeline + upsert + fetch + clear). Migration verified idempotent via canonical cds build. --- app/admin-annotations.cds | 6 +- app/admin/events/webapp/ext/LogoController.js | 137 ++++++++++++++++ app/admin/events/webapp/manifest.json | 20 ++- approuter/xs-app.json | 6 + db/last-dev/csn.json | 15 ++ db/schema.cds | 22 +++ ...ap.developers.ims.Events.hdbmigrationtable | 9 +- hugo-apps/src/app-space/AppSpace.vue | 38 ++++- hugo-apps/src/event-display/EventDisplay.vue | 28 ++++ hugo-apps/src/event-display/useEventStream.ts | 22 ++- srv/admin-service.cds | 9 +- srv/admin-service.js | 2 + srv/developer-service.cds | 10 +- srv/developer-service.js | 2 + srv/event-stream-service.cds | 17 +- srv/event-stream-service.js | 9 +- srv/handlers/event-logo-handlers.js | 47 ++++++ srv/lib/event-logo-store.js | 147 ++++++++++++++++++ srv/routes/event-logo-public.js | 43 +++++ srv/server.js | 2 + test/unit/events/event-logo-store.test.js | 140 +++++++++++++++++ 21 files changed, 714 insertions(+), 17 deletions(-) create mode 100644 app/admin/events/webapp/ext/LogoController.js create mode 100644 srv/handlers/event-logo-handlers.js create mode 100644 srv/lib/event-logo-store.js create mode 100644 srv/routes/event-logo-public.js create mode 100644 test/unit/events/event-logo-store.test.js diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index 7d5a38e86..36447a981 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -46,6 +46,8 @@ annotate AdminService.Events with { }; startDate @Common.Label: 'Start Date'; endDate @Common.Label: 'End Date'; + description @Common.Label: 'Description' @UI.MultiLineText; // Shown on app-space hero (#2133) + logoUpdatedAt @Common.Label: 'Logo Updated' @Common.FieldControl: #ReadOnly; timeZone @Common.Label: 'Time Zone' @Common.ValueList: { CollectionPath: 'TimeZones', @@ -93,10 +95,12 @@ annotate AdminService.Events with @UI: { ], FieldGroup#General: { Data: [ { Value: name }, + { Value: description }, { Value: eventType }, { Value: startDate }, { Value: endDate }, - { Value: timeZone } + { Value: timeZone }, + { Value: logoUpdatedAt } ]} }; diff --git a/app/admin/events/webapp/ext/LogoController.js b/app/admin/events/webapp/ext/LogoController.js new file mode 100644 index 000000000..3195ada00 --- /dev/null +++ b/app/admin/events/webapp/ext/LogoController.js @@ -0,0 +1,137 @@ +// Plain UI5 module for the Events Object Page header actions (#2133). +// +// Mirrors app/admin/devtoberfest/webapp/ext/BannerController.js — see that file +// for the full rationale on why this is a bare `sap.ui.define` module (NOT a +// ControllerExtension): FE V4 resolves manifest `press` references as plain +// modules (loader path `.js`), and press handlers get the binding +// context(s) directly. +// +// The logo is written via the `uploadEventLogo` BOUND ACTION on +// AdminService.Events, which takes base64 (`imageBase64`, `mimeType`) and runs +// the sharp → WebP → BLOB pipeline server-side. We do NOT use a Fiori UploadSet: +// the logo is a 1:1 composition whose key IS the parent association, and FE's +// UploadSet "Create" POSTs a new composition row, which OData rejects. +sap.ui.define([ + "sap/m/MessageToast", + "sap/m/MessageBox" +], function (MessageToast, MessageBox) { + "use strict"; + + // Resolve the binding context regardless of how FE V4 invoked the handler. + function resolveCtx(arg) { + if (!arg) return null; + if (Array.isArray(arg)) return arg[0] || null; + if (typeof arg.getModel === "function") return arg; + if (typeof arg.getSource === "function") { + const src = arg.getSource(); + if (src && typeof src.getBindingContext === "function") { + return src.getBindingContext(); + } + } + return null; + } + + // Read a File into a base64 string (no data: prefix — the server strips it + // defensively anyway, but we send the bare payload). + function fileToBase64(file) { + return new Promise(function (resolve, reject) { + const reader = new FileReader(); + reader.onload = function () { + const comma = String(reader.result).indexOf(","); + resolve(comma >= 0 ? String(reader.result).slice(comma + 1) : String(reader.result)); + }; + reader.onerror = function () { reject(reader.error || new Error("read failed")); }; + reader.readAsDataURL(file); + }); + } + + return { + + /** + * Header action: prompt for an image file, base64-encode it client-side, + * and invoke the AdminService.uploadEventLogo bound action on the current + * event. The server runs the sharp pipeline (resize → WebP), upserts the + * EventLogo row, and flips hasLogo + logoUpdatedAt. + */ + onUploadEventLogoPress: function (arg) { + const ctx = resolveCtx(arg); + if (!ctx) { + MessageToast.show("Open an event first"); + return; + } + const ev = ctx.getObject ? ctx.getObject() : null; + if (ev && ev.IsActiveEntity === false) { + MessageBox.warning( + "Save or cancel your current edits before uploading a logo. " + + "The logo applies to the saved record, not to the draft." + ); + return; + } + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/jpeg,image/png,image/webp"; + input.style.display = "none"; + document.body.appendChild(input); + input.addEventListener("change", async function () { + try { + const file = input.files && input.files[0]; + if (!file) return; + if (file.size > 8 * 1024 * 1024) { + MessageBox.error("Logo too large (max 8 MB)."); + return; + } + MessageToast.show("Uploading logo…"); + const imageBase64 = await fileToBase64(file); + const model = ctx.getModel(); + const op = model.bindContext("AdminService.uploadEventLogo(...)", ctx); + op.setParameter("imageBase64", imageBase64); + op.setParameter("mimeType", file.type || "image/png"); + await op.execute(); + MessageToast.show("Logo uploaded."); + if (ctx.refresh) ctx.refresh(); + } catch (err) { + MessageBox.error("Logo upload failed: " + (err && err.message ? err.message : err)); + } finally { + input.remove(); + } + }); + input.click(); + }, + + /** + * Header action: confirm + call AdminService.clearEventLogo. Drops the + * EventLogo row and flips hasLogo=false. + */ + onClearEventLogoPress: function (arg) { + const ctx = resolveCtx(arg); + if (!ctx) { + MessageToast.show("Open an event first"); + return; + } + const ev = ctx.getObject ? ctx.getObject() : null; + if (ev && ev.IsActiveEntity === false) { + MessageBox.warning("Save or cancel your current edits before clearing the logo."); + return; + } + MessageBox.confirm( + "Remove this event's logo? The image will be deleted from the server.", + { + title: "Clear logo", + onClose: async function (action) { + if (action !== MessageBox.Action.OK) return; + try { + const model = ctx.getModel(); + const op = model.bindContext("AdminService.clearEventLogo(...)", ctx); + await op.execute(); + MessageToast.show("Logo cleared."); + if (ctx.refresh) ctx.refresh(); + } catch (err) { + MessageBox.error("Clear failed: " + (err && err.message ? err.message : err)); + } + } + } + ); + } + + }; +}); diff --git a/app/admin/events/webapp/manifest.json b/app/admin/events/webapp/manifest.json index 6a3725bce..0b8eb90fd 100644 --- a/app/admin/events/webapp/manifest.json +++ b/app/admin/events/webapp/manifest.json @@ -77,7 +77,25 @@ "options": { "settings": { "contextPath": "/Events", - "editableHeaderContent": false + "editableHeaderContent": false, + "content": { + "header": { + "actions": { + "uploadEventLogo": { + "press": "sap.tutorials.admin.events.ext.LogoController.onUploadEventLogoPress", + "visible": true, + "enabled": "{= ${IsActiveEntity} === true }", + "text": "Upload Logo" + }, + "clearEventLogo": { + "press": "sap.tutorials.admin.events.ext.LogoController.onClearEventLogoPress", + "visible": "{= !!${hasLogo} }", + "enabled": "{= ${IsActiveEntity} === true }", + "text": "Clear Logo" + } + } + } + } } } } diff --git a/approuter/xs-app.json b/approuter/xs-app.json index a802fa8ea..363e1ef07 100644 --- a/approuter/xs-app.json +++ b/approuter/xs-app.json @@ -125,6 +125,12 @@ "target": "/version", "destination": "srv-api" }, + { + "source": "^/api/event-logo(\\?.*)?$", + "target": "/api/event-logo$1", + "destination": "srv-api", + "authenticationType": "none" + }, { "source": "^/api/devtoberfest/speaker/([^/?]+)/photo(\\?.*)?$", "target": "/api/devtoberfest/speaker/$1/photo$2", diff --git a/db/last-dev/csn.json b/db/last-dev/csn.json index ea9751704..d3b580812 100644 --- a/db/last-dev/csn.json +++ b/db/last-dev/csn.json @@ -292,6 +292,10 @@ "length": 255, "@cds.persistence.name": "NAME" }, + "description": { + "type": "cds.LargeString", + "@cds.persistence.name": "DESCRIPTION" + }, "startDate": { "type": "cds.Timestamp", "@cds.persistence.name": "STARTDATE" @@ -318,6 +322,17 @@ "length": 36, "@odata.foreignKey4": "mission", "@cds.persistence.name": "MISSION_ID" + }, + "hasLogo": { + "type": "cds.Boolean", + "default": { + "val": false + }, + "@cds.persistence.name": "HASLOGO" + }, + "logoUpdatedAt": { + "type": "cds.Timestamp", + "@cds.persistence.name": "LOGOUPDATEDAT" } }, "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_EVENTS" diff --git a/db/schema.cds b/db/schema.cds index e1d48af79..a5ffcfcab 100644 --- a/db/schema.cds +++ b/db/schema.cds @@ -293,6 +293,7 @@ entity DeveloperEnvironmentLinks : cuid, LegacyKeyed { entity Events : cuid, managed, LegacyKeyed { name : String(255); + description : LargeString; // Shown on the app-space hero (#2133) startDate : Timestamp; endDate : Timestamp; timeZone : String(50); @@ -300,6 +301,27 @@ entity Events : cuid, managed, LegacyKeyed { mission : Association to Missions; taskRecords : Association to many TaskRecords on taskRecords.event = $self; prizes : Association to many Prizes on prizes.event = $self; + // Event logo lockup (#2133). hasLogo/logoUpdatedAt mirror DevtoberfestConfig's + // hasBanner/bannerUpdatedAt so serving handlers can 404 fast without a BLOB read. + hasLogo : Boolean default false; + logoUpdatedAt : Timestamp; + logo : Composition of one EventLogo on logo.event = $self; +} + +// Per-event logo lockup image (#2133). 1:1 composition: the association IS the +// key, so exactly one logo row exists per event. Mirrors DevtoberfestBanner +// (db/devtoberfest.cds). Bytes are a single WebP rendition produced by the +// sharp pipeline in srv/lib/event-logo-store.js. Served publicly (anonymous) +// via GET /api/event-logo?eventLegacyId=N for the event-display + app-space pages. +entity EventLogo { + key event : Association to Events not null; + image : LargeBinary @Core.MediaType: mimeType; + mimeType : String(40) @Core.IsMediaType default 'image/webp'; + sizeBytes : Integer; + sha256 : String(64); + width : Integer; + height : Integer; + uploadedAt : Timestamp; } entity Prizes : cuid, LegacyKeyed { diff --git a/db/src/com.sap.developers.ims.Events.hdbmigrationtable b/db/src/com.sap.developers.ims.Events.hdbmigrationtable index dd387b4ee..3ef02e2c1 100644 --- a/db/src/com.sap.developers.ims.Events.hdbmigrationtable +++ b/db/src/com.sap.developers.ims.Events.hdbmigrationtable @@ -1,4 +1,4 @@ -== version=3 +== version=4 COLUMN TABLE com_sap_developers_ims_Events ( ID NVARCHAR(36) NOT NULL, createdAt TIMESTAMP, @@ -7,14 +7,21 @@ COLUMN TABLE com_sap_developers_ims_Events ( modifiedBy NVARCHAR(255), legacyId INTEGER, name NVARCHAR(255), + description NCLOB, startDate TIMESTAMP, endDate TIMESTAMP, "TIMEZONE" NVARCHAR(50), eventType NVARCHAR(20) DEFAULT 'OTHER', mission_ID NVARCHAR(36), + hasLogo BOOLEAN DEFAULT FALSE, + logoUpdatedAt TIMESTAMP, PRIMARY KEY(ID) ) +== migration=4 +-- generated by cds-compiler version 7.0.1 +ALTER TABLE com_sap_developers_ims_Events ADD (description NCLOB, hasLogo BOOLEAN DEFAULT FALSE, logoUpdatedAt TIMESTAMP); + == migration=3 -- generated by cds-compiler version 6.9.0 ALTER TABLE com_sap_developers_ims_Events ADD (eventType NVARCHAR(20) DEFAULT 'OTHER'); diff --git a/hugo-apps/src/app-space/AppSpace.vue b/hugo-apps/src/app-space/AppSpace.vue index 6e31816e7..512893ee0 100644 --- a/hugo-apps/src/app-space/AppSpace.vue +++ b/hugo-apps/src/app-space/AppSpace.vue @@ -10,6 +10,8 @@ const eventId = ref(null) const isDark = ref(document.documentElement.dataset.theme === 'dark') const activeTheme = ref<'joule' | 'sapphire' | null>(null) const loadedEventName = ref('') +const loadedEventDescription = ref('') +const hasLogo = ref(false) const eventName = computed(() => { if (loadedEventName.value) return loadedEventName.value @@ -17,6 +19,20 @@ const eventName = computed(() => { return 'SAP TechEd' }) +// Event description pulled from the related event (#2133); falls back to the +// generic App Space blurb when the event has no description maintained. +const eventDescription = computed(() => + loadedEventDescription.value || + 'Pick a track, complete the tutorials, and earn prizes along the way.' +) + +// Logo lockup served anonymously from HANA when the event has one (#2133). +const logoUrl = computed(() => + hasLogo.value && eventId.value + ? `/api/event-logo?eventLegacyId=${eventId.value}` + : '' +) + // ── Interfaces ───────────────────────────────────────────────────── interface AppSpaceItem { imsId: number @@ -41,6 +57,8 @@ interface AppSpaceTrack { interface AppSpaceData { eventId: number eventName: string + eventDescription?: string + hasLogo?: boolean type: string paths: AppSpaceTrack[] } @@ -108,6 +126,8 @@ onMounted(async () => { if (data) { tracks.value = data.paths if (data.eventName) loadedEventName.value = data.eventName + if (data.eventDescription) loadedEventDescription.value = data.eventDescription + hasLogo.value = Boolean(data.hasLogo) } loading.value = false }) @@ -278,10 +298,16 @@ const emptyStateMessage = computed(() => {
+

{{ eventName }}

Developer Garage — App Space

- Pick a track, complete the tutorials, and earn prizes along the way. + {{ eventDescription }}

@@ -539,6 +565,16 @@ const emptyStateMessage = computed(() => { gap: 2rem; } +.hero-logo { + max-height: 72px; + max-width: min(70%, 360px); + width: auto; + height: auto; + object-fit: contain; + margin: 0 0 1rem; + display: block; +} + .hero-title { font-size: 2rem; font-weight: 700; diff --git a/hugo-apps/src/event-display/EventDisplay.vue b/hugo-apps/src/event-display/EventDisplay.vue index d7703806a..61c9db06f 100644 --- a/hugo-apps/src/event-display/EventDisplay.vue +++ b/hugo-apps/src/event-display/EventDisplay.vue @@ -14,6 +14,8 @@ const { totalCount, connectionState, errorMessage, + eventName, + hasLogo, connect, startDemo, disconnect, @@ -63,11 +65,21 @@ const visibleBuckets = computed(() => { }) const eventLabel = computed(() => { + // Prefer the real event name fetched from the related event (#2133); fall + // back to the theme-derived label when the name is unavailable (e.g. demo). + if (eventName.value) return eventName.value if (activeTheme.value === 'sapphire') return 'SAP Sapphire' if (activeTheme.value === 'joule') return 'SAP TechEd' return 'Event' }) +// Logo lockup served anonymously from HANA when the event has one (#2133). +const logoUrl = computed(() => + !isDemo.value && hasLogo.value && eventId.value + ? `/api/event-logo?eventLegacyId=${eventId.value}` + : '' +) + const showSetup = computed(() => !isDemo.value && !eventId.value ) @@ -161,6 +173,12 @@ onMounted(() => {
+

{{ isDemo ? 'Demo Mode' : eventLabel }} — Live

{{ formatCount(displayedCount) }}

tutorials completed

@@ -278,6 +296,16 @@ onMounted(() => { margin: 0 auto; } +.hero-logo { + max-height: 96px; + max-width: min(80%, 480px); + width: auto; + height: auto; + object-fit: contain; + margin: 0 auto 1.25rem; + display: block; +} + .hero-label { font-size: 1rem; opacity: 0.85; diff --git a/hugo-apps/src/event-display/useEventStream.ts b/hugo-apps/src/event-display/useEventStream.ts index d882dfe32..098c5bb17 100644 --- a/hugo-apps/src/event-display/useEventStream.ts +++ b/hugo-apps/src/event-display/useEventStream.ts @@ -28,6 +28,9 @@ export function useEventStream() { const totalCount = ref(0) const connectionState = ref('idle') const errorMessage = ref('') + const eventName = ref('') + const eventType = ref('') + const hasLogo = ref(false) let socket: Socket | null = null let demoInterval: ReturnType | null = null @@ -64,13 +67,23 @@ export function useEventStream() { errorMessage.value = '' const url = String(baseUrl).replace(/\/+$/, '') - // Fetch initial bucket data from unauthenticated EventStreamService + // Fetch initial bucket data + event metadata from unauthenticated + // EventStreamService. getEventBuckets now returns a structured object + // { eventName, eventType, hasLogo, buckets:[...] } (#2133); older array + // shapes are tolerated for forward/backward safety. try { const res = await fetch(`${url}/rest/event-stream/getEventBuckets(eventLegacyId=${eventId})`) if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`) const json = await res.json() - const data: Array<{ bucketName: string; count: number }> = json.value ?? json - buckets.value = data.map(b => ({ name: b.bucketName, count: b.count, justUpdated: false })) + const payload = json.value ?? json + const list: Array<{ bucketName: string; count: number }> = + Array.isArray(payload) ? payload : (payload.buckets ?? []) + if (!Array.isArray(payload)) { + eventName.value = payload.eventName ?? '' + eventType.value = payload.eventType ?? '' + hasLogo.value = Boolean(payload.hasLogo) + } + buckets.value = list.map(b => ({ name: b.bucketName, count: b.count, justUpdated: false })) sortBuckets() recalcTotal() } catch (e) { @@ -137,6 +150,9 @@ export function useEventStream() { totalCount: readonly(totalCount), connectionState: readonly(connectionState), errorMessage: readonly(errorMessage), + eventName: readonly(eventName), + eventType: readonly(eventType), + hasLogo: readonly(hasLogo), connect, startDemo, disconnect, diff --git a/srv/admin-service.cds b/srv/admin-service.cds index 9e3bf132e..0ceba3e38 100644 --- a/srv/admin-service.cds +++ b/srv/admin-service.cds @@ -182,7 +182,14 @@ service AdminService { @(requires: ['Tutorial.Author', 'Admin']) action purge(); }; - entity Events as projection on ims.Events { *, cast(legacyId as String) as legacyIdStr : String }; + entity Events as projection on ims.Events { *, cast(legacyId as String) as legacyIdStr : String } actions { + // Base64-over-OData upload (FE UploadSet drops bytes on draft compositions — + // same reason as DevtoberfestConfig.uploadBanner). sharp → WebP → upsert + // EventLogo → flip hasLogo. See srv/handlers/event-logo-handlers.js (#2133). + action uploadEventLogo(imageBase64 : String, mimeType : String) returns Events; + action clearEventLogo() returns Events; + }; + entity EventLogo as projection on ims.EventLogo; entity Prizes as projection on ims.Prizes { *, cast(legacyId as String) as legacyIdStr : String }; entity PrizeRecords as projection on ims.PrizeRecords; @Capabilities.ChangeTracking : { Supported: true } diff --git a/srv/admin-service.js b/srv/admin-service.js index c6b68ec2a..b2ecdeb66 100644 --- a/srv/admin-service.js +++ b/srv/admin-service.js @@ -13,6 +13,7 @@ import { classifyAndPersist } from './lib/category-classifier.js'; import { makeAltGroupHandler } from './handlers/completion-path-items-altgroup.js'; import * as advocateHandlers from './handlers/advocate-handlers.js'; import * as devtoberfestBannerHandlers from './handlers/devtoberfest-banner-handlers.js'; +import * as eventLogoHandlers from './handlers/event-logo-handlers.js'; import { classifySeverity, daysUntil } from './jobs/secret-expiry-check.js'; import { readSecret, writeSecret, deleteSecret } from './lib/credstore.js'; import { invalidateSecret } from './lib/secret-resolver.js'; @@ -1058,6 +1059,7 @@ export default class AdminService extends cds.ApplicationService { // Advocates: auto-derive slug from firstName + lastName on CREATE. advocateHandlers.register(this); devtoberfestBannerHandlers.register(this); + eventLogoHandlers.register(this); // Validate Start Date < End Date on Events this.before(['CREATE', 'PATCH'], 'Events', (req) => { diff --git a/srv/developer-service.cds b/srv/developer-service.cds index faa7b7f9a..6173d19a2 100644 --- a/srv/developer-service.cds +++ b/srv/developer-service.cds @@ -175,10 +175,12 @@ service DeveloperService { // App Space progress by event ID (frontend default: latest event) @(requires: 'authenticated-user') function getAppSpaceProgress(eventLegacyId : Integer) returns { - eventId : Integer; - eventName : String; - eventType : String; - type : String; + eventId : Integer; + eventName : String; + eventDescription : String; + eventType : String; + hasLogo : Boolean; + type : String; paths : many { id : Integer; title : String; diff --git a/srv/developer-service.js b/srv/developer-service.js index ea3ca7f76..3de1dd871 100644 --- a/srv/developer-service.js +++ b/srv/developer-service.js @@ -635,7 +635,9 @@ export default class DeveloperService extends cds.ApplicationService { return { eventId: event.legacyId, eventName: event.name || '', + eventDescription: event.description || '', eventType: event.eventType ?? 'OTHER', + hasLogo: Boolean(event.hasLogo), type: 'COMPLEX', paths: paths.map(p => { const items = allItems diff --git a/srv/event-stream-service.cds b/srv/event-stream-service.cds index 7a7b55add..6e1860c87 100644 --- a/srv/event-stream-service.cds +++ b/srv/event-stream-service.cds @@ -8,9 +8,18 @@ service EventStreamService { tutorialTitle : String; } - function getEventBuckets(eventLegacyId : Integer) returns many { - bucketName : String; - count : Integer; - percentage : Decimal; + // Returns the event's display metadata alongside the completion buckets so the + // event-display page can render the real event name + logo instead of a + // theme-hardcoded label (#2133). `hasLogo` gates the anonymous + // GET /api/event-logo?eventLegacyId=N fetch on the client. + function getEventBuckets(eventLegacyId : Integer) returns { + eventName : String; + eventType : String; + hasLogo : Boolean; + buckets : many { + bucketName : String; + count : Integer; + percentage : Decimal; + }; }; } diff --git a/srv/event-stream-service.js b/srv/event-stream-service.js index b629f0db6..5762c0eff 100644 --- a/srv/event-stream-service.js +++ b/srv/event-stream-service.js @@ -18,7 +18,7 @@ export default class EventStreamService extends cds.ApplicationService { const event = await SELECT.one.from(Events).where({ legacyId: eventLegacyId }); if (!event) return req.reject(404, `Event with legacy ID ${eventLegacyId} not found`); - return cached(`es-buckets:${eventLegacyId}`, CACHE_TTL, async () => { + const buckets = await cached(`es-buckets:${eventLegacyId}`, CACHE_TTL, async () => { const records = await SELECT.from(TaskRecords).where({ event_ID: event.ID, taskType: 'TUTORIAL', @@ -26,6 +26,13 @@ export default class EventStreamService extends cds.ApplicationService { }); return computeBuckets(records); }); + + return { + eventName: event.name || '', + eventType: event.eventType ?? 'OTHER', + hasLogo: Boolean(event.hasLogo), + buckets, + }; }); await super.init(); diff --git a/srv/handlers/event-logo-handlers.js b/srv/handlers/event-logo-handlers.js new file mode 100644 index 000000000..c507bc69b --- /dev/null +++ b/srv/handlers/event-logo-handlers.js @@ -0,0 +1,47 @@ +// Bound-action handlers for the per-event logo lockup (#2133), registered onto +// AdminService.init(). Mirrors srv/handlers/devtoberfest-banner-handlers.js. The +// base64-over-OData path exists because a Fiori UploadSet on a draft-enabled +// `Composition of one` (key = parent association) silently drops uploaded bytes +// on activation. + +import cds from '@sap/cds'; +import { uploadAndUpsertLogo, clearLogo } from '../lib/event-logo-store.js'; + +export function register(srv) { + const { Events } = srv.entities; + + srv.on('uploadEventLogo', Events, async (req) => { + const eventID = req.params?.[0]?.ID || req.params?.[0]; + if (!eventID) return req.error(400, 'uploadEventLogo: missing event key in path'); + + const { imageBase64, mimeType } = req.data || {}; + if (!imageBase64 || typeof imageBase64 !== 'string') { + return req.error(400, 'uploadEventLogo: imageBase64 (string) is required'); + } + let buffer; + try { + const cleaned = imageBase64.replace(/^data:[^,]+,/, ''); + buffer = Buffer.from(cleaned, 'base64'); + } catch { + return req.error(400, 'uploadEventLogo: imageBase64 must be valid base64'); + } + try { + await uploadAndUpsertLogo({ eventID, buffer, mimeType: mimeType || 'image/png' }); + } catch (e) { + return req.error(400, 'uploadEventLogo: ' + e.message); + } + return SELECT.one.from(Events).where({ ID: eventID }); + }); + + srv.on('clearEventLogo', Events, async (req) => { + const eventID = req.params?.[0]?.ID || req.params?.[0]; + if (!eventID) return req.error(400, 'clearEventLogo: missing event key in path'); + + try { + await clearLogo(eventID); + } catch (e) { + return req.error(400, 'clearEventLogo: ' + e.message); + } + return SELECT.one.from(Events).where({ ID: eventID }); + }); +} diff --git a/srv/lib/event-logo-store.js b/srv/lib/event-logo-store.js new file mode 100644 index 000000000..6d92cca84 --- /dev/null +++ b/srv/lib/event-logo-store.js @@ -0,0 +1,147 @@ +// ESM module. Sharp pipeline + upsert + read for the per-event logo lockup (#2133). +// Mirrors srv/lib/devtoberfest-banner-store.js almost exactly, but the row is +// keyed by the parent Event (event_ID) instead of a DevtoberfestConfig, and it +// flips Events.hasLogo / logoUpdatedAt instead of the config's banner flags. + +import cds from '@sap/cds'; +import sharp from 'sharp'; +import crypto from 'node:crypto'; + +const MAX_BYTES = 8 * 1024 * 1024; // raw upload cap +const MAX_WIDTH = 2000; +const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']); + +/** Coerce Buffer | Uint8Array | Readable | string into a Buffer. */ +export async function toBuffer(value) { + if (Buffer.isBuffer(value)) return value; + if (value && typeof value.pipe === 'function') { + const chunks = []; + for await (const chunk of value) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks); + } + if (value instanceof Uint8Array) return Buffer.from(value); + if (typeof value === 'string') return Buffer.from(value); + throw new Error('toBuffer: unsupported value type'); +} + +export async function processLogoUpload(buffer, mimeType) { + if (!Buffer.isBuffer(buffer)) throw new Error('processLogoUpload: buffer is required'); + if (buffer.length > MAX_BYTES) throw new Error('processLogoUpload: image too large (max 8 MB)'); + if (!ALLOWED_MIME.has(String(mimeType || '').toLowerCase())) { + throw new Error('processLogoUpload: unsupported MIME type'); + } + + let meta; + try { + meta = await sharp(buffer).metadata(); + } catch { + throw new Error('processLogoUpload: invalid image bytes'); + } + if (!meta || !meta.format) throw new Error('processLogoUpload: invalid image bytes'); + + // Resize to max-width 2000 without upscaling; height auto to preserve ratio. + const image = await sharp(buffer) + .resize({ width: MAX_WIDTH, withoutEnlargement: true }) + .webp({ quality: 82 }) + .toBuffer(); + + const outMeta = await sharp(image).metadata(); + const sha256 = crypto.createHash('sha256').update(image).digest('hex'); + + return { + image, + mimeType: 'image/webp', + sha256, + sizeBytes: image.length, + width: outMeta.width, + height: outMeta.height, + }; +} + +/** + * Run the pipeline + upsert the EventLogo row + flip Events.hasLogo/logoUpdatedAt. + * @returns {Promise<{ sizeBytes:number, sha256:string, width:number, height:number }>} + */ +export async function uploadAndUpsertLogo({ eventID, buffer, mimeType }) { + if (!eventID) throw new Error('uploadAndUpsertLogo: eventID is required'); + if (!Buffer.isBuffer(buffer)) throw new Error('uploadAndUpsertLogo: buffer is required'); + + const processed = await processLogoUpload(buffer, mimeType || 'image/png'); + const db = await cds.connect.to('db'); + const { Events, EventLogo } = cds.entities('com.sap.developers.ims'); + const now = new Date().toISOString(); + + const existing = await db.run( + SELECT.one.from(EventLogo).columns('event_ID').where({ event_ID: eventID }), + ); + const entry = { + image: processed.image, + mimeType: processed.mimeType, + sizeBytes: processed.sizeBytes, + sha256: processed.sha256, + width: processed.width, + height: processed.height, + uploadedAt: now, + }; + if (existing) { + await db.run(UPDATE(EventLogo).set(entry).where({ event_ID: eventID })); + } else { + await db.run(INSERT.into(EventLogo).entries({ event_ID: eventID, ...entry })); + } + + await db.run( + UPDATE(Events).set({ hasLogo: true, logoUpdatedAt: now }).where({ ID: eventID }), + ); + + return { + sizeBytes: processed.sizeBytes, + sha256: processed.sha256, + width: processed.width, + height: processed.height, + }; +} + +/** + * Delete an event's logo row + flip Events.hasLogo=false. + */ +export async function clearLogo(eventID) { + if (!eventID) throw new Error('clearLogo: eventID is required'); + const db = await cds.connect.to('db'); + const { Events, EventLogo } = cds.entities('com.sap.developers.ims'); + await db.run(DELETE.from(EventLogo).where({ event_ID: eventID })); + await db.run(UPDATE(Events).set({ hasLogo: false, logoUpdatedAt: null }).where({ ID: eventID })); +} + +/** + * Read an event's logo bytes. Returns null when the event has no logo. + * HANA: raw db.run() (LOB locator rule). SQLite: plain CDS QL. + */ +export async function fetchLogo(eventID) { + if (!eventID) return null; + const db = await cds.connect.to('db'); + const isHana = (db.kind || '').toLowerCase() === 'hana'; + + let row; + if (isHana) { + const res = await db.run( + 'SELECT IMAGE AS "image", MIMETYPE AS "mimeType", SHA256 AS "sha256" ' + + 'FROM COM_SAP_DEVELOPERS_IMS_EVENTLOGO WHERE EVENT_ID = ?', + [eventID], + ); + if (!res || !res.length || !res[0].image) return null; + row = res[0]; + } else { + const { EventLogo } = cds.entities('com.sap.developers.ims'); + const b = await db.run( + SELECT.one.from(EventLogo).columns('image', 'mimeType', 'sha256').where({ event_ID: eventID }), + ); + if (!b || !b.image) return null; + row = b; + } + + return { + buffer: await toBuffer(row.image), + mimeType: row.mimeType || 'image/webp', + etag: '"' + row.sha256 + '"', + }; +} diff --git a/srv/routes/event-logo-public.js b/srv/routes/event-logo-public.js new file mode 100644 index 000000000..ac89d740f --- /dev/null +++ b/srv/routes/event-logo-public.js @@ -0,0 +1,43 @@ +// Public read endpoint for per-event logo lockups (#2133). +// Mounted at GET /api/event-logo?eventLegacyId=N. NO auth — the event-display +// kiosk page and the app-space page (both may be viewed anonymously) render the +// logo via a plain . Mirrors the anonymous banner handler in +// srv/routes/devtoberfest-public.js. + +import cds from '@sap/cds'; + +const LOG = cds.log('event-logo'); + +async function logoHandler(req, res) { + try { + const legacyId = parseInt(req.query?.eventLegacyId, 10); + if (!Number.isFinite(legacyId)) return res.status(400).end(); + + await cds.connect.to('db'); + const { Events } = cds.entities('com.sap.developers.ims'); + const event = await SELECT.one.from(Events).columns('ID', 'hasLogo').where({ legacyId }); + if (!event?.hasLogo) return res.status(404).end(); + + // Imported lazily so this module carries no sharp dependency at boot — the + // store's fetchLogo path does not touch sharp, but keeping the import local + // matches the srv-qa boot-safety pattern for content-store-reachable libs. + const { fetchLogo } = await import('../lib/event-logo-store.js'); + const out = await fetchLogo(event.ID); + if (!out) return res.status(404).end(); + + res.setHeader('ETag', out.etag); + res.setHeader('Cache-Control', 'public, max-age=86400'); + if (req.headers['if-none-match'] === out.etag) return res.status(304).end(); + res.setHeader('Content-Type', out.mimeType); + return res.send(out.buffer); + } catch (err) { + LOG.error('GET /api/event-logo failed:', err); + return res.status(500).end(); + } +} + +export function register(app) { + app.get('/api/event-logo', logoHandler); +} + +export { logoHandler }; diff --git a/srv/server.js b/srv/server.js index 766bf9478..2d19682b3 100644 --- a/srv/server.js +++ b/srv/server.js @@ -46,6 +46,7 @@ import { modelJsonHandler } from './lib/model-json-handler.js'; import { kgStatsHandler } from './routes/kg-stats.js'; import * as advocatesPublic from './routes/advocates-public.js'; import * as devtoberfestPublic from './routes/devtoberfest-public.js'; +import * as eventLogoPublic from './routes/event-logo-public.js'; import * as devtoberfestSchedule from './routes/devtoberfest-schedule.js'; import * as devtoberfestScheduleCheck from './routes/devtoberfest-schedule-check.js'; import * as devtoberfestAuth from './routes/devtoberfest-auth.js'; @@ -473,6 +474,7 @@ cds.on('bootstrap', (app) => { // Spec: docs/superpowers/specs/2026-06-17-developer-advocates-design.md advocatesPublic.register(app); devtoberfestPublic.register(app); + eventLogoPublic.register(app); devtoberfestSchedule.register(app); devtoberfestScheduleCheck.register(app); devtoberfestAuth.register(app); diff --git a/test/unit/events/event-logo-store.test.js b/test/unit/events/event-logo-store.test.js new file mode 100644 index 000000000..3f56de281 --- /dev/null +++ b/test/unit/events/event-logo-store.test.js @@ -0,0 +1,140 @@ +import { describe, expect, it, beforeAll } from 'vitest'; +import cds from '@sap/cds'; +import { readFile } from 'node:fs/promises'; +import { + processLogoUpload, + uploadAndUpsertLogo, + clearLogo, + fetchLogo, +} from '../../../srv/lib/event-logo-store.js'; + +// Per-event logo lockup store (#2133). Mirrors the advocate-photo-upsert test: +// exercises the pure helpers against an in-memory DB, not the OData bound +// action or the anonymous GET route (those have their own surfaces). Reuses +// the advocate fixtures (a JPEG + a PNG) — the sharp pipeline is identical. + +const FIX = (name) => readFile(`test/unit/advocates/fixtures/${name}`); + +const project = cds.test('serve', '--project', '.', '--in-memory'); + +const EVENT_ID = 'EVT02133-0000-0000-0000-000000000001'; +const LEGACY_ID = 902133; + +beforeAll(async () => { + const db = await cds.connect.to('db'); + const { Events, EventLogo } = cds.entities('com.sap.developers.ims'); + const existing = await db.run(SELECT.one.from(Events).where({ ID: EVENT_ID })); + if (!existing) { + await db.run(INSERT.into(Events).entries({ + ID: EVENT_ID, + legacyId: LEGACY_ID, + name: 'Logo Store Test Event', + eventType: 'OTHER', + hasLogo: false, + })); + } else { + // Reset state across reruns so tests are order-independent. + await db.run(DELETE.from(EventLogo).where({ event_ID: EVENT_ID })); + await db.run(UPDATE(Events).set({ hasLogo: false, logoUpdatedAt: null }).where({ ID: EVENT_ID })); + } +}); + +describe('processLogoUpload (sharp → WebP pipeline)', () => { + it('rejects a non-buffer', async () => { + await expect(processLogoUpload(null, 'image/png')).rejects.toThrow(/buffer is required/); + }); + + it('rejects an unsupported MIME', async () => { + await expect(processLogoUpload(Buffer.from('x'), 'application/octet-stream')) + .rejects.toThrow(/unsupported MIME/); + }); + + it('rejects oversized input', async () => { + const big = Buffer.alloc(8 * 1024 * 1024 + 1); + await expect(processLogoUpload(big, 'image/png')).rejects.toThrow(/too large/); + }); + + it('rejects garbage bytes even with an allowed MIME', async () => { + await expect(processLogoUpload(Buffer.from('not-an-image'), 'image/png')) + .rejects.toThrow(/invalid image bytes/); + }); + + it('converts a valid JPEG to WebP with a sha256', async () => { + const jpeg = await FIX('portrait.jpg'); + const out = await processLogoUpload(jpeg, 'image/jpeg'); + expect(out.mimeType).toBe('image/webp'); + expect(out.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(out.sizeBytes).toBeGreaterThan(0); + expect(out.width).toBeGreaterThan(0); + expect(out.height).toBeGreaterThan(0); + }); +}); + +describe('uploadAndUpsertLogo + fetchLogo + clearLogo', () => { + it('rejects when eventID is missing', async () => { + await expect(uploadAndUpsertLogo({ eventID: '', buffer: Buffer.from('x'), mimeType: 'image/png' })) + .rejects.toThrow(/eventID is required/); + }); + + it('rejects when buffer is missing', async () => { + await expect(uploadAndUpsertLogo({ eventID: EVENT_ID, buffer: null, mimeType: 'image/png' })) + .rejects.toThrow(/buffer is required/); + }); + + it('uploads a logo, flips hasLogo, and is readable via fetchLogo', async () => { + const db = await cds.connect.to('db'); + const { Events, EventLogo } = cds.entities('com.sap.developers.ims'); + + const jpeg = await FIX('portrait.jpg'); + const result = await uploadAndUpsertLogo({ eventID: EVENT_ID, buffer: jpeg, mimeType: 'image/jpeg' }); + expect(result.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(result.sizeBytes).toBeGreaterThan(0); + + // Events flags flipped. + const ev = await db.run(SELECT.one.from(Events).columns('hasLogo', 'logoUpdatedAt').where({ ID: EVENT_ID })); + expect(ev.hasLogo).toBe(true); + expect(ev.logoUpdatedAt).toBeTruthy(); + + // EventLogo row written (metadata columns only — never SELECT the BLOB alongside). + const row = await db.run( + SELECT.one.from(EventLogo).columns('sha256', 'mimeType', 'sizeBytes').where({ event_ID: EVENT_ID }), + ); + expect(row).toBeTruthy(); + expect(row.sha256).toBe(result.sha256); + expect(row.mimeType).toBe('image/webp'); + + // fetchLogo returns the bytes + a quoted-sha256 ETag. + const fetched = await fetchLogo(EVENT_ID); + expect(Buffer.isBuffer(fetched.buffer)).toBe(true); + expect(fetched.buffer.length).toBe(result.sizeBytes); + expect(fetched.mimeType).toBe('image/webp'); + expect(fetched.etag).toBe(`"${result.sha256}"`); + }); + + it('UPDATE-path: a second upload replaces the row, no duplicate', async () => { + const db = await cds.connect.to('db'); + const { EventLogo } = cds.entities('com.sap.developers.ims'); + + const before = await db.run(SELECT.from(EventLogo).where({ event_ID: EVENT_ID })); + expect(before.length).toBe(1); + + const png = await FIX('square.png'); + await uploadAndUpsertLogo({ eventID: EVENT_ID, buffer: png, mimeType: 'image/png' }); + + const after = await db.run(SELECT.from(EventLogo).where({ event_ID: EVENT_ID })); + expect(after.length).toBe(1); // 1:1 composition — still exactly one + }); + + it('clearLogo removes the row and resets hasLogo', async () => { + const db = await cds.connect.to('db'); + const { Events, EventLogo } = cds.entities('com.sap.developers.ims'); + + await clearLogo(EVENT_ID); + + const row = await db.run(SELECT.one.from(EventLogo).where({ event_ID: EVENT_ID })); + expect(row).toBeFalsy(); + const ev = await db.run(SELECT.one.from(Events).columns('hasLogo').where({ ID: EVENT_ID })); + expect(ev.hasLogo).toBe(false); + expect(await fetchLogo(EVENT_ID)).toBeNull(); + }); +}); From a707180f1edc580461598c5815e11229ff2ba5a7 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 03:44:47 -0400 Subject: [PATCH 007/138] docs(mcp): use prod client_id sb-tutorials-prod in OAuth quickstart The mcp-remote --static-oauth-client-info blocks hardcoded the dev client sb-tutorials!t676072. Prod runs a distinct xsappname (tutorials-prod) in the same XSUAA tenant, so its OAuth client is sb-tutorials-prod!t676072. Against developers.sap.com the dev client_id + prod-owned Tutorial.MCP scope (emitted by the .well-known discovery) is rejected at /oauth/authorize as an invalid authorization request. Switch the example blocks to the prod client_id and add a dev/prod mapping table plus a cf env lookup so users pick the id matching their . --- docs/end-users/mcp-quickstart.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/end-users/mcp-quickstart.md b/docs/end-users/mcp-quickstart.md index 8e59d9686..6f966d964 100644 --- a/docs/end-users/mcp-quickstart.md +++ b/docs/end-users/mcp-quickstart.md @@ -140,13 +140,26 @@ For builds that accept a pre-registered client, bridge through `mcp-remote`: "command": "npx", "args": [ "-y", "mcp-remote", "/mcp-auth/api", - "--static-oauth-client-info", "{\"client_id\":\"sb-tutorials!t676072\"}" + "--static-oauth-client-info", "{\"client_id\":\"sb-tutorials-prod!t676072\"}" ] } } } ``` +> **The `client_id` is environment-specific — match it to your ``:** +> +> | Environment | `` | `client_id` | +> | --- | --- | --- | +> | **Production** | `https://developers.sap.com` | `sb-tutorials-prod!t676072` | +> | **Dev** | your dev route | `sb-tutorials!t676072` | +> +> Dev and prod live in the same XSUAA tenant, so prod uses the distinct xsappname +> `tutorials-prod` (hence the `sb-tutorials-prod!…` client). Using the dev `client_id` +> against production fails at `/oauth/authorize` with **"The request for authorization was +> invalid"** — the dev client can't be granted the prod-owned `Tutorial.MCP` scope that the +> `.well-known` discovery advertises. + On first connection `mcp-remote` opens a browser tab for consent (PKCE, no client secret required). The endpoints are discovered automatically from `/.well-known/oauth-authorization-server`; you supply only the `client_id`. After approval, the token is cached and refreshed silently. **Available authenticated tools** (DeveloperService + HomepageService): @@ -190,17 +203,19 @@ npm install -g mcp-remote "command": "npx", "args": [ "-y", "mcp-remote", "/mcp-auth/api", - "--static-oauth-client-info", "{\"client_id\":\"sb-tutorials!t676072\"}" + "--static-oauth-client-info", "{\"client_id\":\"sb-tutorials-prod!t676072\"}" ] } } } ``` -`sb-tutorials!t676072` is the **XSUAA-generated public client** for the `tutorials` -application (XSUAA auto-creates exactly one `sb-!` client per -instance — there is no separately-named MCP client). To confirm the current id for your -environment, read the bound credentials: `cf env tutorials-srv` → `VCAP_SERVICES.xsuaa[0].credentials.clientid`. +`sb-tutorials-prod!t676072` is the **XSUAA-generated public client** for the production +`tutorials-prod` application (XSUAA auto-creates exactly one `sb-!` +client per instance — there is no separately-named MCP client). **This id is +environment-specific** — dev's client is `sb-tutorials!t676072` (see the table above). To +confirm the current id for your environment, read the bound credentials: +`cf env tutorials-prod-srv` (prod) or `cf env tutorials-srv` (dev) → `VCAP_SERVICES.xsuaa[0].credentials.clientid`. The flow uses PKCE with no client secret. On first run, `mcp-remote` opens your browser for the SAP universal-ID consent flow; after approval the token is cached in `~/.mcp-auth/` and refreshed silently. The server advertises its endpoints at `/.well-known/oauth-authorization-server`, From 480a02f5d2d36c4547147ab787300bdd103662b8 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 03:51:58 -0400 Subject: [PATCH 008/138] docs(mcp): pin --host localhost in OAuth quickstart to match redirect-uri whitelist Some mcp-remote builds bind the callback to the loopback IP 127.0.0.1 (RFC 8252's preferred default), producing http://127.0.0.1:/oauth/callback. XSUAA matches redirect_uri literally and the client whitelists only http://localhost:*/oauth/callback, so the IP form fails at /oauth/authorize with 'redirect_uri does not match the configuration'. Pin --host localhost in both example blocks and add a troubleshooting note. --- docs/end-users/mcp-quickstart.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/end-users/mcp-quickstart.md b/docs/end-users/mcp-quickstart.md index 6f966d964..f67dc4f5a 100644 --- a/docs/end-users/mcp-quickstart.md +++ b/docs/end-users/mcp-quickstart.md @@ -140,7 +140,8 @@ For builds that accept a pre-registered client, bridge through `mcp-remote`: "command": "npx", "args": [ "-y", "mcp-remote", "/mcp-auth/api", - "--static-oauth-client-info", "{\"client_id\":\"sb-tutorials-prod!t676072\"}" + "--static-oauth-client-info", "{\"client_id\":\"sb-tutorials-prod!t676072\"}", + "--host", "localhost" ] } } @@ -203,7 +204,8 @@ npm install -g mcp-remote "command": "npx", "args": [ "-y", "mcp-remote", "/mcp-auth/api", - "--static-oauth-client-info", "{\"client_id\":\"sb-tutorials-prod!t676072\"}" + "--static-oauth-client-info", "{\"client_id\":\"sb-tutorials-prod!t676072\"}", + "--host", "localhost" ] } } @@ -227,6 +229,15 @@ so `mcp-remote` discovers the authorize/token URLs automatically — you only su > `does not support dynamic client registration`. Only `--static-oauth-client-info` (a JSON > blob carrying `client_id`) short-circuits registration. +> **Callback-host note (`--host localhost`):** XSUAA matches the OAuth `redirect_uri` +> literally against the client's registered list, which whitelists +> `http://localhost:*/oauth/callback`. Some `mcp-remote` builds bind the callback to the +> loopback **IP** `127.0.0.1` instead (RFC 8252's preferred default), producing +> `http://127.0.0.1:/oauth/callback` — which does **not** match `localhost` and fails +> at `/oauth/authorize` with **"redirect_uri does not match the configuration."** Passing +> `--host localhost` forces the registered hostname to match. If you still hit this, delete +> the cached tokens in `~/.mcp-auth/` and reconnect. + > **Simplest path for Claude Code:** skip OAuth entirely and use a > [Personal Access Token](#headless--ci-with-a-personal-access-token). The PAT path needs no > browser handshake and no pre-registered client. From 63a581193f4483de5e9e8f7c0e297dc2ce164539 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 04:06:57 -0400 Subject: [PATCH 009/138] fix(mcp-oauth): advertise approuter as authorization server (self-issuer) MCP clients (mcp-remote / MCP SDK) read authorization_servers[0] from the protected-resource metadata and run RFC 8414 discovery against it. Option A pointed that at the raw XSUAA URL, but XSUAA does not implement RFC 8414: GET /.well-known/oauth-authorization-server 302-redirects to /login, which returns 200 (HTML). The SDK follows the redirect, parses HTML as JSON, and every required field is undefined -> ZodError. Because the response is 200 (not 404) the SDK never falls back to XSUAA's working openid-configuration, so the token exchange dies after a successful authorize. Fix: advertise the approuter itself as the authorization server. protected- resource authorization_servers and the AS metadata issuer now use the request- derived self base URL; authorize/token endpoints still point at XSUAA. Clients discover our valid 200 RFC 8414 doc and never touch XSUAA's broken well-known. Token validation on the resource side is unchanged (XSUAA binding). Reverses the authorization_servers/issuer=XSUAA half of the frozen Option A (2026-08-28 spec); the rejected 'Option B' (relocating discovery to the CAP origin) remains out of scope. Updates unit + hybrid tests, architecture doc, and adds a superseding note to the design spec. --- approuter/lib/well-known-oauth.js | 48 ++++++++++++++----- docs/developers/architecture/mcp-server.md | 11 +++-- ...08-28-well-known-oauth-discovery-design.md | 14 ++++++ test/hybrid/oauth-discovery.test.js | 9 +++- test/unit/well-known-oauth.test.js | 20 ++++++-- 5 files changed, 81 insertions(+), 21 deletions(-) diff --git a/approuter/lib/well-known-oauth.js b/approuter/lib/well-known-oauth.js index 0e0ce2449..60f7f4e2e 100644 --- a/approuter/lib/well-known-oauth.js +++ b/approuter/lib/well-known-oauth.js @@ -87,11 +87,28 @@ function sendJson(res, status, body) { res.end(payload) } -function authorizationServerMetadata(issuer, scope) { +// Build the RFC 8414 Authorization-Server metadata. +// +// `issuer` identifies THIS approuter (its own externally-visible base URL) as +// the advertised authorization server — NOT the raw XSUAA URL. The authorize / +// token endpoints still live on XSUAA (`endpointBase`). +// +// Why self-issuer, not the XSUAA issuer (reverses the original Option A): +// MCP clients (mcp-remote / MCP SDK) read the protected-resource metadata, +// take `authorization_servers[0]`, and run RFC 8414 discovery against THAT +// host. XSUAA does not implement RFC 8414 — `/.well-known/oauth- +// authorization-server` 302-redirects to /login, which returns 200 (an HTML +// page). The SDK follows the redirect, sees 200, parses HTML as JSON, and +// every required field is undefined → ZodError. Because the bogus response is +// 200 (not 404) the SDK never falls back to XSUAA's working openid- +// configuration. Advertising the approuter itself (which serves a valid 200 +// RFC 8414 doc here) keeps XSUAA's broken well-known out of the discovery +// path entirely; the actual authorize/token calls still hit XSUAA. +function authorizationServerMetadata(issuer, endpointBase, scope) { return { issuer, - authorization_endpoint: `${issuer}/oauth/authorize`, - token_endpoint: `${issuer}/oauth/token`, + authorization_endpoint: `${endpointBase}/oauth/authorize`, + token_endpoint: `${endpointBase}/oauth/token`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], @@ -100,10 +117,14 @@ function authorizationServerMetadata(issuer, scope) { } } -function protectedResourceMetadata(baseUrl, issuer, scope) { +// RFC 9728 Protected-Resource metadata. `authorization_servers` advertises the +// approuter itself (baseUrl) as the authorization server — see the self-issuer +// rationale on authorizationServerMetadata(). Clients then discover the AS doc +// at our host, which returns a valid RFC 8414 document. +function protectedResourceMetadata(baseUrl, scope) { return { resource: `${baseUrl}${MCP_RESOURCE_SUFFIX}`, - authorization_servers: [issuer], + authorization_servers: [baseUrl], scopes_supported: [scope], bearer_methods_supported: ['header'], } @@ -122,8 +143,10 @@ function wellKnownOAuthHandler(req, res, next) { return next() } - const issuer = resolveIssuer() - if (!issuer) { + // XSUAA base — used ONLY for the authorize/token endpoint URLs, never as the + // advertised issuer (see authorizationServerMetadata()). + const endpointBase = resolveIssuer() + if (!endpointBase) { // No XSUAA binding and no env fallback — cannot produce a valid document. // 503 (not 404) so a misconfiguration is distinguishable from a missing route. return sendJson(res, 503, { error: 'oauth_metadata_unavailable' }) @@ -131,13 +154,16 @@ function wellKnownOAuthHandler(req, res, next) { const scope = resolveScope() + // Both documents advertise THIS approuter (self) as the authorization server, + // so both need the externally-visible base URL derived from the request. + const baseUrl = resolveBaseUrl(req) + if (!baseUrl) return sendJson(res, 503, { error: 'oauth_metadata_unavailable' }) + if (pathOnly === AUTH_SERVER_PATH || pathOnly === OPENID_CONFIG_PATH) { - return sendJson(res, 200, authorizationServerMetadata(issuer, scope)) + return sendJson(res, 200, authorizationServerMetadata(baseUrl, endpointBase, scope)) } - const baseUrl = resolveBaseUrl(req) - if (!baseUrl) return sendJson(res, 503, { error: 'oauth_metadata_unavailable' }) - return sendJson(res, 200, protectedResourceMetadata(baseUrl, issuer, scope)) + return sendJson(res, 200, protectedResourceMetadata(baseUrl, scope)) } module.exports = { diff --git a/docs/developers/architecture/mcp-server.md b/docs/developers/architecture/mcp-server.md index 92dda523a..f0fc3da53 100644 --- a/docs/developers/architecture/mcp-server.md +++ b/docs/developers/architecture/mcp-server.md @@ -94,9 +94,14 @@ host, so they are correct in every environment with no build-time substitution. alias returns the OAuth Authorization Server Metadata body (RFC 8414), intentionally omitting OIDC-only fields like `jwks_uri`; MCP OAuth-fallback clients consume only the OAuth endpoints. `authorization_servers`/`issuer` - point at the XSUAA URL (Option A); `scopes_supported` advertises the - fully-qualified `.Tutorial.MCP` (bare `Tutorial.MCP` is rejected by - XSUAA with `invalid_scope`). + advertise **the approuter itself** (self-as-AS): clients run RFC 8414 + discovery against our host, which returns a valid 200 document. The + authorize/token endpoints inside still point at XSUAA. (This reverses the + original "Option A" that pointed `issuer` at the XSUAA URL — XSUAA does not + implement RFC 8414 and 302→login→200-HTML on that path, which breaks MCP SDK + discovery with a ZodError and no 404 fallback.) `scopes_supported` advertises + the fully-qualified `.Tutorial.MCP` (bare `Tutorial.MCP` is rejected + by XSUAA with `invalid_scope`). - `approuter/lib/well-known-mcp-manifest.js` — `/.well-known/mcp.json`, a non-standard courtesy manifest listing the MCP mounts. Not part of the MCP spec. - `approuter/lib/security-txt.js` — `/.well-known/security.txt` (RFC 9116). diff --git a/docs/superpowers/specs/2026-08-28-well-known-oauth-discovery-design.md b/docs/superpowers/specs/2026-08-28-well-known-oauth-discovery-design.md index 3ce6ae4a8..a07e6132c 100644 --- a/docs/superpowers/specs/2026-08-28-well-known-oauth-discovery-design.md +++ b/docs/superpowers/specs/2026-08-28-well-known-oauth-discovery-design.md @@ -14,6 +14,20 @@ An earlier draft of this spec assumed nothing was served and proposed building a **Option A is retained** (`authorization_servers`/`issuer` point straight at the XSUAA URL; the docs advertise the *fully-qualified* scope `.Tutorial.MCP`, e.g. `tutorials!t676072.Tutorial.MCP` — bare `Tutorial.MCP` is rejected by XSUAA with `invalid_scope`; `resolveScope()` encodes this hard-won behavior). Do **not** rewrite to Option B. +> **⚠️ SUPERSEDED 2026-09-04 (the `authorization_servers`/`issuer`=XSUAA part):** +> Pointing `authorization_servers`/`issuer` at the raw XSUAA URL is broken for +> real MCP clients. mcp-remote / the MCP SDK read `authorization_servers[0]` and +> run RFC 8414 discovery against it; XSUAA does not implement RFC 8414 and +> 302-redirects `/.well-known/oauth-authorization-server` → `/login` → **200 HTML**, +> so the SDK parses HTML as metadata → ZodError, and because it's 200 (not 404) +> it never falls back to XSUAA's working `openid-configuration`. Fix: advertise +> **the approuter itself** as the authorization server (self-issuer) — clients +> discover our valid RFC 8414 doc, while the authorize/token endpoints inside +> still point at XSUAA. This is a targeted change to `authorization_servers`/`issuer` +> only; it is **not** the rejected "Option B" (that was about relocating the whole +> discovery surface to the CAP origin — still out of scope). The scope-qualification +> and runtime-derivation behavior below is unchanged. + **The real reason the docs are unreachable on `developers.sap.com`:** Akamai 403s every `/.well-known/*` path at the edge except `security.txt` (confirmed: `Server: AkamaiGHost` on the 403). The origin serves them correctly; the edge blocks them. ## Scope (four additions) diff --git a/test/hybrid/oauth-discovery.test.js b/test/hybrid/oauth-discovery.test.js index d84263ec3..4dfd222d1 100644 --- a/test/hybrid/oauth-discovery.test.js +++ b/test/hybrid/oauth-discovery.test.js @@ -35,8 +35,12 @@ describeIf('OAuth discovery documents (deployed dev)', { timeout: 20_000 }, () = const doc = await res.json(); // RFC 8414 required fields expect(typeof doc.issuer).toBe('string'); - expect(doc.issuer).toMatch(/hana\.ondemand\.com|localhost/); + // issuer is the approuter's own base URL (self-as-AS), which may be a + // cfapps *.hana.ondemand.com host or the developers.sap.com vanity host. + expect(doc.issuer).toMatch(/hana\.ondemand\.com|sap\.com|localhost/); expect(doc.authorization_endpoint).toBeDefined(); + // The authorize/token endpoints still live on XSUAA. + expect(doc.authorization_endpoint).toMatch(/authentication\..*hana\.ondemand\.com/); expect(doc.token_endpoint).toBeDefined(); // MCP 2.1 requires PKCE support const methods = doc.code_challenge_methods_supported ?? []; @@ -58,7 +62,8 @@ describeIf('OAuth discovery documents (deployed dev)', { timeout: 20_000 }, () = // scopes_supported must include Tutorial.MCP const scopes = doc.scopes_supported ?? []; expect(scopes).toContain('Tutorial.MCP'); - // authorization_servers should point at the XSUAA/IAS issuer + // authorization_servers advertises the approuter itself (self-as-AS), so + // clients discover the RFC 8414 doc here rather than at XSUAA's non-8414 host. expect(Array.isArray(doc.authorization_servers)).toBe(true); expect(doc.authorization_servers.length).toBeGreaterThan(0); }); diff --git a/test/unit/well-known-oauth.test.js b/test/unit/well-known-oauth.test.js index 321b8fbf5..1f74e37c2 100644 --- a/test/unit/well-known-oauth.test.js +++ b/test/unit/well-known-oauth.test.js @@ -82,7 +82,8 @@ describe('.well-known OAuth discovery — dynamic runtime middleware (#1105)', ( }); it('authorization-server metadata has all RFC 8414 required fields', () => { - const m = authorizationServerMetadata('https://t.authentication.eu10.hana.ondemand.com', 'tutorials!t676072.Tutorial.MCP'); + // issuer = self (approuter base); endpoints = XSUAA base. + const m = authorizationServerMetadata('https://developers.sap.com', 'https://t.authentication.eu10.hana.ondemand.com', 'tutorials!t676072.Tutorial.MCP'); for (const key of [ 'issuer', 'authorization_endpoint', 'token_endpoint', 'response_types_supported', 'grant_types_supported', @@ -93,16 +94,22 @@ describe('.well-known OAuth discovery — dynamic runtime middleware (#1105)', ( } expect(m.code_challenge_methods_supported).toContain('S256'); expect(m.token_endpoint_auth_methods_supported).toContain('none'); + // Advertised issuer is the approuter itself (self), NOT the XSUAA URL — + // clients discover the AS doc here instead of at XSUAA's broken well-known. + expect(m.issuer).toBe('https://developers.sap.com'); + // ...but the authorize/token endpoints still point at XSUAA. expect(m.authorization_endpoint).toBe('https://t.authentication.eu10.hana.ondemand.com/oauth/authorize'); + expect(m.token_endpoint).toBe('https://t.authentication.eu10.hana.ondemand.com/oauth/token'); // Must advertise the fully-qualified, grantable scope — not the bare name. expect(m.scopes_supported).toContain('tutorials!t676072.Tutorial.MCP'); expect(m.scopes_supported).not.toContain('Tutorial.MCP'); }); it('protected-resource metadata has MCP 2025-06 required fields', () => { - const m = protectedResourceMetadata('https://host.example', 'https://t.authentication.eu10.hana.ondemand.com', 'tutorials!t676072.Tutorial.MCP'); + const m = protectedResourceMetadata('https://host.example', 'tutorials!t676072.Tutorial.MCP'); expect(m.resource).toBe('https://host.example/mcp-auth'); - expect(m.authorization_servers).toEqual(['https://t.authentication.eu10.hana.ondemand.com']); + // authorization_servers advertises the approuter (self), not XSUAA. + expect(m.authorization_servers).toEqual(['https://host.example']); expect(m.scopes_supported).toContain('tutorials!t676072.Tutorial.MCP'); expect(m.bearer_methods_supported).toEqual(['header']); }); @@ -132,7 +139,10 @@ describe('.well-known OAuth discovery — dynamic runtime middleware (#1105)', ( expect(res.statusCode).toBe(200); expect(res.headers['Content-Type']).toBe('application/json'); const parsed = JSON.parse(res.body); - expect(parsed.issuer).toBe('https://tutorial-system.authentication.eu10-005.hana.ondemand.com'); + // issuer is the SELF host (request-derived), not the XSUAA URL... + expect(parsed.issuer).toBe('https://x.example'); + // ...while the endpoints still point at the bound XSUAA. + expect(parsed.authorization_endpoint).toBe('https://tutorial-system.authentication.eu10-005.hana.ondemand.com/oauth/authorize'); }); it('serves the protected-resource doc keyed to the request host', () => { @@ -206,7 +216,7 @@ describe('well-known-oauth: openid-configuration alias', () => { expect(res.headers['Content-Type']).toBe('application/json'); const doc = JSON.parse(res.body); expect(doc).toEqual(authorizationServerMetadata( - 'https://tenant.authentication.eu10-005.hana.ondemand.com', resolveScope())); + 'https://x.example', 'https://tenant.authentication.eu10-005.hana.ondemand.com', resolveScope())); expect(doc.code_challenge_methods_supported).toContain('S256'); }); }); From 69b5ba1e9c12014ab88d766d6ea19ca822eb2e25 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 10:36:56 -0400 Subject: [PATCH 010/138] docs(2138): design spec for standard Reporting-folder reports --- ...2026-09-04-2138-standard-reports-design.md | 326 ++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-04-2138-standard-reports-design.md diff --git a/docs/superpowers/specs/2026-09-04-2138-standard-reports-design.md b/docs/superpowers/specs/2026-09-04-2138-standard-reports-design.md new file mode 100644 index 000000000..019eed76c --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-2138-standard-reports-design.md @@ -0,0 +1,326 @@ +# Standard Reports in the Admin UI — Reporting Folder (#2138) + +**Status:** Design — pending user review +**Issue:** [sap-tutorials/tutorials-ims#2138](https://github.com/sap-tutorials/tutorials-ims/issues/2138) +**Date:** 2026-09-04 +**Branch:** `feat/2138-reporting` (based on `origin/DEV`; PR targets `DEV`) + +## 1. Problem & Goal + +Authors previously used a Power BI "Beta Tutorial Dashboard" to understand how a +group or mission was used. That dashboard is deprecated. We replace it with +**standard, ready-to-use reports** in the Admin UI under the existing **Reporting** +folder, visible to authors (`Tutorial.Author`). + +The Power BI dashboard had three pages sharing one cascading filter bar +(**Date → Mission → Group → Tutorial**): + +1. **Step Analytics** — a horizontal bar of **Tutorial Starts vs Completions** per + tutorial within the selected mission/group. Answers *which tutorials are most + used* and *which have a poor completion rate* (a proxy for a broken quiz/step). +2. **Tutorial Analytics** — tutorial-level completions detail/trend. +3. **Survey** — the tutorial feedback survey: per-question **score distributions** + (0–10), the NPS question, and a free-text **comments** table. + +Author-stated needs (verbatim from the issue): +- Which tutorials within a group/mission were most used. +- Whether a tutorial had a poor completion rate (⇒ possible quiz problem). +- Quickly read the feedback (comments) for a group or mission. +- **Drill at Group, Mission _and_ Tutorial level in a single report** (the granular + tutorial level was missing from the first design draft — this spec adds it). + +### Decisions locked with the user + +| Decision | Choice | +|---|---| +| Report packaging | **Separate report entries** (three leaves, mirroring the three Power BI pages). | +| Audience | **Authors** — `Tutorial.Author`, served via `AuthorService` (`/author`). | +| Completion rate | **Completed ÷ Started** (distinct users; see §4). | +| UI platform | **Hybrid** — Engagement + Tutorial Completions as in-shell **Fiori Elements**; Survey as a pre-built **Vue** dashboard in `analytics-explorer`. | +| Survey dimensions | **All 7** — 6 `rating*` dimensions + NPS (fuller than Power BI, which omitted `ratingVisuals`). | + +## 2. Why Hybrid (platform rationale) + +Two hard constraints from the data/UI investigation: + +- **The Survey page needs 6–7 per-score histograms.** A Fiori Elements List Report + or Analytical List Page renders **exactly one** `UI.Chart`. Six-to-seven + independent distributions cannot be expressed in an FE template without a + net-new freestyle UI5 charting page (no such precedent exists anywhere in the + admin shell — every freestyle admin app is a form/settings/builder, and the only + chart usage is the shell's KPI `GenericTile` Board). +- **The Vue `analytics-explorer` SPA already does exactly this**: Apache ECharts, + a multi-chart `DashboardGrid`, a shared `FilterBar`, an OData/SQL query layer, + and auth — and it is **already** the "Analytics" leaf under the Reporting folder + (`navigation.json`, external `href` `/analytics-ui/`, `requiredScope` + `Tutorial.Author`). + +The engagement/completions reports, by contrast, map cleanly onto the existing FE +List Report / ALP pattern (`app/admin/analytics` and `app/admin/devtoberfestSignups` +are working templates). So: + +- **Report A — Tutorial Engagement** (Step Analytics): FE **Analytical List Page**. +- **Report B — Tutorial Completions** (Tutorial Analytics): FE **List Report**. +- **Report C — Tutorial Survey**: **Vue** page in `analytics-explorer`. + +## 3. Architecture Overview + +```text +db/views.cds (4 new read-only views over ims.*) + ├─ AuthorTutorialEngagement (pre-aggregated: 1 row per tutorial×mission×group) → Report A (FE ALP) + ├─ AuthorTutorialCompletions (row-per-completion, TUTORIAL grain, + date) → Report B (FE List Report) + ├─ AuthorSurveyDistribution (unpivot: 1 row per tutorialSlug×dimension×score) → Report C (Vue) + └─ AuthorTutorialParents (lookup: tutorialSlug → tutorialTitle/group/mission) → shared filter/value-help + (Report C comments reuse the already-exposed AuthorService.TutorialFeedback) + +srv/author-service.cds → project the 4 views @readonly (service is @requires:'Tutorial.Author') + +app/author-annotations.cds (NEW) → UI/analytical annotations for AuthorTutorialEngagement + AuthorTutorialCompletions + +app/admin/tutorial-engagement/webapp/{Component.js,manifest.json} (FE ALP, dataSource /author/) ← auto-discovered +app/admin/tutorial-completions/webapp/{Component.js,manifest.json} (FE LR, dataSource /author/) ← auto-discovered + +app/analytics-explorer/ → new "Tutorial Survey" report route/view (ECharts histograms + NPS + comments) + +app/admin-shell/webapp/model/navigation.json → 3 new leaves under the existing "reporting" group +app/admin-shell/webapp/controller/Shell.controller.js → NAV_KEY_TO_ROUTE + NAV_KEY_TO_TITLE for the 2 FE keys +app/admin-shell/scripts/admin-shell-overrides.js → route-prefix override only if the auto 2-letter prefix collides +``` + +All four are **computed views** — no persisted entities, so **no** +`@cds.persistence.journal` / migration-table work. No BLOB/LOB columns are +involved (`comment` is `String(2000)` plain text, sanitized on write). + +## 4. Data Model (the load-bearing details) + +### 4.1 Starts, completions, completion rate + +From `TaskRecords` (`db/schema.cds:187`): `taskType` (TUTORIAL/MISSION/GROUP/STEP/…), +`status` (COMPLETED/IN_PROGRESS/SUPERSEDED), `progress`, `completionDate`, +`attemptNumber`, `user`, `taskLegacyId`. + +- **Start** = a user has **any** TUTORIAL `TaskRecord` for that tutorial (any status). + Caveat surfaced in the code: a TUTORIAL row is created lazily on the **first step + completion** (`srv/developer-service.js:_updateTutorialProgress`), so "start" + means *made progress on ≥1 step*, not *opened the page* — there is no page-open + event. This is the best available proxy and matches the Power BI "Starts" measure. +- **Completion** = a `COMPLETED` TUTORIAL record. `uniqueLearners` (distinct users) + is the primary completion measure; a raw `completions` event count is also shown. +- **Completion rate** = `completedLearners ÷ startedLearners` (both **DISTINCT + user** counts), as a percentage. + +**DISTINCT-user is mandatory, not `COUNT(*)`.** The reset/re-take flow +(`resetTutorialProgress`) leaves a user with multiple rows for one tutorial +(e.g. a `SUPERSEDED` attempt-1 + an `IN_PROGRESS` attempt-2). Counting rows would +double-count. Use: +- `startedLearners = COUNT(DISTINCT user_ID)` over TUTORIAL rows, any status. +- `completedLearners = COUNT(DISTINCT CASE WHEN status = 'COMPLETED' THEN user_ID END)`. +- `completionRatePct = CAST(completedLearners AS Decimal(5,2)) * 100 / NULLIF(startedLearners, 0)`. + +> **HANA/SQLite parity check (plan step):** confirm `COUNT(DISTINCT CASE WHEN … END)` +> behaves identically on HANA (`cds bind --exec`) and SQLite. If HANA rejects the +> conditional-distinct inline, fall back to a two-subquery join (started set ⟕ +> completed set) — the pattern already used in `SearchableItems`. + +### 4.2 Mission → Group → Tutorial containment (the genuinely new join) + +`CompletionAnalytics` (`db/views.cds:173`) populates `missionTitle`/`groupTitle` +**only for MISSION-type TaskRecords** — for a TUTORIAL row those columns are NULL. +So it **cannot** tell us which mission/group a tutorial completion belongs to. The +mission/group→tutorial spine must be built from the **content graph**, mirroring +`NavigatorCatalog` (`db/views.cds:76`): + +``` +Missions.completionPaths → CompletionPaths.items → CompletionPathItems (taskType='TUTORIAL').tutorial → Tutorials +Missions.group → Groups ⇒ groupTitle +Missions.title ⇒ missionTitle +``` + +- **Tutorials in a mission**: `CompletionPathItems` where `taskType='TUTORIAL'` + (authoritative), joined via `CompletionPaths` → `Missions`. +- **Group of a mission**: `Missions.group` (a mission belongs to ≤1 group). +- **Tutorials directly under a group** (group without a mission context): + `GroupPathItems.tutorial`. + +Report A/B use the mission spine (Group sits above Mission). A tutorial reused +across missions **fans out** to one row per mission×group — **desirable** for a +Mission/Group/Tutorial cascade filter, but it means tutorial totals must never be +summed across missions without `DISTINCT`. + +Decision: **include unpublished** missions'/groups' tutorials? `NavigatorCatalog` +filters `mission.published=true`. For an author report we **keep unpublished** +(authors need to see in-progress content); documented so the join deliberately +omits the `published` filter. *(Open to override in review.)* + +### 4.3 The distinct-count vs. date-filter tension + +Correct DISTINCT-user counts **do not compose with a date-range slicer** in a +pre-aggregated view (a user active in Jan and Feb is one distinct learner overall +but appears in both monthly buckets; summing over a multi-month selection +over-counts). Power BI avoided this with a row-per-record model + `DISTINCTCOUNT` +measures computed against the filtered set. + +Resolution per report: + +- **Report A (Engagement)** — pre-aggregated **all-time** (no date slicer). Distinct + counts are computed once, correctly, in SQL. `firstCompletion`/`lastCompletion` + give a coarse recency signal. *Date slicing on distinct engagement is a + documented non-goal for v1* (it is the one Power BI affordance we consciously + drop, because doing it correctly in FE is not supported). +- **Report B (Completions)** — row-per-completion; the measure is a **completion + event count** (additive), which **does** compose with a `completionDay` date + filter. So Report B carries the Date dimension; Report A does not. +- **Report C (Survey)** — client-side aggregation in Vue over the filtered slug set + ⇒ distinct/date both handled in the browser as needed. + +### 4.4 Survey distributions & comments + +`TutorialFeedback` (`db/schema.cds:811`): `tutorialSlug`, `submittedAt`, +`wasAuthenticated`, six `rating*` Integers `[0,10]` + `npsScore` Integer `[0,10]` +(nullable — the form's "N/A" sends `null`), `comment : String(2000)` (sanitized). + +Dimension ↔ field mapping (labels from `hugo-apps/src/tutorial-feedback/TutorialFeedbackForm.vue`): + +| Dimension key | Field | Survey label | +|---|---|---| +| `structure` | `ratingStructure` | Well structured | +| `interesting` | `ratingInteresting` | Interesting | +| `useCase` | `ratingUseCase` | Helpful for my use case | +| `relevance` | `ratingRelevance` | Relevant to my work | +| `duration` | `ratingDuration` | Right length | +| `visuals` | `ratingVisuals` | Good visuals & code samples | +| `nps` | `npsScore` | Likely to recommend to a colleague (NPS) | + +`TutorialFeedbackAggregate` (`db/views.cds:427`) gives **averages + promoters/ +detractors per slug only** — it **cannot** produce per-score distributions. New +view **`AuthorSurveyDistribution`** unpivots into `(tutorialSlug, dimension, score, +responseCount)` via a `UNION ALL` of 7 blocks: + +```sql +SELECT 'structure' AS dimension, ratingStructure AS score, COUNT(*) AS responseCount + FROM TutorialFeedback WHERE ratingStructure IS NOT NULL + GROUP BY ratingStructure +UNION ALL … (repeat for the other 6 fields) +``` + +The Vue page sums `responseCount` per `(dimension, score)` over the filtered slug +set and renders % = score-count ÷ dimension-total. + +**Feedback fan-out is multiplicative** (a tutorial in multiple groups × missions). +Therefore mission/group is a **filter that resolves to a distinct slug set**, never +a summed join. `AuthorTutorialParents` supplies `tutorialSlug → {tutorialTitle, +groupTitle, missionTitle}` for the filter dropdowns and slug resolution. + +## 5. Report Specifications + +### Report A — Tutorial Engagement (FE Analytical List Page) + +- **Component**: `app/admin/tutorial-engagement/webapp/` (`sap.fe.templates.AnalyticalListPage`, + `dataSource.uri: "/author/"`, `contextPath: "/AuthorTutorialEngagement"`). +- **View** `AuthorTutorialEngagement`, grain = 1 row per `(tutorialSlug, missionTitle, groupTitle)`: + `tutorialSlug, tutorialTitle, missionTitle, groupTitle, startedLearners, + completedLearners, completions, completionRatePct, firstCompletion, lastCompletion`. +- **Filter bar** (`UI.SelectionFields`): `missionTitle, groupTitle, tutorialTitle`. +- **Chart** (`UI.Chart`, `#Bar`): dimension `tutorialTitle`, measures + `startedLearners` + `completedLearners` (the starts-vs-completions bar). +- **Table** (`UI.LineItem`): tutorial, started, completed, completions, + `completionRatePct` with **criticality** (red below a threshold, e.g. <50%). +- `@Aggregation.ApplySupported` + `@Analytics.*` mirror the `CompletionAnalytics` + block in `app/admin-annotations.cds` (moved to the new `author-annotations.cds`). + +### Report B — Tutorial Completions (FE List Report) + +- **Component**: `app/admin/tutorial-completions/webapp/` (`sap.fe.templates.ListReport`, + `dataSource.uri: "/author/"`, `contextPath: "/AuthorTutorialCompletions"`). +- **View** `AuthorTutorialCompletions`, row-per-completion at TUTORIAL grain (status + IN COMPLETED, plus SUPERSEDED for historical trend — decision below), with the + content-graph mission/group join and `completionDay : Date`: + `ID, tutorialSlug, tutorialTitle, missionTitle, groupTitle, completionDate, + completionDay, completionCount(=1)`. + *Decision:* include `SUPERSEDED` (matches `CompletionAnalytics`, captures + re-completion history for a trend line). +- **Filter bar**: `missionTitle, groupTitle, tutorialTitle, completionDate`. +- **Chart** (`#Line` or `#Column`): completions by `completionDay`. +- **Table**: completion events with drill to tutorial/mission/group. + +### Report C — Tutorial Survey (Vue in analytics-explorer) + +- **Location**: a new **pre-configured report route** in `app/analytics-explorer/` + (e.g. `#/reports/survey`) — a fixed dashboard, *not* the ad-hoc builder. Reuses + `ChartRenderer`/ECharts, `FilterBar`, `api/odata.ts`/`api/sql.ts`, `useAuth`. +- **Filter bar**: Mission, Group, Tutorial (populated from `AuthorTutorialParents`), + Date (client-side filter on `submittedAt`). +- **Charts**: **7 bar charts** — one per dimension (structure, interesting, useCase, + relevance, duration, visuals, nps) showing **% of responses by score 0–10**, from + `AuthorSurveyDistribution` aggregated over the filtered slug set. Plus an **NPS + summary** (avg, promoters, detractors) from `TutorialFeedbackAggregate`. +- **Comments table**: `submittedAt | tutorialTitle | comment` from + `AuthorService.TutorialFeedback` filtered to the slug set, newest first. +- **Nav wiring**: external `href` leaf under "reporting" (like the existing + `analyticsExternal` leaf), `requiredScope: "Tutorial.Author"`. +- **Data access**: queries `/author/` (`AuthorService`). If the SQL query path is + used, the new views must pass the SELECT allowlist + (`srv/lib/analytics-sql-validator.cjs` / `@analytics.exposed`) — plan step. + +## 6. Nav & Shell Wiring + +Under the existing `reporting` group in +`app/admin-shell/webapp/model/navigation.json`, add three leaves (all +`requiredScope: "Tutorial.Author"`): + +| key | title | kind | +|---|---|---| +| `tutorialEngagement` | Tutorial Engagement | in-shell FE route | +| `tutorialCompletions` | Tutorial Completions | in-shell FE route | +| `tutorialSurvey` | Tutorial Survey | external `href` `/analytics-ui/#/reports/survey` | + +- FE leaves: add keys to `NAV_KEY_TO_ROUTE` + `NAV_KEY_TO_TITLE` in + `Shell.controller.js`. The FE components auto-register via + `discover-admin-components.js` (folder `tutorial-engagement` → id ending + `tutorialEngagement`). Add an `admin-shell-overrides.js` route-prefix entry only + if the auto 2-letter prefix collides. +- The Survey leaf is an external link (no route map), matching `analyticsExternal`. + +## 7. Testing + +- **Unit (in-memory SQLite)** — one suite per view. Seed `TaskRecords`, + `TutorialFeedback`, `GroupPathItems`, `CompletionPathItems`, `CompletionPaths`, + `Missions`, `Groups`, `Tutorials`, and assert: + - starts/completions/rate incl. the **SUPERSEDED + IN_PROGRESS attempt** scenario + (distinct-user correctness); + - mission/group join correctness + intended fan-out; + - survey distribution counts (null scores excluded per dimension); + - completion-rate `NULLIF` division-by-zero guard. +- **`npx cds deploy --to sqlite::memory:`** before committing any `db/**` change. +- **HANA parity** — run the two engagement views via `cds bind --exec` (real HANA) + to confirm `COUNT(DISTINCT CASE …)` and the ratio cast behave as on SQLite + (HANA columns are UPPERCASE for raw SQL — N/A here since these are CDS QL views). +- **Vue** — component test for the Survey page's distribution aggregation + + filter-to-slug logic (analytics-explorer's existing Vitest setup). +- **e2e** — advisory committed spec nudge fires on `app/**` changes; real coverage + runs in the post-DEV-deploy `e2e` job (served admin routes). + +## 8. Risks & Non-goals + +- **Non-goal (v1):** date slicing on the Engagement report (distinct-count + date + don't compose correctly in FE — see §4.3). Date lives on Report B and Report C. +- **HANA conditional-distinct** — verify or fall back to subquery join (§4.1). +- **Cascading value-helps** (Group filtered by selected Mission) have **no in-repo + precedent**; v1 uses **independent** filters (the data itself narrows results). + True dependent `ValueListParameterIn` cascading is a possible follow-up. +- **`analytics-explorer` deploy** — it is a separate Vite app copied into the + approuter at `mbt build`; the Survey report ships only via a full deploy. Confirm + `/author/` is a JWT-forwarding authenticated approuter route (memory: authenticated + Vue islands/SPAs need JWT forwarding). +- **No `srv/lib/` changes** ⇒ no `srv-qa` cp-list impact. No new npm deps for the FE + side; the Vue side reuses ECharts already vendored in `analytics-explorer`. +- **Fan-out** is intentional for mission/group filtering; never sum tutorial totals + across parents without `DISTINCT`, and never sum survey counts after the + parent join (aggregate per slug first). + +## 9. Out of Scope + +- Migrating/importing historical Power BI report definitions. +- Admin (non-author) exposure — these are `Tutorial.Author`-scoped. +- Per-`Step`-entity analytics (the Power BI "Step Analytics" page is + per-tutorial-within-a-mission, not per-`Steps`-row — confirmed in §4.1/§4.2). From 6d69b7f3d87f52137913884396c9ba45aa34645c Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 10:56:08 -0400 Subject: [PATCH 011/138] docs(#2138): implementation plan for standard reports --- .../plans/2026-09-04-standard-reports-2138.md | 1672 +++++++++++++++++ 1 file changed, 1672 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-04-standard-reports-2138.md diff --git a/docs/superpowers/plans/2026-09-04-standard-reports-2138.md b/docs/superpowers/plans/2026-09-04-standard-reports-2138.md new file mode 100644 index 000000000..5365edfb0 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-standard-reports-2138.md @@ -0,0 +1,1672 @@ +# Standard Reports in the Admin UI — Reporting Folder (#2138) 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:** Add three author-facing "standard reports" (Tutorial Engagement, Tutorial Completions, Tutorial Survey) under the existing Reporting folder of the Admin UI, replacing the deprecated Power BI Beta Tutorial Dashboard, with drill at Group / Mission / Tutorial level. + +**Architecture:** Four read-only CDS views over `ims.*` feed the reports. Two reports are in-shell Fiori Elements apps (Analytical List Page + List Report) served from `AuthorService` (`/author/`); the third is a pre-configured Vue page in the existing `analytics-explorer` SPA. No persisted entities, no `srv/lib/` changes, no new npm deps. + +**Tech Stack:** SAP CAP (CDS views + `@readonly` projections), Fiori Elements (`sap.fe.templates.AnalyticalListPage` / `ListReport`), the `sap.tnt.ToolPage` admin shell (auto-discovered componentUsages via `generate-manifest.js`), Vue 3 ` + + + + +``` + +- [ ] **Step 4: Add the route in `app/analytics-explorer/src/router.ts`** + +```ts +import { createRouter, createWebHashHistory } from 'vue-router' +import Analytics from './views/Analytics.vue' +import SurveyReport from './views/SurveyReport.vue' + +export const router = createRouter({ + history: createWebHashHistory('/analytics-ui/'), + routes: [ + { path: '/', component: Analytics }, + { path: '/reports/survey', component: SurveyReport }, + ], +}) +``` + +- [ ] **Step 5: Add a shellbar nav item in `app/analytics-explorer/src/App.vue`** + +Add a `` (icon `feedback`, text "Survey") to the existing `` and route on click. In the ` + + + + diff --git a/app/analytics-explorer/src/views/__tests__/SurveyReport.test.ts b/app/analytics-explorer/src/views/__tests__/SurveyReport.test.ts new file mode 100644 index 000000000..1eba13a62 --- /dev/null +++ b/app/analytics-explorer/src/views/__tests__/SurveyReport.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +// Stub the API layer so the component test does no network. +vi.mock('../../api/survey', () => ({ + SURVEY_DIMENSIONS: ['structure', 'interesting', 'useCase', 'relevance', 'duration', 'visuals', 'nps'], + aggregateDistribution: (rows: any[]) => { + const out: Record = {} + for (const r of rows) (out[r.dimension] ||= []).push({ score: r.score, count: r.responseCount, pct: 100 }) + return out + }, + fetchTutorialParents: vi.fn().mockResolvedValue([ + { tutorialSlug: 'a', tutorialTitle: 'Tut A', missionTitle: 'Mission 1', groupTitle: 'Group 1' } + ]), + fetchSurveyDistribution: vi.fn().mockResolvedValue([ + { tutorialSlug: 'a', dimension: 'structure', score: 8, responseCount: 2 } + ]), + fetchSurveyComments: vi.fn().mockResolvedValue([ + { submittedAt: '2026-02-01T00:00:00Z', tutorialSlug: 'a', comment: 'Nice tutorial' } + ]), +})) + +// Stub ChartRenderer (ECharts needs a real canvas; we only assert wiring). +vi.mock('../../components/ChartRenderer.vue', () => ({ + default: { name: 'ChartRenderer', props: ['chartType', 'data', 'dimensions', 'measures'], template: '
' } +})) +vi.mock('../../composables/useChartTheme', () => ({ installChartTheme: vi.fn() })) +vi.mock('../../composables/useAuth', () => ({ + useAuth: () => ({ servicePath: { value: '/author/' }, userRole: { value: 'author' } }) +})) + +import SurveyReport from '../SurveyReport.vue' + +describe('SurveyReport', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders one chart per survey dimension after loading parents + distribution', async () => { + const w = mount(SurveyReport) + await flushPromises() + // 7 dimensions => 7 ChartRenderer stubs + expect(w.findAll('.chart-stub').length).toBe(7) + }) + + it('renders the comments returned by the API', async () => { + const w = mount(SurveyReport) + await flushPromises() + expect(w.text()).toContain('Nice tutorial') + }) +}) From 2fc3ad3ba47bda20d697613ffd0f72dce8104e51 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 10:06:57 -0400 Subject: [PATCH 026/138] docs(channels): design spec for external SAP channels site integration --- ...04-external-channels-integration-design.md | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-04-external-channels-integration-design.md 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 | From 71ca30192fa0fd9aca2337145db3a78171bc739f Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 10:44:11 -0400 Subject: [PATCH 027/138] docs(channels): P1 foundation implementation plan --- ...6-09-04-external-channels-p1-foundation.md | 1145 +++++++++++++++++ 1 file changed, 1145 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-04-external-channels-p1-foundation.md 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. From 81cbf5ca245144d9ba5c8915f18f334ac8e11d07 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 10:55:03 -0400 Subject: [PATCH 028/138] feat(channels): add Channels source-of-truth entity + persistence journal --- db/channels.cds | 43 +++++++++++++++++++++++++++++++++++++ db/persistence.cds | 1 + test/channels-model.test.js | 28 ++++++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 db/channels.cds create mode 100644 test/channels-model.test.js diff --git a/db/channels.cds b/db/channels.cds new file mode 100644 index 000000000..6cd2fe64a --- /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; + 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; +} 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/test/channels-model.test.js b/test/channels-model.test.js new file mode 100644 index 000000000..26ab4316a --- /dev/null +++ b/test/channels-model.test.js @@ -0,0 +1,28 @@ +// 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); + }); +}); From 16d66134b6bd0a9751cfc1d20fe7ec505224e2d3 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 11:00:19 -0400 Subject: [PATCH 029/138] fix(channels): add @assert.range to enum columns + assert relatedUrls round-trip --- db/channels.cds | 4 ++-- test/channels-model.test.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/db/channels.cds b/db/channels.cds index 6cd2fe64a..094737819 100644 --- a/db/channels.cds +++ b/db/channels.cds @@ -19,12 +19,12 @@ entity Channels : cuid, managed { purpose : String(1000); // cleaned of [cite:] markers at ingest notes : String(1000); ownerName : String(120); - ownerType : ChannelOwnerType; + ownerType : ChannelOwnerType @assert.range; isSapOwned : Boolean default false; category : String(60); subcategory : String(80); platform : String(40); - status : ChannelStatus default 'Active'; + status : ChannelStatus default 'Active' @assert.range; focusAreas : array of String(60); tags : array of String(40); updateFrequency: String(40); diff --git a/test/channels-model.test.js b/test/channels-model.test.js index 26ab4316a..884d59d62 100644 --- a/test/channels-model.test.js +++ b/test/channels-model.test.js @@ -23,6 +23,7 @@ describe('Channels entity', () => { 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); }); }); From 0dca7c8e67b42361c36e6c291a3719a1f5a65771 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 11:05:55 -0400 Subject: [PATCH 030/138] feat(channels): normalize module + idempotent re-ingestable seed CLI --- package.json | 1 + scripts/seed-channels.cjs | 58 ++++++++++++++++++++++++++ srv/lib/channels/normalize.js | 73 +++++++++++++++++++++++++++++++++ test/channels-normalize.test.js | 48 ++++++++++++++++++++++ test/channels-seed.test.js | 43 +++++++++++++++++++ 5 files changed, 223 insertions(+) create mode 100644 scripts/seed-channels.cjs create mode 100644 srv/lib/channels/normalize.js create mode 100644 test/channels-normalize.test.js create mode 100644 test/channels-seed.test.js diff --git a/package.json b/package.json index 1009283c5..a3fbe1180 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "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", "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", 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/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/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-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); + }); +}); From b0cb80b593b326f01da1686d17b32a9e465086f7 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 11:11:02 -0400 Subject: [PATCH 031/138] feat(channels): /build/channels read feed (published, non-broken, parsed arrays) --- srv/server.js | 32 +++++++++++++++++++++++++++++++ test/build-channels-feed.test.js | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 test/build-channels-feed.test.js 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/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'); + }); +}); From ff4202e9b9830b5096b2527e4554ed7b1bcf0648 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 11:15:09 -0400 Subject: [PATCH 032/138] feat(channels): bake /build/channels into hugo/data/channels.json --- package.json | 3 ++- scripts/fetch-channels.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 scripts/fetch-channels.ts diff --git a/package.json b/package.json index a3fbe1180..6e0b4881b 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", @@ -89,7 +90,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}`); From 44a4ef5bc5fd4fc5dd5022178839c28261ca19b7 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 11:24:06 -0400 Subject: [PATCH 033/138] feat(channels): /channels directory page + Vue facet/search island - Hugo section hugo/content/channels/_index.md + layout hugo/layouts/channels/list.html baking channels.json via .Site.Data.channels.channels into a + + 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..73f576225 --- /dev/null +++ b/hugo-apps/src/channels-directory/filter.test.ts @@ -0,0 +1,22 @@ +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); + }); +}); 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..9c6ffef68 --- /dev/null +++ b/hugo/layouts/channels/list.html @@ -0,0 +1,19 @@ +{{ define "main" }} +{{- $channels := (.Site.Data.channels.channels) | default slice -}} +
+
+

{{ .Title }}

+

{{ .Description }}

+
+ +
+ +
+ +{{ end }} From d1c93c57eaa332734a1d9ddd28872ff73ab45afd Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 11:29:23 -0400 Subject: [PATCH 034/138] fix(channels): safeJS data-island + combination/exclusion filter tests - list.html: add | safeJS to jsonify pipeline so Go html/template emits the JSON value verbatim inside +

diff --git a/hugo-apps/src/devtoberfest/__tests__/DevtoberfestHome.promo.test.ts b/hugo-apps/src/devtoberfest/__tests__/DevtoberfestHome.promo.test.ts new file mode 100644 index 000000000..1669cf6f8 --- /dev/null +++ b/hugo-apps/src/devtoberfest/__tests__/DevtoberfestHome.promo.test.ts @@ -0,0 +1,38 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import DevtoberfestHome from '../DevtoberfestHome.vue' + +const CONFIG = { + apiStatus: '/api/devtoberfest/status', apiTerms: '/api/devtoberfest/terms', + apiJoin: '/api/devtoberfest/join', apiMe: '/api/devtoberfest/me', + imgKasimir: '/k.svg', imgTeched: '/t.svg', imgDevtoberfest: '/d.svg', +} + +function stubStatusJoined() { + const body = { + event: { name: 'Devtoberfest', startDate: '2026-09-21', endDate: '2026-10-18' }, + joined: true, termsVersion: 1, termsRequired: false, + contentRulesUrl: '', faqUrl: '', gameboardUrl: '', activitiesUrl: '', bannerUrl: '', + } + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, status: 200, json: async () => body, + })) as unknown as typeof fetch) +} + +describe('DevtoberfestHome promo video (#2144)', () => { + beforeEach(() => vi.restoreAllMocks()) + + it('embeds the promo video with muted autoplay on the registered state', async () => { + stubStatusJoined() + const wrapper = mount(DevtoberfestHome, { props: { config: CONFIG } }) + await flushPromises() + const iframe = wrapper.find('.dtf-promo-embed') + expect(iframe.exists()).toBe(true) + const src = iframe.attributes('src') || '' + expect(src).toContain('youtube-nocookie.com/embed/ZvxLbaMg2Gw') + expect(src).toContain('autoplay=1') + expect(src).toContain('mute=1') + expect(iframe.attributes('allowfullscreen')).toBeDefined() + }) +}) diff --git a/hugo-apps/src/devtoberfest/styles.css b/hugo-apps/src/devtoberfest/styles.css index 3f4d9f214..90ef824fe 100644 --- a/hugo-apps/src/devtoberfest/styles.css +++ b/hugo-apps/src/devtoberfest/styles.css @@ -505,6 +505,32 @@ html[data-theme="sap_horizon_dark"] .dtf-ticker { color: #f1acff; } .dtf-ticker-text { animation: none; opacity: 1; } } +/* ------------------------------ Promo video ------------------------------ */ +/* Sits in the content column under the intro (issue #2144). Capped width so it + fills the previously-empty band without overwhelming the page. */ +.dtf-promo { + margin: 0.75rem 0 0; + width: 100%; + max-width: 560px; +} + +.dtf-promo-frame { + position: relative; + aspect-ratio: 16 / 9; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18); + background: #000; +} + +.dtf-promo-embed { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; +} + @keyframes dtfBob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } From 3e2765078629e5c302590a4a47453bf83bdd6348 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 4 Sep 2026 14:05:55 -0400 Subject: [PATCH 042/138] =?UTF-8?q?feat(channels):=20P1=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20facets,=20owner=20badges,=20feed=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directory (spec 8.3/10 Tier-1 facets): - add focusArea + status facets to filter.ts + ChannelsDirectory.vue (was category/platform/owner/search only) - ownerBadge() derives SAP / SAP Advocate / Community / User Group / Third-party from ChannelOwnerType (was a single Community flag); render relatedUrls on the card Feed: - /build/channels now projects an explicit public whitelist instead of spreading the full row (drops managed audit + internal curation cols: sourceId, notes, aliases, contentHash, ingestBatch, lastChecked, isFeatured, linkStatusOverride, createdBy/modifiedBy) Shelf promotion: - FOCUS_TO_VERB now reaches CONNECT (community/network/events/connect) - CATEGORY_TO_SHELF lookup is case-insensitive Tests: filter facets + ownerBadge mapping; feed whitelist assertions; CONNECT + case-insensitive category promotion. --- .../channels-directory/ChannelsDirectory.vue | 34 ++++++++++++++--- .../src/channels-directory/filter.test.ts | 37 +++++++++++++++++-- hugo-apps/src/channels-directory/filter.ts | 24 +++++++++++- srv/lib/channels/promote-to-shelves.js | 8 +++- srv/server.js | 17 ++++++++- test/build-channels-feed.test.js | 22 ++++++++--- test/channels-promote.test.js | 10 ++++- 7 files changed, 132 insertions(+), 20 deletions(-) diff --git a/hugo-apps/src/channels-directory/ChannelsDirectory.vue b/hugo-apps/src/channels-directory/ChannelsDirectory.vue index 1e1c0f7ed..5ed895b2e 100644 --- a/hugo-apps/src/channels-directory/ChannelsDirectory.vue +++ b/hugo-apps/src/channels-directory/ChannelsDirectory.vue @@ -1,17 +1,28 @@