diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index 1463ec2ad..c76a7b2b5 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -172,7 +172,7 @@ modules: - cp -r ../../hugo/assets ./hugo/assets - cp -r ../../hugo/data ./hugo/data - cp -r ../../hugo/i18n ./hugo/i18n - - bash -c "mkdir -p srv/jobs && mkdir -p srv/handlers && mkdir -p srv/lib/branch && mkdir -p srv/lib/runtime-config && mkdir -p srv/lib/prompts && mkdir -p srv/lib/kg && mkdir -p srv/mcp/prompts && cp ../../srv/lib/branch/condition.js ../../srv/lib/branch/engine.js ../../srv/lib/branch/ranker.js ../../srv/lib/branch/user-state.js ../../srv/lib/branch/loaders.js ../../srv/lib/branch/mission-detail.js ../../srv/lib/branch/slug-key.js ../../srv/lib/branch/decide.js ../../srv/lib/branch/joule-tool.js ../../srv/lib/branch/branch-telemetry.js ../../srv/lib/branch/group-by-alt.js ../../srv/lib/branch/profile-fields.js ../../srv/lib/branch/profile-override.js srv/lib/branch/ && cp ../../srv/lib/runtime-config/kg-settings.js ../../srv/lib/runtime-config/ui-events-settings.js ../../srv/lib/runtime-config/search-settings.js ../../srv/lib/runtime-config/navigator-settings.js ../../srv/lib/runtime-config/display-settings.js ../../srv/lib/runtime-config/tenant-settings.js ../../srv/lib/runtime-config/alert-settings.js srv/lib/runtime-config/ && cp ../../srv/lib/kg/on-demand-enqueue.js ../../srv/lib/kg/on-demand-cosine-rank.js srv/lib/kg/ && cp ../../srv/lib/credstore.js ../../srv/lib/secret-resolver.js ../../srv/lib/content-store.js ../../srv/lib/content-delta-flags.js ../../srv/lib/content-cache-coherence.js ../../srv/lib/edge-cache-headers.js ../../srv/lib/content-publish-session.js ../../srv/lib/resolve-tutorial-author.js ../../srv/lib/_tutorials-table.js ../../srv/lib/catalog-renderer.js ../../srv/lib/catalog-data.js ../../srv/lib/catalog-mission-hierarchy.js ../../srv/lib/chrome-shell.js ../../srv/lib/pipeline-log.js ../../srv/lib/legacy-id.js ../../srv/lib/embedding-pipeline.js ../../srv/lib/step-text-extractor.js ../../srv/lib/embedding-client.js ../../srv/lib/step-vectors.js ../../srv/lib/user-progress.js ../../srv/lib/co-completion.js ../../srv/lib/tutorial-centroid.js ../../srv/lib/tag-label-map.js ../../srv/lib/code-check-tool.js ../../srv/lib/code-check-prompt.js ../../srv/lib/code-check-handler.js ../../srv/lib/code-check-llm.js ../../srv/lib/code-check-step-loader.js ../../srv/lib/code-check-spec-publish.js ../../srv/lib/validate-answer-spec-publish.js ../../srv/lib/category-classifier.js ../../srv/lib/category-classifier-llm.js ../../srv/lib/category-seed-embeddings.js ../../srv/lib/build-catalog-categories.js ../../srv/lib/chat-settings-resolver.js ../../srv/lib/kg-extract.js ../../srv/lib/kg-queries.js ../../srv/lib/kg-projection.js ../../srv/lib/kg-similarity.js ../../srv/lib/kg-cycles.js ../../srv/lib/kg-graph-rebuild.js ../../srv/lib/kg-sparql-client.js ../../srv/lib/kg-merge-pair.js ../../srv/lib/kg-concept-loader.js ../../srv/lib/kg-neighborhood-cache.js ../../srv/lib/kg-neighborhood-merge.js ../../srv/lib/kg-neighborhood-full-helpers.js ../../srv/lib/kg-other-resources-loader.js ../../srv/lib/kg-stamp-meta-text.js ../../srv/lib/kg-tutorial-teaches-map.js ../../srv/lib/kg-resource-type-config.js ../../srv/lib/kg-meta-formatters.js ../../srv/lib/discovery-mission-categories.js ../../srv/lib/external-content-ttl.js ../../srv/lib/recompute-tutorial-progress-bulk-sql.js ../../srv/lib/youtube-fetcher.js ../../srv/lib/homepage-events-merger.js ../../srv/lib/homepage-rss-fetcher.js ../../srv/lib/rss-parse.js ../../srv/lib/community-blogs-fetcher.js ../../srv/lib/community-blog-source-defaults.js ../../srv/lib/community-blogs-classifier.js ../../srv/lib/safe-fetch.js ../../srv/lib/curl-transport.js ../../srv/lib/khoros-transport.js ../../srv/lib/explainer-generator.js ../../srv/lib/_token-cost.js ../../srv/lib/metrics.js ../../srv/lib/alerting.js ../../srv/lib/relevance-classifier.js ../../srv/lib/relevance-seed-embeddings.js ../../srv/lib/relevance-keyword-rules.js ../../srv/lib/canonicalize-link.js ../../srv/lib/detect-language-en.js ../../srv/lib/kg-community-coverage.js ../../srv/lib/page-key-map.js ../../srv/lib/page-fallback.js ../../srv/lib/task-record-submission-id.js ../../srv/lib/image-store.cjs ../../srv/lib/image-ingest.cjs ../../srv/lib/image-source-handler.js ../../srv/lib/img-cdn-fetch.cjs ../../srv/lib/img-cdn-retry.cjs ../../srv/lib/image-warm-utils.js ../../srv/lib/attachment-store.cjs ../../srv/lib/attachment-ingest.cjs ../../srv/lib/attachment-mime.cjs ../../srv/lib/attachment-warm-utils.js ../../srv/lib/attachment-source-handler.js ../../srv/lib/attachment-ingest-handler.js ../../srv/lib/island-manifest.json srv/lib/ && mkdir -p srv/lib/feature-flags && cp ../../srv/lib/feature-flags/db-flags.js ../../srv/lib/feature-flags/registry.js srv/lib/feature-flags/ && cp ../../srv/handlers/categories-after-hooks.js ../../srv/handlers/completion-path-items-altgroup.js srv/handlers/ && mkdir -p srv && cp ../../srv/content-moderation-service.js srv/ && cp ../../srv/jobs/consolidate-concepts-job.js ../../srv/jobs/extract-concepts-job.js ../../srv/jobs/job-lock.js ../../srv/jobs/secret-expiry-check.js ../../srv/jobs/homepage-link-health.js ../../srv/jobs/kg-ondemand-job.js ../../srv/jobs/community-blogs-fetch-job.js ../../srv/jobs/community-blogs-classify-job.js ../../srv/jobs/fetch-news-job.js srv/jobs/ && cp ../../srv/lib/prompts/explainer-verb.md ../../srv/lib/prompts/explainer-shelf.md ../../srv/lib/prompts/explainer-shelf-entry.md ../../srv/lib/prompts/community-blogs-classifier.md srv/lib/prompts/ && cp ../../srv/mcp/prompts/summarize_mission_for_beginner.md ../../srv/mcp/prompts/generate_lab_exercise.md ../../srv/mcp/prompts/explain_concept.md ../../srv/mcp/prompts/suggest_learning_path.md srv/mcp/prompts/" + - bash -c "mkdir -p srv/jobs && mkdir -p srv/handlers && mkdir -p srv/lib/branch && mkdir -p srv/lib/runtime-config && mkdir -p srv/lib/prompts && mkdir -p srv/lib/kg && mkdir -p srv/mcp/prompts && cp ../../srv/lib/branch/condition.js ../../srv/lib/branch/engine.js ../../srv/lib/branch/ranker.js ../../srv/lib/branch/user-state.js ../../srv/lib/branch/loaders.js ../../srv/lib/branch/mission-detail.js ../../srv/lib/branch/slug-key.js ../../srv/lib/branch/decide.js ../../srv/lib/branch/joule-tool.js ../../srv/lib/branch/branch-telemetry.js ../../srv/lib/branch/group-by-alt.js ../../srv/lib/branch/profile-fields.js ../../srv/lib/branch/profile-override.js srv/lib/branch/ && cp ../../srv/lib/runtime-config/kg-settings.js ../../srv/lib/runtime-config/ui-events-settings.js ../../srv/lib/runtime-config/search-settings.js ../../srv/lib/runtime-config/navigator-settings.js ../../srv/lib/runtime-config/display-settings.js ../../srv/lib/runtime-config/tenant-settings.js ../../srv/lib/runtime-config/alert-settings.js srv/lib/runtime-config/ && cp ../../srv/lib/kg/on-demand-enqueue.js ../../srv/lib/kg/on-demand-cosine-rank.js srv/lib/kg/ && cp ../../srv/lib/credstore.js ../../srv/lib/secret-resolver.js ../../srv/lib/content-store.js ../../srv/lib/content-delta-flags.js ../../srv/lib/content-cache-coherence.js ../../srv/lib/edge-cache-headers.js ../../srv/lib/content-publish-session.js ../../srv/lib/resolve-tutorial-author.js ../../srv/lib/_tutorials-table.js ../../srv/lib/catalog-renderer.js ../../srv/lib/catalog-data.js ../../srv/lib/catalog-mission-hierarchy.js ../../srv/lib/chrome-shell.js ../../srv/lib/pipeline-log.js ../../srv/lib/legacy-id.js ../../srv/lib/embedding-pipeline.js ../../srv/lib/step-text-extractor.js ../../srv/lib/embedding-client.js ../../srv/lib/step-vectors.js ../../srv/lib/user-progress.js ../../srv/lib/co-completion.js ../../srv/lib/tutorial-centroid.js ../../srv/lib/tag-label-map.js ../../srv/lib/code-check-tool.js ../../srv/lib/code-check-prompt.js ../../srv/lib/code-check-handler.js ../../srv/lib/code-check-llm.js ../../srv/lib/code-check-step-loader.js ../../srv/lib/code-check-spec-publish.js ../../srv/lib/validate-answer-spec-publish.js ../../srv/lib/category-classifier.js ../../srv/lib/category-classifier-llm.js ../../srv/lib/category-seed-embeddings.js ../../srv/lib/build-catalog-categories.js ../../srv/lib/chat-settings-resolver.js ../../srv/lib/kg-extract.js ../../srv/lib/kg-queries.js ../../srv/lib/kg-projection.js ../../srv/lib/kg-similarity.js ../../srv/lib/kg-cycles.js ../../srv/lib/kg-graph-rebuild.js ../../srv/lib/kg-sparql-client.js ../../srv/lib/kg-merge-pair.js ../../srv/lib/kg-concept-loader.js ../../srv/lib/kg-neighborhood-cache.js ../../srv/lib/kg-neighborhood-merge.js ../../srv/lib/kg-neighborhood-full-helpers.js ../../srv/lib/kg-other-resources-loader.js ../../srv/lib/kg-stamp-meta-text.js ../../srv/lib/kg-tutorial-teaches-map.js ../../srv/lib/kg-resource-type-config.js ../../srv/lib/kg-meta-formatters.js ../../srv/lib/discovery-mission-categories.js ../../srv/lib/external-content-ttl.js ../../srv/lib/recompute-tutorial-progress-bulk-sql.js ../../srv/lib/youtube-fetcher.js ../../srv/lib/homepage-events-merger.js ../../srv/lib/homepage-rss-fetcher.js ../../srv/lib/rss-parse.js ../../srv/lib/community-blogs-fetcher.js ../../srv/lib/community-blog-source-defaults.js ../../srv/lib/community-blogs-classifier.js ../../srv/lib/safe-fetch.js ../../srv/lib/curl-transport.js ../../srv/lib/khoros-transport.js ../../srv/lib/explainer-generator.js ../../srv/lib/_token-cost.js ../../srv/lib/metrics.js ../../srv/lib/alerting.js ../../srv/lib/relevance-classifier.js ../../srv/lib/relevance-seed-embeddings.js ../../srv/lib/relevance-keyword-rules.js ../../srv/lib/canonicalize-link.js ../../srv/lib/detect-language-en.js ../../srv/lib/kg-community-coverage.js ../../srv/lib/page-key-map.js ../../srv/lib/page-fallback.js ../../srv/lib/task-record-submission-id.js ../../srv/lib/image-store.cjs ../../srv/lib/image-ingest.cjs ../../srv/lib/image-source-handler.js ../../srv/lib/img-cdn-fetch.cjs ../../srv/lib/img-cdn-retry.cjs ../../srv/lib/image-warm-utils.js ../../srv/lib/attachment-store.cjs ../../srv/lib/attachment-ingest.cjs ../../srv/lib/attachment-mime.cjs ../../srv/lib/attachment-warm-utils.js ../../srv/lib/attachment-source-handler.js ../../srv/lib/attachment-ingest-handler.js ../../srv/lib/contributors-publish.js ../../srv/lib/validation-rules-publish.js ../../srv/lib/island-manifest.json srv/lib/ && mkdir -p srv/lib/feature-flags && cp ../../srv/lib/feature-flags/db-flags.js ../../srv/lib/feature-flags/registry.js srv/lib/feature-flags/ && cp ../../srv/handlers/categories-after-hooks.js ../../srv/handlers/completion-path-items-altgroup.js srv/handlers/ && mkdir -p srv && cp ../../srv/content-moderation-service.js srv/ && cp ../../srv/jobs/consolidate-concepts-job.js ../../srv/jobs/extract-concepts-job.js ../../srv/jobs/job-lock.js ../../srv/jobs/secret-expiry-check.js ../../srv/jobs/homepage-link-health.js ../../srv/jobs/kg-ondemand-job.js ../../srv/jobs/community-blogs-fetch-job.js ../../srv/jobs/community-blogs-classify-job.js ../../srv/jobs/fetch-news-job.js srv/jobs/ && cp ../../srv/lib/prompts/explainer-verb.md ../../srv/lib/prompts/explainer-shelf.md ../../srv/lib/prompts/explainer-shelf-entry.md ../../srv/lib/prompts/community-blogs-classifier.md srv/lib/prompts/ && cp ../../srv/mcp/prompts/summarize_mission_for_beginner.md ../../srv/mcp/prompts/generate_lab_exercise.md ../../srv/mcp/prompts/explain_concept.md ../../srv/mcp/prompts/suggest_learning_path.md srv/mcp/prompts/" - bash -c "node -e \"const p=require('./package.json'); p.dependencies=Object.assign(p.dependencies||{},{cheerio:'^1.2.0','@sap-ai-sdk/foundation-models':'^2.10.0'}); require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2));\"" properties: EXPOSE_CAP_UI: false diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index caf86fb23..8c97cea5f 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -714,12 +714,14 @@ annotate AdminService.Tutorials with { annotate AdminService.TutorialContributors with { name @Common.Label: 'Name'; + login @Common.Label: 'GitHub'; email @Common.Label: 'Email'; role @Common.Label: 'Role'; }; annotate AdminService.TutorialContributors with @UI.LineItem: [ { Value: name }, + { $Type: 'UI.DataFieldWithUrl', Value: login, Url: profileUrl, Label: 'GitHub' }, { Value: email }, { Value: role } ]; @@ -881,6 +883,19 @@ annotate AdminService.ValidateAnswerSpecs with @UI: { } }; +// TutorialValidationRules — all validation rules for a tutorial (step, question, +// type, rule, AI-grading flag, correct answer). Joined via `validationRules` association. +annotate AdminService.TutorialValidationRules with @( + UI.LineItem: [ + { Value: stepNumber, Label: 'Step' }, + { Value: questionText, Label: 'Question' }, + { Value: questionType, Label: 'Type' }, + { Value: ruleType, Label: 'Rule' }, + { Value: aiGrading, Label: 'AI-Graded' }, + { Value: correctAnswer, Label: 'Correct Answer' } + ] +); + // CodeCheckSpecs — per-step code-check specs (goal + reference solution). // Joined via `codeCheckSpecs` association. annotate AdminService.CodeCheckSpecs with { @@ -954,8 +969,10 @@ annotate AdminService.Tutorials with @UI: { { $Type: 'UI.ReferenceFacet', Label: 'Contributors', ID: 'ContributorsFacet', Target: 'contributors/@UI.LineItem' }, { $Type: 'UI.ReferenceFacet', Label: 'Completion Stats', ID: 'CompletionStatsFacet', Target: 'completionStats/@UI.FieldGroup#Stats' }, - { $Type: 'UI.ReferenceFacet', Label: 'Validation Questions', ID: 'ValidationSpecsFacet', + { $Type: 'UI.ReferenceFacet', Label: 'AI-Graded Validation', ID: 'ValidationSpecsFacet', Target: 'validationSpecs/@UI.LineItem' }, + { $Type: 'UI.ReferenceFacet', Label: 'All Validation Rules', ID: 'AllValidationRulesFacet', + Target: 'validationRules/@UI.LineItem' }, { $Type: 'UI.ReferenceFacet', Label: 'Code-Check Specs', ID: 'CodeCheckSpecsFacet', Target: 'codeCheckSpecs/@UI.LineItem' }, { $Type: 'UI.ReferenceFacet', Label: 'AI-Author Requests', ID: 'AiRequestsFacet', diff --git a/db/last-dev/csn.json b/db/last-dev/csn.json index c574809bc..ea9751704 100644 --- a/db/last-dev/csn.json +++ b/db/last-dev/csn.json @@ -1075,6 +1075,21 @@ "length": 50, "@cds.persistence.name": "ROLE" }, + "login": { + "type": "cds.String", + "length": 255, + "@cds.persistence.name": "LOGIN" + }, + "avatarUrl": { + "type": "cds.String", + "length": 1024, + "@cds.persistence.name": "AVATARURL" + }, + "profileUrl": { + "type": "cds.String", + "length": 1024, + "@cds.persistence.name": "PROFILEURL" + }, "user_ID": { "type": "cds.String", "length": 36, @@ -1457,6 +1472,66 @@ }, "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_DEVELOPERENVIRONMENTLINKS" }, + "com.sap.developers.ims.TutorialValidationRules": { + "kind": "entity", + "@cds.persistence.journal": true, + "elements": { + "tutorial_ID": { + "type": "cds.String", + "length": 36, + "@odata.foreignKey4": "tutorial", + "key": true, + "@cds.persistence.name": "TUTORIAL_ID" + }, + "stepNumber": { + "key": true, + "type": "cds.Integer", + "@cds.persistence.name": "STEPNUMBER" + }, + "questionId": { + "key": true, + "type": "cds.String", + "length": 100, + "@cds.persistence.name": "QUESTIONID" + }, + "questionText": { + "type": "cds.String", + "length": 2000, + "@cds.persistence.name": "QUESTIONTEXT" + }, + "ruleType": { + "type": "cds.String", + "length": 50, + "@cds.persistence.name": "RULETYPE" + }, + "questionType": { + "type": "cds.String", + "length": 20, + "@cds.persistence.name": "QUESTIONTYPE" + }, + "choiceMode": { + "type": "cds.String", + "length": 20, + "@cds.persistence.name": "CHOICEMODE" + }, + "options": { + "type": "cds.LargeString", + "@cds.persistence.name": "OPTIONS" + }, + "correctAnswer": { + "type": "cds.LargeString", + "@cds.persistence.name": "CORRECTANSWER" + }, + "aiGrading": { + "type": "cds.Boolean", + "default": { + "val": false + }, + "@cds.persistence.name": "AIGRADING" + } + }, + "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_TUTORIALVALIDATIONRULES" + }, "com.sap.developers.ims.Puzzles": { "kind": "entity", "@assert.unique.slug": [ diff --git a/db/persistence.cds b/db/persistence.cds index 97d8c91c8..1ba6e23f8 100644 --- a/db/persistence.cds +++ b/db/persistence.cds @@ -50,3 +50,4 @@ annotate ims.CatGameAwards with @cds.persistence.journal; // #2042 Hit-the-Cat // #805 — Observability annotate ims.MetricSnapshots with @cds.persistence.journal; annotate ims.PublishTimings with @cds.persistence.journal; +annotate ims.TutorialValidationRules with @cds.persistence.journal; diff --git a/db/schema.cds b/db/schema.cds index 07ffce7df..e1d48af79 100644 --- a/db/schema.cds +++ b/db/schema.cds @@ -453,6 +453,9 @@ entity TutorialContributors : cuid, LegacyKeyed { name : String(255); email : String(255); role : String(50); + login : String(255); // GitHub handle + avatarUrl : String(1024); // https://github.com/.png + profileUrl : String(1024); // https://github.com/ user : Association to Users; } @@ -872,6 +875,23 @@ entity ValidateAnswerSpecs : managed { aiGrading : Boolean default false; } +// Full parsed rules.vr rule set for a tutorial, persisted at publish time. +// Unlike ValidateAnswerSpecs (AI-graded only), this holds ALL rule types so +// the admin Validation Questions facet can display the complete rule set. +// options and correctAnswer are JSON-serialised; null when not applicable. +entity TutorialValidationRules { + key tutorial : Association to Tutorials; + key stepNumber : Integer; + key questionId : String(100); + questionText : String(2000); + ruleType : String(50); // single-choice | multiple-choice | regex | exact-match | ... + questionType : String(20); // MCQ | TEXT + choiceMode : String(20); // single | multiple | null + options : LargeString; // JSON array of option strings (MCQ) or null + correctAnswer: LargeString; // reference answer (client-graded) or null when aiGrading + aiGrading : Boolean default false; +} + // Every learner submission. Drives offline grader-quality evaluation. // 'verdict' allows 'error' as a server-side outcome value (the LLM JSON // schema only emits 'pass' | 'partial' | 'fail'). diff --git a/db/src/com.sap.developers.ims.TutorialContributors.hdbmigrationtable b/db/src/com.sap.developers.ims.TutorialContributors.hdbmigrationtable index 4d27bb3be..8d7511e59 100644 --- a/db/src/com.sap.developers.ims.TutorialContributors.hdbmigrationtable +++ b/db/src/com.sap.developers.ims.TutorialContributors.hdbmigrationtable @@ -1,4 +1,4 @@ -== version=2 +== version=3 COLUMN TABLE com_sap_developers_ims_TutorialContributors ( ID NVARCHAR(36) NOT NULL, legacyId INTEGER, @@ -6,10 +6,17 @@ COLUMN TABLE com_sap_developers_ims_TutorialContributors ( name NVARCHAR(255), email NVARCHAR(255), role NVARCHAR(50), + login NVARCHAR(255), + avatarUrl NVARCHAR(1024), + profileUrl NVARCHAR(1024), user_ID NVARCHAR(36), PRIMARY KEY(ID) ) +== migration=3 +-- generated by cds-compiler version 7.0.1 +ALTER TABLE com_sap_developers_ims_TutorialContributors ADD (login NVARCHAR(255), avatarUrl NVARCHAR(1024), profileUrl NVARCHAR(1024)); + == migration=2 -- generated by cds-compiler version 6.9.0 ALTER TABLE com_sap_developers_ims_TutorialContributors ADD (user_ID NVARCHAR(36)); diff --git a/db/src/com.sap.developers.ims.TutorialValidationRules.hdbmigrationtable b/db/src/com.sap.developers.ims.TutorialValidationRules.hdbmigrationtable new file mode 100644 index 000000000..99ba6938b --- /dev/null +++ b/db/src/com.sap.developers.ims.TutorialValidationRules.hdbmigrationtable @@ -0,0 +1,14 @@ +== version=1 +COLUMN TABLE com_sap_developers_ims_TutorialValidationRules ( + tutorial_ID NVARCHAR(36) NOT NULL, + stepNumber INTEGER NOT NULL, + questionId NVARCHAR(100) NOT NULL, + questionText NVARCHAR(2000), + ruleType NVARCHAR(50), + questionType NVARCHAR(20), + choiceMode NVARCHAR(20), + options NCLOB, + correctAnswer NCLOB, + aiGrading BOOLEAN DEFAULT FALSE, + PRIMARY KEY(tutorial_ID, stepNumber, questionId) +) diff --git a/docs/superpowers/plans/2026-08-31-tutorials-admin-op-phase1-empty-facets.md b/docs/superpowers/plans/2026-08-31-tutorials-admin-op-phase1-empty-facets.md new file mode 100644 index 000000000..b2ee34f24 --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-tutorials-admin-op-phase1-empty-facets.md @@ -0,0 +1,1215 @@ +# Tutorials Admin OP — Phase 1 (Empty Facets) 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:** Populate the three empty Tutorials Object Page facets — Categories, Contributors, Validation Questions — by closing the fetch/publish pipeline gaps that leave their backing entities unpopulated. + +**Architecture:** Reuse the existing "fetch-sidecar → publish aux step → server REPLACE-per-slug inside `cds.tx()`" pattern (proven by `validate-answer-spec-publish.js`). Contributors and full validation rules get new `..json` cache sidecars written in the same `fetch-tutorials.ts` per-tutorial pass, POSTed by new client collectors in `publish-content.ts`, and upserted by new `srv/lib/*-publish.js` handlers. Categories are fixed by a fire-and-forget `classifyAndPersist('tutorial', id)` call after publish upserts a tutorial (publish bypasses the CAP `after('CREATE')` hook), plus a one-time backfill. + +**Tech Stack:** SAP CAP (Node.js, CDS), HANA (prod) / SQLite (unit), Fiori Elements annotations (`app/admin-annotations.cds`), Vitest (unit + hybrid projects). + +**Spec:** `docs/superpowers/specs/2026-08-31-tutorials-admin-op-enhancements-design.md` + +## Global Constraints + +- **Slugs are lowercase-canonical** — lowercase in both sidecar filename and JSON body; server resolves tutorial by lowercased slug. +- **REPLACE-per-slug** — publishing slug A must never touch slug B's rows. DELETE-by-`tutorial_ID` then INSERT inside `cds.tx()`. +- **srv-qa cp-list** — every new `srv/lib/*.js` MUST be appended to the `cp` command in `.deploy/mta.yaml` module `tutorials-srv-qa` (~line 175), or QA boot fails with `MODULE_NOT_FOUND`. +- **QA namespace guard** — new publish routes apply the `entity_not_in_model` fail-fast guard and skip the aux step on `channel === 'qa'` if the QA CDS model lacks the entity. +- **Never throw into the publish/completion tx** — fire-and-forget classification must `.catch(warn)`. +- **Schema changes** — `cds build --production`; register new persisted entities in `db/persistence.cds` (`@cds.persistence.journal`); never hand-author `.hdbmigrationtable`; run `npx cds deploy --to sqlite::memory:` before committing db changes. +- **BLOB reads stay raw `db.run()`** — never mix LOB + metadata in one CDS QL query (not expected in Phase 1). +- **Aux publish steps are non-fatal** — a sidecar publish failure warns, never fails the deploy. +- **Tests:** unit via `npm test` (in-memory SQLite); hybrid via `npm run test:hybrid` (real HANA, `--project hybrid`). Bare `vitest ` skips hybrid setup. +- **PR targets DEV, never main.** + +--- + +## File Structure + +**WS1 — Categories self-heal + backfill:** +- Modify: `srv/lib/content-publish-session.js` — add fire-and-forget `classifyAndPersist` loop over touched `tutorialIds` after the metadata/authorship block. +- Test: `test/unit/publish-category-selfheal.test.js`, `test/hybrid/publish-categories.test.js`. +- Ops: run `scripts/backfill-categories.cjs` (existing). + +**WS2 — Contributors:** +- Modify: `db/schema.cds` — add `login`/`avatarUrl`/`profileUrl` to `TutorialContributors`. +- Modify: `db/persistence.cds` — ensure `TutorialContributors` journaled. +- Modify: `scripts/fetch-tutorials.ts` — write `.contributors.json` sidecar. +- Create: `scripts/publish/publish-contributors.ts` (client collector), wired into `scripts/publish-content.ts`. +- Create: `srv/lib/contributors-publish.js` (server REPLACE handler). +- Modify: `srv/server.js` — mount the publish route. +- Modify: `app/admin-annotations.cds` — add `login` (GitHub link) + `avatarUrl` to `TutorialContributors` `@UI.LineItem`. +- Modify: `.deploy/mta.yaml` — add `contributors-publish.js` to srv-qa cp-list. +- Test: `test/unit/contributors-publish.test.js`, `test/hybrid/publish-contributors.test.js`. + +**WS3 — All validation rules:** +- Modify: `db/schema.cds` — add `TutorialValidationRules` entity. +- Modify: `db/persistence.cds` — journal `TutorialValidationRules`. +- Modify: `srv/admin-service.cds` — projection + `validationRules` association on `Tutorials`. +- Modify: `scripts/parsers/rules.ts` — add `collectAllRules()` alongside `collectAiGradedSpecs()`. +- Modify: `scripts/fetch-tutorials.ts` — write `.validation-rules.json` sidecar. +- Create: `scripts/publish/publish-validation-rules.ts` + wire into `publish-content.ts`. +- Create: `srv/lib/validation-rules-publish.js`. +- Modify: `srv/server.js` — mount route. +- Modify: `app/admin-annotations.cds` — relabel AI facet "AI-Graded Validation"; add "All Validation Rules" facet + LineItem. +- Modify: `.deploy/mta.yaml` — add `validation-rules-publish.js` to srv-qa cp-list. +- Test: `test/unit/collect-all-rules.test.js`, `test/unit/validation-rules-publish.test.js`, `test/hybrid/publish-validation-rules.test.js`. + +--- + +## WS1 — Categories: self-heal at publish + backfill + +### Task 1: Fire-and-forget classification after publish upsert + +**Files:** +- Modify: `srv/lib/content-publish-session.js` (~line 198-210, after `upsertTutorialMetadata`/`linkTutorialAuthorship`; `tutorialIds` already collected/returned per research) +- Test: `test/unit/publish-category-selfheal.test.js` + +**Interfaces:** +- Consumes: `classifyAndPersist(kind, id, _opts?)` — named export from `srv/lib/category-classifier.js:127`. +- Produces: nothing new; side effect is `TutorialCategories` rows for published tutorials. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/publish-category-selfheal.test.js +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock the classifier so the test asserts invocation without real embeddings/LLM. +const classifySpy = vi.fn().mockResolvedValue(undefined) +vi.mock('../../srv/lib/category-classifier.js', () => ({ + classifyAndPersist: classifySpy, +})) + +import { classifyTouchedTutorials } from '../../srv/lib/content-publish-session.js' + +describe('publish category self-heal', () => { + beforeEach(() => classifySpy.mockClear()) + + it('classifies every touched tutorial id, fire-and-forget', async () => { + await classifyTouchedTutorials(['id-a', 'id-b']) + expect(classifySpy).toHaveBeenCalledTimes(2) + expect(classifySpy).toHaveBeenCalledWith('tutorial', 'id-a') + expect(classifySpy).toHaveBeenCalledWith('tutorial', 'id-b') + }) + + it('never rejects even if a classification throws', async () => { + classifySpy.mockRejectedValueOnce(new Error('boom')) + await expect(classifyTouchedTutorials(['id-a'])).resolves.toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/unit/publish-category-selfheal.test.js --project unit` +Expected: FAIL — `classifyTouchedTutorials` is not exported. + +- [ ] **Step 3: Implement the helper + call site** + +In `srv/lib/content-publish-session.js`, add near the top (after existing imports): + +```js +const { classifyAndPersist } = require('./category-classifier.js') + +// Exported for unit testing; classifies touched tutorials without ever throwing +// into the publish tx (publish bypasses the CAP after('CREATE') classifier hook). +async function classifyTouchedTutorials(tutorialIds) { + await Promise.all( + (tutorialIds || []).map((id) => + Promise.resolve() + .then(() => classifyAndPersist('tutorial', id)) + .catch((e) => console.warn('[publish] category classify skipped', id, e?.message)), + ), + ) +} +module.exports.classifyTouchedTutorials = classifyTouchedTutorials +``` + +> Match the file's existing module system. Research shows `category-classifier.js` uses ES named exports; if `content-publish-session.js` is CommonJS, use dynamic `import()` inside the helper instead of top-level `require`. Verify the first two lines of `content-publish-session.js` before choosing. + +Then at the post-metadata call site (~line 198-210, where `tutorialIds` is in scope): + +```js +// Fire-and-forget: keep categories populated for publish-created tutorials. +classifyTouchedTutorials(tutorialIds) +``` + +(No `await` — must not block or fail the publish.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/unit/publish-category-selfheal.test.js --project unit` +Expected: PASS (both cases). + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/content-publish-session.js test/unit/publish-category-selfheal.test.js +git commit -m "feat(publish): self-heal categories via classifyAndPersist after upsert (#WS1)" +``` + +### Task 2: Hybrid guard — published tutorial gets categories + +**Files:** +- Test: `test/hybrid/publish-categories.test.js` + +**Interfaces:** +- Consumes: real `AdminService` + publish session against HANA (via `cds bind --exec`). + +- [ ] **Step 1: Write the hybrid test** + +```js +// test/hybrid/publish-categories.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' + +describe('publish populates categories (hybrid)', () => { + let db + beforeAll(async () => { db = await cds.connect.to('db') }) + + it('a freshly published tutorial has >= 0 category rows and no orphan write errors', async () => { + // Precondition: category seed embeddings must exist in this env. + const { Categories } = cds.entities('com.sap.developers.ims') + const seeds = await db.run(SELECT.from(Categories)) + expect(seeds.length).toBeGreaterThan(0) // else run embedAllSeeds first + + // Assert the classifier is reachable and idempotent for a known slug. + // (Use a slug known to exist in the bound DB.) + const { Tutorials, TutorialCategories } = cds.entities('com.sap.developers.ims') + const t = await db.run(SELECT.one.from(Tutorials).columns('ID', 'slug')) + expect(t).toBeTruthy() + const rows = await db.run(SELECT.from(TutorialCategories).where({ tutorial_ID: t.ID })) + expect(Array.isArray(rows)).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run the hybrid test** + +Run: `npm run test:hybrid -- test/hybrid/publish-categories.test.js` +Expected: PASS if seed embeddings exist. If `seeds.length === 0`, run the `embedAllSeeds` admin action first, then re-run. + +- [ ] **Step 3: Commit** + +```bash +git add test/hybrid/publish-categories.test.js +git commit -m "test(publish): hybrid guard for category population (#WS1)" +``` + +### Task 3: One-time backfill (ops step — documented, not code) + +**Files:** none (uses existing `scripts/backfill-categories.cjs`). + +- [ ] **Step 1: Confirm seed embeddings exist** — via admin action `embedAllSeeds` on the target env, or query `Categories` seed rows. +- [ ] **Step 2: Dry-run then run backfill** + +Run (against bound env): `node scripts/backfill-categories.cjs --dry-run` then without the flag. +Expected: rows inserted into `TutorialCategories` for previously-empty tutorials. + +- [ ] **Step 3: Spot-check in admin UI** — open the reference tutorial's Categories facet; confirm rows render. + +--- + +## WS2 — Contributors: map git list + GitHub links + +### Task 4: Schema — add GitHub columns to TutorialContributors + +**Files:** +- Modify: `db/schema.cds:451-457` (`TutorialContributors`) +- Modify: `db/persistence.cds` (ensure journaled) +- Test: `test/unit/schema-contributors.test.js` + +**Interfaces:** +- Produces: `TutorialContributors` now has `login : String(255)`, `avatarUrl : String(1024)`, `profileUrl : String(1024)`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/schema-contributors.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' + +describe('TutorialContributors schema', () => { + let m + beforeAll(async () => { m = await cds.load('*') }) + it('has GitHub link columns', () => { + const e = m.definitions['com.sap.developers.ims.TutorialContributors'] + expect(e.elements.login).toBeTruthy() + expect(e.elements.avatarUrl).toBeTruthy() + expect(e.elements.profileUrl).toBeTruthy() + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run test/unit/schema-contributors.test.js --project unit` +Expected: FAIL — elements undefined. + +- [ ] **Step 3: Add the columns** + +In `db/schema.cds`, extend the `TutorialContributors` entity body: + +```cds +entity TutorialContributors : cuid, LegacyKeyed { + tutorial : Association to Tutorials; + name : String(255); + email : String(255); + role : String(50); + login : String(255); // GitHub handle + avatarUrl : String(1024); // https://github.com/.png + profileUrl : String(1024); // https://github.com/ + user : Association to Users; +} +``` + +Confirm `db/persistence.cds` journals `TutorialContributors` (add `@cds.persistence.journal` registration entry if a new column set requires a migration table — follow the existing entries' shape). + +- [ ] **Step 4: Verify schema + deploy dry-run** + +Run: `npx vitest run test/unit/schema-contributors.test.js --project unit` +Then: `npx cds deploy --to sqlite::memory:` +Expected: test PASS; deploy succeeds with no errors. + +- [ ] **Step 5: Build migration + commit** + +```bash +npx cds build --production +git add db/schema.cds db/persistence.cds db/src/gen test/unit/schema-contributors.test.js +git commit -m "feat(db): add GitHub link columns to TutorialContributors (#WS2)" +``` + +### Task 5: Fetch sidecar — write `.contributors.json` + +**Files:** +- Modify: `scripts/fetch-tutorials.ts` (~line 1043, beside the validate-answer sidecar write; `contributors` array in scope from ~`:936-948`) +- Test: `test/unit/contributors-sidecar.test.js` + +**Interfaces:** +- Produces: cache file `.contributors.json` = `{ slug, contributors: Array<{login,name,email,avatarUrl}> }` (max 10). + +- [ ] **Step 1: Write the failing test** (extract a pure helper to keep it testable) + +```js +// test/unit/contributors-sidecar.test.js +import { describe, it, expect } from 'vitest' +import { buildContributorsSidecar } from '../../scripts/parsers/contributors-sidecar' + +describe('buildContributorsSidecar', () => { + it('lowercases slug and caps at 10', () => { + const contribs = Array.from({ length: 12 }, (_, i) => ({ + login: `u${i}`, name: `N${i}`, email: `${i}@x.com`, avatarUrl: `a${i}`, + })) + const out = buildContributorsSidecar('My-Slug', contribs) + expect(out.slug).toBe('my-slug') + expect(out.contributors).toHaveLength(10) + expect(out.contributors[0]).toEqual({ login: 'u0', name: 'N0', email: '0@x.com', avatarUrl: 'a0' }) + }) + it('returns null when no contributors', () => { + expect(buildContributorsSidecar('s', [])).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run test/unit/contributors-sidecar.test.js --project unit` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the helper + wire the write** + +Create `scripts/parsers/contributors-sidecar.ts`: + +```ts +export interface SidecarContributor { login: string; name: string; email: string; avatarUrl: string } +export interface ContributorsSidecar { slug: string; contributors: SidecarContributor[] } + +export function buildContributorsSidecar( + slug: string, + contributors: Array>, +): ContributorsSidecar | null { + if (!contributors || contributors.length === 0) return null + return { + slug: slug.toLowerCase(), + contributors: contributors.slice(0, 10).map((c) => ({ + login: c.login ?? '', name: c.name ?? '', email: c.email ?? '', avatarUrl: c.avatarUrl ?? '', + })), + } +} +``` + +In `scripts/fetch-tutorials.ts`, beside the validate-answer write (~`:1043`): + +```ts +import { buildContributorsSidecar } from './parsers/contributors-sidecar' +// ... +const contribSidecar = buildContributorsSidecar(t.slug, contributors) +if (contribSidecar) { + writeFileSync( + join(CACHE_DIR, `${t.slug.toLowerCase()}.contributors.json`), + JSON.stringify(contribSidecar, null, 2), + ) +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run test/unit/contributors-sidecar.test.js --project unit` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/parsers/contributors-sidecar.ts scripts/fetch-tutorials.ts test/unit/contributors-sidecar.test.js +git commit -m "feat(fetch): write contributors sidecar from git contributor list (#WS2)" +``` + +### Task 6: Server handler — REPLACE contributors per slug + +**Files:** +- Create: `srv/lib/contributors-publish.js` +- Modify: `srv/server.js` (mount route, mirror validate-answer mount) +- Modify: `.deploy/mta.yaml` (~line 175, srv-qa cp-list) +- Test: `test/unit/contributors-publish.test.js` + +**Interfaces:** +- Consumes: POST body `{ slug, contributors: [{login,name,email,avatarUrl}] }`. +- Produces: `publishContributors(req, res)` Express handler; REPLACE-by-`tutorial_ID` in `TutorialContributors`. + +- [ ] **Step 1: Write the failing test** (mirror `validate-answer-spec-publish` test shape, in-memory SQLite) + +```js +// test/unit/contributors-publish.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +import { replaceContributorsForSlug } from '../../srv/lib/contributors-publish.js' + +describe('replaceContributorsForSlug', () => { + let db + beforeAll(async () => { + await cds.test('serve', '--in-memory').in(process.cwd()) + db = await cds.connect.to('db') + }) + + it('replaces rows for the slug and derives profileUrl', async () => { + const { Tutorials, TutorialContributors } = cds.entities('com.sap.developers.ims') + const ID = cds.utils.uuid() + await db.run(INSERT.into(Tutorials).entries({ ID, slug: 'demo', title: 'Demo' })) + + await replaceContributorsForSlug(db, 'DEMO', [ + { login: 'octocat', name: 'Octo Cat', email: 'o@x.com', avatarUrl: 'https://github.com/octocat.png' }, + ]) + let rows = await db.run(SELECT.from(TutorialContributors).where({ tutorial_ID: ID })) + expect(rows).toHaveLength(1) + expect(rows[0].login).toBe('octocat') + expect(rows[0].profileUrl).toBe('https://github.com/octocat') + + // Second publish REPLACES, does not append. + await replaceContributorsForSlug(db, 'demo', [ + { login: 'hubot', name: 'Hubot', email: 'h@x.com', avatarUrl: 'https://github.com/hubot.png' }, + ]) + rows = await db.run(SELECT.from(TutorialContributors).where({ tutorial_ID: ID })) + expect(rows).toHaveLength(1) + expect(rows[0].login).toBe('hubot') + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run test/unit/contributors-publish.test.js --project unit` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the handler** + +Create `srv/lib/contributors-publish.js` (mirror `validate-answer-spec-publish.js` structure — namespace guard, per-slug REPLACE in `cds.tx()`): + +```js +const cds = require('@sap/cds') +const NS = 'com.sap.developers.ims' +const MAX_FIELD_BYTES = 2000 + +function githubProfileUrl(login) { + return login ? `https://github.com/${login}` : null +} + +// Core, unit-testable: REPLACE all contributor rows for one slug. +async function replaceContributorsForSlug(db, slug, contributors) { + const { Tutorials, TutorialContributors } = cds.entities(NS) + const lcSlug = String(slug || '').toLowerCase() + const tut = await db.run(SELECT.one.from(Tutorials).columns('ID').where({ slug: lcSlug })) + if (!tut) return { ok: false, reason: 'tutorial_not_found', slug: lcSlug } + + const entries = (contributors || []) + .filter((c) => c && (c.login || c.name || c.email)) + .slice(0, 10) + .map((c) => ({ + ID: cds.utils.uuid(), + tutorial_ID: tut.ID, + login: (c.login || '').slice(0, 255), + name: (c.name || '').slice(0, 255), + email: (c.email || '').slice(0, 255), + avatarUrl: (c.avatarUrl || '').slice(0, 1024), + profileUrl: githubProfileUrl(c.login), + })) + + await cds.tx(async (tx) => { + await tx.run(DELETE.from(TutorialContributors).where({ tutorial_ID: tut.ID })) + if (entries.length) await tx.run(INSERT.into(TutorialContributors).entries(entries)) + }) + return { ok: true, slug: lcSlug, count: entries.length } +} + +// Express handler mirroring validate-answer-spec-publish route. +async function publishContributors(req, res) { + try { + const { slug, contributors } = req.body || {} + if (!slug || !Array.isArray(contributors)) { + return res.status(400).json({ error: 'bad_request', detail: 'expected { slug, contributors[] }' }) + } + let entities + try { entities = cds.entities(NS) } catch { entities = null } + if (!entities || !entities.TutorialContributors) { + return res.status(409).json({ error: 'entity_not_in_model' }) + } + const db = await cds.connect.to('db') + const result = await replaceContributorsForSlug(db, slug, contributors) + if (!result.ok) return res.status(404).json(result) + return res.json(result) + } catch (e) { + return res.status(500).json({ error: 'internal', detail: e?.message }) + } +} + +module.exports = { replaceContributorsForSlug, publishContributors, githubProfileUrl } +``` + +Mount in `srv/server.js` beside the validate-answer route (guard with the same `CONTENT_API_KEY` middleware the other publish routes use): + +```js +const { publishContributors } = require('./lib/contributors-publish.js') +app.post('/content/publish-contributors', requireContentApiKey, express.json({ limit: '1mb' }), publishContributors) +``` + +> Verify the exact auth-middleware name and JSON body parser used by the existing `/content/publish` + validate-answer routes and match it. + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run test/unit/contributors-publish.test.js --project unit` +Expected: PASS (both replace + derive assertions). + +- [ ] **Step 5: Add to srv-qa cp-list** + +In `.deploy/mta.yaml`, module `tutorials-srv-qa` `cp` command (~line 175), append `../../srv/lib/contributors-publish.js` to the `srv/lib/` copy segment. + +- [ ] **Step 6: Commit** + +```bash +git add srv/lib/contributors-publish.js srv/server.js .deploy/mta.yaml test/unit/contributors-publish.test.js +git commit -m "feat(publish): server REPLACE handler for TutorialContributors (#WS2)" +``` + +### Task 7: Client publish step + wire into publish-content + +**Files:** +- Create: `scripts/publish/publish-contributors.ts` +- Modify: `scripts/publish-content.ts` (~line 1327-1352, beside `publishValidateAnswerSpecs`; non-fatal aux step) +- Test: `test/unit/publish-contributors-client.test.js` + +**Interfaces:** +- Consumes: cache dir globbed for `*.contributors.json`; POSTs each to `/content/publish-contributors`. +- Produces: `publishContributors({ cacheDir, baseUrl, apiKey })`. + +- [ ] **Step 1: Write the failing test** (mock `fetch`) + +```js +// test/unit/publish-contributors-client.test.js +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { publishContributors } from '../../scripts/publish/publish-contributors' + +describe('publishContributors client', () => { + let dir + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'contrib-')) + writeFileSync(join(dir, 'demo.contributors.json'), + JSON.stringify({ slug: 'demo', contributors: [{ login: 'octocat', name: 'O', email: 'o@x', avatarUrl: 'a' }] })) + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true, count: 1 }) }) + }) + afterEach(() => { vi.restoreAllMocks() }) + + it('POSTs each sidecar to the endpoint', async () => { + const res = await publishContributors({ cacheDir: dir, baseUrl: 'http://x', apiKey: 'k' }) + expect(global.fetch).toHaveBeenCalledTimes(1) + const [url, opts] = global.fetch.mock.calls[0] + expect(url).toBe('http://x/content/publish-contributors') + expect(JSON.parse(opts.body).slug).toBe('demo') + expect(res.published).toBe(1) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run test/unit/publish-contributors-client.test.js --project unit` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement + wire** + +Create `scripts/publish/publish-contributors.ts`: + +```ts +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +export async function publishContributors(opts: { cacheDir: string; baseUrl: string; apiKey: string }) { + const { cacheDir, baseUrl, apiKey } = opts + const files = readdirSync(cacheDir).filter((f) => f.endsWith('.contributors.json')) + let published = 0 + for (const f of files) { + const body = readFileSync(join(cacheDir, f), 'utf8') + const res = await fetch(`${baseUrl}/content/publish-contributors`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': apiKey }, + body, + }) + if (res.ok) published += 1 + else console.warn(`[publish-contributors] ${f} -> ${res.status}`) + } + return { published, total: files.length } +} +``` + +> Match the exact auth header name the existing publish client uses (research: validate-answer client — confirm `x-api-key` vs `authorization`). + +In `scripts/publish-content.ts`, beside `publishValidateAnswerSpecs` (~`:1327`), add a non-fatal aux step, skipping QA channel: + +```ts +if (channel !== 'qa') { + try { + const r = await publishContributors({ cacheDir: CACHE_DIR, baseUrl, apiKey }) + console.log(`[publish] contributors: ${r.published}/${r.total}`) + } catch (e) { + console.warn('[publish] contributors step failed (non-fatal)', (e as Error).message) + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run test/unit/publish-contributors-client.test.js --project unit` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/publish/publish-contributors.ts scripts/publish-content.ts test/unit/publish-contributors-client.test.js +git commit -m "feat(publish): non-fatal client step to publish contributors sidecars (#WS2)" +``` + +### Task 8: UI — GitHub-linked Contributors LineItem + +**Files:** +- Modify: `app/admin-annotations.cds:715-725` (`TutorialContributors` `@UI.LineItem`) +- Test: `test/unit/annotations-contributors.test.js` + +**Interfaces:** +- Consumes: `TutorialContributors.login`/`avatarUrl`/`profileUrl` (Task 4). + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/annotations-contributors.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' + +describe('Contributors LineItem', () => { + let m + beforeAll(async () => { m = await cds.load('*') }) + it('LineItem includes login column', () => { + const e = m.definitions['AdminService.TutorialContributors'] + const li = e['@UI.LineItem'] + const values = li.map((x) => x.Value?.['='] || x.Value) + expect(values).toContain('login') + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run test/unit/annotations-contributors.test.js --project unit` +Expected: FAIL — `login` not in LineItem. + +- [ ] **Step 3: Add columns + GitHub link** + +In `app/admin-annotations.cds`, extend the `TutorialContributors` `@UI.LineItem` (add `login` and render it as an external link to `profileUrl`): + +```cds +annotate AdminService.TutorialContributors with @( + UI.LineItem: [ + { Value: name, Label: 'Name' }, + { Value: login, Label: 'GitHub', @HTML5.LinkTarget: '_blank' }, + { Value: email, Label: 'Email' }, + { Value: role, Label: 'Role' } + ] +); +annotate AdminService.TutorialContributors with { + login @Common.Text: profileUrl @Common.TextArrangement: #TextOnly; +}; +``` + +> Preferred: make `login` a link via a `DataFieldWithUrl` pointing at `profileUrl` so the cell navigates to `github.com/`: +> ```cds +> { $Type: 'UI.DataFieldWithUrl', Value: login, Url: profileUrl, Label: 'GitHub' } +> ``` +> Use whichever renders as a clickable GitHub link in the current FE version; verify against the running admin UI. + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run test/unit/annotations-contributors.test.js --project unit` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/admin-annotations.cds test/unit/annotations-contributors.test.js +git commit -m "feat(admin-ui): GitHub-linked login column on Contributors table (#WS2)" +``` + +### Task 9: Hybrid guard — publish links contributors to tutorial + +**Files:** +- Test: `test/hybrid/publish-contributors.test.js` + +- [ ] **Step 1: Write the hybrid test** + +```js +// test/hybrid/publish-contributors.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +import { replaceContributorsForSlug } from '../../srv/lib/contributors-publish.js' + +describe('contributors publish (hybrid)', () => { + let db + beforeAll(async () => { db = await cds.connect.to('db') }) + it('links contributor rows to an existing tutorial by slug', async () => { + const { Tutorials, TutorialContributors } = cds.entities('com.sap.developers.ims') + const t = await db.run(SELECT.one.from(Tutorials).columns('ID', 'slug')) + expect(t).toBeTruthy() + await replaceContributorsForSlug(db, t.slug, [ + { login: 'octocat', name: 'Octo', email: 'o@x.com', avatarUrl: 'https://github.com/octocat.png' }, + ]) + const rows = await db.run(SELECT.from(TutorialContributors).where({ tutorial_ID: t.ID, login: 'octocat' })) + expect(rows.length).toBe(1) + expect(rows[0].profileUrl).toBe('https://github.com/octocat') + // cleanup + await db.run(DELETE.from(TutorialContributors).where({ tutorial_ID: t.ID, login: 'octocat' })) + }) +}) +``` + +- [ ] **Step 2: Run** `npm run test:hybrid -- test/hybrid/publish-contributors.test.js` — Expected: PASS. +- [ ] **Step 3: Commit** + +```bash +git add test/hybrid/publish-contributors.test.js +git commit -m "test(publish): hybrid guard for contributor linking (#WS2)" +``` + +--- + +## WS3 — All validation rules + +### Task 10: Schema — `TutorialValidationRules` entity + +**Files:** +- Modify: `db/schema.cds` (add entity near `ValidateAnswerSpecs` ~`:865`) +- Modify: `db/persistence.cds` (journal it) +- Test: `test/unit/schema-validation-rules.test.js` + +**Interfaces:** +- Produces: `com.sap.developers.ims.TutorialValidationRules` with key `(tutorial, stepNumber, questionId)`, fields `questionText`, `ruleType`, `questionType`, `choiceMode`, `options` (LargeString JSON), `correctAnswer`, `aiGrading : Boolean`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/schema-validation-rules.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('TutorialValidationRules schema', () => { + let m + beforeAll(async () => { m = await cds.load('*') }) + it('exists with expected elements', () => { + const e = m.definitions['com.sap.developers.ims.TutorialValidationRules'] + expect(e).toBeTruthy() + for (const k of ['stepNumber','questionId','questionText','ruleType','questionType','choiceMode','options','correctAnswer','aiGrading']) + expect(e.elements[k]).toBeTruthy() + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run test/unit/schema-validation-rules.test.js --project unit` → FAIL. + +- [ ] **Step 3: Add the entity** + +In `db/schema.cds`: + +```cds +entity TutorialValidationRules { + key tutorial : Association to Tutorials; + key stepNumber : Integer; + key questionId : String(100); + questionText : String(2000); + ruleType : String(50); // single-choice | multiple-choice | regex | exact-match | ... + questionType : String(20); // MCQ | TEXT + choiceMode : String(20); // single | multiple | null + options : LargeString; // JSON array of option strings (MCQ) or null + correctAnswer: LargeString; // reference answer (client-graded) or null when aiGrading + aiGrading : Boolean default false; +} +``` + +Register in `db/persistence.cds` mirroring the existing entries' `@cds.persistence.journal` shape. + +- [ ] **Step 4: Verify + deploy dry-run** — `npx vitest run test/unit/schema-validation-rules.test.js --project unit` then `npx cds deploy --to sqlite::memory:` → PASS + clean deploy. + +- [ ] **Step 5: Build migration + commit** + +```bash +npx cds build --production +git add db/schema.cds db/persistence.cds db/src/gen test/unit/schema-validation-rules.test.js +git commit -m "feat(db): add TutorialValidationRules entity for all rules.vr rules (#WS3)" +``` + +### Task 11: Parser — `collectAllRules()` + +**Files:** +- Modify: `scripts/parsers/rules.ts` (add beside `collectAiGradedSpecs` ~`:312`) +- Test: `test/unit/collect-all-rules.test.js` + +**Interfaces:** +- Consumes: `validationMap`, `ruleTypeByStepAndId`, `correctAnswerByStepAndId` (from `parseRulesVrEnriched`). +- Produces: `collectAllRules(map, ruleTypeMap, answerMap) => Array<{ stepNumber, questionId, questionText, ruleType, questionType, choiceMode, options, correctAnswer, aiGrading }>`. + +- [ ] **Step 1: Write the failing test** + +```ts +// test/unit/collect-all-rules.test.ts +import { describe, it, expect } from 'vitest' +import { collectAllRules } from '../../scripts/parsers/rules' + +describe('collectAllRules', () => { + it('includes non-AI MCQ rules with options + correctAnswer', () => { + const map = new Map([[1, [ + { id: 'validate-1', question: 'Pick one', type: 'QUESTION_TYPE_MCQ', options: ['A','B'], choiceMode: 'single', correctAnswer: 'A' }, + { id: 'validate-1b', question: 'AI graded', type: 'QUESTION_TYPE_TEXT', aiGrading: true }, + ]]]) + const ruleTypeMap = new Map([['1::validate-1', 'single-choice'], ['1::validate-1b', 'regex']]) + const answerMap = new Map([['1::validate-1', 'A']]) + const rows = collectAllRules(map, ruleTypeMap, answerMap) + expect(rows).toHaveLength(2) + const mcq = rows.find((r) => r.questionId === 'validate-1') + expect(mcq.aiGrading).toBe(false) + expect(mcq.questionType).toBe('MCQ') + expect(JSON.parse(mcq.options)).toEqual(['A','B']) + expect(mcq.correctAnswer).toBe('A') + const ai = rows.find((r) => r.questionId === 'validate-1b') + expect(ai.aiGrading).toBe(true) + expect(ai.correctAnswer).toBeNull() + }) +}) +``` + +> Confirm the exact key format of `ruleTypeByStepAndId`/`correctAnswerByStepAndId` in `rules.ts` (research indicated a `step::id` style). Adjust the test's key strings to match the real format before implementing. + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run test/unit/collect-all-rules.test.ts --project unit` → FAIL. + +- [ ] **Step 3: Implement `collectAllRules`** + +In `scripts/parsers/rules.ts` (adapt key access to the confirmed map format): + +```ts +export interface AllRuleRow { + stepNumber: number; questionId: string; questionText: string; + ruleType: string; questionType: 'MCQ' | 'TEXT'; choiceMode: string | null; + options: string | null; correctAnswer: string | null; aiGrading: boolean; +} + +export function collectAllRules( + map: Map, + ruleTypeByStepAndId: Map, + correctAnswerByStepAndId: Map, +): AllRuleRow[] { + const rows: AllRuleRow[] = [] + for (const [stepNumber, questions] of map.entries()) { + for (const q of questions) { + const key = `${stepNumber}::${q.id}` + const isMcq = q.type === 'QUESTION_TYPE_MCQ' + const ai = Boolean((q as any).aiGrading) + rows.push({ + stepNumber, + questionId: q.id, + questionText: q.question, + ruleType: ruleTypeByStepAndId.get(key) ?? '', + questionType: isMcq ? 'MCQ' : 'TEXT', + choiceMode: (q as any).choiceMode ?? null, + options: isMcq && (q as any).options ? JSON.stringify((q as any).options) : null, + correctAnswer: ai ? null : (correctAnswerByStepAndId.get(key) ?? (q as any).correctAnswer ?? null), + aiGrading: ai, + }) + } + } + return rows +} +``` + +- [ ] **Step 4: Run to verify it passes** — `npx vitest run test/unit/collect-all-rules.test.ts --project unit` → PASS. +- [ ] **Step 5: Commit** + +```bash +git add scripts/parsers/rules.ts test/unit/collect-all-rules.test.ts +git commit -m "feat(parser): collectAllRules for full rules.vr rule set (#WS3)" +``` + +### Task 12: Fetch sidecar — `.validation-rules.json` + +**Files:** +- Modify: `scripts/fetch-tutorials.ts` (~`:975` where `validationMap` etc. are destructured; write beside other sidecars ~`:1043`) +- Test: covered by Task 11 helper + Task 13 server test; add a small write-path assertion. + +- [ ] **Step 1: Wire the sidecar write** + +```ts +import { collectAllRules } from './parsers/rules' +// ... +const allRules = collectAllRules(validationMap, ruleTypeByStepAndId, correctAnswerByStepAndId) +if (allRules.length > 0) { + writeFileSync( + join(CACHE_DIR, `${t.slug.toLowerCase()}.validation-rules.json`), + JSON.stringify({ slug: t.slug.toLowerCase(), rules: allRules }, null, 2), + ) +} +``` + +- [ ] **Step 2: Sanity build** — run `npm run fetch-tutorials` for a small subset if a `--slug`/limit flag exists, or type-check: `npx tsc --noEmit -p tsconfig.json` (confirm project has this). Expected: no type errors. +- [ ] **Step 3: Commit** + +```bash +git add scripts/fetch-tutorials.ts +git commit -m "feat(fetch): write validation-rules sidecar (all rule types) (#WS3)" +``` + +### Task 13: Server handler — REPLACE validation rules per slug + +**Files:** +- Create: `srv/lib/validation-rules-publish.js` +- Modify: `srv/server.js` (mount route) +- Modify: `.deploy/mta.yaml` (srv-qa cp-list ~line 175) +- Test: `test/unit/validation-rules-publish.test.js` + +**Interfaces:** +- Consumes: `{ slug, rules: AllRuleRow[] }`. +- Produces: `replaceValidationRulesForSlug(db, slug, rules)` + `publishValidationRules(req, res)`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/validation-rules-publish.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +import { replaceValidationRulesForSlug } from '../../srv/lib/validation-rules-publish.js' + +describe('replaceValidationRulesForSlug', () => { + let db + beforeAll(async () => { await cds.test('serve', '--in-memory').in(process.cwd()); db = await cds.connect.to('db') }) + it('replaces all-rule rows for a slug', async () => { + const { Tutorials, TutorialValidationRules } = cds.entities('com.sap.developers.ims') + const ID = cds.utils.uuid() + await db.run(INSERT.into(Tutorials).entries({ ID, slug: 'vr-demo', title: 'VR' })) + await replaceValidationRulesForSlug(db, 'VR-DEMO', [ + { stepNumber: 1, questionId: 'validate-1', questionText: 'Q', ruleType: 'single-choice', questionType: 'MCQ', choiceMode: 'single', options: '["A","B"]', correctAnswer: 'A', aiGrading: false }, + ]) + let rows = await db.run(SELECT.from(TutorialValidationRules).where({ tutorial_ID: ID })) + expect(rows).toHaveLength(1) + expect(rows[0].aiGrading).toBe(false) + await replaceValidationRulesForSlug(db, 'vr-demo', []) + rows = await db.run(SELECT.from(TutorialValidationRules).where({ tutorial_ID: ID })) + expect(rows).toHaveLength(0) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run test/unit/validation-rules-publish.test.js --project unit` → FAIL. + +- [ ] **Step 3: Implement the handler** (mirror `contributors-publish.js`) + +```js +const cds = require('@sap/cds') +const NS = 'com.sap.developers.ims' + +async function replaceValidationRulesForSlug(db, slug, rules) { + const { Tutorials, TutorialValidationRules } = cds.entities(NS) + const lcSlug = String(slug || '').toLowerCase() + const tut = await db.run(SELECT.one.from(Tutorials).columns('ID').where({ slug: lcSlug })) + if (!tut) return { ok: false, reason: 'tutorial_not_found', slug: lcSlug } + const entries = (rules || []).map((r) => ({ + tutorial_ID: tut.ID, + stepNumber: r.stepNumber, + questionId: String(r.questionId).slice(0, 100), + questionText: (r.questionText || '').slice(0, 2000), + ruleType: (r.ruleType || '').slice(0, 50), + questionType: (r.questionType || '').slice(0, 20), + choiceMode: r.choiceMode || null, + options: r.options || null, + correctAnswer: r.correctAnswer ?? null, + aiGrading: Boolean(r.aiGrading), + })) + await cds.tx(async (tx) => { + await tx.run(DELETE.from(TutorialValidationRules).where({ tutorial_ID: tut.ID })) + if (entries.length) await tx.run(INSERT.into(TutorialValidationRules).entries(entries)) + }) + return { ok: true, slug: lcSlug, count: entries.length } +} + +async function publishValidationRules(req, res) { + try { + const { slug, rules } = req.body || {} + if (!slug || !Array.isArray(rules)) return res.status(400).json({ error: 'bad_request' }) + let entities; try { entities = cds.entities(NS) } catch { entities = null } + if (!entities || !entities.TutorialValidationRules) return res.status(409).json({ error: 'entity_not_in_model' }) + const db = await cds.connect.to('db') + const result = await replaceValidationRulesForSlug(db, slug, rules) + return res.status(result.ok ? 200 : 404).json(result) + } catch (e) { return res.status(500).json({ error: 'internal', detail: e?.message }) } +} + +module.exports = { replaceValidationRulesForSlug, publishValidationRules } +``` + +Mount in `srv/server.js`: + +```js +const { publishValidationRules } = require('./lib/validation-rules-publish.js') +app.post('/content/publish-validation-rules', requireContentApiKey, express.json({ limit: '4mb' }), publishValidationRules) +``` + +- [ ] **Step 4: Run to verify it passes** — `npx vitest run test/unit/validation-rules-publish.test.js --project unit` → PASS. +- [ ] **Step 5: Add to srv-qa cp-list** — append `../../srv/lib/validation-rules-publish.js` to the `.deploy/mta.yaml` srv-qa `cp` command (~line 175). +- [ ] **Step 6: Commit** + +```bash +git add srv/lib/validation-rules-publish.js srv/server.js .deploy/mta.yaml test/unit/validation-rules-publish.test.js +git commit -m "feat(publish): server REPLACE handler for TutorialValidationRules (#WS3)" +``` + +### Task 14: Client publish step for validation rules + +**Files:** +- Create: `scripts/publish/publish-validation-rules.ts` +- Modify: `scripts/publish-content.ts` (beside contributors aux step) +- Test: `test/unit/publish-validation-rules-client.test.js` + +**Interfaces:** +- Produces: `publishValidationRules({ cacheDir, baseUrl, apiKey })` — globs `*.validation-rules.json`, POSTs to `/content/publish-validation-rules`. + +- [ ] **Step 1: Write the failing test** (mirror Task 7 client test, glob `*.validation-rules.json`, endpoint `/content/publish-validation-rules`). + +```js +// test/unit/publish-validation-rules-client.test.js +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { join } from 'node:path'; import { tmpdir } from 'node:os' +import { publishValidationRules } from '../../scripts/publish/publish-validation-rules' + +describe('publishValidationRules client', () => { + let dir + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'vr-')) + writeFileSync(join(dir, 'demo.validation-rules.json'), + JSON.stringify({ slug: 'demo', rules: [{ stepNumber: 1, questionId: 'validate-1' }] })) + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) }) + }) + afterEach(() => vi.restoreAllMocks()) + it('POSTs each sidecar', async () => { + const res = await publishValidationRules({ cacheDir: dir, baseUrl: 'http://x', apiKey: 'k' }) + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(global.fetch.mock.calls[0][0]).toBe('http://x/content/publish-validation-rules') + expect(res.published).toBe(1) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** — → FAIL (module not found). + +- [ ] **Step 3: Implement + wire** (copy `publish-contributors.ts`, swap glob suffix + endpoint): + +```ts +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +export async function publishValidationRules(opts: { cacheDir: string; baseUrl: string; apiKey: string }) { + const { cacheDir, baseUrl, apiKey } = opts + const files = readdirSync(cacheDir).filter((f) => f.endsWith('.validation-rules.json')) + let published = 0 + for (const f of files) { + const res = await fetch(`${baseUrl}/content/publish-validation-rules`, { + method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': apiKey }, + body: readFileSync(join(cacheDir, f), 'utf8'), + }) + if (res.ok) published += 1; else console.warn(`[publish-validation-rules] ${f} -> ${res.status}`) + } + return { published, total: files.length } +} +``` + +In `scripts/publish-content.ts`, add a non-fatal aux step (skip `channel === 'qa'`), mirroring Task 7. + +- [ ] **Step 4: Run to verify it passes** — → PASS. +- [ ] **Step 5: Commit** + +```bash +git add scripts/publish/publish-validation-rules.ts scripts/publish-content.ts test/unit/publish-validation-rules-client.test.js +git commit -m "feat(publish): non-fatal client step to publish validation-rules sidecars (#WS3)" +``` + +### Task 15: Service projection + association + UI facets + +**Files:** +- Modify: `srv/admin-service.cds` (projection + association on `Tutorials`) +- Modify: `app/admin-annotations.cds` (relabel AI facet; add "All Validation Rules" facet + LineItem) +- Test: `test/unit/annotations-validation-rules.test.js` + +**Interfaces:** +- Consumes: `TutorialValidationRules` (Task 10). +- Produces: `AdminService.TutorialValidationRules` (read-only) + `Tutorials.validationRules` association. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/annotations-validation-rules.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('validation rules exposure + facet', () => { + let m + beforeAll(async () => { m = await cds.load('*') }) + it('AdminService exposes TutorialValidationRules read-only', () => { + expect(m.definitions['AdminService.TutorialValidationRules']).toBeTruthy() + }) + it('Tutorials has validationRules association', () => { + expect(m.definitions['AdminService.Tutorials'].elements.validationRules).toBeTruthy() + }) + it('OP facets include an All Validation Rules facet', () => { + const facets = m.definitions['AdminService.Tutorials']['@UI.Facets'] + const ids = facets.map((f) => f.ID) + expect(ids).toContain('AllValidationRulesFacet') + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** — → FAIL. + +- [ ] **Step 3: Implement projection + association + facets** + +In `srv/admin-service.cds`: + +```cds +@readonly entity TutorialValidationRules as projection on ims.TutorialValidationRules; +``` + +Add to the `Tutorials` projection body (beside `validationSpecs` ~`:66`): + +```cds +validationRules : Association to many TutorialValidationRules on validationRules.tutorial = $self; +``` + +In `app/admin-annotations.cds`: +1. Relabel the existing AI facet (`ValidationSpecsFacet`, ~`:957`) `Label: 'AI-Graded Validation'`. +2. Add a LineItem + facet: + +```cds +annotate AdminService.TutorialValidationRules with @( + UI.LineItem: [ + { Value: stepNumber, Label: 'Step' }, + { Value: questionText, Label: 'Question' }, + { Value: questionType, Label: 'Type' }, + { Value: ruleType, Label: 'Rule' }, + { Value: aiGrading, Label: 'AI-Graded' }, + { Value: correctAnswer, Label: 'Correct Answer' } + ] +); +``` + +Add to the winning `@UI.Facets` block (~`:948-974`): + +```cds +{ $Type: 'UI.ReferenceFacet', Label: 'All Validation Rules', ID: 'AllValidationRulesFacet', Target: 'validationRules/@UI.LineItem' }, +``` + +- [ ] **Step 4: Run to verify it passes** — `npx vitest run test/unit/annotations-validation-rules.test.js --project unit` → PASS. Then `npx cds deploy --to sqlite::memory:` → clean. +- [ ] **Step 5: Commit** + +```bash +git add srv/admin-service.cds app/admin-annotations.cds test/unit/annotations-validation-rules.test.js +git commit -m "feat(admin-ui): All Validation Rules facet + relabel AI facet (#WS3)" +``` + +### Task 16: Hybrid guard — publish populates all rules + +**Files:** +- Test: `test/hybrid/publish-validation-rules.test.js` + +- [ ] **Step 1: Write the hybrid test** — publish a slug via `replaceValidationRulesForSlug` against HANA, assert both AI and non-AI rows land in `TutorialValidationRules`, and that AI rows still exist in `ValidateAnswerSpecs` (unchanged). Clean up after. + +```js +// test/hybrid/publish-validation-rules.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +import { replaceValidationRulesForSlug } from '../../srv/lib/validation-rules-publish.js' +describe('validation rules publish (hybrid)', () => { + let db; beforeAll(async () => { db = await cds.connect.to('db') }) + it('lands mixed AI + client rules for an existing slug', async () => { + const { Tutorials, TutorialValidationRules } = cds.entities('com.sap.developers.ims') + const t = await db.run(SELECT.one.from(Tutorials).columns('ID','slug')) + await replaceValidationRulesForSlug(db, t.slug, [ + { stepNumber: 99, questionId: 'vr-test-a', questionText: 'client', ruleType: 'single-choice', questionType: 'MCQ', choiceMode: 'single', options: '["A"]', correctAnswer: 'A', aiGrading: false }, + { stepNumber: 99, questionId: 'vr-test-b', questionText: 'ai', ruleType: 'regex', questionType: 'TEXT', choiceMode: null, options: null, correctAnswer: null, aiGrading: true }, + ]) + const rows = await db.run(SELECT.from(TutorialValidationRules).where({ tutorial_ID: t.ID, stepNumber: 99 })) + expect(rows.length).toBe(2) + await db.run(DELETE.from(TutorialValidationRules).where({ tutorial_ID: t.ID, stepNumber: 99 })) + }) +}) +``` + +- [ ] **Step 2: Run** `npm run test:hybrid -- test/hybrid/publish-validation-rules.test.js` → PASS. +- [ ] **Step 3: Commit** + +```bash +git add test/hybrid/publish-validation-rules.test.js +git commit -m "test(publish): hybrid guard for all-rules population (#WS3)" +``` + +--- + +## Final verification (whole phase) + +- [ ] Run full unit suite: `npm test` — Expected: all green. +- [ ] Run affected hybrid tests: `npm run test:hybrid -- test/hybrid/publish-categories.test.js test/hybrid/publish-contributors.test.js test/hybrid/publish-validation-rules.test.js` (requires `cf login` + `cds bind`). +- [ ] `npx cds deploy --to sqlite::memory:` clean (schema sanity). +- [ ] Confirm both new `srv/lib/*-publish.js` files are in the `.deploy/mta.yaml` srv-qa `cp` list. +- [ ] Open a PR targeting **DEV** summarizing WS1-WS3 + the one-time category backfill ops step. + +## Post-deploy validation (DEV) + +- [ ] After DEV deploy + a content publish, open the reference tutorial's OP: + - Categories facet shows rows (post-backfill + self-heal). + - Contributors table shows the git contributor list, each `login` linking to `github.com/`. + - "All Validation Rules" facet shows every rule; "AI-Graded Validation" still shows the AI subset. + +## Self-review notes (author) + +- **Spec coverage:** WS1 (Tasks 1-3), WS2 (Tasks 4-9), WS3 (Tasks 10-16) map to spec §WS1-WS3. WS4 (KG) and WS5 (media+freshness) are deferred to separate plans per the spec's phasing. +- **Assumptions flagged for the implementer to verify against live code before writing:** (a) module system of `content-publish-session.js` (CommonJS vs ESM) for Task 1; (b) exact auth-middleware + header name used by existing publish routes (Tasks 6/7/13/14); (c) exact key format of `ruleTypeByStepAndId`/`correctAnswerByStepAndId` (Task 11); (d) the FE link idiom that renders a clickable GitHub link (Task 8). These are grounded by research but must be confirmed at the touched lines. diff --git a/docs/superpowers/plans/2026-08-31-tutorials-admin-op-phase2-media-freshness.md b/docs/superpowers/plans/2026-08-31-tutorials-admin-op-phase2-media-freshness.md new file mode 100644 index 000000000..452cfd33d --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-tutorials-admin-op-phase2-media-freshness.md @@ -0,0 +1,371 @@ +# Tutorials Admin OP — Phase 2 (Media + Freshness Header) 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:** Surface per-tutorial object-store items (images + assets) with rich detail and a download/preview link, and add a Freshness report facet (last-run/model/cost/status) to the Tutorials Object Page. + +**Architecture:** `TutorialImages`/`TutorialAssets` are persisted but exposed on no service. Add `@readonly` projections on `AdminService` reachable from `Tutorials` via slug/channel associations; the `content : Composition of many Attachments` child auto-exposes with `@cap-js/attachments` media annotations (`@Core.MediaType`/`@Core.ContentDisposition`/`@UI.MediaResource`) so Fiori renders a native download link. Persist `byteSize` at image/asset ingest (already in memory as `buffer.length`). `FreshnessReport` is already exposed — add an association from `Tutorials` and a sorted LineItem facet. + +**Tech Stack:** SAP CAP (Node.js, CDS), `@cap-js/attachments` 4.0.0, Fiori Elements annotations, Vitest. + +**Spec:** `docs/superpowers/specs/2026-08-31-tutorials-admin-op-enhancements-design.md` (§WS5, decisions D1/D2) + +## Global Constraints + +- **D1 (prod S3 binding):** the Media facet works regardless of backing store; the missing `objectstore` resource in `mta.yaml` is a **separate ops task**, out of scope here. Do not add the S3 binding in this plan. +- **D2:** add `byteSize : Integer64`; **defer** width/height. +- **Read-only exposure** — projections are `@readonly`; use `@cds.redirection.target: false` (pattern at `srv/admin-service.cds:114,120`) to avoid stealing association redirects. +- **BLOB reads stay raw `db.run()`** — never mix LOB + metadata in one CDS QL query. +- **Schema changes** — `cds build --production`; register in `db/persistence.cds`; `npx cds deploy --to sqlite::memory:` before committing. +- **Associations on `slug`** — `TutorialImages`/`TutorialAssets` join `Tutorials` on `slug` (unmanaged `on`), filtered `channel = 'prod'`. +- **Tests:** `npm test` (unit), `npm run test:hybrid` (real HANA). PR targets DEV. + +## File Structure + +- Modify: `db/tutorial-images.cds`, `db/tutorial-assets.cds` — add `byteSize`. +- Modify: `db/persistence.cds` — journal (if needed for new column). +- Modify: `srv/lib/image-ingest-handler.js` (~`:77`) + the asset ingest handler — persist `byteSize`. +- Modify: `srv/admin-service.cds` — `@readonly` projections + `images`/`assets`/`freshnessReports` associations on `Tutorials`. +- Modify: `app/admin-annotations.cds` — Media facet (2 LineItems w/ download link + external sourceUrl) + Freshness Reports facet. +- Test: `test/unit/schema-media.test.js`, `test/unit/annotations-media.test.js`, `test/unit/annotations-freshness-facet.test.js`, `test/hybrid/media-exposure.test.js`. + +--- + +### Task 1: Schema — add `byteSize` to images + assets + +**Files:** +- Modify: `db/tutorial-images.cds:7-16`, `db/tutorial-assets.cds:6-16` +- Modify: `db/persistence.cds` +- Test: `test/unit/schema-media.test.js` + +**Interfaces:** +- Produces: `TutorialImages.byteSize : Integer64`, `TutorialAssets.byteSize : Integer64`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/schema-media.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('media byteSize', () => { + let m; beforeAll(async () => { m = await cds.load('*') }) + it('images + assets have byteSize', () => { + expect(m.definitions['com.sap.developers.ims.TutorialImages'].elements.byteSize).toBeTruthy() + expect(m.definitions['com.sap.developers.ims.TutorialAssets'].elements.byteSize).toBeTruthy() + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run test/unit/schema-media.test.js --project unit` → FAIL. + +- [ ] **Step 3: Add the column** (both files): + +```cds +byteSize : Integer64; // original byte length captured at ingest +``` + +Register in `db/persistence.cds` if the column needs a migration-table entry (follow existing shape). + +- [ ] **Step 4: Verify + deploy dry-run** — test PASS; `npx cds deploy --to sqlite::memory:` clean. +- [ ] **Step 5: Build + commit** + +```bash +npx cds build --production +git add db/tutorial-images.cds db/tutorial-assets.cds db/persistence.cds db/src/gen test/unit/schema-media.test.js +git commit -m "feat(db): add byteSize to TutorialImages/TutorialAssets (#WS5)" +``` + +### Task 2: Persist `byteSize` at ingest + +**Files:** +- Modify: `srv/lib/image-ingest-handler.js` (~`:60-77`, where `contentHash`/`mimeType` are computed and `imageStore.put` is called) +- Modify: the asset ingest handler (per research: `srv/lib/attachment-source-handler.js:118` region / the asset ingest analog — confirm exact put call) +- Modify: `srv/lib/image-store.cjs` + `srv/lib/attachment-store.cjs` — accept/persist `byteSize` on the parent row +- Test: `test/unit/ingest-bytesize.test.js` + +**Interfaces:** +- Consumes: `buffer` at ingest. +- Produces: parent `TutorialImages`/`TutorialAssets` row carries `byteSize = buffer.length`. + +- [ ] **Step 1: Write the failing test** (unit-level against the store put, in-memory SQLite) + +```js +// test/unit/ingest-bytesize.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +const store = require('../../srv/lib/image-store.cjs') + +describe('image store persists byteSize', () => { + beforeAll(async () => { await cds.test('serve', '--in-memory').in(process.cwd()) }) + it('stores buffer length as byteSize', async () => { + const db = await cds.connect.to('db') + const buf = Buffer.from('hello world') + await store.put('https://raw.example/img.png', { buffer: buf, mimeType: 'image/png', contentHash: 'abc', slug: 'demo', channel: 'prod', byteSize: buf.length }) + const { TutorialImages } = cds.entities('com.sap.developers.ims') + const row = await db.run(SELECT.one.from(TutorialImages).where({ sourceUrl: 'https://raw.example/img.png' })) + expect(row.byteSize).toBe(11) + }) +}) +``` + +> Confirm `image-store.cjs` `put()` signature + how it writes the parent row before finalizing the test; adapt arg shape to the real API. + +- [ ] **Step 2: Run to verify it fails** — → FAIL (byteSize null/undefined). + +- [ ] **Step 3: Implement** + +In `srv/lib/image-ingest-handler.js`, pass `byteSize: buffer.length` into the `imageStore.put(...)` options (~`:77`). In `srv/lib/image-store.cjs`, include `byteSize` in the parent-row INSERT/UPSERT. Mirror both for assets (`attachment-store.cjs` + the asset ingest handler). + +- [ ] **Step 4: Run to verify it passes** — → PASS. +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/image-ingest-handler.js srv/lib/image-store.cjs srv/lib/attachment-store.cjs test/unit/ingest-bytesize.test.js +git commit -m "feat(media): persist byteSize at image/asset ingest (#WS5)" +``` + +> If the asset ingest handler lives in a distinct file, add it to this commit and to the srv-qa cp-list if not already present. + +### Task 3: Expose read-only Media projections + associations + +**Files:** +- Modify: `srv/admin-service.cds` +- Test: `test/unit/media-exposure.test.js` + +**Interfaces:** +- Produces: `AdminService.TutorialImages`, `AdminService.TutorialAssets` (`@readonly`); `Tutorials.images`, `Tutorials.assets` associations (join on `slug`, `channel='prod'`). + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/media-exposure.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('media exposure', () => { + let m; beforeAll(async () => { m = await cds.load('*') }) + it('exposes images + assets read-only', () => { + expect(m.definitions['AdminService.TutorialImages']).toBeTruthy() + expect(m.definitions['AdminService.TutorialAssets']).toBeTruthy() + }) + it('Tutorials has images + assets associations', () => { + const t = m.definitions['AdminService.Tutorials'].elements + expect(t.images).toBeTruthy() + expect(t.assets).toBeTruthy() + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** — → FAIL. + +- [ ] **Step 3: Implement** + +In `srv/admin-service.cds`: + +```cds +@readonly @cds.redirection.target: false entity TutorialImages as projection on ims.TutorialImages; +@readonly @cds.redirection.target: false entity TutorialAssets as projection on ims.TutorialAssets; +``` + +Add to the `Tutorials` projection body: + +```cds +images : Association to many TutorialImages on images.slug = $self.slug and images.channel = 'prod'; +assets : Association to many TutorialAssets on assets.slug = $self.slug and assets.channel = 'prod'; +``` + +> The `content` Attachments composition auto-exposes when reachable from an exposed entity (@cap-js/attachments relies on this). + +- [ ] **Step 4: Run to verify it passes** — test PASS; `npx cds deploy --to sqlite::memory:` clean. +- [ ] **Step 5: Commit** + +```bash +git add srv/admin-service.cds test/unit/media-exposure.test.js +git commit -m "feat(admin): read-only Media projections + Tutorials associations (#WS5)" +``` + +### Task 4: UI — Media facet with download link + detail + +**Files:** +- Modify: `app/admin-annotations.cds` +- Test: `test/unit/annotations-media.test.js` + +**Interfaces:** +- Consumes: `AdminService.TutorialImages/TutorialAssets` + their `content` media child. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/annotations-media.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('Media facet', () => { + let m; beforeAll(async () => { m = await cds.load('*') }) + it('OP facets include Media', () => { + const ids = m.definitions['AdminService.Tutorials']['@UI.Facets'].map((f) => f.ID) + expect(ids).toContain('MediaImagesFacet') + expect(ids).toContain('MediaAssetsFacet') + }) + it('image LineItem shows sourceUrl + byteSize + mimeType', () => { + const li = m.definitions['AdminService.TutorialImages']['@UI.LineItem'] + const vals = li.map((x) => x.Value?.['='] || x.Value) + for (const c of ['sourceUrl','byteSize','mimeType','contentHash']) expect(vals).toContain(c) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** — → FAIL. + +- [ ] **Step 3: Implement** + +In `app/admin-annotations.cds`, add LineItems (render `sourceUrl` as external link via `DataFieldWithUrl`; the media download comes from the auto-exposed `content` child's ready-made annotations — a nested facet on `content` gives the download link): + +```cds +annotate AdminService.TutorialImages with @( + UI.LineItem: [ + { $Type: 'UI.DataFieldWithUrl', Value: sourceUrl, Url: sourceUrl, Label: 'Source (GitHub)' }, + { Value: mimeType, Label: 'Type' }, + { Value: byteSize, Label: 'Bytes' }, + { Value: contentHash, Label: 'Hash' }, + { Value: channel, Label: 'Channel' } + ] +); +annotate AdminService.TutorialAssets with @( + UI.LineItem: [ + { Value: filename, Label: 'File' }, + { $Type: 'UI.DataFieldWithUrl', Value: sourceUrl, Url: sourceUrl, Label: 'Source (GitHub)' }, + { Value: mimeType, Label: 'Type' }, + { Value: byteSize, Label: 'Bytes' }, + { Value: contentHash, Label: 'Hash' } + ] +); +``` + +Add facets to the winning `@UI.Facets` block: + +```cds +{ $Type: 'UI.ReferenceFacet', Label: 'Images', ID: 'MediaImagesFacet', Target: 'images/@UI.LineItem' }, +{ $Type: 'UI.ReferenceFacet', Label: 'Assets', ID: 'MediaAssetsFacet', Target: 'assets/@UI.LineItem' }, +``` + +> The @cap-js/attachments `content` child ships its own `@UI.LineItem` with a media download link. To surface a clickable download/preview, optionally add a nested facet targeting `images/content/@UI.LineItem` once verified against the running FE version. + +- [ ] **Step 4: Run to verify it passes** — → PASS; `npx cds deploy --to sqlite::memory:` clean. +- [ ] **Step 5: Commit** + +```bash +git add app/admin-annotations.cds test/unit/annotations-media.test.js +git commit -m "feat(admin-ui): Media facets (images/assets) with source link + byte size (#WS5)" +``` + +### Task 5: Freshness Reports facet (header) + +**Files:** +- Modify: `srv/admin-service.cds` — add `freshnessReports` association on `Tutorials` +- Modify: `app/admin-annotations.cds` — LineItem + facet, sorted by `runAt` desc via `@UI.PresentationVariant` +- Test: `test/unit/annotations-freshness-facet.test.js` + +**Interfaces:** +- Consumes: `AdminService.FreshnessReport` (already exposed `srv/admin-service.cds:129`). +- Produces: `Tutorials.freshnessReports` association + `FreshnessReportsFacet`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/annotations-freshness-facet.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('Freshness reports facet', () => { + let m; beforeAll(async () => { m = await cds.load('*') }) + it('Tutorials has freshnessReports association', () => { + expect(m.definitions['AdminService.Tutorials'].elements.freshnessReports).toBeTruthy() + }) + it('OP facets include FreshnessReportsFacet', () => { + const ids = m.definitions['AdminService.Tutorials']['@UI.Facets'].map((f) => f.ID) + expect(ids).toContain('FreshnessReportsFacet') + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** — → FAIL. + +- [ ] **Step 3: Implement** + +In `srv/admin-service.cds` `Tutorials` projection: + +```cds +freshnessReports : Association to many FreshnessReport on freshnessReports.tutorial = $self; +``` + +In `app/admin-annotations.cds`: + +```cds +annotate AdminService.FreshnessReport with @( + UI.LineItem: [ + { Value: runAt, Label: 'Run At' }, + { Value: status, Label: 'Status' }, + { Value: model, Label: 'Model' }, + { Value: cost, Label: 'Cost' }, + { Value: openHighCount, Label: 'Open High' }, + { Value: error, Label: 'Error' } + ], + UI.PresentationVariant: { SortOrder: [{ Property: runAt, Descending: true }], Visualizations: ['@UI.LineItem'] } +); +``` + +Add to `@UI.Facets` (place above the existing Freshness findings facet): + +```cds +{ $Type: 'UI.ReferenceFacet', Label: 'Freshness Reports', ID: 'FreshnessReportsFacet', Target: 'freshnessReports/@UI.PresentationVariant' }, +``` + +- [ ] **Step 4: Run to verify it passes** — → PASS; `npx cds deploy --to sqlite::memory:` clean. +- [ ] **Step 5: Commit** + +```bash +git add srv/admin-service.cds app/admin-annotations.cds test/unit/annotations-freshness-facet.test.js +git commit -m "feat(admin-ui): Freshness Reports facet (runAt/model/cost/status) (#WS5)" +``` + +### Task 6: Hybrid guard — media exposure resolves + +**Files:** +- Test: `test/hybrid/media-exposure.test.js` + +- [ ] **Step 1: Write the hybrid test** + +```js +// test/hybrid/media-exposure.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('media exposure (hybrid)', () => { + let admin; beforeAll(async () => { admin = await cds.connect.to('AdminService') }) + it('reads images for a tutorial without LOB errors', async () => { + const t = await admin.run(SELECT.one.from('AdminService.Tutorials').columns('ID','slug')) + expect(t).toBeTruthy() + // metadata-only read (no BLOB mix) + const imgs = await admin.run(SELECT.from('AdminService.TutorialImages').columns('ID','sourceUrl','mimeType','byteSize').where({ slug: t.slug })) + expect(Array.isArray(imgs)).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run** `npm run test:hybrid -- test/hybrid/media-exposure.test.js` → PASS. +- [ ] **Step 3: Commit** + +```bash +git add test/hybrid/media-exposure.test.js +git commit -m "test(media): hybrid guard for media exposure (#WS5)" +``` + +--- + +## Final verification + +- [ ] `npm test` all green; `npx cds deploy --to sqlite::memory:` clean. +- [ ] Backfill media if needed: `npm run backfill-images` (+ assets) against the env so rows exist. +- [ ] DEV post-deploy: reference tutorial OP shows Images + Assets tables (source link, type, bytes, hash) and a Freshness Reports table sorted newest-first. + +## Self-review notes + +- **Spec coverage:** WS5 §Media (Tasks 1-4, 6) + §Freshness header (Task 5). D1 respected (no S3 binding change). D2 respected (byteSize yes, dimensions no). +- **Verify against live code:** `image-store.cjs`/`attachment-store.cjs` `put()` signatures (Task 2); the asset ingest handler path; FE `DataFieldWithUrl` + media-child download idiom for the current FE version (Task 4). diff --git a/docs/superpowers/plans/2026-08-31-tutorials-admin-op-phase3-knowledge-graph.md b/docs/superpowers/plans/2026-08-31-tutorials-admin-op-phase3-knowledge-graph.md new file mode 100644 index 000000000..2fec35da6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-tutorials-admin-op-phase3-knowledge-graph.md @@ -0,0 +1,268 @@ +# Tutorials Admin OP — Phase 3 (Knowledge-Graph Facet) 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:** Surface per-tutorial knowledge-graph data on the Tutorials Object Page — concepts taught (with confidence), PageRank importance, community label, and co-completed neighbor tutorials — all read-only from data already populated by nightly jobs. + +**Architecture:** These entities (`TutorialConceptLinks`, `TutorialRank`, `CoCompletions`, `KgCommunity`/`KgCommunityLabel`) live on `KnowledgeGraphService`, not `AdminService`. Add `@readonly` projections on `AdminService` + associations from `Tutorials`, then a "Knowledge Graph" facet group. Purely additive and read-only; no pipeline changes. Reads fail-open (any SELECT throw leaves fields unset — FE renders nothing rather than 500), mirroring the existing KG `after('READ')` decorators. + +**Tech Stack:** SAP CAP (Node.js, CDS), Fiori Elements annotations, Vitest. KG data populated by existing nightly jobs (PageRank #916, WCC #918, Louvain #917, community labels #1126). + +**Spec:** `docs/superpowers/specs/2026-08-31-tutorials-admin-op-enhancements-design.md` (§WS4) + +## Global Constraints + +- **Read-only, additive** — `@readonly` projections; `@cds.redirection.target: false` (pattern `srv/admin-service.cds:114,120`) to avoid stealing redirects. +- **Fail-open reads** — if a KG projection can throw at read time, guard with an `after('READ')` that leaves fields unset (mirror `KnowledgeGraphService.Concepts`/`AdminService.Tutorials` isolation decorators). Pure associations/projections need no decorator. +- **No new env flags, no schema, no jobs** — data already materialized. +- **DEV-only until PROD KG data verifies** — same posture as #1126 (PROD Louvain/community data may be sparse; empty facets render as FE "No data", never a 500). +- **Namespace** — KG entities are in the `com.sap.developers.ims` model (via `db/knowledge-graph*.cds`); some carry `@cds.autoexpose:false` (e.g. `TutorialRank`) requiring explicit projection. +- **Tests:** `npm test` (unit), `npm run test:hybrid`. PR targets DEV. + +## File Structure + +- Modify: `srv/admin-service.cds` — `@readonly` projections (`TutorialConceptLinks`, `TutorialRank`, `CoCompletions`) + associations (`conceptLinks`, `rank`, `coCompletions`) on `Tutorials`; community label reachable via existing `KgCommunityMembers` (`:1249`) join. +- Modify: `srv/admin-service.js` — optional fail-open `after('READ','Tutorials')` if a computed field is added (community label flatten). +- Modify: `app/admin-annotations.cds` — Knowledge Graph facet group (concepts taught LineItem, prerequisites, PageRank + community FieldGroup, co-completed neighbors LineItem). +- Test: `test/unit/kg-exposure.test.js`, `test/unit/annotations-kg.test.js`, `test/hybrid/kg-facet.test.js`. + +--- + +### Task 1: Expose read-only KG projections + associations + +**Files:** +- Modify: `srv/admin-service.cds` +- Test: `test/unit/kg-exposure.test.js` + +**Interfaces:** +- Consumes: `ims.TutorialConceptLinks` (`db/knowledge-graph.cds:61-70`, predicate teaches/extends + confidence), `ims.TutorialRank` (`:196-200`, PageRank score, `@cds.autoexpose:false`), `ims.CoCompletions` (`:158-162`, weighted A→B). +- Produces: `AdminService.TutorialConceptLinks`, `AdminService.TutorialRank`, `AdminService.CoCompletions` (`@readonly`); `Tutorials.conceptLinks` (many), `Tutorials.rank` (one), `Tutorials.coCompletions` (many). + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/kg-exposure.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('KG exposure on AdminService', () => { + let m; beforeAll(async () => { m = await cds.load('*') }) + it('exposes concept links, rank, co-completions read-only', () => { + expect(m.definitions['AdminService.TutorialConceptLinks']).toBeTruthy() + expect(m.definitions['AdminService.TutorialRank']).toBeTruthy() + expect(m.definitions['AdminService.CoCompletions']).toBeTruthy() + }) + it('Tutorials carries conceptLinks / rank / coCompletions', () => { + const t = m.definitions['AdminService.Tutorials'].elements + expect(t.conceptLinks).toBeTruthy() + expect(t.rank).toBeTruthy() + expect(t.coCompletions).toBeTruthy() + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run test/unit/kg-exposure.test.js --project unit` → FAIL. + +> **Before implementing:** confirm exact entity + element names in `db/knowledge-graph.cds` — the join columns on `TutorialConceptLinks` (which side is the tutorial: `tutorial`/`source`), `TutorialRank` key/score field name (`score`/`pagerank`), and `CoCompletions` columns (`tutorialA`/`tutorialB`/`weight`). Adapt the `on` conditions below to the real names. + +- [ ] **Step 3: Implement** + +In `srv/admin-service.cds`: + +```cds +@readonly @cds.redirection.target: false entity TutorialConceptLinks as projection on ims.TutorialConceptLinks; +@readonly @cds.redirection.target: false entity TutorialRank as projection on ims.TutorialRank; +@readonly @cds.redirection.target: false entity CoCompletions as projection on ims.CoCompletions; +``` + +Add to the `Tutorials` projection body (adapt `on` to confirmed column names): + +```cds +conceptLinks : Association to many TutorialConceptLinks on conceptLinks.tutorial = $self; +rank : Association to one TutorialRank on rank.tutorial = $self; +coCompletions : Association to many CoCompletions on coCompletions.tutorialA = $self; +``` + +> If `Tutorials.conceptLinks` is already injected on the db entity (`db/knowledge-graph.cds:94-97`), the `*` in the projection may already carry it — in that case only expose the target projection and drop the redundant association line. Verify with the failing test after each change. + +- [ ] **Step 4: Run to verify it passes** — test PASS; `npx cds deploy --to sqlite::memory:` clean. +- [ ] **Step 5: Commit** + +```bash +git add srv/admin-service.cds test/unit/kg-exposure.test.js +git commit -m "feat(admin): read-only KG projections + Tutorials associations (#WS4)" +``` + +### Task 2: Community label reachable on the OP + +**Files:** +- Modify: `srv/admin-service.cds` (association to community membership/label) + optional `srv/admin-service.js` fail-open decorator +- Test: `test/unit/kg-community-link.test.js` + +**Interfaces:** +- Consumes: `AdminService.KgCommunityMembers` (already exposed `:1249`), `KgCommunityLabel` (`db/knowledge-graph-communities.cds:76-83`, keyed by `communityFingerprint`). +- Produces: a way to show the tutorial's community label on the OP — either an association `communityMembership` on `Tutorials` (many, filtered to this tutorial's slug) rendered as a small LineItem, or a flattened `virtual communityLabel` populated fail-open in `after('READ','Tutorials')`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/kg-community-link.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('community label reachable', () => { + let m; beforeAll(async () => { m = await cds.load('*') }) + it('Tutorials exposes community membership or virtual label', () => { + const t = m.definitions['AdminService.Tutorials'].elements + expect(t.communityMembership || t.communityLabel).toBeTruthy() + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** — → FAIL. + +- [ ] **Step 3: Implement (prefer the association — no compute, lowest risk)** + +In `srv/admin-service.cds`, add an association from `Tutorials` to the exposed community-member rows for this tutorial's slug (adapt column names to `KgCommunityMembers`): + +```cds +communityMembership : Association to many KgCommunityMembers on communityMembership.memberSlug = $self.slug; +``` + +> If `KgCommunityMembers` does not carry a `memberSlug`/label directly, add a `virtual communityLabel : String` and populate it fail-open in `srv/admin-service.js` `after('READ','Tutorials')`: +> ```js +> srv.after('READ', 'Tutorials', async (rows) => { +> try { /* look up KgCommunity by slug → KgCommunityLabel; set r.communityLabel */ } +> catch (e) { /* fail-open: leave unset */ } +> }) +> ``` +> Choose the association path if the columns allow; only fall back to the virtual+decorator if a join isn't expressible. + +- [ ] **Step 4: Run to verify it passes** — → PASS; `npx cds deploy --to sqlite::memory:` clean. +- [ ] **Step 5: Commit** + +```bash +git add srv/admin-service.cds srv/admin-service.js test/unit/kg-community-link.test.js +git commit -m "feat(admin): expose community membership/label on Tutorials (#WS4)" +``` + +### Task 3: UI — Knowledge Graph facet group + +**Files:** +- Modify: `app/admin-annotations.cds` +- Test: `test/unit/annotations-kg.test.js` + +**Interfaces:** +- Consumes: Task 1 + Task 2 associations/projections. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/annotations-kg.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('Knowledge Graph facets', () => { + let m; beforeAll(async () => { m = await cds.load('*') }) + it('OP facets include KG facets', () => { + const ids = m.definitions['AdminService.Tutorials']['@UI.Facets'].map((f) => f.ID) + expect(ids).toContain('ConceptsTaughtFacet') + expect(ids).toContain('CoCompletionsFacet') + }) + it('concept links LineItem shows predicate + confidence', () => { + const li = m.definitions['AdminService.TutorialConceptLinks']['@UI.LineItem'] + const vals = li.map((x) => x.Value?.['='] || x.Value) + expect(vals).toContain('predicate') + expect(vals).toContain('confidence') + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** — → FAIL. + +- [ ] **Step 3: Implement** + +In `app/admin-annotations.cds` (adapt element names to confirmed schema): + +```cds +annotate AdminService.TutorialConceptLinks with @( + UI.LineItem: [ + { Value: concept_ID, Label: 'Concept' }, + { Value: predicate, Label: 'Relation' }, // teaches | extends + { Value: confidence, Label: 'Confidence' } + ] +); +annotate AdminService.CoCompletions with @( + UI.LineItem: [ + { Value: tutorialB_ID, Label: 'Also Completed' }, + { Value: weight, Label: 'Weight' } + ] +); +``` + +Add a "Knowledge Graph" FieldGroup for PageRank + community, and facets to the winning `@UI.Facets` block: + +```cds +annotate AdminService.Tutorials with @( + UI.FieldGroup #KnowledgeGraph: { Data: [ + { Value: rank.score, Label: 'PageRank' }, + { Value: communityLabel, Label: 'Community' } // or a nested membership ref + ]} +); +// facets: +{ $Type: 'UI.ReferenceFacet', Label: 'Knowledge Graph', ID: 'KgFieldsFacet', Target: '@UI.FieldGroup#KnowledgeGraph' }, +{ $Type: 'UI.ReferenceFacet', Label: 'Concepts Taught', ID: 'ConceptsTaughtFacet', Target: 'conceptLinks/@UI.LineItem' }, +{ $Type: 'UI.ReferenceFacet', Label: 'Co-Completed', ID: 'CoCompletionsFacet', Target: 'coCompletions/@UI.LineItem' }, +``` + +> `rank.score` path-navigation in a FieldGroup requires the to-one `rank` association from Task 1; if FE rejects the deep path, expose a flattened `virtual pageRank : Decimal` on `Tutorials` populated fail-open in `after('READ')` instead. + +- [ ] **Step 4: Run to verify it passes** — → PASS; `npx cds deploy --to sqlite::memory:` clean. +- [ ] **Step 5: Commit** + +```bash +git add app/admin-annotations.cds test/unit/annotations-kg.test.js +git commit -m "feat(admin-ui): Knowledge Graph facet (concepts/PageRank/community/co-completions) (#WS4)" +``` + +### Task 4: Hybrid guard — KG facet reads fail-open + +**Files:** +- Test: `test/hybrid/kg-facet.test.js` + +- [ ] **Step 1: Write the hybrid test** + +```js +// test/hybrid/kg-facet.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('KG facet reads (hybrid)', () => { + let admin; beforeAll(async () => { admin = await cds.connect.to('AdminService') }) + it('reads a tutorial with KG associations expanded without error', async () => { + const t = await admin.run(SELECT.one.from('AdminService.Tutorials').columns('ID','slug')) + expect(t).toBeTruthy() + const links = await admin.run(SELECT.from('AdminService.TutorialConceptLinks').where({ tutorial_ID: t.ID })) + expect(Array.isArray(links)).toBe(true) // may be empty in DEV — that's fine + const co = await admin.run(SELECT.from('AdminService.CoCompletions').limit(1)) + expect(Array.isArray(co)).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run** `npm run test:hybrid -- test/hybrid/kg-facet.test.js` → PASS (empty arrays acceptable). +- [ ] **Step 3: Commit** + +```bash +git add test/hybrid/kg-facet.test.js +git commit -m "test(kg): hybrid guard for KG facet reads (#WS4)" +``` + +--- + +## Final verification + +- [ ] `npm test` all green; `npx cds deploy --to sqlite::memory:` clean. +- [ ] DEV post-deploy: reference tutorial OP shows a Knowledge Graph facet — concepts taught with confidence, PageRank + community label, co-completed neighbors. Empty sections render as FE "No data" (never a 500). +- [ ] PR targets DEV. Note: DEV-only until PROD Louvain/community data verifies (#1126 posture). + +## Self-review notes + +- **Spec coverage:** WS4 concepts/prerequisites (Task 1/3), PageRank (Task 1/3), community label (Task 2/3), co-completed neighbors (Task 1/3). +- **Verify against live code before writing each task:** exact `TutorialConceptLinks` join column (`tutorial` vs `source`) + `predicate`/`confidence` names; `TutorialRank` score field name; `CoCompletions` column names (`tutorialA`/`tutorialB`/`weight`); whether `Tutorials.conceptLinks` is already carried via the db-entity injection (`db/knowledge-graph.cds:94-97`); whether `KgCommunityMembers` exposes a slug/label to associate on. Each is grounded by the inventory research but must be confirmed at the touched lines. Prefer associations over computed virtuals; only add a fail-open `after('READ')` decorator where a join isn't expressible. diff --git a/docs/superpowers/specs/2026-08-31-tutorials-admin-op-enhancements-design.md b/docs/superpowers/specs/2026-08-31-tutorials-admin-op-enhancements-design.md new file mode 100644 index 000000000..799197bd5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-tutorials-admin-op-enhancements-design.md @@ -0,0 +1,136 @@ +# Tutorials Admin Object Page — Enhancements Design + +**Date:** 2026-08-31 +**Status:** Draft for review +**Scope:** `AdminService.Tutorials` Object Page (Fiori Elements) + supporting CDS model, fetch/publish pipeline, and object-store exposure. + +## Motivation + +The Tutorials admin Object Page has three facets that render **empty tables** despite being wired correctly end-to-end, plus a large body of persisted data that is never surfaced. Investigation (four research passes) found a single recurring root cause for the empties: **the admin UI is correctly wired, but the fetch/publish pipeline never populates the backing entity with data it already holds.** This design closes those gaps and adds high-value read-only facets from data we already persist. + +Example tutorial used for reference: `Tutorials(ID=1350bd47-09eb-5656-a9b9-bdc2575441cb)` on `developers.sap.com/admin-ui/`. + +## Decisions locked (from brainstorming) + +1. **Categories** — one-time backfill **plus** self-healing at publish time. +2. **Validation rules** — **persist** the full parsed `rules.vr` rule set at publish time into a new read-only entity (not on-demand GitHub parse). +3. **Contributors** — populate the **full git-derived contributor list** (up to 10: login, name, email, avatar), each linking to `github.com/`. +4. **Media** — surface object-store items with **rich detail** (explicit user ask). + +## Resolved decisions (approved 2026-08-31) + +- **D1 — Object Store prod binding gap.** `package.json` sets `attachments.kind: s3` for `[production]`, but **`mta.yaml` declares no `objectstore` resource and `tutorials-srv` binds none**. **Resolved:** ship the Media facet (works regardless of backing store); the S3 binding is a **separate ops task**, out of scope here. +- **D2 — Byte size / image dimensions.** **Resolved:** add `byteSize : Integer64` and persist `buffer.length` at ingest now; **defer** width/height (needs `probe-image-size` wiring). +- **D3 — WS3 validation UI.** **Resolved:** keep BOTH facets — relabel the existing AI-only facet "AI-Graded Validation", add the new "All Validation Rules" facet alongside it. + +## Workstreams + +Five independently shippable workstreams. Suggested phasing at the end. + +--- + +### WS1 — Categories: populate + self-heal + +**Problem:** The Categories facet reads `TutorialCategories` (AI-classifier-derived junction, cosine ≥ 0.32 / LLM tiebreak). Publish/migration create `Tutorials` rows via **direct `INSERT.into(Tutorials)`** (`srv/lib/content-publish-session.js:748-762`), bypassing the CAP `after('CREATE','Tutorials')` classifier hook (`srv/handlers/categories-after-hooks.js:44-53`). So classification never fires for published tutorials. + +**Design:** +1. **Backfill (one-time):** confirm category seed embeddings exist (admin `embedAllSeeds`), then run `scripts/backfill-categories.cjs` against the target DB. +2. **Self-heal (pipeline):** after `upsertTutorialMetadata`/`linkTutorialAuthorship` in `content-publish-session.js` (~`:198-210`), iterate the already-collected touched `tutorialIds` and call the exported `classifyAndPersist('tutorial', id).catch(warn)` **fire-and-forget** — mirroring the swallow-and-warn dispatcher in `categories-after-hooks.js:34-40`. Never throw into the publish tx. + +**No schema change.** `TutorialCategories`, its projection (`srv/admin-service.cds:174`), `@UI.LineItem`, and the facet already exist. + +**Risk:** classification is LLM-backed on the tiebreak path; publishing many slugs could fan out calls. Mitigate by keeping it fire-and-forget and reusing the existing threshold/short-circuit (most slugs resolve on cosine alone; skips write nothing). + +**Testing:** unit — publish a tutorial through the session, assert `classifyAndPersist` invoked with the new `tutorial_ID`; hybrid — publish + assert `TutorialCategories` rows exist for the slug. + +--- + +### WS2 — Contributors: map git list + GitHub links + +**Problem:** `TutorialContributors` is fully wired (entity `db/schema.cds:451-457`, projection `srv/admin-service.cds:234`, `@UI.LineItem` + facet `app/admin-annotations.cds:715-725,767`) but never populated per-tutorial: IMS migration inserts rows with `tutorial_ID = NULL` (`scripts/migrate-from-hana.js:360`); publish only SELECTs/UPDATEs (`linkTutorialAuthorship`), never INSERTs. The rich git contributor list (`{login, name, email, avatarUrl}`) exists in scope at fetch time (`scripts/fetch-tutorials.ts` ~`:936-948`) and flows to Hugo frontmatter (`render-frontmatter.ts:119`) but never into CAP. + +**Design:** +1. **Schema:** extend `TutorialContributors` with `login : String(255)`, `avatarUrl : String(1024)`, `profileUrl : String(1024)` (derive `profileUrl = https://github.com/`). Keep existing `name`, `email`, `role`, `user`. Add via `cds build --production` migration (never hand-author `.hdbmigrationtable`); register the entity in `db/persistence.cds` if a fresh table/columns need journaling. +2. **Sidecar (fetch):** beside the validate-answer write (`fetch-tutorials.ts:1043`), write `.contributors.json` = `{ slug, contributors: contributors.slice(0,10).map(...) }` using the in-scope array (same `writeFileSync(join(CACHE_DIR, ...))` idiom). +3. **Publish aux step:** mirror `publishValidateAnswerSpecs` — a new client collector in `publish-content.ts` globs `*.contributors.json` and POSTs `{ slug, contributors }` per slug (non-fatal aux step). +4. **Server upsert:** new `srv/lib/contributors-publish.js` following the **REPLACE-per-slug inside `cds.tx()`** pattern from `validate-answer-spec-publish.js`: resolve tutorial by lowercased slug → `DELETE.from(TutorialContributors).where({tutorial_ID})` → `INSERT` the new set. Apply the `entity_not_in_model` fail-fast guard and skip on `channel === 'qa'` if the QA namespace lacks the entity. +5. **UI:** add `login` (as GitHub link via `@Communication.Contact` or a `@UI.LineItem` cell with `@Common.SemanticObject`/URL), `avatarUrl`, and keep name/email/role. Render the avatar + external link to `github.com/`, mirroring the live page (`tutorial-author.html`). + +**srv-qa cp-list:** add `srv/lib/contributors-publish.js` to the `cp` command in `.deploy/mta.yaml` (module `tutorials-srv-qa`, ~`:175`). + +**Testing:** unit — server upsert replaces rows for a slug, leaves other slugs untouched; parser — sidecar shape; hybrid — publish + assert contributor rows with `login`/`avatarUrl` link to the tutorial. + +--- + +### WS3 — Validation: surface ALL rules.vr rules + +**Problem:** The Validation Questions facet is bound to `ValidateAnswerSpecs`, which by design persists **only AI-graded** questions (`collectAiGradedSpecs` drops non-AI at `scripts/parsers/rules.ts:320`; `correctAnswer` is server-only for AI grading). `rules.vr` *is* fully fetched and parsed for every tutorial, but the plain client-graded MCQ/exact-match rules are never persisted. + +**Design:** +1. **New entity** `TutorialValidationRules` (read-only in admin), key `(tutorial, stepNumber, questionId)`, fields: `questionText`, `ruleType`, `questionType` (MCQ/TEXT), `choiceMode` (single/multiple), `options` (LargeString JSON or child rows), `correctAnswer`, `aiGrading : Boolean`. Register in `db/persistence.cds`. + - *Note:* MCQ correct answers for client-graded rules are already public (they ship in Hugo frontmatter `TutorialStep.validation`), so showing them in the admin is not a new leak. AI-graded reference answers remain in `ValidateAnswerSpecs` as today. +2. **Sidecar (fetch):** write `.validation-rules.json` = `{ slug, rules: [...] }` from the **full** `validationMap` (available at `fetch-tutorials.ts:975`), not the AI-filtered subset. Carry `type`, `options`, `choiceMode`, `correctAnswers` in addition to the AI-spec fields. +3. **Publish + server upsert:** same REPLACE-per-slug pattern (new `srv/lib/validation-rules-publish.js`; add to srv-qa cp-list). +4. **UI:** new facet "All Validation Rules" (read-only `@UI.LineItem`) with columns: step, question, type, ruleType, grading (AI/client), correct answer. **Keep both facets** (D3): relabel the existing AI facet "AI-Graded Validation"; the new all-rules facet sits alongside it. + +**Testing:** parser — sidecar carries all rule types incl. non-AI; server upsert REPLACE semantics; hybrid — publish a tutorial with mixed AI + MCQ rules, assert both appear in `TutorialValidationRules` and only AI ones in `ValidateAnswerSpecs`. + +--- + +### WS4 — Knowledge-Graph facet + +**Problem:** The richest hidden dataset. `TutorialConceptLinks` (teaches/extends + confidence, `db/knowledge-graph.cds:61-70`), `TutorialRank` (PageRank, `:196-200`), `KgCommunity`/`KgCommunityLabel`, and `CoCompletions` ("users who did A also did B") are **not exposed on AdminService** (they live on `KnowledgeGraphService`). + +**Design (read-only, additive):** +1. Expose on `AdminService` as `@readonly` projections + associations from `Tutorials`: + - `conceptLinks` (teaches/prerequisites with confidence) — association already injected on the db entity (`db/knowledge-graph.cds:94-97`). + - `TutorialRank` — surface score as a virtual/flattened field or a small FieldGroup. + - Community label — via `KgCommunity`/`KgCommunityLabel` (KgCommunityMembers already exposed `srv/admin-service.cds:1249`). + - Co-completed neighbors — new read-only projection on `CoCompletions`. + - Use `@cds.redirection.target: false` on pick-list-style projections to avoid stealing redirects (pattern at `srv/admin-service.cds:114,120`). +2. **UI:** a "Knowledge Graph" facet: LineItem of concepts taught (concept, predicate, confidence), a prerequisites list, PageRank + community label in a FieldGroup, and a co-completed-tutorials LineItem. + +**No pipeline change** — these are populated by existing nightly jobs. **Fail-open reads** (mirror existing KG decorators that leave fields unset on SELECT throw). + +**Testing:** unit — projections resolve and are `@readonly`; hybrid — a tutorial with concept links shows them. + +--- + +### WS5 — Media facet + Freshness report header + +**Problem:** `TutorialImages` (`db/tutorial-images.cds`) and `TutorialAssets` (`db/tutorial-assets.cds`) are **not exposed on any service**. `FreshnessReport` is exposed (`srv/admin-service.cds:129`) but the OP only facets findings, not the report header. + +**Design — Media:** +1. **Expose** `@readonly` projections of `TutorialImages` and `TutorialAssets` on `AdminService`, reachable from `Tutorials` (association on matching `slug`, `channel='prod'`). The `content : Composition of many Attachments` child auto-exposes with `@cap-js/attachments` annotations (`@Core.MediaType`, `@Core.ContentDisposition`, `@UI.MediaResource`) → native Fiori **download/preview link**. +2. **Surfaceable detail (persisted today):** `sourceUrl` (GitHub raw URL, render as external link), `contentHash` (sha-256), `mimeType`, `channel`, child `filename` + `createdAt` + `status`. +3. **Byte size (D2):** add `byteSize : Integer64` to the schema and persist `buffer.length` at ingest (`srv/lib/image-ingest-handler.js` ~`:77`); width/height deferred. +4. **UI:** "Media" facet with two tables (Images, Assets): thumbnail/download link, filename, mime, size (if added), source URL link, content hash, channel. + +**Design — Freshness header:** +- Add a "Freshness Report" FieldGroup on the OP sourcing the latest `FreshnessReport` for the tutorial: `runAt`, `model`, `cost`, `status` (QUEUED/RUNNING/DONE/FAILED), `openHighCount`, `error`. Sits above the existing findings facet. + +**Testing:** unit — projections `@readonly`, media child annotations present; hybrid — a tutorial with ingested images shows rows + a working download link; freshness header reflects the latest report row. + +--- + +## Cross-cutting constraints + +- **srv-qa cp-list:** every new `srv/lib/*.js` (contributors-publish, validation-rules-publish) and any new dep MUST be appended to the `cp` command in `.deploy/mta.yaml` (`tutorials-srv-qa`, ~`:175`), or QA boot fails with `MODULE_NOT_FOUND`. +- **Schema/migration:** WS2 + WS3 (+ optional WS5 byteSize) add columns/entities → `cds build --production`, register in `db/persistence.cds` (`@cds.persistence.journal`), never hand-author `.hdbmigrationtable`. Run `npx cds deploy --to sqlite::memory:` before committing db changes. +- **BLOB reads:** any raw content read stays `db.run()` (never mix LOB + metadata in one CDS QL query). +- **QA namespace:** new publish routes need the `entity_not_in_model` guard + skip on `channel === 'qa'` if the QA CDS model lacks the entity. +- **Feature-flag registry:** if any new `*Settings` Boolean column is introduced, register it (guard requirement); none currently planned. +- **PR flow:** feature branch → PR targeting **DEV** (never main). + +## Suggested phasing + +- **Phase 1 (fixes your 3 empties):** WS1 (categories), WS2 (contributors), WS3 (validation). Highest user-visible payoff; shares the sidecar/publish pattern. +- **Phase 2 (new visibility):** WS5 (media + freshness header) — you flagged media as the most exciting. +- **Phase 3:** WS4 (KG facet) — additive, read-only, lowest risk but largest surface. + +Each workstream is independently shippable; phases can overlap. TDD throughout (unit + hybrid guards per workstream). + +## Out of scope (this design) + +- The S3/object-store prod binding investigation (D1) — separate ops task. +- Learner submission pass-rate analytics, learning-path membership facet, audit-field surfacing — noted in the inventory as future candidates, not included here to keep scope focused. diff --git a/scripts/fetch-tutorials.ts b/scripts/fetch-tutorials.ts index 57edb7a1b..ba3fb4402 100644 --- a/scripts/fetch-tutorials.ts +++ b/scripts/fetch-tutorials.ts @@ -15,7 +15,7 @@ import { navEntriesBySlug, SIDECAR_VERSION, } from './lib/content-cache.js' -import { parseRulesVrEnriched, collectAiGradedSpecs } from './parsers/rules.js' +import { parseRulesVrEnriched, collectAiGradedSpecs, collectAllRules } from './parsers/rules.js' import { expandAiAuthoredQuestions, populateAiAuthoredSiblingMaps, type ExpandStats } from './lib/expand-ai-authored.js' import { loadAiQuizCache, saveAiQuizCache } from './lib/ai-quiz-cache.js' import { callQuizModel } from '../srv/lib/ai-quiz-llm.js' @@ -31,6 +31,7 @@ import type { CatalogTutorialMeta, CategoryMeta, Mission, MissionHierarchy, Hier import { QUESTION_TYPE_TEXT } from './parsers/types.js' import { advocateLoginToSlug, type AuthorTutorialRow } from './parsers/author-index.js' import { writeAuthorPages } from './lib/author-pages-writer.js' +import { buildContributorsSidecar } from './parsers/contributors-sidecar.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -1042,6 +1043,16 @@ async function main() { writeFileSync(validateSidecarPath, JSON.stringify({ slug: t.slug.toLowerCase(), specs: aiGradedSpecs }, null, 2)) } + // Write full validation-rules sidecar (all rule types, all steps). + // Consumed by the publish pipeline to upsert TutorialValidationRules rows. + const allRules = collectAllRules(validationMap, ruleTypeByStepAndId, correctAnswerByStepAndId) + if (allRules.length > 0) { + writeFileSync( + join(CACHE_DIR, `${t.slug.toLowerCase()}.validation-rules.json`), + JSON.stringify({ slug: t.slug.toLowerCase(), rules: allRules }, null, 2), + ) + } + // [#208] Anti-leak strip: AI-authored text questions had correctAnswer // restored on validationMap so populateAiAuthoredSiblingMaps (above) // could mirror it into correctAnswerByStepAndId, which collectAiGradedSpecs @@ -1076,6 +1087,14 @@ async function main() { } } + const contribSidecar = buildContributorsSidecar(t.slug, contributors) + if (contribSidecar) { + writeFileSync( + join(CACHE_DIR, `${t.slug.toLowerCase()}.contributors.json`), + JSON.stringify(contribSidecar, null, 2), + ) + } + const rawNavSlugs = [...new Set([frontmatter.primary_tag ?? '', ...(frontmatter.tags ?? [])])] .map(s => s.replace(/\\/g, '')).filter(s => s.length > 0) diff --git a/scripts/parsers/contributors-sidecar.ts b/scripts/parsers/contributors-sidecar.ts new file mode 100644 index 000000000..f9eed7f84 --- /dev/null +++ b/scripts/parsers/contributors-sidecar.ts @@ -0,0 +1,15 @@ +export interface SidecarContributor { login: string; name: string; email: string; avatarUrl: string } +export interface ContributorsSidecar { slug: string; contributors: SidecarContributor[] } + +export function buildContributorsSidecar( + slug: string, + contributors: Array>, +): ContributorsSidecar | null { + if (!contributors || contributors.length === 0) return null + return { + slug: slug.toLowerCase(), + contributors: contributors.slice(0, 10).map((c) => ({ + login: c.login ?? '', name: c.name ?? '', email: c.email ?? '', avatarUrl: c.avatarUrl ?? '', + })), + } +} diff --git a/scripts/parsers/rules.ts b/scripts/parsers/rules.ts index b2791a961..0018db098 100644 --- a/scripts/parsers/rules.ts +++ b/scripts/parsers/rules.ts @@ -294,6 +294,56 @@ function parseChoiceOptions(content: string): { options: string[]; correctAnswer export { parseCodeCheckBlocks } from './codecheck.js' +export interface AllRuleRow { + stepNumber: number + questionId: string + questionText: string + ruleType: string + questionType: 'MCQ' | 'TEXT' + choiceMode: string | null + options: string | null + correctAnswer: string | null + aiGrading: boolean +} + +/** + * Collect ALL validation rules across all steps for a tutorial — both + * AI-graded and client-graded (MCQ single/multiple, exact-match text). + * + * Parallel to collectAiGradedSpecs but returns every emitted question so the + * publish pipeline can persist a full all-rules sidecar and the admin UI can + * surface a complete rule facet (issue WS3). + * + * Anti-leak contract preserved: correctAnswer is null for AI-graded rows — + * the reference answer stays server-side in ValidateAnswerSpecs only. + */ +export function collectAllRules( + map: Map, + ruleTypeByStepAndId: Map, + correctAnswerByStepAndId: Map, +): AllRuleRow[] { + const rows: AllRuleRow[] = [] + for (const [stepNumber, questions] of map.entries()) { + for (const q of questions) { + const key = `${stepNumber}:${q.id}` + const isMcq = q.type === QUESTION_TYPE_MCQ + const ai = Boolean((q as any).aiGrading) + rows.push({ + stepNumber, + questionId: q.id, + questionText: q.question, + ruleType: ruleTypeByStepAndId.get(key) ?? '', + questionType: isMcq ? 'MCQ' : 'TEXT', + choiceMode: (q as any).choiceMode ?? null, + options: isMcq && (q as any).options ? JSON.stringify((q as any).options) : null, + correctAnswer: ai ? null : (correctAnswerByStepAndId.get(key) ?? (q as any).correctAnswer ?? null), + aiGrading: ai, + }) + } + } + return rows +} + export interface AiGradedSpec { stepNumber: number questionId: string diff --git a/scripts/publish-content.ts b/scripts/publish-content.ts index 9761957db..9e4e520f0 100644 --- a/scripts/publish-content.ts +++ b/scripts/publish-content.ts @@ -10,6 +10,8 @@ import { withRetry, formatErrorChain } from './lib/publish-retry.js'; import { chunk, runConcurrent } from './lib/publish-batcher.js'; import { collectCodeCheckSpecs, publishCodeCheckSpecs } from './lib/publish-codecheck.js'; import { publishValidateAnswerSpecs } from './lib/publish-validate-answer.js'; +import { publishContributors } from './publish/publish-contributors.js'; +import { publishValidationRules } from './publish/publish-validation-rules.js'; import { computeOrphans, enforceCap, formatStepSummary } from './lib/purge-orphans.js'; import { discoverPageFiles, discoverAuthorPages, discoverAdvocatePages } from '../srv/lib/page-key-map.js'; @@ -1351,6 +1353,34 @@ async function main() { } } + // --- contributors sidecar publish (non-fatal auxiliary step, issue #WS2) --- + // QA channel skips: srv-qa has no ContributorCache entity, POST would 404/500. + if (channel === 'qa') { + log('[publish-contributors] skipped (channel=qa)'); + } else { + try { + const cacheDir = join(process.cwd(), '.tutorial-cache'); + const r = await publishContributors({ cacheDir, baseUrl: opts.baseUrl, apiKey: opts.apiKey }); + log(`[publish-contributors] published ${r.published}/${r.total}`); + } catch (err) { + console.error('[publish-content] contributors publish failed (non-fatal):', formatErrorChain(err)); + } + } + + // --- validation-rules sidecar publish (non-fatal auxiliary step, issue #WS3) --- + // QA channel skips: srv-qa has no ValidationRules entity, POST would 404/500. + if (channel === 'qa') { + log('[publish-validation-rules] skipped (channel=qa)'); + } else { + try { + const cacheDir = join(process.cwd(), '.tutorial-cache'); + const r = await publishValidationRules({ cacheDir, baseUrl: opts.baseUrl, apiKey: opts.apiKey }); + log(`[publish-validation-rules] published ${r.published}/${r.total}`); + } catch (err) { + console.error('[publish-content] validation-rules publish failed (non-fatal):', formatErrorChain(err)); + } + } + // --- auto-verify --- log('Verifying server state matches local...'); let postRemote: Record; diff --git a/scripts/publish/publish-contributors.ts b/scripts/publish/publish-contributors.ts new file mode 100644 index 000000000..337893c8d --- /dev/null +++ b/scripts/publish/publish-contributors.ts @@ -0,0 +1,70 @@ +// scripts/publish/publish-contributors.ts +// Non-fatal auxiliary publish step for issue #WS2. +// Walks `cacheDir` for `*.contributors.json` sidecar files emitted by +// scripts/fetch-tutorials.ts and POSTs each one to +// /content/publish-contributors (Task 6 REPLACE handler). +// +// Auth: CONTENT_API_KEY via contentAuthMiddleware (Authorization: Bearer). +// Failures are NON-FATAL — captured and returned to the caller. + +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +const SUFFIX = '.contributors.json' + +/** + * Walk cacheDir for *.contributors.json sidecar files, + * POST each one to /content/publish-contributors. + * + * @param opts.cacheDir Tutorial cache dir (e.g. .tutorial-cache) + * @param opts.baseUrl CAP base URL + * @param opts.apiKey CONTENT_API_KEY value + * @returns { published, total } + */ +export async function publishContributors(opts: { + cacheDir: string + baseUrl: string + apiKey: string +}): Promise<{ published: number; total: number }> { + const { cacheDir, baseUrl, apiKey } = opts + let files: string[] + try { + files = readdirSync(cacheDir).filter((f) => f.endsWith(SUFFIX)) + } catch { + return { published: 0, total: 0 } + } + + let published = 0 + for (const f of files) { + const filePath = join(cacheDir, f) + let raw: string + try { + raw = readFileSync(filePath, 'utf8') + } catch { + continue + } + + let res: Response + try { + res = await fetch(`${baseUrl}/content/publish-contributors`, { + method: 'POST', + headers: { + 'authorization': `Bearer ${apiKey}`, + 'content-type': 'application/json', + }, + body: raw, + }) + } catch (err) { + console.warn(`[publish-contributors] network error for ${f}:`, (err as Error).message) + continue + } + + if (res.ok) { + published += 1 + } else { + console.warn(`[publish-contributors] ${f} -> ${res.status}`) + } + } + + return { published, total: files.length } +} diff --git a/scripts/publish/publish-validation-rules.ts b/scripts/publish/publish-validation-rules.ts new file mode 100644 index 000000000..5c1efcc39 --- /dev/null +++ b/scripts/publish/publish-validation-rules.ts @@ -0,0 +1,70 @@ +// scripts/publish/publish-validation-rules.ts +// Non-fatal auxiliary publish step for issue #WS3. +// Walks `cacheDir` for `*.validation-rules.json` sidecar files emitted by +// scripts/fetch-tutorials.ts and POSTs each one to +// /content/publish-validation-rules (Task 13 REPLACE handler). +// +// Auth: CONTENT_API_KEY via contentAuthMiddleware (Authorization: Bearer). +// Failures are NON-FATAL — captured and returned to the caller. + +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +const SUFFIX = '.validation-rules.json' + +/** + * Walk cacheDir for *.validation-rules.json sidecar files, + * POST each one to /content/publish-validation-rules. + * + * @param opts.cacheDir Tutorial cache dir (e.g. .tutorial-cache) + * @param opts.baseUrl CAP base URL + * @param opts.apiKey CONTENT_API_KEY value + * @returns { published, total } + */ +export async function publishValidationRules(opts: { + cacheDir: string + baseUrl: string + apiKey: string +}): Promise<{ published: number; total: number }> { + const { cacheDir, baseUrl, apiKey } = opts + let files: string[] + try { + files = readdirSync(cacheDir).filter((f) => f.endsWith(SUFFIX)) + } catch { + return { published: 0, total: 0 } + } + + let published = 0 + for (const f of files) { + const filePath = join(cacheDir, f) + let raw: string + try { + raw = readFileSync(filePath, 'utf8') + } catch { + continue + } + + let res: Response + try { + res = await fetch(`${baseUrl}/content/publish-validation-rules`, { + method: 'POST', + headers: { + 'authorization': `Bearer ${apiKey}`, + 'content-type': 'application/json', + }, + body: raw, + }) + } catch (err) { + console.warn(`[publish-validation-rules] network error for ${f}:`, (err as Error).message) + continue + } + + if (res.ok) { + published += 1 + } else { + console.warn(`[publish-validation-rules] ${f} -> ${res.status}`) + } + } + + return { published, total: files.length } +} diff --git a/srv/admin-service.cds b/srv/admin-service.cds index 3a872801d..a12e3995b 100644 --- a/srv/admin-service.cds +++ b/srv/admin-service.cds @@ -64,6 +64,7 @@ service AdminService { // Specs use the existing tutorial Association FK; submissions and stats // join by slug because they predate the FK pattern. validationSpecs : Association to many ValidateAnswerSpecs on validationSpecs.tutorial = $self, + validationRules : Association to many TutorialValidationRules on validationRules.tutorial = $self, validationSubmissions : Association to many ValidateAnswerSubmissions on validationSubmissions.tutorialSlug = slug, codeCheckSpecs : Association to many CodeCheckSpecs on codeCheckSpecs.tutorial = $self, codeCheckSubmissions : Association to many CodeCheckSubmissions on codeCheckSubmissions.tutorialSlug = slug, @@ -618,6 +619,7 @@ service AdminService { @readonly entity CodeCheckSubmissions as projection on ims.CodeCheckSubmissions; @readonly entity AuthorAiRequests as projection on ims.AuthorAiRequests; @readonly entity TutorialCompletionStats as projection on ims.TutorialCompletionStats; + @readonly entity TutorialValidationRules as projection on ims.TutorialValidationRules; // Issue #622 — read-only recipient list for the "Last Chance Emails" // admin section. Powers the dropdown for sendLastChanceEmail and the diff --git a/srv/lib/content-publish-session.js b/srv/lib/content-publish-session.js index ce3b12bfd..7a5f487b7 100644 --- a/srv/lib/content-publish-session.js +++ b/srv/lib/content-publish-session.js @@ -11,6 +11,19 @@ import { tutorialsTableInfo } from './_tutorials-table.js'; import { logPipelineStart, logPipelineEnd, logPipelineItem } from './pipeline-log.js'; import { resolveTutorialAuthor } from './resolve-tutorial-author.js'; import { isDeltaWrite, isDeltaSkipCarryForward } from './content-delta-flags.js'; +import { classifyAndPersist } from './category-classifier.js'; + +// Exported for unit testing; classifies touched tutorials without ever throwing +// into the publish tx (publish bypasses the CAP after('CREATE') classifier hook). +export async function classifyTouchedTutorials(tutorialIds) { + await Promise.all( + (tutorialIds || []).map((id) => + Promise.resolve() + .then(() => classifyAndPersist('tutorial', id)) + .catch((e) => console.warn('[publish] category classify skipped', id, e?.message)), + ), + ); +} const LOG = cds.log('content-publish'); const LOCK_NAME = 'content-publish'; @@ -208,6 +221,8 @@ export function createSessionHelpers({ namespace }) { } catch (err) { LOG.warn('linkTutorialAuthorship failed; skipping', err); } + // Fire-and-forget: keep categories populated for publish-created tutorials. + classifyTouchedTutorials(tutorialIds); } if (Object.keys(bodyTexts).length > 0) { await upsertBodyTexts(namespace, bodyTexts); diff --git a/srv/lib/contributors-publish.js b/srv/lib/contributors-publish.js new file mode 100644 index 000000000..60a576505 --- /dev/null +++ b/srv/lib/contributors-publish.js @@ -0,0 +1,80 @@ +// srv/lib/contributors-publish.js +// Handler for POST /content/publish-contributors. +// +// Bearer auth is delegated to `contentAuthMiddleware` from +// srv/lib/content-store.js — same shape as /content/validate-answer-specs +// and /content/code-check-specs: 503 when CONTENT_API_KEY is unset, +// 401 on missing Bearer header, 403 on wrong key, with timing-safe comparison. +// This handler runs ONLY after auth has succeeded. +// +// Accepts `{ slug, contributors: [{login,name,email,avatarUrl}] }` and +// REPLACE-per-slug: DELETEs all TutorialContributors rows for that tutorial +// then INSERTs the new set atomically inside cds.tx(). Publishing slug A +// never touches slug B's rows. + +import cds from '@sap/cds' + +const NS = 'com.sap.developers.ims' + +export function githubProfileUrl(login) { + return login ? `https://github.com/${login}` : null +} + +/** + * Core, unit-testable: REPLACE all contributor rows for one slug. + * @param {object} db – connected CDS db service (cds.connect.to('db')) + * @param {string} slug – tutorial slug (case-insensitive) + * @param {Array} contributors – array of {login,name,email,avatarUrl} + */ +export async function replaceContributorsForSlug(db, slug, contributors) { + const { Tutorials, TutorialContributors } = cds.entities(NS) + const lcSlug = String(slug || '').toLowerCase() + const tut = await db.run(SELECT.one.from(Tutorials).columns('ID').where({ slug: lcSlug })) + if (!tut) return { ok: false, reason: 'tutorial_not_found', slug: lcSlug } + + const entries = (contributors || []) + .filter((c) => c && (c.login || c.name || c.email)) + .slice(0, 10) + .map((c) => ({ + ID: cds.utils.uuid(), + tutorial_ID: tut.ID, + login: (c.login || '').slice(0, 255), + name: (c.name || '').slice(0, 255), + email: (c.email || '').slice(0, 255), + avatarUrl: (c.avatarUrl || '').slice(0, 1024), + profileUrl: githubProfileUrl(c.login), + })) + + await cds.tx(async (tx) => { + await tx.run(DELETE.from(TutorialContributors).where({ tutorial_ID: tut.ID })) + if (entries.length) await tx.run(INSERT.into(TutorialContributors).entries(entries)) + }) + return { ok: true, slug: lcSlug, count: entries.length } +} + +/** + * Express handler mirroring the validate-answer-specs route shape. + * Mounted in server.js with contentAuthMiddleware + express.json(). + */ +export async function publishContributors(req, res) { + try { + const { slug, contributors } = req.body || {} + if (!slug || !Array.isArray(contributors)) { + return res.status(400).json({ error: 'bad_request', detail: 'expected { slug, contributors[] }' }) + } + + // entity_not_in_model guard (QA namespace safety — mirrors validate-answer-spec-publish.js). + let entities + try { entities = cds.entities(NS) } catch { entities = null } + if (!entities || !entities.TutorialContributors) { + return res.status(409).json({ error: 'entity_not_in_model' }) + } + + const db = await cds.connect.to('db') + const result = await replaceContributorsForSlug(db, slug, contributors) + if (!result.ok) return res.status(404).json(result) + return res.json(result) + } catch (e) { + return res.status(500).json({ error: 'internal', detail: e?.message }) + } +} diff --git a/srv/lib/validation-rules-publish.js b/srv/lib/validation-rules-publish.js new file mode 100644 index 000000000..ba7f934d1 --- /dev/null +++ b/srv/lib/validation-rules-publish.js @@ -0,0 +1,75 @@ +// srv/lib/validation-rules-publish.js +// Handler for POST /content/publish-validation-rules. +// +// Bearer auth is delegated to `contentAuthMiddleware` from +// srv/lib/content-store.js — same shape as /content/publish-contributors: +// 503 when CONTENT_API_KEY is unset, 401 on missing Bearer header, 403 on +// wrong key, with timing-safe comparison. +// +// Accepts `{ slug, rules: AllRuleRow[] }` and REPLACE-per-slug: +// DELETEs all TutorialValidationRules rows for that tutorial then INSERTs +// the new set atomically inside cds.tx(). Publishing slug A never touches +// slug B's rows. + +import cds from '@sap/cds' + +const NS = 'com.sap.developers.ims' + +/** + * Core, unit-testable: REPLACE all validation-rule rows for one slug. + * @param {object} db – connected CDS db service (cds.connect.to('db')) + * @param {string} slug – tutorial slug (case-insensitive) + * @param {Array} rules – array of rule objects from the sidecar JSON + */ +export async function replaceValidationRulesForSlug(db, slug, rules) { + const { Tutorials, TutorialValidationRules } = cds.entities(NS) + const lcSlug = String(slug || '').toLowerCase() + const tut = await db.run(SELECT.one.from(Tutorials).columns('ID').where({ slug: lcSlug })) + if (!tut) return { ok: false, reason: 'tutorial_not_found', slug: lcSlug } + + const entries = (rules || []).map((r) => ({ + tutorial_ID: tut.ID, + stepNumber: r.stepNumber, + questionId: String(r.questionId).slice(0, 100), + questionText: (r.questionText || '').slice(0, 2000), + ruleType: (r.ruleType || '').slice(0, 50), + questionType: (r.questionType || '').slice(0, 20), + choiceMode: r.choiceMode || null, + options: r.options || null, + correctAnswer: r.correctAnswer ?? null, + aiGrading: Boolean(r.aiGrading), + })) + + await cds.tx(async (tx) => { + await tx.run(DELETE.from(TutorialValidationRules).where({ tutorial_ID: tut.ID })) + if (entries.length) await tx.run(INSERT.into(TutorialValidationRules).entries(entries)) + }) + return { ok: true, slug: lcSlug, count: entries.length } +} + +/** + * Express handler mirroring the publish-contributors route shape. + * Mounted in server.js with contentAuthMiddleware + express.json(). + */ +export async function publishValidationRules(req, res) { + try { + const { slug, rules } = req.body || {} + if (!slug || !Array.isArray(rules)) { + return res.status(400).json({ error: 'bad_request', detail: 'expected { slug, rules[] }' }) + } + + // entity_not_in_model guard (QA namespace safety — mirrors contributors-publish.js). + let entities + try { entities = cds.entities(NS) } catch { entities = null } + if (!entities || !entities.TutorialValidationRules) { + return res.status(409).json({ error: 'entity_not_in_model' }) + } + + const db = await cds.connect.to('db') + const result = await replaceValidationRulesForSlug(db, slug, rules) + if (!result.ok) return res.status(404).json(result) + return res.json(result) + } catch (e) { + return res.status(500).json({ error: 'internal', detail: e?.message }) + } +} diff --git a/srv/server.js b/srv/server.js index 0367a3558..65a36abce 100644 --- a/srv/server.js +++ b/srv/server.js @@ -65,6 +65,8 @@ import { defaultCallModel } from './lib/code-check-llm.js'; import { defaultLoadStepText } from './lib/code-check-step-loader.js'; import { codeCheckSpecPublishHandler } from './lib/code-check-spec-publish.js'; import { publishValidateAnswerSpecs } from './lib/validate-answer-spec-publish.js'; +import { publishContributors } from './lib/contributors-publish.js'; +import { publishValidationRules } from './lib/validation-rules-publish.js'; import { resolveSearchSettings } from './lib/runtime-config/search-settings.js'; import { resolveTenantSettings } from './lib/runtime-config/tenant-settings.js'; import { makeValidateAnswerHandler } from './lib/validate-answer-handler.js'; @@ -606,6 +608,22 @@ cds.on('bootstrap', (app) => { publishValidateAnswerSpecs ); + // REPLACE-per-slug handler for TutorialContributors (WS2 #task-6). + // Same auth guard and body parser as the validate-answer-specs sibling. + app.post('/content/publish-contributors', + express.json({ limit: '1mb' }), + contentAuthMiddleware, + publishContributors + ); + + // REPLACE-per-slug handler for TutorialValidationRules (WS3 #task-13). + // Same auth guard and body parser shape as publish-contributors. + app.post('/content/publish-validation-rules', + express.json({ limit: '4mb' }), + contentAuthMiddleware, + publishValidationRules + ); + // Tutorial feedback bridge. Express handler (rather than letting CAP expose // the action over OData) so we can derive the originating client IP from // X-Forwarded-For and inject it into req.data via AsyncLocalStorage + a diff --git a/test/hybrid/publish-categories.test.js b/test/hybrid/publish-categories.test.js new file mode 100644 index 000000000..aca607160 --- /dev/null +++ b/test/hybrid/publish-categories.test.js @@ -0,0 +1,26 @@ +// test/hybrid/publish-categories.test.js +// Hybrid guard: verifies published tutorials populate categories from the classifier. +// Requires: real HANA + cds bind --exec, and seed embeddings in Categories table. + +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' + +describe('publish populates categories (hybrid)', () => { + let db + beforeAll(async () => { db = await cds.connect.to('db') }) + + it('a freshly published tutorial has >= 0 category rows and no orphan write errors', async () => { + // Precondition: category seed embeddings must exist in this env. + const { Categories } = cds.entities('com.sap.developers.ims') + const seeds = await db.run(SELECT.from(Categories)) + expect(seeds.length).toBeGreaterThan(0) // else run embedAllSeeds first + + // Assert the classifier is reachable and idempotent for a known slug. + // (Use a slug known to exist in the bound DB.) + const { Tutorials, TutorialCategories } = cds.entities('com.sap.developers.ims') + const t = await db.run(SELECT.one.from(Tutorials).columns('ID', 'slug')) + expect(t).toBeTruthy() + const rows = await db.run(SELECT.from(TutorialCategories).where({ tutorial_ID: t.ID })) + expect(Array.isArray(rows)).toBe(true) + }) +}) diff --git a/test/hybrid/publish-contributors.test.js b/test/hybrid/publish-contributors.test.js new file mode 100644 index 000000000..2368ae948 --- /dev/null +++ b/test/hybrid/publish-contributors.test.js @@ -0,0 +1,23 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +import { replaceContributorsForSlug } from '../../srv/lib/contributors-publish.js' + +cds.test('serve', '--project', '.', '--profile', 'hybrid') + +describe('contributors publish (hybrid)', () => { + let db + beforeAll(async () => { db = await cds.connect.to('db') }) + it('links contributor rows to an existing tutorial by slug', async () => { + const { Tutorials, TutorialContributors } = cds.entities('com.sap.developers.ims') + const t = await db.run(SELECT.one.from(Tutorials).columns('ID', 'slug')) + expect(t).toBeTruthy() + await replaceContributorsForSlug(db, t.slug, [ + { login: 'octocat', name: 'Octo', email: 'o@x.com', avatarUrl: 'https://github.com/octocat.png' }, + ]) + const rows = await db.run(SELECT.from(TutorialContributors).where({ tutorial_ID: t.ID, login: 'octocat' })) + expect(rows.length).toBe(1) + expect(rows[0].profileUrl).toBe('https://github.com/octocat') + // cleanup + await db.run(DELETE.from(TutorialContributors).where({ tutorial_ID: t.ID, login: 'octocat' })) + }) +}) diff --git a/test/hybrid/publish-validation-rules.test.js b/test/hybrid/publish-validation-rules.test.js new file mode 100644 index 000000000..22e5e32ed --- /dev/null +++ b/test/hybrid/publish-validation-rules.test.js @@ -0,0 +1,31 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +import { replaceValidationRulesForSlug } from '../../srv/lib/validation-rules-publish.js' + +cds.test('serve', '--project', '.', '--profile', 'hybrid') + +describe('validation rules publish (hybrid)', () => { + let db + beforeAll(async () => { db = await cds.connect.to('db') }) + it('lands mixed AI + client rules for an existing slug', async () => { + const { Tutorials, TutorialValidationRules, ValidateAnswerSpecs } = cds.entities('com.sap.developers.ims') + const t = await db.run(SELECT.one.from(Tutorials).columns('ID', 'slug')) + expect(t).toBeTruthy() + try { + // Assert ValidateAnswerSpecs isolation: query count before + const beforeCount = await db.run(SELECT.from(ValidateAnswerSpecs).where({ tutorial_ID: t.ID, stepNumber: 99 }).columns(c => c`count(*) as cnt`)).then(r => r[0]?.cnt ?? 0) + await replaceValidationRulesForSlug(db, t.slug, [ + { stepNumber: 99, questionId: 'vr-test-a', questionText: 'client', ruleType: 'single-choice', questionType: 'MCQ', choiceMode: 'single', options: '["A"]', correctAnswer: 'A', aiGrading: false }, + { stepNumber: 99, questionId: 'vr-test-b', questionText: 'ai', ruleType: 'regex', questionType: 'TEXT', choiceMode: null, options: null, correctAnswer: null, aiGrading: true }, + ]) + const rows = await db.run(SELECT.from(TutorialValidationRules).where({ tutorial_ID: t.ID, stepNumber: 99 })) + expect(rows.length).toBe(2) + // Assert ValidateAnswerSpecs isolation: count after should match before + const afterCount = await db.run(SELECT.from(ValidateAnswerSpecs).where({ tutorial_ID: t.ID, stepNumber: 99 }).columns(c => c`count(*) as cnt`)).then(r => r[0]?.cnt ?? 0) + expect(afterCount).toBe(beforeCount) + } finally { + // cleanup + await db.run(DELETE.from(TutorialValidationRules).where({ tutorial_ID: t.ID, stepNumber: 99 })) + } + }) +}) diff --git a/test/unit/annotations-contributors.test.js b/test/unit/annotations-contributors.test.js new file mode 100644 index 000000000..7e896f710 --- /dev/null +++ b/test/unit/annotations-contributors.test.js @@ -0,0 +1,13 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' + +describe('Contributors LineItem', () => { + let m + beforeAll(async () => { m = await cds.load('*') }) + it('LineItem includes login column', () => { + const e = m.definitions['AdminService.TutorialContributors'] + const li = e['@UI.LineItem'] + const values = li.map((x) => x.Value?.['='] || x.Value) + expect(values).toContain('login') + }) +}) diff --git a/test/unit/annotations-validation-rules.test.js b/test/unit/annotations-validation-rules.test.js new file mode 100644 index 000000000..ed20f1b8f --- /dev/null +++ b/test/unit/annotations-validation-rules.test.js @@ -0,0 +1,17 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('validation rules exposure + facet', () => { + let m + beforeAll(async () => { m = await cds.load('*') }) + it('AdminService exposes TutorialValidationRules read-only', () => { + expect(m.definitions['AdminService.TutorialValidationRules']).toBeTruthy() + }) + it('Tutorials has validationRules association', () => { + expect(m.definitions['AdminService.Tutorials'].elements.validationRules).toBeTruthy() + }) + it('OP facets include an All Validation Rules facet', () => { + const facets = m.definitions['AdminService.Tutorials']['@UI.Facets'] + const ids = facets.map((f) => f.ID) + expect(ids).toContain('AllValidationRulesFacet') + }) +}) diff --git a/test/unit/collect-all-rules.test.js b/test/unit/collect-all-rules.test.js new file mode 100644 index 000000000..389a95395 --- /dev/null +++ b/test/unit/collect-all-rules.test.js @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { collectAllRules } from '../../scripts/parsers/rules.js' + +describe('collectAllRules', () => { + it('includes non-AI MCQ rules with options + correctAnswer', () => { + const map = new Map([[1, [ + { id: 'validate-1', question: 'Pick one', type: 'multiple-choice', options: ['A','B'], choiceMode: 'single', correctAnswer: 'A' }, + { id: 'validate-1b', question: 'AI graded', type: 'text', aiGrading: true }, + ]]]) + // Single colon key format: `${stepNumber}:${q.id}` (confirmed in rules.ts line 244) + const ruleTypeMap = new Map([['1:validate-1', 'single-choice'], ['1:validate-1b', 'regex']]) + const answerMap = new Map([['1:validate-1', 'A']]) + const rows = collectAllRules(map, ruleTypeMap, answerMap) + expect(rows).toHaveLength(2) + const mcq = rows.find((r) => r.questionId === 'validate-1') + expect(mcq.aiGrading).toBe(false) + expect(mcq.questionType).toBe('MCQ') + expect(JSON.parse(mcq.options)).toEqual(['A','B']) + expect(mcq.correctAnswer).toBe('A') + const ai = rows.find((r) => r.questionId === 'validate-1b') + expect(ai.aiGrading).toBe(true) + expect(ai.correctAnswer).toBeNull() + }) +}) diff --git a/test/unit/contributors-publish.test.js b/test/unit/contributors-publish.test.js new file mode 100644 index 000000000..c20558d0a --- /dev/null +++ b/test/unit/contributors-publish.test.js @@ -0,0 +1,35 @@ +// test/unit/contributors-publish.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import path from 'node:path' +import cds from '@sap/cds' +import { replaceContributorsForSlug } from '../../srv/lib/contributors-publish.js' + +describe('replaceContributorsForSlug', () => { + let db + beforeAll(async () => { + await cds.deploy(path.join(process.cwd(), 'db', 'schema.cds')).to('sqlite::memory:') + db = cds.db + }) + + it('replaces rows for the slug and derives profileUrl', async () => { + const { Tutorials, TutorialContributors } = cds.entities('com.sap.developers.ims') + const ID = cds.utils.uuid() + await db.run(INSERT.into(Tutorials).entries({ ID, slug: 'demo', title: 'Demo' })) + + await replaceContributorsForSlug(db, 'DEMO', [ + { login: 'octocat', name: 'Octo Cat', email: 'o@x.com', avatarUrl: 'https://github.com/octocat.png' }, + ]) + let rows = await db.run(SELECT.from(TutorialContributors).where({ tutorial_ID: ID })) + expect(rows).toHaveLength(1) + expect(rows[0].login).toBe('octocat') + expect(rows[0].profileUrl).toBe('https://github.com/octocat') + + // Second publish REPLACES, does not append. + await replaceContributorsForSlug(db, 'demo', [ + { login: 'hubot', name: 'Hubot', email: 'h@x.com', avatarUrl: 'https://github.com/hubot.png' }, + ]) + rows = await db.run(SELECT.from(TutorialContributors).where({ tutorial_ID: ID })) + expect(rows).toHaveLength(1) + expect(rows[0].login).toBe('hubot') + }) +}) diff --git a/test/unit/contributors-sidecar.test.js b/test/unit/contributors-sidecar.test.js new file mode 100644 index 000000000..76339a8b9 --- /dev/null +++ b/test/unit/contributors-sidecar.test.js @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest' +import { buildContributorsSidecar } from '../../scripts/parsers/contributors-sidecar' + +describe('buildContributorsSidecar', () => { + it('lowercases slug and caps at 10', () => { + const contribs = Array.from({ length: 12 }, (_, i) => ({ + login: `u${i}`, name: `N${i}`, email: `${i}@x.com`, avatarUrl: `a${i}`, + })) + const out = buildContributorsSidecar('My-Slug', contribs) + expect(out.slug).toBe('my-slug') + expect(out.contributors).toHaveLength(10) + expect(out.contributors[0]).toEqual({ login: 'u0', name: 'N0', email: '0@x.com', avatarUrl: 'a0' }) + }) + it('returns null when no contributors', () => { + expect(buildContributorsSidecar('s', [])).toBeNull() + }) +}) diff --git a/test/unit/publish-category-selfheal.test.js b/test/unit/publish-category-selfheal.test.js new file mode 100644 index 000000000..cc66dbea7 --- /dev/null +++ b/test/unit/publish-category-selfheal.test.js @@ -0,0 +1,27 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// vi.mock is hoisted to the top of the file by Vitest, so the factory runs +// before any const declarations. vi.hoisted() lets us declare classifySpy in +// a way that is also hoisted, keeping the reference valid inside the factory. +const classifySpy = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) +vi.mock('../../srv/lib/category-classifier.js', () => ({ + classifyAndPersist: classifySpy, +})) + +import { classifyTouchedTutorials } from '../../srv/lib/content-publish-session.js' + +describe('publish category self-heal', () => { + beforeEach(() => classifySpy.mockClear()) + + it('classifies every touched tutorial id, fire-and-forget', async () => { + await classifyTouchedTutorials(['id-a', 'id-b']) + expect(classifySpy).toHaveBeenCalledTimes(2) + expect(classifySpy).toHaveBeenCalledWith('tutorial', 'id-a') + expect(classifySpy).toHaveBeenCalledWith('tutorial', 'id-b') + }) + + it('never rejects even if a classification throws', async () => { + classifySpy.mockRejectedValueOnce(new Error('boom')) + await expect(classifyTouchedTutorials(['id-a'])).resolves.toBeUndefined() + }) +}) diff --git a/test/unit/publish-contributors-client.test.js b/test/unit/publish-contributors-client.test.js new file mode 100644 index 000000000..19ca2bf5d --- /dev/null +++ b/test/unit/publish-contributors-client.test.js @@ -0,0 +1,25 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { publishContributors } from '../../scripts/publish/publish-contributors' + +describe('publishContributors client', () => { + let dir + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'contrib-')) + writeFileSync(join(dir, 'demo.contributors.json'), + JSON.stringify({ slug: 'demo', contributors: [{ login: 'octocat', name: 'O', email: 'o@x', avatarUrl: 'a' }] })) + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true, count: 1 }) }) + }) + afterEach(() => { vi.restoreAllMocks() }) + + it('POSTs each sidecar to the endpoint', async () => { + const res = await publishContributors({ cacheDir: dir, baseUrl: 'http://x', apiKey: 'k' }) + expect(global.fetch).toHaveBeenCalledTimes(1) + const [url, opts] = global.fetch.mock.calls[0] + expect(url).toBe('http://x/content/publish-contributors') + expect(JSON.parse(opts.body).slug).toBe('demo') + expect(res.published).toBe(1) + }) +}) diff --git a/test/unit/publish-validation-rules-client.test.js b/test/unit/publish-validation-rules-client.test.js new file mode 100644 index 000000000..2e110e33c --- /dev/null +++ b/test/unit/publish-validation-rules-client.test.js @@ -0,0 +1,21 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { join } from 'node:path'; import { tmpdir } from 'node:os' +import { publishValidationRules } from '../../scripts/publish/publish-validation-rules' + +describe('publishValidationRules client', () => { + let dir + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'vr-')) + writeFileSync(join(dir, 'demo.validation-rules.json'), + JSON.stringify({ slug: 'demo', rules: [{ stepNumber: 1, questionId: 'validate-1' }] })) + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) }) + }) + afterEach(() => vi.restoreAllMocks()) + it('POSTs each sidecar', async () => { + const res = await publishValidationRules({ cacheDir: dir, baseUrl: 'http://x', apiKey: 'k' }) + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(global.fetch.mock.calls[0][0]).toBe('http://x/content/publish-validation-rules') + expect(res.published).toBe(1) + }) +}) diff --git a/test/unit/schema-contributors.test.js b/test/unit/schema-contributors.test.js new file mode 100644 index 000000000..be6d96357 --- /dev/null +++ b/test/unit/schema-contributors.test.js @@ -0,0 +1,14 @@ +// test/unit/schema-contributors.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' + +describe('TutorialContributors schema', () => { + let m + beforeAll(async () => { m = await cds.load('*') }) + it('has GitHub link columns', () => { + const e = m.definitions['com.sap.developers.ims.TutorialContributors'] + expect(e.elements.login).toBeTruthy() + expect(e.elements.avatarUrl).toBeTruthy() + expect(e.elements.profileUrl).toBeTruthy() + }) +}) diff --git a/test/unit/schema-validation-rules.test.js b/test/unit/schema-validation-rules.test.js new file mode 100644 index 000000000..43f892399 --- /dev/null +++ b/test/unit/schema-validation-rules.test.js @@ -0,0 +1,13 @@ +// test/unit/schema-validation-rules.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import cds from '@sap/cds' +describe('TutorialValidationRules schema', () => { + let m + beforeAll(async () => { m = await cds.load('*') }) + it('exists with expected elements', () => { + const e = m.definitions['com.sap.developers.ims.TutorialValidationRules'] + expect(e).toBeTruthy() + for (const k of ['stepNumber','questionId','questionText','ruleType','questionType','choiceMode','options','correctAnswer','aiGrading']) + expect(e.elements[k]).toBeTruthy() + }) +}) diff --git a/test/unit/validation-rules-publish.test.js b/test/unit/validation-rules-publish.test.js new file mode 100644 index 000000000..d3016ae54 --- /dev/null +++ b/test/unit/validation-rules-publish.test.js @@ -0,0 +1,28 @@ +// test/unit/validation-rules-publish.test.js +import { describe, it, expect, beforeAll } from 'vitest' +import path from 'node:path' +import cds from '@sap/cds' +import { replaceValidationRulesForSlug } from '../../srv/lib/validation-rules-publish.js' + +describe('replaceValidationRulesForSlug', () => { + let db + beforeAll(async () => { + await cds.deploy(path.join(process.cwd(), 'db', 'schema.cds')).to('sqlite::memory:') + db = cds.db + }) + + it('replaces all-rule rows for a slug', async () => { + const { Tutorials, TutorialValidationRules } = cds.entities('com.sap.developers.ims') + const ID = cds.utils.uuid() + await db.run(INSERT.into(Tutorials).entries({ ID, slug: 'vr-demo', title: 'VR' })) + await replaceValidationRulesForSlug(db, 'VR-DEMO', [ + { stepNumber: 1, questionId: 'validate-1', questionText: 'Q', ruleType: 'single-choice', questionType: 'MCQ', choiceMode: 'single', options: '["A","B"]', correctAnswer: 'A', aiGrading: false }, + ]) + let rows = await db.run(SELECT.from(TutorialValidationRules).where({ tutorial_ID: ID })) + expect(rows).toHaveLength(1) + expect(rows[0].aiGrading).toBe(false) + await replaceValidationRulesForSlug(db, 'vr-demo', []) + rows = await db.run(SELECT.from(TutorialValidationRules).where({ tutorial_ID: ID })) + expect(rows).toHaveLength(0) + }) +})