Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 44 additions & 57 deletions srv/developer-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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,
}),
})),
};
});

Expand Down
126 changes: 126 additions & 0 deletions srv/lib/path-item-task-resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>} 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];
}
89 changes: 89 additions & 0 deletions test/app-space-fk-resolution.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading