Skip to content

Commit 3e3d1f4

Browse files
committed
DTSCCI-4116: Add Global events and notifications
1 parent 6e71c56 commit 3e3d1f4

File tree

2 files changed

+112
-10
lines changed

2 files changed

+112
-10
lines changed

bin/utils/enrich-state-event-model.js

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,9 +325,21 @@ function findBpmnForEvent(eventName, bpmnDir) {
325325
* 3. Extract all caseEvent parameters from the BPMN
326326
* 4. Look up their PostConditionState in the model
327327
*/
328-
function resolveViaBpmnChain(handlerFile, taskFiles, model, bpmnDir) {
328+
/**
329+
* Recursively follow BPMN chains to resolve state transitions.
330+
* For each BusinessProcess.ready() call:
331+
* 1. Find the BPMN process file
332+
* 2. Extract caseEvent parameters (downstream CCD events)
333+
* 3. If a downstream event has a concrete postState → collect it
334+
* 4. If a downstream event has postState=* → find its handler, extract .state() calls,
335+
* and recursively follow its own BPMN chains
336+
*
337+
* Uses visited sets for cycle detection.
338+
*/
339+
function resolveViaBpmnChain(handlerFile, taskFiles, model, bpmnDir, allFiles, serviceRoot, visited) {
329340
const states = new Set();
330341
const eventMap = Object.fromEntries(model.events.map(e => [e.id, e]));
342+
if (!visited) visited = { bpmnFiles: new Set(), eventIds: new Set() };
331343

332344
// Collect BusinessProcess.ready() calls from handler and its tasks
333345
const bpEvents = new Set();
@@ -338,14 +350,40 @@ function resolveViaBpmnChain(handlerFile, taskFiles, model, bpmnDir) {
338350

339351
for (const bpEvent of bpEvents) {
340352
const bpmnFile = findBpmnForEvent(bpEvent, bpmnDir);
341-
if (!bpmnFile) continue;
353+
if (!bpmnFile || visited.bpmnFiles.has(bpmnFile)) continue;
354+
visited.bpmnFiles.add(bpmnFile);
342355

343356
const caseEvents = extractBpmnCaseEvents(bpmnFile);
344357
for (const ce of caseEvents) {
358+
if (visited.eventIds.has(ce)) continue;
359+
visited.eventIds.add(ce);
360+
345361
const ev = eventMap[ce];
346362
if (!ev) continue;
363+
364+
// If the downstream event has a concrete postState, collect it
347365
if (ev.postState !== '*' && VALID_STATES.has(ev.postState)) {
348366
states.add(ev.postState);
367+
continue;
368+
}
369+
370+
// If postState is *, find its handler and resolve deeper
371+
if (ev.postState === '*') {
372+
const downstreamHandlers = findHandlersForEvent(ce, allFiles);
373+
for (const dh of downstreamHandlers) {
374+
// Extract .state() calls from the downstream handler
375+
extractStatesFromStateCalls(dh).forEach(s => states.add(s));
376+
377+
// Check its task files too
378+
const dTasks = findTaskFiles(dh, allFiles, serviceRoot);
379+
for (const dt of dTasks) {
380+
extractStatesFromStateCalls(dt).forEach(s => states.add(s));
381+
}
382+
383+
// Recursively follow its BPMN chains
384+
resolveViaBpmnChain(dh, dTasks, model, bpmnDir, allFiles, serviceRoot, visited)
385+
.forEach(s => states.add(s));
386+
}
349387
}
350388
}
351389
}
@@ -409,9 +447,9 @@ function main() {
409447
extractStatesFromStateCalls(tf).forEach(s => allStates.add(s));
410448
}
411449

412-
// Follow BusinessProcess.ready() → BPMN → CCD event chain
450+
// Follow BusinessProcess.ready() → BPMN → CCD event chain (recursive)
413451
if (hasBpmn) {
414-
resolveViaBpmnChain(handlerFile, tasks, model, bpmnDir)
452+
resolveViaBpmnChain(handlerFile, tasks, model, bpmnDir, allFiles, serviceRoot)
415453
.forEach(s => allStates.add(s));
416454
}
417455
}

bin/utils/generate-state-event-svg.js

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ if (fs.existsSync(notifHtmlPath)) {
2121
console.warn('email-notifications.html not found, links will be disabled');
2222
}
2323

24+
// Alias map: user-facing events → notification page event they trigger downstream
25+
const NOTIF_ALIASES = {
26+
'VIEW_AND_RESPOND_TO_DEFENCE': 'claimant_response',
27+
'APPLY_NOC_DECISION': 'apply_noc_decision_defendant_lip',
28+
'UPLOAD_TRANSLATED_DOCUMENT': 'upload_translated_document_claimant_intention',
29+
};
30+
31+
function getNotifLink(eventId) {
32+
const lower = eventId.toLowerCase();
33+
if (notifPageEvents.has(lower)) return lower;
34+
const alias = NOTIF_ALIASES[eventId];
35+
if (alias && notifPageEvents.has(alias)) return alias;
36+
return null;
37+
}
38+
2439
// Layout constants
2540
const COL_W = 320, PILL_W = 300, PILL_H = 22, PILL_GAP = 3, PILL_R = 8;
2641
const DEST_ROW_H = 14; // extra height per destination row
@@ -335,12 +350,12 @@ function renderColumn(stateId, x, y) {
335350

336351
// Pill background + event name (with suffix for duplicates)
337352
const displayName = displayNames.get(ev.id) || ev.name;
338-
const hasNotifLink = notifPageEvents.has(ev.id.toLowerCase());
353+
const notifTarget = getNotifLink(ev.id);
339354
svg += `<rect x="${pillX}" y="${py}" width="${PILL_W}" height="${PILL_H}" rx="${PILL_R}" fill="${bg}" stroke="#bbb" stroke-width="0.5"/>`;
340355
svg += `<text x="${pillX+8}" y="${py+PILL_H/2+3}" font-size="${FONT}" fill="#333" font-family="Arial">${esc(displayName)}</text>`;
341356

342357
// Notification link icon (envelope)
343-
if (hasNotifLink) {
358+
if (notifTarget) {
344359
// Shift left if enabling condition icon is also present
345360
const hasEC = !!ev.enablingCondition;
346361
const hasRightTag = info.type === 'stays' || info.type === 'unknown';
@@ -349,7 +364,7 @@ function renderColumn(stateId, x, y) {
349364
const tagShift = hasRightTag ? tagWidth : 0;
350365
const envX = pillX + PILL_W - 16 - ecShift - tagShift;
351366
const envY = py + (PILL_H - 12) / 2;
352-
svg += `<a href="${NOTIF_PAGE}#${ev.id.toLowerCase()}" target="_blank">`;
367+
svg += `<a href="${NOTIF_PAGE}#${notifTarget}" target="_blank">`;
353368
svg += `<g cursor="pointer">`;
354369
svg += `<rect x="${envX}" y="${envY}" width="12" height="12" rx="3" fill="#e8f0fe" stroke="#1a56db" stroke-width="0.5"/>`;
355370
svg += `<text x="${envX+6}" y="${envY+9}" text-anchor="middle" font-size="8" fill="#1a56db" font-family="Arial">\u2709</text>`;
@@ -463,10 +478,59 @@ for (let i = 0; i < MAIN_FLOW.length - 1; i++) {
463478
spineSvg += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#4472c4" stroke-width="2" marker-end="url(#arrow)" opacity="0.35"/>`;
464479
}
465480

481+
// Global Events column (after the main flow)
482+
// Includes: all global user events + global camunda events that have notification links
483+
const globalColumnEventsAll = model.events.filter(e =>
484+
globalIds.has(e.id) && (e.sourceType === 'user' || getNotifLink(e.id))
485+
);
486+
const seenGlobalCol = new Set();
487+
const globalUserEvents = globalColumnEventsAll.filter(e => {
488+
if (seenGlobalCol.has(e.id)) return false;
489+
seenGlobalCol.add(e.id);
490+
return true;
491+
}).sort((a, b) => a.name.localeCompare(b.name));
492+
493+
const guX = mainOffset + MAIN_FLOW.length * TOTAL_COL, guY = PAD + titleH;
494+
const guPillX = guX + (COL_W - PILL_W) / 2;
495+
let guBodyH = PILL_GAP;
496+
for (let i = 0; i < globalUserEvents.length; i++) {
497+
guBodyH += PILL_H + PILL_GAP;
498+
}
499+
guBodyH += PILL_GAP;
500+
const guColH = HDR_H + guBodyH;
501+
maxMainH = Math.max(maxMainH, guColH);
502+
503+
let guSvg = '';
504+
guSvg += `<rect x="${guX}" y="${guY}" width="${COL_W}" height="${guColH}" rx="4" fill="white" stroke="#ccc"/>`;
505+
guSvg += `<rect x="${guX}" y="${guY}" width="${COL_W}" height="${HDR_H}" rx="4" fill="#5b4a8a"/>`;
506+
guSvg += `<rect x="${guX}" y="${guY+HDR_H-6}" width="${COL_W}" height="6" fill="#5b4a8a"/>`;
507+
guSvg += `<text x="${guX+COL_W/2}" y="${guY+17}" text-anchor="middle" font-size="8.5" font-weight="bold" fill="${HEADER_TEXT}" font-family="Arial">GLOBAL EVENTS</text>`;
508+
guSvg += `<text x="${guX+COL_W/2}" y="${guY+33}" text-anchor="middle" font-size="7.5" fill="white" font-family="Arial">Available in all states (*\u2192*)</text>`;
509+
const guDisplayNames = computeDisplayNames(globalUserEvents);
510+
let guPillY = guY + HDR_H + PILL_GAP;
511+
for (const ev of globalUserEvents) {
512+
const bg = pillBg(ev);
513+
const guName = guDisplayNames.get(ev.id) || ev.name;
514+
guSvg += `<rect x="${guPillX}" y="${guPillY}" width="${PILL_W}" height="${PILL_H}" rx="${PILL_R}" fill="${bg}" stroke="#bbb" stroke-width="0.5"/>`;
515+
guSvg += `<text x="${guPillX+8}" y="${guPillY+PILL_H/2+3}" font-size="${FONT}" fill="#333" font-family="Arial">${esc(guName)}</text>`;
516+
const guNotifTarget = getNotifLink(ev.id);
517+
if (guNotifTarget) {
518+
const envX = guPillX + PILL_W - 16;
519+
const envY = guPillY + (PILL_H - 12) / 2;
520+
guSvg += `<a href="${NOTIF_PAGE}#${guNotifTarget}" target="_blank">`;
521+
guSvg += `<g cursor="pointer">`;
522+
guSvg += `<rect x="${envX}" y="${envY}" width="12" height="12" rx="3" fill="#e8f0fe" stroke="#1a56db" stroke-width="0.5"/>`;
523+
guSvg += `<text x="${envX+6}" y="${envY+9}" text-anchor="middle" font-size="8" fill="#1a56db" font-family="Arial">\u2709</text>`;
524+
guSvg += `<title>View notifications for ${esc(ev.id)}</title>`;
525+
guSvg += `</g></a>`;
526+
}
527+
guPillY += PILL_H + PILL_GAP;
528+
}
529+
466530
// Exception row
467531
const exY = PAD + titleH + maxMainH + 50;
468532
let exSvg = '', maxExH = 0;
469-
const totalCols = MAIN_FLOW.length + 1; // +1 for Case Creation column
533+
const totalCols = MAIN_FLOW.length + 2; // +1 Case Creation, +1 Global User Events
470534
const exOffset = mainOffset + Math.floor((MAIN_FLOW.length - EXCEPTION_FLOW.length) / 2) * TOTAL_COL;
471535
EXCEPTION_FLOW.forEach((sid, i) => {
472536
const x = exOffset + i * TOTAL_COL, y = exY;
@@ -623,7 +687,7 @@ legSvg += `<text x="${legX+647}" y="${legY+62}" text-anchor="middle" font-size="
623687
legSvg += `<rect x="${legX+620}" y="${legY+26}" width="55" height="16" rx="4" fill="#e8f0fe" stroke="#1a56db" stroke-width="0.5"/>`;
624688
legSvg += `<text x="${legX+647}" y="${legY+38}" text-anchor="middle" font-size="5.5" fill="#1a56db" font-family="Arial">\u2709 Triggers Notifs</text>`;
625689

626-
legSvg += `<text x="${legX+10}" y="${legY+94}" font-size="7" font-family="Arial" fill="#888">Generated ${new Date().toISOString().split('T')[0]} | ${model.summary.stateCount} states | ${model.summary.eventCount} events | Global (*\u2192*) events not shown (${globalIds.size}) | ${model.summary.resolvedDynamicEvents || 0} dynamic transitions resolved from civil-service</text>`;
690+
legSvg += `<text x="${legX+10}" y="${legY+94}" font-size="7" font-family="Arial" fill="#888">Generated ${new Date().toISOString().split('T')[0]} | ${model.summary.stateCount} states | ${model.summary.eventCount} events | Global (*\u2192*) system events not shown (${globalIds.size - globalUserEvents.length}) | ${model.summary.resolvedDynamicEvents || 0} dynamic transitions resolved from civil-service</text>`;
627691

628692
const svgW = PAD * 2 + totalCols * TOTAL_COL;
629693
const svgH = legY + 115;
@@ -638,7 +702,7 @@ const svg = `<?xml version="1.0" encoding="UTF-8"?>
638702
</defs>
639703
<rect width="100%" height="100%" fill="white"/>
640704
<text x="${svgW/2}" y="${PAD+10}" text-anchor="middle" font-size="14" font-weight="bold" font-family="Arial" fill="${HEADER_BG}">Civil CCD State &amp; Event Model</text>
641-
${ccSvg}${spineSvg}${mainSvg}${exSvg}${crossSvg}${legSvg}
705+
${ccSvg}${spineSvg}${mainSvg}${guSvg}${exSvg}${crossSvg}${legSvg}
642706
</svg>`;
643707

644708
fs.writeFileSync('build/state-event-model.svg', svg);

0 commit comments

Comments
 (0)