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.27.0
version: 1.27.1

# Top-level parameters (overridable per-env via deploy/<env>.mtaext).
parameters:
Expand Down
16 changes: 12 additions & 4 deletions srv/homepage-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,14 @@ async function _devtoberfestAlways(db) {
// the flag. Always included / region-agnostic (like Devtoberfest). Each card
// links to its session URL, falling back to the /teched/ landing page. Returns
// [] when the flag is off, no upcoming sessions exist, or the query fails.
//
// #2417 — Individual TechEd sessions are always surfaced as VIRTUAL on the band,
// regardless of the session's physical venue (BERLIN/VIRTUAL). The band card is
// a catalog link into the TechEd session catalog, not attendance guidance: a
// Berlin session is still watchable online, and treating it as in-person made
// it land in the EMEA (in-person) region section, which is wrong. Mapping every
// session to region 'VIRTUAL' / isVirtual:true keeps them out of the physical
// region lanes and shows them only under the "Virtual only" (or "All") filter.
async function _techedAlways(db) {
if (!isFlagEnabled('TECHED_HOMEPAGE_ENABLED')) return [];
try {
Expand All @@ -314,18 +322,18 @@ async function _techedAlways(db) {
.limit(3)
);
return (rows ?? []).map(s => {
const isVirtual = s.venue === 'VIRTUAL';
return {
title: s.title || 'SAP TechEd 2026',
startsAt: s.scheduledStart || null,
endsAt: s.scheduledEnd || null,
location: s.room || (isVirtual ? 'Virtual' : 'Berlin'),
location: 'Virtual',
url: s.url || '/teched/',
format: 'teched',
register: null,
eventType: 'teched',
region: isVirtual ? 'VIRTUAL' : 'EMEA',
isVirtual,
// #2417 — always VIRTUAL / region-agnostic (see fn header).
region: 'VIRTUAL',
isVirtual: true,
};
});
} catch (err) {
Expand Down
13 changes: 5 additions & 8 deletions srv/lib/devtoberfest-feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,14 @@ function normalizeSlugSet(rows) {
return set;
}

function assembleFeed({ sessions = [], activities = [], tracks = [], editions = [], activeEditionId = null, speakers = [], sessionSpeakers = [], relatedTechEdBySlug = new Map() }) {
function assembleFeed({ sessions = [], activities = [], tracks = [], editions = [], activeEditionId = null, speakers = [], sessionSpeakers = [], relatedTechEdBySession = new Map() }) {
const trackById = new Map(tracks.map((t) => [t.ID, t]));
const mapTrack = (id) => trackById.get(id) || {};
// Devtoberfest → TechEd cross-links (issue #2312, feature-flag gated + fail-open;
// an empty map — flag OFF, concept links / planner facade absent — yields []).
const activityById = new Map(activities.map((a) => [a.ID, a]));
const relatedTechEdFor = (session) => {
const act = session.ACTIVITY_ID ? activityById.get(session.ACTIVITY_ID) : null;
const taskSlug = (act?.TASKSLUG || '').toLowerCase();
return (taskSlug && relatedTechEdBySlug.get(taskSlug)) || [];
};
// an empty map — flag OFF, concept links absent — yields []). Keyed by
// Devtoberfest session ID: sessions are first-class KG nodes with their own
// concept links (#2311), so the match is session↔session, not via the activity.
const relatedTechEdFor = (session) => relatedTechEdBySession.get(session.ID) || [];
const speakerById = new Map(speakers.map((sp) => [sp.ID, sp]));
const speakersBySession = new Map();
for (const link of sessionSpeakers) {
Expand Down
2 changes: 1 addition & 1 deletion srv/lib/feature-flags/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ export const FEATURE_FLAGS = [
key: 'TECHED_DEVTOBERFEST_CROSSLINK_ENABLED', label: 'TechEd ↔ Devtoberfest session cross-links', category: 'Knowledge Graph',
kind: 'db', imsConfigKey: 'flag.teched.devtoberfestCrosslink',
valueType: 'boolean', default: false, issue: '#2312', status: 'dev-only',
description: 'Bidirectional related-session cross-linking between Devtoberfest sessions and SAP TechEd sessions, computed from shared Knowledge-Graph concepts (Devtoberfest session → tutorial via Activity.TASKSLUG → TutorialConceptLinks; TechEd session → TechEdSessionConceptLinks). When ON, the Devtoberfest schedule feed attaches relatedTechEdSessions and /build/teched attaches relatedDevtoberfestSessions (top 3 by concept overlap). Fail-open: when concept links are cold or the cross-container Devtoberfest planner facades are absent (e.g. unit SQLite), the related arrays are empty and nothing throws. DB-driven config (ImsConfig key flag.teched.devtoberfestCrosslink); no env var. Default OFF.',
description: 'Bidirectional related-session cross-linking between Devtoberfest sessions and SAP TechEd sessions, computed from shared Knowledge-Graph concepts. Both are first-class KG nodes (#2311): Devtoberfest session → DevtoberfestSessionConceptLinks; TechEd session → TechEdSessionConceptLinks, over the SAME Concepts registry. When ON, the Devtoberfest schedule feed attaches relatedTechEdSessions (keyed by session ID) and /build/teched attaches relatedDevtoberfestSessions (top 3 by concept overlap). Fail-open: when either concept-link table is cold/empty (e.g. unit SQLite), the related arrays are empty and nothing throws. DB-driven config (ImsConfig key flag.teched.devtoberfestCrosslink); no env var. Default OFF.',
howToChange: featureFlagUpsert('TECHED_DEVTOBERFEST_CROSSLINK_ENABLED', 'flag.teched.devtoberfestCrosslink'),
},
{
Expand Down
139 changes: 50 additions & 89 deletions srv/lib/teched-devtoberfest-crosslink.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,31 @@
//
// Bidirectional related-session cross-linking between Devtoberfest sessions and
// SAP TechEd sessions, based on shared Knowledge-Graph concepts (issue #2312,
// Unit 9). A Devtoberfest session links to a tutorial via its Activity
// (Activity.TASKSLUG); that tutorial has TutorialConceptLinks → Concepts. A
// TechEd session has TechEdSessionConceptLinks → the SAME Concepts registry.
// Two sessions are "related" when their concept sets overlap; ranked by overlap
// Unit 9). Both session types are first-class KG nodes (issue #2311): a
// Devtoberfest session has DevtoberfestSessionConceptLinks → Concepts, a TechEd
// session has TechEdSessionConceptLinks → the SAME Concepts registry. Two
// sessions are "related" when their concept sets overlap; ranked by overlap
// count, capped at the top MAX_RELATED_SESSIONS.
//
// GATING & FAIL-OPEN CONTRACT (mirrors the KG feature-flag convention):
// - The whole feature is behind the DB feature flag
// TECHED_DEVTOBERFEST_CROSSLINK_ENABLED (ImsConfig flag.teched.devtoberfestCrosslink,
// default OFF, dev-only). When OFF the orchestrators return an EMPTY map so
// callers attach empty related arrays.
// - Devtoberfest planner entities are @cds.persistence.exists cross-container
// facades — ABSENT on unit SQLite. Concept-link tables may also be empty.
// Every DB read is wrapped so a missing facade / cold KG degrades to empty
// arrays and NEVER throws into feed assembly or the /build/teched handler.
// - Both session concept-link tables are owned entities (no cross-container
// planner facade). They may be empty (cold KG, unit SQLite) — every DB read
// is wrapped so a missing/cold table degrades to empty arrays and NEVER
// throws into feed assembly or the /build/teched handler.
//
// The pure ranking helpers (buildRelated*) take pre-fetched, normalized inputs
// and are trivially unit-testable with no cds/db access.

import cds from '@sap/cds';
import { isFlagEnabled } from './feature-flags/db-flags.js';
import { isVisibleStatus } from './devtoberfest-feed.js';

const LOG = cds.log('teched-crosslink');

const KG_NS = 'com.sap.developers.ims';
const EXT_NS = 'com.sap.developers.ims.external';
const DTF_NS = 'external.devtoberfest';

const FLAG = 'TECHED_DEVTOBERFEST_CROSSLINK_ENABLED';

Expand Down Expand Up @@ -60,16 +57,16 @@ function byOverlapThen(keyFn) {
/**
* Forward direction (Devtoberfest → TechEd). PURE.
*
* @param {Map<string, Set<string>>} tutorialConceptsBySlug lowercased tutorial slug → concept-id set
* @param {Array<{id,slug,title,sessionCode,taskSlug,conceptIds}>} dtfSessions
* @param {Array<{slug,title,sessionCode,venue,url,conceptIds}>} techEdSessions
* @returns {Map<string, Array>} lowercased tutorial slug → top related TechEd sessions
* @returns {Map<string, Array>} Devtoberfest session ID → top related TechEd sessions
*/
export function buildRelatedTechEdBySlug(tutorialConceptsBySlug, techEdSessions) {
export function buildRelatedTechEdByDtfSession(dtfSessions, techEdSessions) {
const out = new Map();
if (!(tutorialConceptsBySlug instanceof Map) || tutorialConceptsBySlug.size === 0) return out;
if (!Array.isArray(dtfSessions) || dtfSessions.length === 0) return out;
if (!Array.isArray(techEdSessions) || techEdSessions.length === 0) return out;
for (const [slug, concepts] of tutorialConceptsBySlug) {
const cset = toSet(concepts);
for (const d of dtfSessions) {
const cset = toSet(d.conceptIds);
if (!cset.size) continue;
const scored = [];
for (const te of techEdSessions) {
Expand All @@ -87,7 +84,7 @@ export function buildRelatedTechEdBySlug(tutorialConceptsBySlug, techEdSessions)
}
if (scored.length) {
scored.sort(byOverlapThen((x) => x.slug));
out.set(slug, scored.slice(0, MAX_RELATED_SESSIONS));
out.set(d.id, scored.slice(0, MAX_RELATED_SESSIONS));
}
}
return out;
Expand Down Expand Up @@ -189,71 +186,38 @@ export function __bustTechEdCacheForTest() {
TECHED_CACHE.inflight = null;
}

// lowercased tutorial slug → concept-id set (predicate 'teaches'), restricted to
// the requested slug set. Tutorial slugs are lowercase-canonical (CLAUDE.md).
async function loadTutorialConceptsBySlug(slugsLower) {
const set = slugsLower instanceof Set ? slugsLower : new Set(slugsLower || []);
if (!set.size) return new Map();
const { TutorialConceptLinks, Tutorials } = cds.entities(KG_NS);
const tuts = await SELECT.from(Tutorials).columns('ID', 'slug').where({ slug: { in: [...set] } });
if (!tuts.length) return new Map();
const idToSlug = new Map();
for (const t of tuts) {
if (t.slug) idToSlug.set(t.ID, String(t.slug).toLowerCase());
}
const tutIds = [...idToSlug.keys()];
const links = await SELECT.from(TutorialConceptLinks)
.columns('tutorial_ID', 'concept_ID')
.where({ tutorial_ID: { in: tutIds }, predicate: 'teaches' });
const map = new Map();
// Owned Devtoberfest sessions with their FIRST-CLASS concept-id sets, read from
// DevtoberfestSessions + DevtoberfestSessionConceptLinks (issue #2311 — sessions
// are KG nodes in their own right, extracted by the fetch-devtoberfest-sessions
// job). No cross-container planner facade, no Activity/tutorial walk, no edition
// scoping — the KG ingest already tracks only the current-edition catalog. On
// unit SQLite the tables exist but are empty, so this returns [] (fail-soft).
// Only sessions with a non-empty concept set are returned. Metadata-only columns
// (no LargeString `description`), so plain CDS QL is LOB-safe on HANA.
async function loadDevtoberfestSessionsWithConcepts() {
const { DevtoberfestSessions, DevtoberfestSessionConceptLinks } = cds.entities(EXT_NS);
const sessions = await SELECT.from(DevtoberfestSessions)
.columns('ID', 'slug', 'title', 'sessionCode', 'activityTaskSlug');
if (!sessions.length) return [];
const links = await SELECT.from(DevtoberfestSessionConceptLinks).columns('session_ID', 'concept_ID');
const bySession = new Map();
for (const l of links) {
if (!l.concept_ID) continue;
const slug = idToSlug.get(l.tutorial_ID);
if (!slug) continue;
if (!map.has(slug)) map.set(slug, new Set());
map.get(slug).add(l.concept_ID);
if (!bySession.has(l.session_ID)) bySession.set(l.session_ID, new Set());
bySession.get(l.session_ID).add(l.concept_ID);
}
return map;
}

// Visible Devtoberfest sessions carrying the concept set of the tutorial they
// link to (via Activity.TASKSLUG). Reads the cross-container planner facades,
// which are absent on unit SQLite — the caller's try/catch turns that into an
// empty result (fail-soft). Scoped to the CURRENT edition (ISCURRENT) so the
// reverse cross-links stay symmetric with the edition-scoped forward feed and
// never surface sessions from a past Devtoberfest year. Only sessions with a
// non-empty concept set are returned.
async function loadDevtoberfestSessionsWithConcepts() {
let ext;
try { ext = cds.entities(DTF_NS); } catch { ext = null; }
if (!ext?.Session || !ext?.Activity || !ext?.Track || !ext?.Edition) return [];

const currentEdition = await SELECT.one.from(ext.Edition).columns('ID').where({ ISCURRENT: true });
if (!currentEdition?.ID) return [];
const tracks = await SELECT.from(ext.Track).columns('ID').where({ EDITION_ID: currentEdition.ID });
const trackIds = tracks.map((t) => t.ID);
if (!trackIds.length) return [];

const sessions = await SELECT.from(ext.Session)
.columns('ID', 'TITLE', 'SESSIONCODE', 'STATUS', 'ACTIVITY_ID')
.where({ TRACK_ID: { in: trackIds } });
const visible = sessions.filter(isVisibleStatus);
if (!visible.length) return [];
const activityIds = [...new Set(visible.map((s) => s.ACTIVITY_ID).filter(Boolean))];
const activities = activityIds.length
? await SELECT.from(ext.Activity).columns('ID', 'TASKSLUG').where({ ID: { in: activityIds } })
: [];
const slugByActivity = new Map(
activities.map((a) => [a.ID, (a.TASKSLUG || '').toLowerCase()]),
);
const taskSlugs = new Set([...slugByActivity.values()].filter(Boolean));
const tutMap = await loadTutorialConceptsBySlug(taskSlugs);
const out = [];
for (const s of visible) {
const taskSlug = s.ACTIVITY_ID ? slugByActivity.get(s.ACTIVITY_ID) : '';
const conceptIds = (taskSlug && tutMap.get(taskSlug)) || new Set();
if (conceptIds.size) {
out.push({ id: s.ID, title: s.TITLE, sessionCode: s.SESSIONCODE, taskSlug, conceptIds });
for (const s of sessions) {
const conceptIds = bySession.get(s.ID);
if (conceptIds?.size) {
out.push({
id: s.ID,
slug: s.slug,
title: s.title,
sessionCode: s.sessionCode,
taskSlug: (s.activityTaskSlug || '').toLowerCase(),
conceptIds,
});
}
}
return out;
Expand All @@ -262,26 +226,23 @@ async function loadDevtoberfestSessionsWithConcepts() {
// ── orchestrators (flag-gated, fail-open) ───────────────────────────────────

/**
* Forward: lowercased tutorial slug → top related TechEd sessions. Empty map
* Forward: Devtoberfest session ID → top related TechEd sessions. Empty map
* when the flag is OFF, inputs are empty, or any read fails. Never throws.
*
* @param {Set<string>|string[]} taskSlugsLower Devtoberfest activity task slugs (lowercased)
* @returns {Promise<Map<string, Array>>}
*/
export async function computeRelatedTechEdBySlug(taskSlugsLower) {
export async function computeRelatedTechEdByDtfSession() {
try {
if (!isFlagEnabled(FLAG)) return new Map();
const slugs = taskSlugsLower instanceof Set ? taskSlugsLower : new Set(taskSlugsLower || []);
if (!slugs.size) return new Map();
await cds.connect.to('db');
const [techEd, tutMap] = await Promise.all([
const [techEd, dtf] = await Promise.all([
loadTechEdSessionsWithConcepts(),
loadTutorialConceptsBySlug(slugs),
loadDevtoberfestSessionsWithConcepts(),
]);
if (!techEd.length || tutMap.size === 0) return new Map();
return buildRelatedTechEdBySlug(tutMap, techEd);
if (!techEd.length || !dtf.length) return new Map();
return buildRelatedTechEdByDtfSession(dtf, techEd);
} catch (err) {
LOG.warn('computeRelatedTechEdBySlug failed; returning empty:', err?.message);
LOG.warn('computeRelatedTechEdByDtfSession failed; returning empty:', err?.message);
return new Map();
}
}
Expand Down
18 changes: 8 additions & 10 deletions srv/routes/devtoberfest-schedule.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// soft (503 / empty) when the facades are unavailable (e.g. unit SQLite).
import cds from '@sap/cds';
import { assembleFeed, completedActivityPoints, normalizeSlugSet, filterCompletionsWithinWindow } from '../lib/devtoberfest-feed.js';
import { computeRelatedTechEdBySlug } from '../lib/teched-devtoberfest-crosslink.js';
import { computeRelatedTechEdByDtfSession } from '../lib/teched-devtoberfest-crosslink.js';
import { buildICS, buildEventICS, addToCalendarLinks } from '../lib/devtoberfest-ical.js';
import { buildRSS } from '../lib/devtoberfest-rss.js';
import { resolveUser } from '../lib/resolve-user.js';
Expand Down Expand Up @@ -79,20 +79,18 @@ async function loadAssembledFeed(req) {
}

// Devtoberfest → TechEd related-session cross-links (issue #2312). Flag-gated
// + fail-open inside computeRelatedTechEdBySlug; the outer guard is belt-and-
// suspenders so a cross-link fault never blanks the schedule feed.
let relatedTechEdBySlug = new Map();
// + fail-open inside computeRelatedTechEdByDtfSession; the outer guard is belt-
// and-suspenders so a cross-link fault never blanks the schedule feed. Keyed by
// Devtoberfest session ID — the sessions carry their own KG concepts (#2311).
let relatedTechEdBySession = new Map();
try {
const taskSlugs = new Set(
activities.map((a) => (a.TASKSLUG || '').toLowerCase()).filter(Boolean),
);
relatedTechEdBySlug = await computeRelatedTechEdBySlug(taskSlugs);
relatedTechEdBySession = await computeRelatedTechEdByDtfSession();
} catch (err) {
LOG.warn('teched cross-link failed, feed proceeds without it:', err.message);
relatedTechEdBySlug = new Map();
relatedTechEdBySession = new Map();
}

const feed = assembleFeed({ sessions, activities, tracks, editions, activeEditionId: editionId, speakers, sessionSpeakers, relatedTechEdBySlug });
const feed = assembleFeed({ sessions, activities, tracks, editions, activeEditionId: editionId, speakers, sessionSpeakers, relatedTechEdBySession });
return { ok: true, editionId, feed };
}

Expand Down
Loading
Loading