diff --git a/srv/developer-service.js b/srv/developer-service.js index e15299b93..9b0d97ac8 100644 --- a/srv/developer-service.js +++ b/srv/developer-service.js @@ -14,7 +14,7 @@ import { maybeAutoSendCompletion } from './lib/ngds-autosend.js'; import { stampSubmissionId } from './lib/task-record-submission-id.js'; import { rollUpParentsForCompletion } from './lib/completion-rollup.js'; import { renderMarkdown } from './lib/markdown.js'; -import { buildTaskMap, resolveTask, effectiveLegacyId } from './lib/path-item-task-resolver.js'; +import { buildTaskMap, effectiveLegacyId, expandGroupTutorials, buildPathItems, groupChildLegacyIds } from './lib/path-item-task-resolver.js'; import { handleRequestAccountMerge, handleConfirmAccountMerge } from './lib/account-merge-request.js'; // Per-user rate limit for resetTutorialProgress — same window as the @@ -84,6 +84,7 @@ export default class DeveloperService extends cds.ApplicationService { Users: dbUsers, Events: dbEvents, Missions: dbMissions, CompletionPaths: dbPaths, CompletionPathItems: dbPathItems, Checkpoints: dbCheckpoints, Petoberfests: dbPetoberfests, + GroupPathItems: dbGroupPathItems, UserLearningPreferences } = cds.entities('com.sap.developers.ims'); // Auto-assign legacyId on TaskRecord creation @@ -573,10 +574,21 @@ export default class DeveloperService extends cds.ApplicationService { entities: { Tutorials: dbTutorials, Checkpoints: dbCheckpoints, Petoberfests: dbPetoberfests }, }); + // Expand GROUP path items into their child tutorials (GroupPathItems), + // so a path whose item is a GROUP renders its tutorials instead of a + // single empty "0 tutorials" card. + const groupTutorials = await expandGroupTutorials({ + items: allItems, + entities: { GroupPathItems: dbGroupPathItems, Tutorials: dbTutorials }, + }); + // Effective legacyId per item (own taskLegacyId, else resolved tutorial's) // drives both TaskRecord lookups and the output imsId. const effLegacyByItem = new Map(allItems.map(i => [i, effectiveLegacyId(taskMap, i)])); - const recordLegacyIds = [...new Set([...effLegacyByItem.values()].filter(v => v != null))]; + const recordLegacyIds = [...new Set([ + ...[...effLegacyByItem.values()].filter(v => v != null), + ...groupChildLegacyIds(groupTutorials), // child tutorials need their own records too + ])]; let userRecords = []; // #2730: use the tiered resolver so null-sapId identity-link users are found. @@ -605,33 +617,15 @@ export default class DeveloperService extends cds.ApplicationService { eventId: event?.legacyId ?? 0, eventType: event?.eventType ?? 'OTHER', type: 'COMPLEX', - paths: paths.map(p => { - const items = allItems - .filter(i => i.path_ID === p.ID) - .map(i => { - const task = resolveTask(taskMap, i); - const effLegacyId = effLegacyByItem.get(i); - const record = recordMap.get(`${i.taskType}:${effLegacyId}`); - return { - imsId: effLegacyId, - title: task?.title || record?.titleSnapshot || '', - type: i.taskType, - status: record?.status || '', - progress: record?.progress || 0, - experience: task?.experienceTag || '', - timeToComplete: task?.averageTimeToComplete || 0, - url: task?.slug ? `/tutorials/${task.slug}.html` : '', - description: task?.description || '', - recordId: record?.legacyId || 0 - }; - }); - return { - id: p.legacyId, - title: p.name, - description: mission.description || '', - items - }; - }) + paths: paths.map(p => ({ + id: p.legacyId, + title: p.name, + description: p.description || mission.description || '', + items: buildPathItems({ + pathItems: allItems.filter(i => i.path_ID === p.ID), + taskMap, effLegacyByItem, groupTutorials, recordMap, + }), + })), }; return result; @@ -687,8 +681,19 @@ export default class DeveloperService extends cds.ApplicationService { entities: { Tutorials: dbTutorials, Checkpoints: dbCheckpoints, Petoberfests: dbPetoberfests }, }); + // Expand GROUP path items into their child tutorials (GroupPathItems), + // so a path whose item is a GROUP renders its tutorials instead of a + // single empty "0 tutorials" card. + const groupTutorials = await expandGroupTutorials({ + items: allItems, + entities: { GroupPathItems: dbGroupPathItems, Tutorials: dbTutorials }, + }); + const effLegacyByItem = new Map(allItems.map(i => [i, effectiveLegacyId(taskMap, i)])); - const recordLegacyIds = [...new Set([...effLegacyByItem.values()].filter(v => v != null))]; + const recordLegacyIds = [...new Set([ + ...[...effLegacyByItem.values()].filter(v => v != null), + ...groupChildLegacyIds(groupTutorials), // child tutorials need their own records too + ])]; let userRecords = []; // #2730: use the tiered resolver so null-sapId identity-link users are found. @@ -710,33 +715,15 @@ export default class DeveloperService extends cds.ApplicationService { hasLogo: Boolean(event.hasLogo), authenticated: Boolean(dbUser), // #2314/#2730: false only for anonymous callers → client skips the logged-in-only realtime/confetti path. Truthy when user resolves via any tier (sapId, identity link, or pin). type: 'COMPLEX', - paths: paths.map(p => { - const items = allItems - .filter(i => i.path_ID === p.ID) - .map(i => { - const task = resolveTask(taskMap, i); - const effLegacyId = effLegacyByItem.get(i); - const record = recordMap.get(`${i.taskType}:${effLegacyId}`); - return { - imsId: effLegacyId, - title: task?.title || record?.titleSnapshot || '', - type: i.taskType, - status: record?.status || '', - progress: record?.progress || 0, - experience: task?.experienceTag || '', - timeToComplete: task?.averageTimeToComplete || 0, - url: task?.slug ? `/tutorials/${task.slug}.html` : '', - description: task?.description || '', - recordId: record?.legacyId || 0 - }; - }); - return { - id: p.legacyId, - title: p.name, - description: mission.description || '', - items - }; - }) + paths: paths.map(p => ({ + id: p.legacyId, + title: p.name, + description: p.description || mission.description || '', + items: buildPathItems({ + pathItems: allItems.filter(i => i.path_ID === p.ID), + taskMap, effLegacyByItem, groupTutorials, recordMap, + }), + })), }; }); diff --git a/srv/lib/path-item-task-resolver.js b/srv/lib/path-item-task-resolver.js index 1a9182ec7..b114ca2d9 100644 --- a/srv/lib/path-item-task-resolver.js +++ b/srv/lib/path-item-task-resolver.js @@ -114,3 +114,129 @@ export function effectiveLegacyId(taskMap, item) { const task = resolveTask(taskMap, item); return task?.legacyId ?? null; } + +/** + * Expand the GROUP CompletionPathItems among `items` into their child tutorials. + * + * A path item with `taskType === 'GROUP'` carries a `group_ID` (NOT a + * tutorial) — the group's ordered tutorial list lives in `GroupPathItems` + * (group_ID → tutorial_ID, ordered by itemOrder). The event / app-space + * progress handlers emit one output item per path row, so a GROUP row would + * render as a single untyped card with zero tutorials. This helper reads the + * child tutorials once (batched across all groups in the path set) so each + * GROUP can be flattened into its TUTORIAL children — mirroring + * srv/lib/build-catalog.js's nested-group handling. + * + * @param {object} opts + * @param {Array} opts.items CompletionPathItems rows (must include taskType, group_ID). + * @param {object} opts.entities { GroupPathItems, Tutorials } CDS entities. + * @returns {Promise} Map keyed by group_ID → ordered array of child + * tutorial rows ({ ID, legacyId, slug, title, description, experienceTag, + * averageTimeToComplete }). Empty map when there are no GROUP items. + */ +export async function expandGroupTutorials({ items, entities }) { + const { GroupPathItems, Tutorials } = entities; + + const groupIds = [...new Set( + items + .filter(i => i.taskType === 'GROUP' && i.group_ID) + .map(i => i.group_ID) + )]; + const byGroupId = new Map(); + if (groupIds.length === 0) return byGroupId; + + // All child rows for every GROUP in one query, ordered so the per-group + // slices stay in itemOrder. + const gpItems = await SELECT.from(GroupPathItems) + .where({ group_ID: { in: groupIds } }) + .orderBy('itemOrder'); + + const tutorialUuids = [...new Set(gpItems.map(gpi => gpi.tutorial_ID).filter(Boolean))]; + const tutorialByUuid = new Map(); + if (tutorialUuids.length > 0) { + for (const t of await SELECT.from(Tutorials).where({ ID: { in: tutorialUuids } })) { + tutorialByUuid.set(t.ID, t); + } + } + + for (const gpi of gpItems) { + const t = tutorialByUuid.get(gpi.tutorial_ID); + if (!t) continue; // orphaned child; skip rather than emit a blank card + if (!byGroupId.has(gpi.group_ID)) byGroupId.set(gpi.group_ID, []); + byGroupId.get(gpi.group_ID).push(t); + } + return byGroupId; +} + +/** + * Shape a resolved tutorial row into the progress-handler output item. + * Shared so direct path items and expanded GROUP children render identically. + * @param {object} task tutorial/checkpoint row (may be undefined) + * @param {number|null} effLegacyId effective legacyId (imsId + record key) + * @param {object} record user TaskRecord for this task (may be undefined) + * @param {string} type output item type ('TUTORIAL' | 'CHECKPOINT' | ...) + */ +function shapeItem(task, effLegacyId, record, type) { + return { + imsId: effLegacyId, + title: task?.title || record?.titleSnapshot || '', + type, + status: record?.status || '', + progress: record?.progress || 0, + experience: task?.experienceTag || '', + timeToComplete: task?.averageTimeToComplete || 0, + url: task?.slug ? `/tutorials/${task.slug}.html` : '', + description: task?.description || '', + recordId: record?.legacyId || 0, + }; +} + +/** + * Build the ordered output items for a single path, expanding any GROUP item + * into its child tutorials. Direct items keep their existing shape/behavior; + * GROUP items are replaced (in place, preserving itemOrder) by one TUTORIAL + * output item per child tutorial. + * + * Fixes the "0 tutorials" App-Space bug: a GROUP path item used to emit a + * single `type:'GROUP'` card the frontend filtered out. + * + * @param {object} opts + * @param {Array} opts.pathItems CompletionPathItems for this path, itemOrder-sorted. + * @param {Map} opts.taskMap from buildTaskMap (direct items). + * @param {Map} opts.effLegacyByItem item → effective legacyId (direct items). + * @param {Map} opts.groupTutorials group_ID → child tutorial rows (expandGroupTutorials). + * @param {Map} opts.recordMap `${taskType}:${legacyId}` → user TaskRecord. + * @returns {Array} output items + */ +export function buildPathItems({ pathItems, taskMap, effLegacyByItem, groupTutorials, recordMap }) { + const out = []; + for (const i of pathItems) { + if (i.taskType === 'GROUP') { + const children = (i.group_ID && groupTutorials.get(i.group_ID)) || []; + for (const t of children) { + const record = t.legacyId != null ? recordMap.get(`TUTORIAL:${t.legacyId}`) : undefined; + out.push(shapeItem(t, t.legacyId ?? null, record, 'TUTORIAL')); + } + continue; + } + const task = resolveTask(taskMap, i); + const effLegacyId = effLegacyByItem.get(i); + const record = recordMap.get(`${i.taskType}:${effLegacyId}`); + out.push(shapeItem(task, effLegacyId, record, i.taskType)); + } + return out; +} + +/** + * The TUTORIAL legacyIds reachable through GROUP items (child tutorials), so + * the handler can batch their user TaskRecord lookups alongside direct items. + * @param {Map} groupTutorials group_ID → child tutorial rows + * @returns {number[]} de-duped legacyIds + */ +export function groupChildLegacyIds(groupTutorials) { + const ids = new Set(); + for (const children of groupTutorials.values()) { + for (const t of children) if (t.legacyId != null) ids.add(t.legacyId); + } + return [...ids]; +} diff --git a/test/app-space-fk-resolution.test.js b/test/app-space-fk-resolution.test.js index 85e091500..9afbf9634 100644 --- a/test/app-space-fk-resolution.test.js +++ b/test/app-space-fk-resolution.test.js @@ -103,3 +103,92 @@ describe('App-Space / event progress: FK-first tutorial resolution (#1866)', () expect(fkItem.imsId).toBe(TUT_FK_ONLY.legacyId); }); }); + +// App-Space rendering fixes: (1) a path whose item is a GROUP must expand into +// the group's child tutorials (previously rendered "0 tutorials"); (2) each +// path must show its OWN CompletionPaths.description, not the mission's. +const G_EVENT_ID = 10009209; +const G_MISSION_ID = '11111111-9209-aaaa-0000-000000000001'; +const G_PATH_GROUP = '22222222-9209-aaaa-0000-000000000001'; // path containing a GROUP item +const G_PATH_FLAT = '22222222-9209-aaaa-0000-000000000002'; // path with a direct tutorial +const G_GROUP_ID = '33333333-9209-aaaa-0000-000000000001'; +const G_TUT_A = { ID: 'cccccccc-9209-aaaa-0000-000000000001', legacyId: 10009281, slug: 'group-child-a', title: 'Group Child A' }; +const G_TUT_B = { ID: 'cccccccc-9209-aaaa-0000-000000000002', legacyId: 10009282, slug: 'group-child-b', title: 'Group Child B' }; +const G_TUT_FLAT = { ID: 'cccccccc-9209-aaaa-0000-000000000003', legacyId: 10009283, slug: 'flat-tut', title: 'Flat Tutorial' }; + +describe('App-Space: GROUP expansion + per-path description', () => { + beforeAll(async () => { + const { Events, Missions, Groups, CompletionPaths, CompletionPathItems, GroupPathItems, Tutorials } = + cds.entities('com.sap.developers.ims'); + + await INSERT.into(Tutorials).entries([G_TUT_A, G_TUT_B, G_TUT_FLAT]); + await INSERT.into(Missions).entries({ + ID: G_MISSION_ID, legacyId: 10009201, title: 'Garage Mission', slug: 'garage-mission', + description: 'Ready to get your hands dirty? Swing by the Developer Garage.', published: true, event_ID: null, + }); + await INSERT.into(Events).entries({ + ID: 'eeeeeeee-9209-aaaa-0000-000000000001', legacyId: G_EVENT_ID, + name: 'Garage Event', eventType: 'OTHER', mission_ID: G_MISSION_ID, + }); + await INSERT.into(Groups).entries({ + ID: G_GROUP_ID, legacyId: 10009250, title: 'Joule Studio Group', slug: 'joule-studio-group', published: true, + }); + await INSERT.into(CompletionPaths).entries([ + // Path 1: its own description + a GROUP item (no direct tutorials). + { ID: G_PATH_GROUP, legacyId: 10009260, name: 'Joule Studio (web)', mission_ID: G_MISSION_ID, + description: 'Joule Studio (web)' }, + // Path 2: its own description + a direct tutorial. + { ID: G_PATH_FLAT, legacyId: 10009261, name: 'Joule Work', mission_ID: G_MISSION_ID, + description: 'Joule Work path description' }, + ]); + await INSERT.into(CompletionPathItems).entries([ + // GROUP item on path 1 — carries group_ID, no tutorial. + { ID: 'dddddddd-9209-aaaa-0000-000000000001', path_ID: G_PATH_GROUP, itemOrder: 1, + taskType: 'GROUP', taskLegacyId: null, group_ID: G_GROUP_ID }, + // Direct tutorial on path 2. + { ID: 'dddddddd-9209-aaaa-0000-000000000002', path_ID: G_PATH_FLAT, itemOrder: 1, + taskType: 'TUTORIAL', taskLegacyId: null, tutorial_ID: G_TUT_FLAT.ID }, + ]); + await INSERT.into(GroupPathItems).entries([ + { ID: 'ffffffff-9209-aaaa-0000-000000000001', group_ID: G_GROUP_ID, tutorial_ID: G_TUT_A.ID, itemOrder: 1 }, + { ID: 'ffffffff-9209-aaaa-0000-000000000002', group_ID: G_GROUP_ID, tutorial_ID: G_TUT_B.ID, itemOrder: 2 }, + ]); + }); + + afterAll(async () => { + const { Events, Missions, Groups, CompletionPaths, CompletionPathItems, GroupPathItems, Tutorials } = + cds.entities('com.sap.developers.ims'); + await DELETE.from(GroupPathItems).where({ group_ID: G_GROUP_ID }); + await DELETE.from(CompletionPathItems).where({ path_ID: { in: [G_PATH_GROUP, G_PATH_FLAT] } }); + await DELETE.from(CompletionPaths).where({ ID: { in: [G_PATH_GROUP, G_PATH_FLAT] } }); + await DELETE.from(Groups).where({ ID: G_GROUP_ID }); + await DELETE.from(Events).where({ legacyId: G_EVENT_ID }); + await DELETE.from(Missions).where({ ID: G_MISSION_ID }); + await DELETE.from(Tutorials).where({ ID: { in: [G_TUT_A.ID, G_TUT_B.ID, G_TUT_FLAT.ID] } }); + }); + + it('expands a GROUP path item into its child tutorials (not "0 tutorials")', async () => { + const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${G_EVENT_ID})`); + + const groupPath = data.paths.find(p => p.id === 10009260); + expect(groupPath).toBeDefined(); + const tutorials = groupPath.items.filter(i => i.type === 'TUTORIAL'); + expect(tutorials.map(t => t.title)).toEqual([G_TUT_A.title, G_TUT_B.title]); // ordered by itemOrder + for (const t of tutorials) { + expect(t.type).toBe('TUTORIAL'); // not 'GROUP' — the frontend counts only TUTORIAL + expect(t.url).not.toBe(''); + } + // No leftover untyped GROUP card. + expect(groupPath.items.some(i => i.type === 'GROUP')).toBe(false); + }); + + it('each path shows its OWN description, not the mission description', async () => { + const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${G_EVENT_ID})`); + const byId = Object.fromEntries(data.paths.map(p => [p.id, p])); + expect(byId[10009260].description).toBe('Joule Studio (web)'); + expect(byId[10009261].description).toBe('Joule Work path description'); + // The two descriptions differ (the bug made them identical = mission text). + expect(byId[10009260].description).not.toBe(byId[10009261].description); + expect(byId[10009260].description).not.toContain('hands dirty'); + }); +});