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
2 changes: 1 addition & 1 deletion .deploy/mta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ID: tutorials-ims
# Bump this on each release you deploy — it's the version shown by `cf mtas`
# and in the mtar filename (tutorials-ims_<version>.mtar). Deploy is manual:
# `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_<version>.mtar -e ../deploy/<env>.mtaext -f`.
version: 1.41.0
version: 1.41.1

# Top-level parameters (overridable per-env via deploy/<env>.mtaext).
parameters:
Expand Down
18 changes: 16 additions & 2 deletions hugo-apps/src/app-space/AppSpace.vue
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,17 @@ function hasSegments(track: AppSpaceTrack): boolean {
return Array.isArray(track.segments) && track.segments.length > 0
}

// Widest fork in the track (max lanes across all fork segments; 1 if none).
// Drives the timeline's max-width so multi-lane forks get the horizontal room
// they need instead of being squished into the single-column 740px cap — a
// 3-4 lane fork at 740px wraps titles to one word per line (#2887 follow-up).
function maxLaneCount(track: AppSpaceTrack): number {
return trackSegments(track).reduce(
(max, seg) => (seg.kind === 'fork' ? Math.max(max, seg.lanes.length) : max),
1,
)
}

// Flatten every item a segment contains (cluster/linear items, or all lane items
// for a fork). Used for progress math and "all tutorials" totals.
function segmentItems(seg: AppSpaceSegment): AppSpaceItem[] {
Expand Down Expand Up @@ -670,7 +681,7 @@ const emptyStateMessage = computed(() => {
Renders from selectedTrack.segments: linear nodes, side-by-side fork
lanes with fork/join connectors, and any-order clusters. Falls back
to the legacy flat timeline for payloads without segments. -->
<div class="timeline" v-if="hasSegments(selectedTrack)">
<div class="timeline" v-if="hasSegments(selectedTrack)" :style="{ '--max-lanes': maxLaneCount(selectedTrack) }">
<template v-for="(seg, segIndex) in trackSegments(selectedTrack)" :key="seg.order">

<!-- Linear segment: one node per item (as today) -->
Expand Down Expand Up @@ -1412,7 +1423,10 @@ const emptyStateMessage = computed(() => {
/* ── Timeline ──────────────────────────────────────────── */
.timeline {
position: relative;
max-width: 740px;
/* Single-column (linear/cluster) tracks stay at the readable 740px cap.
Multi-lane forks widen to ~280px per lane so lane cards don't squish
(#2887 follow-up) — clamped by the 1200px .content-area parent. */
max-width: max(740px, calc(var(--max-lanes, 1) * 280px));
}

.timeline-item {
Expand Down
6 changes: 6 additions & 0 deletions hugo/assets/css/sap-theme-vars.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
--sapShell_BorderColor: transparent;
--sapShell_Shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.2);
--sapTextColor: #32363a;
--sapTitleColor: #1d2d3e;
--sapContent_IconColor: #0070f2;
--sapTile_BorderColor: #d9d9d9;
--sapNeutralTextColor: #6a6d70;
--sapNeutralBorderColor: #d9d9d9;
--sapNeutralBackground: #f5f6f7;
Expand Down Expand Up @@ -72,6 +75,9 @@ html.dark {
--sapShellColor: #1c2228;
--sapShell_TextColor: #eaecee;
--sapTextColor: #eaecee;
--sapTitleColor: #eaecee;
--sapContent_IconColor: #4db1ff;
--sapTile_BorderColor: #3c4854;
--sapNeutralTextColor: #a9b4be;
--sapNeutralBorderColor: #3c4854;
--sapNeutralBackground: #1c2228;
Expand Down
54 changes: 53 additions & 1 deletion hugo/data/whats_new.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-10-24T10:33:13.644Z",
"generatedAt": "2026-10-25T11:43:41.582Z",
"repos": [
{
"key": "tutorials-ims",
Expand All @@ -8,6 +8,58 @@
}
],
"entries": [
{
"id": "tutorials-ims#2915",
"repo": "tutorials-ims",
"label": "Developer Portal",
"number": 2915,
"title": "Prize-drawing winner cards show the winner's email again",
"summary": "Prize-drawing winner cards now show the winner's email by falling back to their verified sign-in identity when no address is on their main profile, so operators can reach winners instead of seeing \"No Email on File\".",
"category": "Fix",
"mergedAt": "2026-10-25T08:20:00Z",
"week": "2026-W43",
"weekStart": "2026-10-19",
"url": "https://github.com/sap-tutorials/tutorials-ims/pull/2915"
},
{
"id": "tutorials-ims#2914",
"repo": "tutorials-ims",
"label": "Developer Portal",
"number": 2914,
"title": "Badge scanner shows the contestant name in dark mode",
"summary": "The badge scanner's contestant name is now legible in dark mode instead of rendering dark-on-dark, and the underlying theme fix also corrects icon and card-border colors across every page island.",
"category": "Fix",
"mergedAt": "2026-10-25T08:08:40Z",
"week": "2026-W43",
"weekStart": "2026-10-19",
"url": "https://github.com/sap-tutorials/tutorials-ims/pull/2914"
},
{
"id": "tutorials-ims#2913",
"repo": "tutorials-ims",
"label": "Developer Portal",
"number": 2913,
"title": "App Space parallel-path timelines use the full width",
"summary": "App Space tracks with multiple parallel lanes now widen to fill the available space so card titles stop wrapping one word per line, while single-path tracks keep their comfortable reading width.",
"category": "Fix",
"mergedAt": "2026-10-24T14:02:14Z",
"week": "2026-W43",
"weekStart": "2026-10-19",
"url": "https://github.com/sap-tutorials/tutorials-ims/pull/2913"
},
{
"id": "tutorials-ims#2911",
"repo": "tutorials-ims",
"label": "Developer Portal",
"number": 2911,
"title": "release v1.41.0 DEV to main promotion",
"summary": "Release 1.41.0 promoted the prior window of fixes and features from DEV to the production branch.",
"category": "Maintenance",
"mergedAt": "2026-10-24T10:35:54Z",
"week": "2026-W43",
"weekStart": "2026-10-19",
"url": "https://github.com/sap-tutorials/tutorials-ims/pull/2911"
},
{
"id": "tutorials-ims#2910",
"repo": "tutorials-ims",
Expand Down
2 changes: 1 addition & 1 deletion mta-mcp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ _schema-version: 3.3.0
# This file lives at repo root → path: gen/srv-mcp (root-relative, no ../).
# before-all runs from ROOT (no cd ..) — mbt uses the descriptor's directory as cwd.
ID: tutorials-mcp
version: 1.1.0
version: 1.1.1

# Destinations are subaccount-level, so DEV and PROD (sharing one subaccount)
# need DISTINCT destination names. `dest-main-srv` defaults to the PROD name;
Expand Down
43 changes: 41 additions & 2 deletions srv/drawing-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,39 @@ import cds from '@sap/cds';
export default class DrawingService extends cds.ApplicationService {

async init() {
const { Events, PrizeRecords, Users } = cds.entities('com.sap.developers.ims');
const { Events, PrizeRecords, Users, UserIdentities } = cds.entities('com.sap.developers.ims');

// Synthetic GitHub privacy emails (jung-thomas@users.noreply.github.com) are
// dirty data, never a real contact address — exclude them from the fallback,
// same posture as resolve-db-user.js's NOREPLY_GITHUB_RE (#2552).
const NOREPLY_GITHUB_RE = /@users\.noreply\.github\.com$/i;

// Best contact email for a set of users, mirroring the two-source model the
// identity-linkage layer maintains (#2552): Users.email is NULL on ~97.6% of
// migrated rows and only backfills lazily on login, but UserIdentities.email
// (the per-IdP asserted address) is written on every tiered link-write. So a
// winner with a blank Users.email often still has a usable email on their
// UserIdentities rows. Returns a map { user_ID -> email }, preferring verified
// links, then most-recently-linked. Read-only; fail-safe (no row -> absent).
async function identityEmailByUser(userIds) {
if (!userIds.length) return {};
const links = await SELECT.from(UserIdentities)
.columns('user_ID', 'email', 'emailVerified', 'linkedAt')
.where({ user_ID: { in: userIds } });

const best = {};
for (const l of links) {
if (!l.email || NOREPLY_GITHUB_RE.test(l.email)) continue;
const cur = best[l.user_ID];
// Prefer a verified link; among equal verified-ness, the newest linkedAt.
if (!cur
|| (!!l.emailVerified && !cur.emailVerified)
|| (!!l.emailVerified === !!cur.emailVerified && (l.linkedAt ?? '') > (cur.linkedAt ?? ''))) {
best[l.user_ID] = l;
}
}
return Object.fromEntries(Object.entries(best).map(([id, l]) => [id, l.email]));
}

// Resolve an event by legacyId; rejects with 404 if not found (mirrors display-service.js pattern).
async function resolveEvent(eventLegacyId, req) {
Expand All @@ -26,7 +58,14 @@ export default class DrawingService extends cds.ApplicationService {
.where({ ID: { in: ids } });

// Exclude anonymized users
return users.filter(u => u.displayName !== 'ANONYMIZED');
const active = users.filter(u => u.displayName !== 'ANONYMIZED');

// Fall back to a UserIdentities email for anyone whose Users.email is blank
// (the #2552 two-source model — see identityEmailByUser). Only query for the
// users that actually need it.
const needFallback = active.filter(u => !u.email).map(u => u.ID);
const fallback = await identityEmailByUser(needFallback);
return active.map(u => ({ ...u, email: u.email || fallback[u.ID] || null }));
}

this.on('listEvents', async () => {
Expand Down
65 changes: 61 additions & 4 deletions test/drawing-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ describe('DrawingService', () => {
// ---------------------------------------------------------------------------
describe('pool and draw', () => {
beforeAll(async () => {
const { Events, Users, PrizeRecords } = cds.entities('com.sap.developers.ims');
const { Events, Users, PrizeRecords, UserIdentities } = cds.entities('com.sap.developers.ims');

// Dedicated events for pool/draw tests
await INSERT.into(Events).entries([
Expand All @@ -134,6 +134,13 @@ describe('DrawingService', () => {
startDate: '2026-09-07T00:00:00Z',
endDate: '2026-09-09T23:59:59Z',
legacyId: 7012
},
{
ID: 'bbbbbbbb-2222-0000-0000-000000000040',
name: 'Pool Draw Event D (E only, no email anywhere)',
startDate: '2026-09-10T00:00:00Z',
endDate: '2026-09-12T23:59:59Z',
legacyId: 7013
}
]);

Expand All @@ -142,7 +149,9 @@ describe('DrawingService', () => {
{ ID: 'bbbbbbbb-2222-0000-0000-000000000031', uuid: 'draw-pool-u1', legacyId: 8031, displayName: 'UserA', email: 'a@test.com' },
{ ID: 'bbbbbbbb-2222-0000-0000-000000000032', uuid: 'draw-pool-u2', legacyId: 8032, displayName: 'UserB', email: null },
{ ID: 'bbbbbbbb-2222-0000-0000-000000000033', uuid: 'draw-pool-u3', legacyId: 8033, displayName: 'ANONYMIZED', email: null },
{ ID: 'bbbbbbbb-2222-0000-0000-000000000034', uuid: 'draw-pool-u4', legacyId: 8034, displayName: 'UserD', email: 'd@test.com' }
{ ID: 'bbbbbbbb-2222-0000-0000-000000000034', uuid: 'draw-pool-u4', legacyId: 8034, displayName: 'UserD', email: 'd@test.com' },
// UserE: blank Users.email AND no UserIdentities → stays null ("no email on file")
{ ID: 'bbbbbbbb-2222-0000-0000-000000000035', uuid: 'draw-pool-u5', legacyId: 8035, displayName: 'UserE', email: null }
]);

await INSERT.into(PrizeRecords).entries([
Expand Down Expand Up @@ -192,6 +201,42 @@ describe('DrawingService', () => {
event_ID: 'bbbbbbbb-2222-0000-0000-000000000030',
status: 'CLAIMED',
legacyId: 8046
},
// Event 7013 — only UserE (null email, no identities) — stays null
{
ID: 'bbbbbbbb-2222-0000-0000-000000000047',
user_ID: 'bbbbbbbb-2222-0000-0000-000000000035',
event_ID: 'bbbbbbbb-2222-0000-0000-000000000040',
status: 'CLAIMED',
legacyId: 8047
}
]);

// UserB has a blank Users.email but DOES have UserIdentities links (#2552
// two-source model). Seed three: a verified SAP-ID email, a newer but
// UNVERIFIED github email, and a noreply-github synthetic. The fallback
// must pick the verified sap-id address and ignore the other two.
await INSERT.into(UserIdentities).entries([
{
ID: 'bbbbbbbb-2222-0000-0000-000000000051',
user_ID: 'bbbbbbbb-2222-0000-0000-000000000032',
issuer: 'https://accounts.sap.com', subject: 'userb-sap',
provider: 'sap-id', email: 'userb@sap.com', emailVerified: true,
linkedAt: '2026-01-01T00:00:00Z'
},
{
ID: 'bbbbbbbb-2222-0000-0000-000000000052',
user_ID: 'bbbbbbbb-2222-0000-0000-000000000032',
issuer: 'https://github.com', subject: 'userb-gh',
provider: 'github', email: 'userb-personal@gmail.com', emailVerified: false,
linkedAt: '2026-09-01T00:00:00Z'
},
{
ID: 'bbbbbbbb-2222-0000-0000-000000000053',
user_ID: 'bbbbbbbb-2222-0000-0000-000000000032',
issuer: 'https://github.com', subject: 'userb-gh2',
provider: 'github', email: 'userb@users.noreply.github.com', emailVerified: true,
linkedAt: '2026-10-01T00:00:00Z'
}
]);
});
Expand Down Expand Up @@ -238,11 +283,23 @@ describe('DrawingService', () => {
expect(data.eligibleCount).toBe(0);
});

it('draw can return a winner with null email', async () => {
// Event 7012 has only UserB (email:null) as eligible claimer
it('draw falls back to a verified UserIdentities email when Users.email is blank', async () => {
// Event 7012 has only UserB (Users.email:null) as eligible claimer. UserB
// has three UserIdentities links: the fallback must pick the VERIFIED
// sap-id address, not the newer unverified gmail or the noreply synthetic.
const { status, data } = await project.get('/drawings/draw(eventId=7012)', adminAuth);
expect(status).toBe(200);
expect(data.winner.displayName).toBe('UserB');
expect(data.winner.email).toBe('userb@sap.com');
expect(data.eligibleCount).toBe(1);
});

it('draw returns null email when neither Users.email nor any UserIdentities email exists', async () => {
// Event 7013 has only UserE (Users.email:null, no UserIdentities rows) →
// "no email on file" still surfaces correctly.
const { status, data } = await project.get('/drawings/draw(eventId=7013)', adminAuth);
expect(status).toBe(200);
expect(data.winner.displayName).toBe('UserE');
expect(data.winner.email).toBeNull();
expect(data.eligibleCount).toBe(1);
});
Expand Down
Loading