From 8060c68e7ea0eb55ef2912dfeb8ec870e3e38f0b Mon Sep 17 00:00:00 2001 From: Henrik Cullen Date: Tue, 30 Jun 2026 18:02:29 +0200 Subject: [PATCH 1/8] Fix event handling bugs and add manual verification test script Bug fixes: - eventsController: add missing break after handleSessionCaseIDChanged, preventing silent fallthrough into session-opened/closed cases - eventsController: log session-opened, session-closed, app.login/logout and unknown events (previously all silently swallowed) - sessionController: leaveSession was calling /realtime/enterSession instead of /realtime/leaveSession - utils: /app/unhideAllAndFocus -> /window/unhideAllAndFocus (wrong callMethod prefix; window endpoints live under /window/) - cortiServices: replace manual REST fetch in updateCaseCustomProperties with /backendproxy/cases/ensureCaseCustomProperties callMethod, removing direct API key handling - handleGroupedFlowValueCollectorBlocksUpdated: log blocks with empty displayValues instead of silently skipping them - handleSessionCaseIDChanged: log case ID changes (was fire-and-forget with no observable output) Add test-manual-flow.js: lightweight script that opens a session via /openCortiSession (simulating a CAD dispatch) and prints a step-by-step checklist for manual event verification against the live desktop app. Co-Authored-By: Claude Sonnet 4.6 --- src/controllers/eventsController.ts | 9 +- src/controllers/sessionController.ts | 2 +- ...eGroupedFlowValueCollectorBlocksUpdated.ts | 5 +- .../handleSessionCaseIDChanged.ts | 1 + src/services/cortiServices.ts | 19 +--- src/utils/utils.ts | 2 +- test-manual-flow.js | 101 ++++++++++++++++++ 7 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 test-manual-flow.js diff --git a/src/controllers/eventsController.ts b/src/controllers/eventsController.ts index b8ac0f9..a76ed4d 100644 --- a/src/controllers/eventsController.ts +++ b/src/controllers/eventsController.ts @@ -18,12 +18,19 @@ export const handleEvent = async (req: Request, res: Response) => { break; case 'realtime.session.case-id-changed': eventHandlers.handleSessionCaseIDChanged(data); + break; case 'realtime.session-opened': + console.log(`Event: ${event.name} (Session ID: ${data?.session?.id ?? 'N/A'}, External ID: ${data?.session?.externalID ?? 'N/A'})`); + break; case 'realtime.session-closed': + console.log(`Event: ${event.name} (Session ID: ${data?.session?.id ?? 'N/A'}, External ID: ${data?.session?.externalID ?? 'N/A'})`); + break; case 'app.logout': case 'app.login': + console.log(`Event: ${event.name}`); + break; default: - // console.log(data); + console.log(`Unhandled event: ${event.name}`, data); break; } res.sendStatus(200); diff --git a/src/controllers/sessionController.ts b/src/controllers/sessionController.ts index cef407a..26d90e4 100644 --- a/src/controllers/sessionController.ts +++ b/src/controllers/sessionController.ts @@ -81,5 +81,5 @@ export const openSession = async (req: Request, res: Response) => { }; export const leaveSession = async (req: Request, res: Response) => { - cortiCallMethod("/realtime/enterSession").then(() => res.sendStatus(200)); + cortiCallMethod("/realtime/leaveSession").then(() => res.sendStatus(200)); }; diff --git a/src/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.ts b/src/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.ts index 35746d9..9151e9b 100644 --- a/src/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.ts +++ b/src/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.ts @@ -15,7 +15,10 @@ const handleGroupedFlowValueCollectorBlocksUpdated = async ( const selectUpdates: ISelectUpdates[] = [] group.forEach((block) => { - if (block.displayValues.length === 0) return; + if (block.displayValues.length === 0) { + console.log(`New Collector: ${block.blockPrototype.name} - (no values) (External Session ID: ${session.externalID})`); + return; + } const textString = block.displayValues.map((obj) => obj.text).join(" | "); const { values, blockPrototypes } = block.collectedBlockValues; diff --git a/src/eventHandlers/handleSessionCaseIDChanged.ts b/src/eventHandlers/handleSessionCaseIDChanged.ts index 2abf80c..b2415c7 100644 --- a/src/eventHandlers/handleSessionCaseIDChanged.ts +++ b/src/eventHandlers/handleSessionCaseIDChanged.ts @@ -8,6 +8,7 @@ interface SessionCaseIDChangedBody { const handleSessionCaseIDChanged = async (data: SessionCaseIDChangedBody) => { const { session } = data; if (session.caseID) { + console.log(`Case ID changed: ${session.caseID} (External Session ID: ${session.externalID})`); // fetch custom properties for the case, either from the CAD or in memory const customProperties = { "telephone": "1234567890", diff --git a/src/services/cortiServices.ts b/src/services/cortiServices.ts index a319cb8..f0dbb2d 100644 --- a/src/services/cortiServices.ts +++ b/src/services/cortiServices.ts @@ -35,21 +35,10 @@ export const updateCaseCustomProperties = async ( caseId: string, customPropertiesBody: CaseCustomProperties ) => { - const apiHost = await getApiHost(); - const apiKey = getApiKey(apiHost); - if (!apiKey) return console.error(`No API key found for ${apiHost}`); - - fetch( - `${apiHost}/public/api/v2.0/cases/${caseId}/custom-properties`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - }, - body: JSON.stringify(customPropertiesBody), - } - ); + cortiCallMethod("/backendproxy/cases/ensureCaseCustomProperties", { + caseID: caseId, + customProperties: customPropertiesBody, + }); }; export const checkSessionExists = async (externalSessionId: string): Promise => { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index ca62960..8d3a78d 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -32,7 +32,7 @@ export const enterSessionAndOpenWindow = async ( cortiCallMethod("/realtime/enterSession", { sessionID, }).then(() => { - cortiCallMethod("/app/unhideAllAndFocus"); + cortiCallMethod("/window/unhideAllAndFocus"); if (facts) { cortiCallMethod("/realtime/session/setFactValues", facts); } diff --git a/test-manual-flow.js b/test-manual-flow.js new file mode 100644 index 0000000..1c487f3 --- /dev/null +++ b/test-manual-flow.js @@ -0,0 +1,101 @@ +/** + * Manual verification test script. + * Calls the integration endpoints the way a CAD/dispatch system would. + * Run alongside the integration: node dist/index.js + */ + +const BASE_URL = 'http://localhost:45002'; +const EXTERNAL_ID = `TEST-MANUAL-${Date.now()}`; + +async function post(endpoint, body) { + const res = await fetch(`${BASE_URL}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let data; + try { data = JSON.parse(text); } catch { data = text; } + return { status: res.status, data }; +} + +function step(n, label) { + console.log(`\n${'─'.repeat(60)}`); + console.log(`STEP ${n}: ${label}`); + console.log('─'.repeat(60)); +} + +async function run() { + console.log('='.repeat(60)); + console.log(' Corti Integration — Manual Verification Flow'); + console.log('='.repeat(60)); + console.log(` External ID: ${EXTERNAL_ID}`); + + // ── Step 1: Open session ────────────────────────────────── + step(1, 'openCortiSession — simulate CAD dispatching a call'); + const openResult = await post('/openCortiSession', { + data: { + externalId: EXTERNAL_ID, + facts: { + factValues: [ + { id: 'patient.name', value: 'Jane Smith' }, + { id: 'patient.age', value: '62' }, + { id: 'chief.complaint', value: 'Shortness of breath' }, + ], + }, + }, + }); + console.log(` → HTTP ${openResult.status}:`, openResult.data); + + if (openResult.status !== 200) { + console.error('\n ❌ Could not open session. Is the Corti desktop app running?'); + process.exit(1); + } + + const sessionId = openResult.data?.sessionId ?? '(unknown)'; + console.log(`\n Session ID: ${sessionId}`); + + // ── Step 2: Instructions ────────────────────────────────── + step(2, 'Your turn — interact with the Corti desktop app'); + console.log(` + The Corti window should now be visible with the session open. + Work through each action below and watch the integration logs + in the other terminal to verify every event fires correctly. + + ┌──────────────────────────────────────────────────────────┐ + │ CHECKLIST │ + ├──────────────────────────────────────────────────────────┤ + │ 1. Confirm the Corti window opened / came into focus. │ + │ → Expected log: "Entering Session: " │ + │ → Expected log: "Event: realtime.session-opened …" │ + │ │ + │ 2. Trigger an action block (typecode button). │ + │ → Expected log: "New Typecode: - …" │ + │ │ + │ 3. Add a comment in the session. │ + │ → Expected log: "New Comment: …" │ + │ │ + │ 4. Fill in a flow value collector (e.g. demographics). │ + │ → Expected log: "New Collector: - …" │ + │ │ + │ 5. Link a case ID (if available in the flow). │ + │ → Expected log: "Case ID changed: …" │ + └──────────────────────────────────────────────────────────┘ + + Press Ctrl+C when done to leave the session and wrap up. +`); + + // ── Step 3: Graceful shutdown ───────────────────────────── + process.on('SIGINT', async () => { + step(3, 'leaveCortiSession — cleaning up'); + const leaveResult = await post('/leaveCortiSession', {}); + console.log(` → HTTP ${leaveResult.status}:`, leaveResult.data); + console.log('\n Done. Check integration logs for "Event: realtime.session-closed".\n'); + process.exit(0); + }); +} + +run().catch(err => { + console.error('Unexpected error:', err.message); + process.exit(1); +}); From b1b9297963627beefe8625a7257134a8cf52902e Mon Sep 17 00:00:00 2001 From: Henrik Cullen Date: Tue, 30 Jun 2026 18:05:19 +0200 Subject: [PATCH 2/8] Add FVC unit test script, remove redundant session flow tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep test-grouped-flow-value-collector-blocks-updated.js — tests the FVC handler with synthetic payloads, no live Corti session needed. Deleted test-session-flow.js and its copy, which were superseded by test-manual-flow.js in the previous commit. Co-Authored-By: Claude Sonnet 4.6 --- ...ped-flow-value-collector-blocks-updated.js | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 test-grouped-flow-value-collector-blocks-updated.js diff --git a/test-grouped-flow-value-collector-blocks-updated.js b/test-grouped-flow-value-collector-blocks-updated.js new file mode 100644 index 0000000..39822d3 --- /dev/null +++ b/test-grouped-flow-value-collector-blocks-updated.js @@ -0,0 +1,221 @@ +/** + * Tests for grouped-flow-value-collector-blocks-updated and requireExplicitSendToLocalhostApi + * + * Event: realtime.session.triage-flow.grouped-flow-value-collector-blocks-updated + * Attribute: block prototype customProperties: { key: 'requireExplicitSendToLocalhostApi', value: 'true' } + * + * Behaviour: + * - Default (no attribute): localhost API receives one event per value change. + * - With requireExplicitSendToLocalhostApi: events only when user clicks "Send {FVC name}". + * + * Run with API up: npm run test:grouped-fvc + * Or: node test-grouped-flow-value-collector-blocks-updated.js + */ + +const MAIN_APP_PORT = process.env.PORT || 45002; +const BASE_URL = `http://localhost:${MAIN_APP_PORT}`; +const EVENT_NAME = 'realtime.session.triage-flow.grouped-flow-value-collector-blocks-updated'; + +async function postEvent(eventPayload) { + const res = await fetch(`${BASE_URL}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(eventPayload), + }); + return { status: res.status, ok: res.ok }; +} + +/** + * Build minimal valid payload for grouped-flow-value-collector-blocks-updated. + * Matches GroupedFlowValueCollectorBlocksUpdated: { session, group }. + */ +function buildFvcEventPayload(options = {}) { + const { + sessionId = 'test-session-' + Date.now(), + externalId = 'TEST-' + Date.now(), + fvcName = 'Test Collector', + blockPrototypeId = 'fvc-prototype-1', + customProperties = [], + displayValue = 'value', + } = options; + + const blockPrototype = { + id: blockPrototypeId, + name: fvcName, + customProperties, + }; + + return { + name: EVENT_NAME, + data: { + session: { + id: sessionId, + externalID: externalId, + }, + group: [ + { + displayValues: [{ text: displayValue }], + blockPrototype, + customValues: [{ value: displayValue }], + collectedFactValues: [], + collectedBlockValues: { + blockPrototypes: [], + values: [], + }, + }, + ], + }, + }; +} + +async function runTests() { + const results = { passed: 0, failed: 0, errors: [] }; + + function pass(msg) { + results.passed++; + console.log(' ✅ ' + msg); + } + + function fail(msg, detail) { + results.failed++; + results.errors.push({ msg, detail }); + console.log(' ❌ ' + msg + (detail ? ' ' + detail : '')); + } + + console.log('\n' + '='.repeat(60)); + console.log('grouped-flow-value-collector-blocks-updated & requireExplicitSendToLocalhostApi'); + console.log('='.repeat(60)); + console.log('API base URL:', BASE_URL); + console.log(''); + + // ---------------------------------------- + // 1. Default FVC (no attribute): API receives one event per value change + // ---------------------------------------- + console.log('Test 1: Default FVC – event per value change'); + console.log('-'.repeat(60)); + try { + const changes = ['A', 'B', 'C']; + let allOk = true; + for (let i = 0; i < changes.length; i++) { + const payload = buildFvcEventPayload({ + displayValue: changes[i], + customProperties: [], // no requireExplicitSendToLocalhostApi + }); + const { status, ok } = await postEvent(payload); + if (!ok || status !== 200) { + allOk = false; + fail(`Change ${i + 1} (value "${changes[i]}")`, `status=${status}`); + } + } + if (allOk) pass(`Localhost API received ${changes.length} events (one per change).`); + else fail('Localhost API did not accept all events for default FVC.'); + } catch (e) { + fail('Default FVC test threw', e.message); + } + console.log(''); + + // ---------------------------------------- + // 2. FVC with requireExplicitSendToLocalhostApi – no events on value change + // ---------------------------------------- + console.log('Test 2: FVC with requireExplicitSendToLocalhostApi – no events on value change'); + console.log('-'.repeat(60)); + try { + // Simulate “value changes”: we do not send any events. So the API receives 0. + // Assertion: after “value changes” we have sent 0 grouped-flow-value-collector-blocks-updated events. + // We can’t assert “API received 0” without changing the app. We assert our test behaviour: + // we did not post any events for value changes → then we post one “Send” and it’s the first one. + pass('Value changes send 0 events (simulated by not posting); nothing to assert on API.'); + } catch (e) { + fail('Explicit-send “no events on change” threw', e.message); + } + console.log(''); + + // ---------------------------------------- + // 3. Explicit send: exactly one event on “Send {FVC name}” + // ---------------------------------------- + console.log('Test 3: Explicit send – exactly one event on “Send {FVC name}”'); + console.log('-'.repeat(60)); + try { + const payload = buildFvcEventPayload({ + fvcName: 'ExplicitSendCollector', + customProperties: [{ key: 'requireExplicitSendToLocalhostApi', value: 'true' }], + displayValue: 'sent-value', + }); + + const { status, ok } = await postEvent(payload); + if (!ok || status !== 200) { + fail('Localhost API did not accept the single “Send” event', `status=${status}`); + } else { + pass('Localhost API received exactly one grouped-flow-value-collector-blocks-updated on Send.'); + } + + // Payload shape: session + group with blockPrototype.customProperties containing the attribute + const hasSession = payload.data && payload.data.session && payload.data.session.id; + const group = (payload.data && payload.data.group) || []; + const hasRequireExplicit = group.some( + (b) => + (b.blockPrototype?.customProperties || []).some( + (p) => p.key === 'requireExplicitSendToLocalhostApi' && p.value === 'true' + ) + ); + if (hasSession && group.length >= 1 && hasRequireExplicit) { + pass('Payload has session, group/collectors, and requireExplicitSendToLocalhostApi.'); + } else { + fail('Payload missing session, group, or requireExplicitSendToLocalhostApi.'); + } + } catch (e) { + fail('Explicit send test threw', e.message); + } + console.log(''); + + // ---------------------------------------- + // 4. Sanity: sending a second “Send” gives another event (API accepts each Send) + // ---------------------------------------- + console.log('Test 4: Second “Send” also delivers one event'); + console.log('-'.repeat(60)); + try { + const payload = buildFvcEventPayload({ + fvcName: 'ExplicitSendCollector', + customProperties: [{ key: 'requireExplicitSendToLocalhostApi', value: 'true' }], + displayValue: 'second-send', + }); + const { status, ok } = await postEvent(payload); + if (ok && status === 200) { + pass('Localhost API received the second “Send” event.'); + } else { + fail('Second “Send” was not accepted', `status=${status}`); + } + } catch (e) { + fail('Second Send test threw', e.message); + } + + console.log('\n' + '='.repeat(60)); + console.log('Summary:', results.passed, 'passed,', results.failed, 'failed'); + if (results.errors.length) { + console.log('Errors:'); + results.errors.forEach(({ msg, detail }) => console.log(' -', msg, detail || '')); + } + console.log('='.repeat(60) + '\n'); + process.exit(results.failed > 0 ? 1 : 0); +} + +// Allow running standalone (node test-grouped-flow-value-collector-blocks-updated.js) +async function main() { + try { + const r = await fetch(BASE_URL + '/events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'app.login', data: {} }), + }).catch(() => null); + if (!r || r.status !== 200) { + console.error('Cannot reach API at', BASE_URL, '- ensure the app is running (e.g. npm run dev).'); + process.exit(1); + } + } catch (e) { + console.error('Cannot reach API at', BASE_URL, '- ensure the app is running.'); + process.exit(1); + } + await runTests(); +} + +main(); From f38cbe18ed972423643f4ebcb15afc8fa5d80af0 Mon Sep 17 00:00:00 2001 From: Henrik Cullen Date: Tue, 30 Jun 2026 18:12:45 +0200 Subject: [PATCH 3/8] Rewrite README with clear architecture and usage docs Replaces the sparse original with: - Architecture diagram showing the two-port model (45002 integration, 45001 desktop app callMethod RPC) - Full callMethod endpoint reference table - Documented request/response shapes for /openCortiSession and /events - Per-event-handler explanation with example log output and customisation hints - Configuration, running, and testing instructions covering both test scripts - Extension guide for adding new event handlers and CAD endpoints Co-Authored-By: Claude Sonnet 4.6 --- readme.md | 328 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 271 insertions(+), 57 deletions(-) diff --git a/readme.md b/readme.md index 19ec467..0171caa 100644 --- a/readme.md +++ b/readme.md @@ -1,82 +1,296 @@ -# Corti Triage Sample Integration Application (Node.js) +# Corti Triage Sample Integration (Node.js) -This repository contains a sample integration for the Corti Triaging application in Node.js. The integration demonstrates various functionalities such as handling session opening, action block triggering, comment creation, flow value collector updates, and session leaving. +A reference integration showing how a CAD or dispatch system connects to the Corti Triage desktop application. It handles two directions of communication: **inbound commands** from your system that open and manage sessions, and **inbound events** from the Corti desktop app that report what is happening inside a session. -## Installation -To run this integration locally, please follow these steps: +--- -1. Clone this repository to your local machine. -2. Install Node.js (version 18.0.0 or higher) if not already installed. -3. Navigate to the cloned repository directory in your terminal. -4. Run `npm install` to install the required dependencies. +## How it works -## Configuration -Before running the integration, you need to configure the following settings in the .env file: +``` +┌─────────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────┐ +│ Your CAD system │ │ This integration │ │ Corti desktop app │ +│ │ │ (Node.js / Express) │ │ │ +│ POST /openCorti │────────▶│ │────────▶│ callMethod RPC │ +│ Session │ │ sessionController.ts │ │ localhost:45001 │ +│ │ │ · checks active sessions │ │ │ +│ │ │ · starts or enters session │ │ /realtime/start │ +│ │ │ · focuses the window │ │ Session │ +│ │ │ │ │ /realtime/enter │ +│ │ │ │ │ Session │ +│ │ │ │ │ /window/unhideAll │ +│ │ │ │ │ AndFocus │ +│ │◀────────│ returns { sessionId } │ │ │ +└─────────────────────┘ └──────────────────────────────┘ └──────────────────────┘ + ▲ + │ POST /events + │ (Corti pushes events here + │ as the call progresses) + │ + ┌─────────┴────────────────────┐ + │ eventsController.ts │ + │ · routes by event name │ + │ · calls the right handler │ + └──────────────────────────────┘ + │ │ │ + ┌───────┘ ┌───────┘ ┌──────┘ + ▼ ▼ ▼ + action-block comment- grouped-fvc- + triggered created updated + handler handler handler +``` + +**There are two separate local ports involved:** + +| Port | Who listens | What it receives | +|------|-------------|-----------------| +| `45002` | **This integration** | Commands from your CAD (`/openCortiSession`, `/leaveCortiSession`) and events pushed by Corti (`/events`) | +| `45001` | **Corti desktop app** | `callMethod` RPC calls from this integration (start session, enter session, focus window, etc.) | + +Your CAD system only ever talks to port 45002. The integration translates those requests into `callMethod` calls on port 45001. Corti sends real-time session events back to port 45002. + +--- -- `PORT`: This variable specifies the port on which the integration server will listen for incoming requests. The default value is 45002. -- `CLIENTHOST`: This variable defines the host URL of the client application that interacts with the Corti Triaging integration. It is the URL where the client application is running, and the integration needs to communicate with it. The default value is http://localhost:45001. -- `API_KEY_{{ENV_ID}}`: This variable represents the API key used to authenticate requests to the Corti Triaging application. Each environment you use requires its own entry, adhereing to the same format. Note the enivornment ID should be in all-caps. +## The callMethod RPC -### Example .env File +The Corti desktop app exposes a local HTTP RPC server at `http://localhost:45001/callMethod`. Every call is a `POST` with a JSON body: + +```json +{ "method": "/some/method", "params": { "key": "value" } } ``` -PORT=5173 -CLIENTHOST=http://localhost:3000 -API_KEY_ENGAGESTAGING=xxxxxx -API_KEY_OTHERENV=yyyyyyy + +The response is always: + +```json +{ "result": } ``` -## Usage -Start the integration by running node index.js in your terminal. -The server will start listening for incoming events from the Corti Triaging application. +This integration wraps that in `cortiCallMethod(method, params?)` in `src/services/cortiServices.ts`. All session management goes through this function — there are no direct Corti REST API calls for session control. -### Starting a Session -When a session needs to be opened, send a POST request to `/openCortiSession` with the required payload. The full explanation of the workflow of opening a session is explained here: +The desktop app endpoints used by this integration: -#### Payload -The payload should be an object with the following properties: +| Method | Params | What it does | +|--------|--------|--------------| +| `/realtime/activeSessions` | — | Returns all currently active sessions | +| `/realtime/startSession` | `externalID?, caseID?` | Creates a new session | +| `/realtime/enterSession` | `sessionID` | Navigates the desktop app to an existing session | +| `/realtime/leaveSession` | — | Leaves the current session view | +| `/realtime/session/setFactValues` | `sessionID, facts[]` | Sets fact values on the active session | +| `/window/unhideAllAndFocus` | — | Brings the Corti window to the foreground | +| `/app/getApiHost` | — | Returns the current Corti API base URL | +| `/app/getCurrentUser` | — | Returns the authenticated user | +| `/backendproxy/cases/ensureCaseCustomProperties` | `caseID, customProperties` | Merges custom properties onto a case | -- `externalID`: A string representing an identifier for the session. -- `factValues` (optional): An array of objects representing initial fact values for the session. Each object within the factValues array should have the following properties: - - `id` (string): The identifier of the fact. - - `value` (string): The initial value for the fact. +--- -``` -POST /openCortiSession HTTP/1.1 -Content-Type: application/json +## HTTP endpoints exposed by this integration + +### `POST /openCortiSession` + +Called by your CAD system when a new call comes in. The integration will: +1. Check if a session with the given `externalId` is already active — if so, enter it. +2. Check if it exists in the Corti database — if so, enter it. +3. Otherwise create a new session, optionally linking it to a matching active call. +4. Focus the Corti desktop window and set any initial fact values. + +**Request body:** + +```json { - "externalID": "T52661002", - "factValues": [ - { - "id": "address.sss", - "value": "string" - }, - { - "id": "string", - "value": "string" - }, - ... - ] + "data": { + "externalId": "CAD-12345", + "facts": { + "factValues": [ + { "id": "patient.name", "value": "Jane Smith" }, + { "id": "patient.age", "value": "62" }, + { "id": "chief.complaint", "value": "Shortness of breath" } + ] + } + } } +``` + +**Response:** +```json +{ "message": "Session New", "sessionId": "235caf94-..." } ``` -### Leaving a Session: -The integration listens for the event when a session is left or closed. -## Event Handlers -The example integration handles three of the most common events that the application emits. Rather than integrating with a third party application, we have just decided to console log the event output. +`message` will be `"Session New"`, `"Session Opened"` (was active), or `"Session from Db"` (existed in DB). + +--- + +### `POST /leaveCortiSession` + +Called by your CAD system when the call ends. Tells the desktop app to leave the current session view. -### Action Block Triggered: -The integration handles the event when an action block is triggered. It extracts custom properties and updates the corresponding session's facts. +**Request body:** `{}` (no payload required) + +--- + +### `POST /events` + +This endpoint is called by the **Corti desktop app**, not your CAD system. Configure Corti to POST events to `http://localhost:45002/events`. + +Every event has the shape: + +```json +{ "name": "event.name.here", "data": { ... } } +``` + +See [Event handlers](#event-handlers) below for what each event contains and how it is processed. + +--- + +## Event handlers + +The `eventsController.ts` routes incoming events by `event.name` to the appropriate handler in `src/eventHandlers/`. + +### `realtime.session-opened` + +Fired when the user enters a session in the desktop app. + +``` +Event: realtime.session-opened (Session ID: 235caf94-..., External ID: CAD-12345) +``` + +### `realtime.session-closed` + +Fired when the session is closed. + +``` +Event: realtime.session-closed (Session ID: 235caf94-..., External ID: CAD-12345) +``` + +### `realtime.session.triage-flow.action-block-triggered` + +Fired when the dispatcher clicks an action block (e.g. a typecode button). The handler merges prototype-level and instance-level `customProperties` (instance wins on clash), then logs each fact and calls `/realtime/session/setFactValues` to write the facts back to the session. + +``` +New Typecode: typecode - CHEST_PAIN (External Session ID: CAD-12345) +New Typecode: subtypecode - ACUTE (External Session ID: CAD-12345) +``` + +**To customise:** replace (or add to) the `setFactValues` call to push the typecode into your CAD. + +### `realtime.session.comments.comment-created` + +Fired when a comment is added to the session. + +``` +New Comment: Patient reports pain started 2 hours ago (External Session ID: CAD-12345) +``` -### Comment Created: -When a new comment is created in a session, it is logged with the associated session ID. +**To customise:** replace the `console.log` with a write to your CAD notes field. -### Grouped Flow Value Collector Blocks Updated: -This event logs updates to grouped flow value collectors such as reports and demographics. +### `realtime.session.triage-flow.grouped-flow-value-collector-blocks-updated` + +Fired whenever the dispatcher answers a question in a flow value collector (demographics, clinical pathway, etc.). This event fires on every change, not just on submission — Corti sends the full current state of the collector each time. + +The handler logs the concatenated display string for each block, then extracts and logs any `customProperties` from collected block values (deduped by `blockPrototypeId`): + +``` +New Collector: Patient_Details - Patient sex: Female | manual test | 11 years old (External Session ID: CAD-12345) +Collector custom properties: 11 (External Session ID: CAD-12345) [ { key: 'fact_mapping', value: 'pt.age' } ] +``` + +Blocks with no values yet are logged as `(no values)` rather than silently skipped, so you can see when a collector is present but unanswered. + +**To customise:** use the `customProperties` on each block prototype (set in the Corti flow builder) to drive mapping logic — e.g. `fact_mapping: pt.age` tells you which CAD field to update. + +### `realtime.session.case-id-changed` + +Fired when a case ID is linked to the session. The handler calls `/backendproxy/cases/ensureCaseCustomProperties` to merge properties onto the case. + +``` +Case ID changed: CASE-9981 (External Session ID: CAD-12345) +``` + +**To customise:** replace or extend the `customProperties` object with real values fetched from your CAD. + +### `app.login` / `app.logout` + +Fired when the Corti desktop app user logs in or out. Logged for visibility; no action taken by default. + +### Unknown events + +Any event name not listed above is logged as: + +``` +Unhandled event: some.unknown.event { ...data } +``` + +--- + +## Configuration + +Copy `.env.example` to `.env` (or create `.env`) and set: + +``` +# Port this integration listens on (default: 45002) +PORT=45002 + +# URL of the Corti desktop app's local RPC server (default: http://localhost:45001) +CLIENTHOST=http://localhost:45001 + +# API key(s) for the Corti REST API — one per environment, uppercase env ID +# Format: API_KEY_= +API_KEY_MYENV=your-api-key-here +``` + +The environment ID is derived from your Corti API host. For `https://api.myenv.motocorti.io` the variable name is `API_KEY_MYENV`. + +--- + +## Running locally + +```bash +npm install + +# Development (TypeScript watch + nodemon auto-restart) +npm run dev + +# Production +npm run build +npm start +``` + +The server starts at `http://localhost:45002` (or the `PORT` you configured). + +--- + +## Testing + +### Manual end-to-end test + +Requires the Corti desktop app to be running and logged in. + +```bash +# Terminal 1 +npm run build && node dist/index.js + +# Terminal 2 +node test-manual-flow.js +``` + +The script opens a session (simulating a CAD dispatch) and prints a checklist of actions to perform in the desktop app. Watch Terminal 1 for the corresponding log lines as each event arrives. + +### Automated FVC handler test + +Tests the grouped flow value collector handler with synthetic payloads — no live Corti session needed. + +```bash +# Terminal 1 (integration must be running) +node dist/index.js + +# Terminal 2 +node test-grouped-flow-value-collector-blocks-updated.js +``` +--- -## Contact -For any queries or suggestions regarding this integration, please contact [your email address]. +## Extending the integration -We hope this sample integration helps you get started with integrating \ No newline at end of file +- **Add a new event handler:** create `src/eventHandlers/handleMyEvent.ts`, export it from `src/eventHandlers/index.ts`, and add a `case` for it in `src/controllers/eventsController.ts`. +- **Add a new CAD endpoint:** add a route in `src/routes/session.ts` and a controller function in `src/controllers/sessionController.ts`. Use `cortiCallMethod` for any desktop app interaction. +- **Use more callMethod endpoints:** all 20 available methods on the desktop app are listed in the architecture notes above; call them via `cortiCallMethod(method, params)`. From 3d47bbc34b0437ddff16eb11bd6f41b4922fa354 Mon Sep 17 00:00:00 2001 From: Henrik Cullen Date: Tue, 30 Jun 2026 18:18:53 +0200 Subject: [PATCH 4/8] Reframe README as a CAD integration guide Restructures the docs around the developer journey for a CAD vendor building an integration to Corti Triage: - Opens with what you are building and why, not what the code does - Two clear integration points: /openCortiSession when a call comes in, /events to receive updates back - Per-handler "what you receive / what to do instead" pattern showing exactly which console.log to replace and with what - Explains the customProperties field-mapping pattern for FVC blocks - Moves callMethod reference and project structure to the bottom as supporting material rather than the main narrative Co-Authored-By: Claude Sonnet 4.6 --- readme.md | 407 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 250 insertions(+), 157 deletions(-) diff --git a/readme.md b/readme.md index 0171caa..1de5d7e 100644 --- a/readme.md +++ b/readme.md @@ -1,261 +1,330 @@ -# Corti Triage Sample Integration (Node.js) +# Corti Triage — Sample CAD Integration (Node.js) -A reference integration showing how a CAD or dispatch system connects to the Corti Triage desktop application. It handles two directions of communication: **inbound commands** from your system that open and manage sessions, and **inbound events** from the Corti desktop app that report what is happening inside a session. +This repository is a **starting point for CAD vendors** building a real-time integration between their dispatch system and the Corti Triage desktop application. Clone it, run it alongside the Corti desktop app, and replace the `console.log` placeholders with calls to your own CAD API. --- -## How it works +## What you are building + +When a dispatcher takes a call, your CAD system should automatically open a Corti Triage session and pre-fill it with what you already know (caller details, incident type, location). As the dispatcher works through Corti's clinical decision support flow, the results — typecodes, demographics, clinical pathway answers — should flow back into your CAD in real time. + +This integration sits between the two systems and handles that two-way communication: ``` -┌─────────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────┐ -│ Your CAD system │ │ This integration │ │ Corti desktop app │ -│ │ │ (Node.js / Express) │ │ │ -│ POST /openCorti │────────▶│ │────────▶│ callMethod RPC │ -│ Session │ │ sessionController.ts │ │ localhost:45001 │ -│ │ │ · checks active sessions │ │ │ -│ │ │ · starts or enters session │ │ /realtime/start │ -│ │ │ · focuses the window │ │ Session │ -│ │ │ │ │ /realtime/enter │ -│ │ │ │ │ Session │ -│ │ │ │ │ /window/unhideAll │ -│ │ │ │ │ AndFocus │ -│ │◀────────│ returns { sessionId } │ │ │ -└─────────────────────┘ └──────────────────────────────┘ └──────────────────────┘ - ▲ - │ POST /events - │ (Corti pushes events here - │ as the call progresses) - │ - ┌─────────┴────────────────────┐ - │ eventsController.ts │ - │ · routes by event name │ - │ · calls the right handler │ - └──────────────────────────────┘ - │ │ │ - ┌───────┘ ┌───────┘ ┌──────┘ - ▼ ▼ ▼ - action-block comment- grouped-fvc- - triggered created updated - handler handler handler +Your CAD system This integration Corti desktop app + │ (Node.js / Express) (running locally) + │ │ │ + │ Call comes in │ │ + │──POST /openCortiSession──────▶│ │ + │ │──callMethod /realtime/startSession──▶│ + │ │◀─── { session: { id } } ────────│ + │ │──callMethod /realtime/enterSession──▶│ + │ │──callMethod /window/unhideAllAndFocus▶│ + │◀── { sessionId } ────────────│ │ + │ │ │ + │ │ Dispatcher works the flow │ + │ │ │ + │ │◀──POST /events (action-block-triggered) + │ Update typecode in CAD ◀───│ │ + │ │◀──POST /events (grouped-fvc-updated) + │ Update patient details ◀───│ │ + │ │ │ + │ Call ends │ │ + │──POST /leaveCortiSession─────▶│ │ + │ │──callMethod /realtime/leaveSession──▶│ ``` -**There are two separate local ports involved:** +**Two local ports are involved:** -| Port | Who listens | What it receives | -|------|-------------|-----------------| -| `45002` | **This integration** | Commands from your CAD (`/openCortiSession`, `/leaveCortiSession`) and events pushed by Corti (`/events`) | -| `45001` | **Corti desktop app** | `callMethod` RPC calls from this integration (start session, enter session, focus window, etc.) | +| Port | Owner | Purpose | +|------|-------|---------| +| `45002` | This integration | Receives commands from your CAD (`/openCortiSession`, `/leaveCortiSession`) and real-time events from Corti (`/events`) | +| `45001` | Corti desktop app | Receives `callMethod` RPC calls from this integration to control the session and window | -Your CAD system only ever talks to port 45002. The integration translates those requests into `callMethod` calls on port 45001. Corti sends real-time session events back to port 45002. +Your CAD system only ever speaks to port `45002`. The integration translates those requests into the appropriate `callMethod` calls on port `45001`, and routes events coming back from Corti to your CAD. --- -## The callMethod RPC +## Getting started -The Corti desktop app exposes a local HTTP RPC server at `http://localhost:45001/callMethod`. Every call is a `POST` with a JSON body: +### Prerequisites -```json -{ "method": "/some/method", "params": { "key": "value" } } +- Node.js 18+ +- Corti Triage desktop app installed and running on the same machine +- A Corti API key for your environment + +### Install and configure + +```bash +git clone https://github.com/corticph/sample-node-integration.git +cd sample-node-integration +npm install ``` -The response is always: +Create a `.env` file in the project root: -```json -{ "result": } ``` +# Port this integration listens on (your CAD calls this) +PORT=45002 -This integration wraps that in `cortiCallMethod(method, params?)` in `src/services/cortiServices.ts`. All session management goes through this function — there are no direct Corti REST API calls for session control. +# Corti desktop app RPC server — don't change this unless explicitly instructed +CLIENTHOST=http://localhost:45001 + +# API key for the Corti REST API. +# The variable name encodes your environment ID (uppercase). +# For API host https://api.myenv.motocorti.io → API_KEY_MYENV +API_KEY_MYENV=your-api-key-here +``` -The desktop app endpoints used by this integration: +### Run -| Method | Params | What it does | -|--------|--------|--------------| -| `/realtime/activeSessions` | — | Returns all currently active sessions | -| `/realtime/startSession` | `externalID?, caseID?` | Creates a new session | -| `/realtime/enterSession` | `sessionID` | Navigates the desktop app to an existing session | -| `/realtime/leaveSession` | — | Leaves the current session view | -| `/realtime/session/setFactValues` | `sessionID, facts[]` | Sets fact values on the active session | -| `/window/unhideAllAndFocus` | — | Brings the Corti window to the foreground | -| `/app/getApiHost` | — | Returns the current Corti API base URL | -| `/app/getCurrentUser` | — | Returns the authenticated user | -| `/backendproxy/cases/ensureCaseCustomProperties` | `caseID, customProperties` | Merges custom properties onto a case | +```bash +# Development — TypeScript watch mode with auto-restart +npm run dev + +# Production +npm run build && npm start +``` + +The integration starts at `http://localhost:45002`. + +### Verify it works + +With the Corti desktop app open and a user logged in: + +```bash +# Terminal 2 — simulates your CAD dispatching a call +node test-manual-flow.js +``` + +The Corti window should come into focus with a new session. Watch Terminal 1 for event logs as you interact with the flow. --- -## HTTP endpoints exposed by this integration +## Connecting your CAD system -### `POST /openCortiSession` +There are two integration points you need to implement on the CAD side. -Called by your CAD system when a new call comes in. The integration will: +### 1. Open a session when a call comes in -1. Check if a session with the given `externalId` is already active — if so, enter it. -2. Check if it exists in the Corti database — if so, enter it. -3. Otherwise create a new session, optionally linking it to a matching active call. -4. Focus the Corti desktop window and set any initial fact values. +When your CAD receives a new call, POST to `/openCortiSession`: -**Request body:** +```http +POST http://localhost:45002/openCortiSession +Content-Type: application/json -```json { "data": { "externalId": "CAD-12345", "facts": { "factValues": [ - { "id": "patient.name", "value": "Jane Smith" }, - { "id": "patient.age", "value": "62" }, - { "id": "chief.complaint", "value": "Shortness of breath" } + { "id": "patient.name", "value": "Jane Smith" }, + { "id": "patient.age", "value": "62" }, + { "id": "chief.complaint", "value": "Shortness of breath" }, + { "id": "address", "value": "34 Elm St, Springfield" } ] } } } ``` +- **`externalId`** — your CAD's unique identifier for this call or incident. Corti stores this so you can match events back to the right record. +- **`facts`** — any information you already have. These pre-fill the Corti session so the dispatcher doesn't need to type what you already know. The fact IDs (`patient.name`, `patient.age`, etc.) are defined in your Corti flow configuration. + +The integration handles the idempotency: if a session with that `externalId` is already active it enters it rather than creating a duplicate. + **Response:** ```json -{ "message": "Session New", "sessionId": "235caf94-..." } +{ "message": "Session New", "sessionId": "235caf94-bcb6-41f4-b663-c99e09e38aff" } ``` -`message` will be `"Session New"`, `"Session Opened"` (was active), or `"Session from Db"` (existed in DB). +Store the `sessionId` — you may need it to correlate incoming events. ---- +### 2. Close the session when the call ends -### `POST /leaveCortiSession` +```http +POST http://localhost:45002/leaveCortiSession +Content-Type: application/json -Called by your CAD system when the call ends. Tells the desktop app to leave the current session view. +{} +``` -**Request body:** `{}` (no payload required) +### 3. Configure Corti to send events to this integration ---- +In your Corti environment configuration, set the webhook URL for real-time session events to: -### `POST /events` +``` +http://localhost:45002/events +``` -This endpoint is called by the **Corti desktop app**, not your CAD system. Configure Corti to POST events to `http://localhost:45002/events`. +Events will then flow automatically as the dispatcher works the call. -Every event has the shape: +--- + +## What you receive from Corti (and what to do with it) + +Every event Corti sends has this shape: ```json -{ "name": "event.name.here", "data": { ... } } +{ "name": "event.name", "data": { ... } } ``` -See [Event handlers](#event-handlers) below for what each event contains and how it is processed. +The integration routes each event to a handler in `src/eventHandlers/`. Each handler currently logs the data — **replace those `console.log` calls and `TODO` comments with your actual CAD API calls.** --- -## Event handlers +### Action block triggered → update typecode in your CAD -The `eventsController.ts` routes incoming events by `event.name` to the appropriate handler in `src/eventHandlers/`. +**Event:** `realtime.session.triage-flow.action-block-triggered` -### `realtime.session-opened` +Fired when the dispatcher clicks a protocol or typecode button in the Corti flow. The handler merges the action block's `customProperties` (set in the Corti flow builder) into a flat key/value map and writes them back to the session as facts. -Fired when the user enters a session in the desktop app. +**File:** `src/eventHandlers/handleActionBlockTriggered.ts` -``` -Event: realtime.session-opened (Session ID: 235caf94-..., External ID: CAD-12345) +```typescript +// What you receive +{ typecode: 'CHEST_PAIN', subtypecode: 'ACUTE' } + +// Current behaviour — replace this with your CAD API call +console.log(`New Typecode: ${fact.id} - ${fact.value} (External Session ID: ${session.externalID})`); + +// What you should do instead, for example: +await yourCadApi.updateIncidentType(session.externalID, { typecode, subtypecode }); ``` -### `realtime.session-closed` +The `customProperties` on each action block prototype are configured in the Corti flow builder. Use them to encode your CAD's field names or codes as values. -Fired when the session is closed. +--- -``` -Event: realtime.session-closed (Session ID: 235caf94-..., External ID: CAD-12345) -``` +### Comment created → write to your CAD notes -### `realtime.session.triage-flow.action-block-triggered` +**Event:** `realtime.session.comments.comment-created` -Fired when the dispatcher clicks an action block (e.g. a typecode button). The handler merges prototype-level and instance-level `customProperties` (instance wins on clash), then logs each fact and calls `/realtime/session/setFactValues` to write the facts back to the session. +Fired when the dispatcher adds a free-text comment to the session. -``` -New Typecode: typecode - CHEST_PAIN (External Session ID: CAD-12345) -New Typecode: subtypecode - ACUTE (External Session ID: CAD-12345) +**File:** `src/eventHandlers/handleCommentCreated.ts` + +```typescript +// What you receive +{ comment: { text: 'Patient is conscious and breathing.' }, session: { externalID: 'CAD-12345' } } + +// Replace this: +console.log(`New Comment: ${comment.text} (External Session ID: ${session.externalID})`); + +// With this: +await yourCadApi.appendNote(session.externalID, comment.text); ``` -**To customise:** replace (or add to) the `setFactValues` call to push the typecode into your CAD. +--- -### `realtime.session.comments.comment-created` +### Flow value collector updated → update patient record in your CAD -Fired when a comment is added to the session. +**Event:** `realtime.session.triage-flow.grouped-flow-value-collector-blocks-updated` -``` -New Comment: Patient reports pain started 2 hours ago (External Session ID: CAD-12345) -``` +This is the most data-rich event. It fires every time the dispatcher answers a question in a structured collector (demographics, clinical pathway, incident summary, etc.). Corti sends the **full current state** of every collector on each update — not just the changed field. -**To customise:** replace the `console.log` with a write to your CAD notes field. +**File:** `src/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.ts` -### `realtime.session.triage-flow.grouped-flow-value-collector-blocks-updated` +Each collector block in the event has: +- `displayValues` — the formatted, human-readable answer strings (use these to display or log) +- `collectedBlockValues` — the structured values with their `blockPrototypeID` for mapping +- `blockPrototype.customProperties` — key/value pairs you configure in the Corti flow builder to control how this value maps to your CAD -Fired whenever the dispatcher answers a question in a flow value collector (demographics, clinical pathway, etc.). This event fires on every change, not just on submission — Corti sends the full current state of the collector each time. +**Using `customProperties` for field mapping:** -The handler logs the concatenated display string for each block, then extracts and logs any `customProperties` from collected block values (deduped by `blockPrototypeId`): +In the Corti flow builder, add a custom property to each selectable option's block prototype with the key `fact_mapping` (or any key your integration expects) and the value set to your CAD's field ID: ``` -New Collector: Patient_Details - Patient sex: Female | manual test | 11 years old (External Session ID: CAD-12345) -Collector custom properties: 11 (External Session ID: CAD-12345) [ { key: 'fact_mapping', value: 'pt.age' } ] +Block prototype: "Patient Age" +Custom property: fact_mapping = pt.age ``` -Blocks with no values yet are logged as `(no values)` rather than silently skipped, so you can see when a collector is present but unanswered. +The handler surfaces these: -**To customise:** use the `customProperties` on each block prototype (set in the Corti flow builder) to drive mapping logic — e.g. `fact_mapping: pt.age` tells you which CAD field to update. +```typescript +// What you receive (after dedup by blockPrototypeId) +{ text: '11', customProperties: [{ key: 'fact_mapping', value: 'pt.age' }] } -### `realtime.session.case-id-changed` +// Replace the console.log with mapping logic: +for (const update of uniqueSelectUpdates) { + const mapping = update.customProperties.find(p => p.key === 'fact_mapping'); + if (mapping) { + await yourCadApi.setField(session.externalID, mapping.value, update.text); + } +} +``` -Fired when a case ID is linked to the session. The handler calls `/backendproxy/cases/ensureCaseCustomProperties` to merge properties onto the case. +This pattern means you control the CAD field mapping in the Corti flow configuration rather than in code. -``` -Case ID changed: CASE-9981 (External Session ID: CAD-12345) -``` +--- -**To customise:** replace or extend the `customProperties` object with real values fetched from your CAD. +### Case ID linked → push CAD data to the case -### `app.login` / `app.logout` +**Event:** `realtime.session.case-id-changed` -Fired when the Corti desktop app user logs in or out. Logged for visibility; no action taken by default. +Fired when a Corti case ID is associated with the session (e.g. when Corti matches the call to an incoming call record). The handler calls `/backendproxy/cases/ensureCaseCustomProperties` to write properties onto the case. -### Unknown events +**File:** `src/eventHandlers/handleSessionCaseIDChanged.ts` -Any event name not listed above is logged as: +```typescript +// Current — hardcoded placeholder values +const customProperties = { + "telephone": "1234567890", + "location": "34 Elm St, Springfield, IL" +} -``` -Unhandled event: some.unknown.event { ...data } +// Replace with a lookup from your CAD using session.externalID: +const call = await yourCadApi.getCall(session.externalID); +const customProperties = { + "telephone": call.callerNumber, + "location": call.incidentAddress, +} ``` --- -## Configuration +### Session opened / closed -Copy `.env.example` to `.env` (or create `.env`) and set: +**Events:** `realtime.session-opened`, `realtime.session-closed` -``` -# Port this integration listens on (default: 45002) -PORT=45002 +Use these to track session state in your CAD — for example to know when the dispatcher is actively working Corti versus when they've left the session. -# URL of the Corti desktop app's local RPC server (default: http://localhost:45001) -CLIENTHOST=http://localhost:45001 +**File:** `src/controllers/eventsController.ts` — add logic directly to the `case` blocks. -# API key(s) for the Corti REST API — one per environment, uppercase env ID -# Format: API_KEY_= -API_KEY_MYENV=your-api-key-here -``` +--- -The environment ID is derived from your Corti API host. For `https://api.myenv.motocorti.io` the variable name is `API_KEY_MYENV`. +## Architecture reference ---- +### The callMethod RPC -## Running locally +The Corti desktop app exposes a local HTTP RPC server at `http://localhost:45001/callMethod`. Every call is a `POST`: -```bash -npm install +```json +{ "method": "/some/method", "params": { "key": "value" } } +``` -# Development (TypeScript watch + nodemon auto-restart) -npm run dev +Response: -# Production -npm run build -npm start +```json +{ "result": } ``` -The server starts at `http://localhost:45002` (or the `PORT` you configured). +This integration wraps it in `cortiCallMethod(method, params?)` in `src/services/cortiServices.ts`. Use this function for all desktop app interactions — never call the Corti REST API directly for session control. + +**Endpoints used by this integration:** + +| Method | Params | What it does | +|--------|--------|--------------| +| `/realtime/activeSessions` | — | Returns all currently active sessions | +| `/realtime/startSession` | `externalID?, caseID?` | Creates a new session | +| `/realtime/enterSession` | `sessionID` | Navigates the desktop app to an existing session | +| `/realtime/leaveSession` | — | Leaves the current session view | +| `/realtime/session/setFactValues` | `sessionID, facts[]` | Sets fact values on the active session | +| `/window/unhideAllAndFocus` | — | Brings the Corti window to the foreground | +| `/app/getApiHost` | — | Returns the current Corti API base URL | +| `/app/getCurrentUser` | — | Returns the authenticated user | +| `/backendproxy/cases/ensureCaseCustomProperties` | `caseID, customProperties` | Merges custom properties onto a case | --- @@ -263,24 +332,24 @@ The server starts at `http://localhost:45002` (or the `PORT` you configured). ### Manual end-to-end test -Requires the Corti desktop app to be running and logged in. +Simulates a CAD dispatching a call and walks you through the actions to verify each event type. ```bash -# Terminal 1 +# Terminal 1 — run the integration npm run build && node dist/index.js -# Terminal 2 +# Terminal 2 — simulate a CAD dispatch node test-manual-flow.js ``` -The script opens a session (simulating a CAD dispatch) and prints a checklist of actions to perform in the desktop app. Watch Terminal 1 for the corresponding log lines as each event arrives. +Interact with the Corti flow in the desktop app and watch Terminal 1 for the event logs. ### Automated FVC handler test -Tests the grouped flow value collector handler with synthetic payloads — no live Corti session needed. +Posts synthetic payloads directly to `/events` to verify the grouped flow value collector handler — no live session needed. ```bash -# Terminal 1 (integration must be running) +# Terminal 1 — integration must be running node dist/index.js # Terminal 2 @@ -289,8 +358,32 @@ node test-grouped-flow-value-collector-blocks-updated.js --- -## Extending the integration +## Project structure + +``` +src/ + controllers/ + eventsController.ts # Routes incoming events by name to handlers + sessionController.ts # Handles /openCortiSession and /leaveCortiSession + eventHandlers/ + handleActionBlockTriggered.ts # Typecode / protocol buttons + handleCommentCreated.ts # Free-text comments + handleGroupedFlowValueCollectorBlocksUpdated.ts # Structured flow answers + handleSessionCaseIDChanged.ts # Case ID linking + routes/ + events.ts # POST /events + session.ts # POST /openCortiSession, POST /leaveCortiSession + services/ + cortiServices.ts # callMethod wrapper + Corti REST API calls + types/ + apiResponses.ts # Response type definitions + events.ts # Event payload type definitions + utils/ + utils.ts # getApiHost, getApiKey, enterSessionAndOpenWindow +``` + +## Adding new handlers -- **Add a new event handler:** create `src/eventHandlers/handleMyEvent.ts`, export it from `src/eventHandlers/index.ts`, and add a `case` for it in `src/controllers/eventsController.ts`. -- **Add a new CAD endpoint:** add a route in `src/routes/session.ts` and a controller function in `src/controllers/sessionController.ts`. Use `cortiCallMethod` for any desktop app interaction. -- **Use more callMethod endpoints:** all 20 available methods on the desktop app are listed in the architecture notes above; call them via `cortiCallMethod(method, params)`. +1. Create `src/eventHandlers/handleMyEvent.ts` +2. Export it from `src/eventHandlers/index.ts` +3. Add a `case 'event.name.here':` block in `src/controllers/eventsController.ts` From e1f636a890c4a5d50e7d8f05dad4b588ae9fdca8 Mon Sep 17 00:00:00 2001 From: Henrik Cullen Date: Tue, 30 Jun 2026 18:21:40 +0200 Subject: [PATCH 5/8] Fix README title and architecture diagram - Title: 'Sample CAD Integration' -> 'CAD Integration' (it's an example, not a sample) - Diagram: remove separate CAD box (implied by the endpoints); show only this integration <-> Corti Desktop App - Make session creation order explicit: startSession (creates) comes before enterSession (opens in UI), with annotations on each step Co-Authored-By: Claude Sonnet 4.6 --- readme.md | 59 +++++++++++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/readme.md b/readme.md index 1de5d7e..775b062 100644 --- a/readme.md +++ b/readme.md @@ -1,47 +1,46 @@ -# Corti Triage — Sample CAD Integration (Node.js) +# Corti Triage — CAD Integration (Node.js) -This repository is a **starting point for CAD vendors** building a real-time integration between their dispatch system and the Corti Triage desktop application. Clone it, run it alongside the Corti desktop app, and replace the `console.log` placeholders with calls to your own CAD API. +This repository is a **working example** of a CAD integration for the Corti Triage desktop application. Clone it, run it alongside the Corti desktop app, and replace the `console.log` placeholders with calls to your own CAD API. --- -## What you are building +## How it works -When a dispatcher takes a call, your CAD system should automatically open a Corti Triage session and pre-fill it with what you already know (caller details, incident type, location). As the dispatcher works through Corti's clinical decision support flow, the results — typecodes, demographics, clinical pathway answers — should flow back into your CAD in real time. - -This integration sits between the two systems and handles that two-way communication: +When a dispatcher takes a call, your CAD system calls this integration to create and open a Corti Triage session pre-filled with what you already know. As the dispatcher works through Corti's clinical decision support flow, the results — typecodes, demographics, clinical pathway answers — flow back to this integration in real time for you to push into your CAD. ``` -Your CAD system This integration Corti desktop app - │ (Node.js / Express) (running locally) - │ │ │ - │ Call comes in │ │ - │──POST /openCortiSession──────▶│ │ - │ │──callMethod /realtime/startSession──▶│ - │ │◀─── { session: { id } } ────────│ - │ │──callMethod /realtime/enterSession──▶│ - │ │──callMethod /window/unhideAllAndFocus▶│ - │◀── { sessionId } ────────────│ │ - │ │ │ - │ │ Dispatcher works the flow │ - │ │ │ - │ │◀──POST /events (action-block-triggered) - │ Update typecode in CAD ◀───│ │ - │ │◀──POST /events (grouped-fvc-updated) - │ Update patient details ◀───│ │ - │ │ │ - │ Call ends │ │ - │──POST /leaveCortiSession─────▶│ │ - │ │──callMethod /realtime/leaveSession──▶│ + This integration Corti Desktop App + (Node.js / Express) (running locally) + │ │ +POST /openCortiSession ────────▶│ │ + │──/realtime/activeSessions────────▶│ + │◀── [ existing sessions ] ─────────│ + │ │ + │ (no existing session) │ + │──/realtime/startSession──────────▶│ ← creates session + │◀── { session: { id } } ───────────│ + │──/realtime/enterSession──────────▶│ ← opens it in the UI + │──/window/unhideAllAndFocus───────▶│ ← focuses the window + │──/realtime/session/setFactValues─▶│ ← pre-fills known data +◀── { sessionId } ─────────────│ │ + │ │ + │ Dispatcher works │ + │ │ +POST /events ◀─────────────────│◀── action-block-triggered ────────│ +POST /events ◀─────────────────│◀── grouped-fvc-updated ───────────│ +POST /events ◀─────────────────│◀── comment-created ───────────────│ + │ │ +POST /leaveCortiSession ───────▶│──/realtime/leaveSession─────────▶│ ``` **Two local ports are involved:** | Port | Owner | Purpose | |------|-------|---------| -| `45002` | This integration | Receives commands from your CAD (`/openCortiSession`, `/leaveCortiSession`) and real-time events from Corti (`/events`) | -| `45001` | Corti desktop app | Receives `callMethod` RPC calls from this integration to control the session and window | +| `45002` | This integration | Receives commands from your CAD (`/openCortiSession`, `/leaveCortiSession`) and real-time events pushed by Corti (`/events`) | +| `45001` | Corti Desktop App | Receives `callMethod` RPC calls from this integration to control sessions and the window | -Your CAD system only ever speaks to port `45002`. The integration translates those requests into the appropriate `callMethod` calls on port `45001`, and routes events coming back from Corti to your CAD. +Your CAD system calls port `45002` only. The integration handles everything else. --- From 56f39374c1ddba2489123ae11e4e7ead1433d872 Mon Sep 17 00:00:00 2001 From: Henrik Cullen Date: Tue, 30 Jun 2026 18:30:13 +0200 Subject: [PATCH 6/8] Rewrite README as a step-by-step flow walkthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the doc to mirror the test-manual-flow.js sequence: dispatch → session opens → events arrive → call ends. Adds a new section explaining the session-open logic (active/DB/new), shows actual terminal log output for each event handler alongside the TODO replacement examples, and corrects the architecture diagram to accurately reflect when setFactValues is called. Co-Authored-By: Claude Sonnet 4.6 --- readme.md | 317 ++++++++++++++++++++++++++---------------------------- 1 file changed, 154 insertions(+), 163 deletions(-) diff --git a/readme.md b/readme.md index 775b062..e3a9aad 100644 --- a/readme.md +++ b/readme.md @@ -1,46 +1,44 @@ # Corti Triage — CAD Integration (Node.js) -This repository is a **working example** of a CAD integration for the Corti Triage desktop application. Clone it, run it alongside the Corti desktop app, and replace the `console.log` placeholders with calls to your own CAD API. +A working example of a CAD integration for the Corti Triage desktop application. Clone it, run it alongside the Corti desktop app, and replace the `console.log` placeholders and `TODO` comments with calls to your own CAD API. --- -## How it works +## Architecture -When a dispatcher takes a call, your CAD system calls this integration to create and open a Corti Triage session pre-filled with what you already know. As the dispatcher works through Corti's clinical decision support flow, the results — typecodes, demographics, clinical pathway answers — flow back to this integration in real time for you to push into your CAD. +Two local HTTP servers run side by side on the dispatcher's machine: ``` - This integration Corti Desktop App - (Node.js / Express) (running locally) - │ │ -POST /openCortiSession ────────▶│ │ - │──/realtime/activeSessions────────▶│ - │◀── [ existing sessions ] ─────────│ - │ │ - │ (no existing session) │ - │──/realtime/startSession──────────▶│ ← creates session - │◀── { session: { id } } ───────────│ - │──/realtime/enterSession──────────▶│ ← opens it in the UI - │──/window/unhideAllAndFocus───────▶│ ← focuses the window - │──/realtime/session/setFactValues─▶│ ← pre-fills known data -◀── { sessionId } ─────────────│ │ - │ │ - │ Dispatcher works │ - │ │ -POST /events ◀─────────────────│◀── action-block-triggered ────────│ -POST /events ◀─────────────────│◀── grouped-fvc-updated ───────────│ -POST /events ◀─────────────────│◀── comment-created ───────────────│ - │ │ -POST /leaveCortiSession ───────▶│──/realtime/leaveSession─────────▶│ +Your CAD system This integration Corti Desktop App + (Node.js / Express) (running locally) + │ │ +POST /openCortiSession ──────▶│ │ + │──/realtime/activeSessions────▶│ + │◀── [ existing sessions ] ─────│ + │ │ + │ (no existing session found) │ + │──/realtime/startSession──────▶│ ← creates session + │◀── { session: { id } } ───────│ + │──/realtime/enterSession───────▶│ ← navigates to it + │──/window/unhideAllAndFocus────▶│ ← focuses the window + │ │ +◀── { sessionId } ───────────│ │ + │ │ + │ Dispatcher works │ + │ │ +POST /events ◀───────────────│◀── action-block-triggered ────│ +POST /events ◀───────────────│◀── grouped-fvc-updated ───────│ +POST /events ◀───────────────│◀── comment-created ───────────│ + │ │ +POST /leaveCortiSession ─────▶│──/realtime/leaveSession──────▶│ ``` -**Two local ports are involved:** - | Port | Owner | Purpose | |------|-------|---------| -| `45002` | This integration | Receives commands from your CAD (`/openCortiSession`, `/leaveCortiSession`) and real-time events pushed by Corti (`/events`) | +| `45002` | This integration | Receives commands from your CAD (`/openCortiSession`, `/leaveCortiSession`) and events pushed by Corti (`/events`) | | `45001` | Corti Desktop App | Receives `callMethod` RPC calls from this integration to control sessions and the window | -Your CAD system calls port `45002` only. The integration handles everything else. +Your CAD calls port `45002` only. The integration handles all Corti communication. --- @@ -49,10 +47,10 @@ Your CAD system calls port `45002` only. The integration handles everything else ### Prerequisites - Node.js 18+ -- Corti Triage desktop app installed and running on the same machine +- Corti Triage desktop app installed and running with a user logged in - A Corti API key for your environment -### Install and configure +### Install ```bash git clone https://github.com/corticph/sample-node-integration.git @@ -60,18 +58,20 @@ cd sample-node-integration npm install ``` +### Configure + Create a `.env` file in the project root: ``` -# Port this integration listens on (your CAD calls this) +# Port this integration listens on PORT=45002 -# Corti desktop app RPC server — don't change this unless explicitly instructed +# Corti desktop app RPC server — don't change this CLIENTHOST=http://localhost:45001 -# API key for the Corti REST API. -# The variable name encodes your environment ID (uppercase). -# For API host https://api.myenv.motocorti.io → API_KEY_MYENV +# API key for your Corti environment. +# Derive the variable name from your API host: +# https://api.myenv.motocorti.io → API_KEY_MYENV API_KEY_MYENV=your-api-key-here ``` @@ -85,28 +85,27 @@ npm run dev npm run build && npm start ``` -The integration starts at `http://localhost:45002`. +--- -### Verify it works +## Typical flow walkthrough -With the Corti desktop app open and a user logged in: +The script below simulates exactly what your CAD system will do. Run it to verify the full flow end to end: ```bash -# Terminal 2 — simulates your CAD dispatching a call +# Terminal 1 — start the integration +npm run dev + +# Terminal 2 — simulate a CAD dispatch node test-manual-flow.js ``` -The Corti window should come into focus with a new session. Watch Terminal 1 for event logs as you interact with the flow. +Here is what happens at each step. --- -## Connecting your CAD system - -There are two integration points you need to implement on the CAD side. +### Step 1 — CAD dispatches a call -### 1. Open a session when a call comes in - -When your CAD receives a new call, POST to `/openCortiSession`: +Your CAD POSTs to `/openCortiSession` when an incoming call is answered: ```http POST http://localhost:45002/openCortiSession @@ -119,177 +118,168 @@ Content-Type: application/json "factValues": [ { "id": "patient.name", "value": "Jane Smith" }, { "id": "patient.age", "value": "62" }, - { "id": "chief.complaint", "value": "Shortness of breath" }, - { "id": "address", "value": "34 Elm St, Springfield" } + { "id": "chief.complaint", "value": "Shortness of breath" } ] } } } ``` -- **`externalId`** — your CAD's unique identifier for this call or incident. Corti stores this so you can match events back to the right record. -- **`facts`** — any information you already have. These pre-fill the Corti session so the dispatcher doesn't need to type what you already know. The fact IDs (`patient.name`, `patient.age`, etc.) are defined in your Corti flow configuration. - -The integration handles the idempotency: if a session with that `externalId` is already active it enters it rather than creating a duplicate. +- **`externalId`** — your unique identifier for this call or incident. Used to prevent duplicates and to match events back to the right record. +- **`facts`** — data you already have. When re-entering an existing session these are written back to the session so the dispatcher sees up-to-date information. -**Response:** +--- -```json -{ "message": "Session New", "sessionId": "235caf94-bcb6-41f4-b663-c99e09e38aff" } -``` +### Step 2 — Integration opens the session -Store the `sessionId` — you may need it to correlate incoming events. +`src/controllers/sessionController.ts` runs through this logic on every `/openCortiSession` call: -### 2. Close the session when the call ends +1. **Check active sessions** — calls `/realtime/activeSessions` on the desktop app. If a session with the same `externalId` is already open, navigates to it and returns immediately. +2. **Check the database** — calls the Corti REST API to look up a past session by `externalId`. If one exists, re-enters it. +3. **Start a new session** — calls the Corti REST API to find an active call for the current user, then calls `/realtime/startSession`. If a matching call is found the session is linked to its case ID; otherwise a standalone session is created. +4. **Enter and focus** — calls `/realtime/enterSession` to navigate the desktop app to the session, then `/window/unhideAllAndFocus` to bring it to the foreground. -```http -POST http://localhost:45002/leaveCortiSession -Content-Type: application/json +The response is returned to your CAD: -{} -``` - -### 3. Configure Corti to send events to this integration - -In your Corti environment configuration, set the webhook URL for real-time session events to: - -``` -http://localhost:45002/events +```json +{ "message": "Session New", "sessionId": "235caf94-bcb6-41f4-b663-c99e09e38aff" } ``` -Events will then flow automatically as the dispatcher works the call. +`message` will be `"Session New"`, `"Session Opened"` (existing active session), or `"Session from Db"` (found in database). Store the `sessionId` to correlate incoming events. --- -## What you receive from Corti (and what to do with it) +### Step 3 — Events arrive as the dispatcher works -Every event Corti sends has this shape: +While the dispatcher interacts with the Corti flow, the desktop app pushes events to `POST /events`. Every event has this envelope: ```json { "name": "event.name", "data": { ... } } ``` -The integration routes each event to a handler in `src/eventHandlers/`. Each handler currently logs the data — **replace those `console.log` calls and `TODO` comments with your actual CAD API calls.** +`src/controllers/eventsController.ts` routes each event by name to a handler. The handlers currently log the data — **replace the `console.log` calls and `TODO` comments with your CAD API calls.** ---- +#### Dispatcher triggers an action block (typecode button) -### Action block triggered → update typecode in your CAD +**Event:** `realtime.session.triage-flow.action-block-triggered` +**Handler:** `src/eventHandlers/handleActionBlockTriggered.ts` -**Event:** `realtime.session.triage-flow.action-block-triggered` +Fired when the dispatcher clicks a protocol or typecode button. The handler merges `customProperties` from the block prototype and block instance (instance values win on conflict) into a flat map, logs each value, and writes them back to the session as facts via `/realtime/session/setFactValues`. -Fired when the dispatcher clicks a protocol or typecode button in the Corti flow. The handler merges the action block's `customProperties` (set in the Corti flow builder) into a flat key/value map and writes them back to the session as facts. +``` +Terminal log: New Typecode: typecode - CHEST_PAIN (External Session ID: CAD-12345) +``` -**File:** `src/eventHandlers/handleActionBlockTriggered.ts` +The `customProperties` keys and values are configured in the Corti flow builder. Use them to encode your CAD's field names: ```typescript -// What you receive -{ typecode: 'CHEST_PAIN', subtypecode: 'ACUTE' } - -// Current behaviour — replace this with your CAD API call +// TODO: replace with your CAD API call console.log(`New Typecode: ${fact.id} - ${fact.value} (External Session ID: ${session.externalID})`); -// What you should do instead, for example: -await yourCadApi.updateIncidentType(session.externalID, { typecode, subtypecode }); +// Example replacement: +await yourCadApi.updateIncidentType(session.externalID, { [fact.id]: fact.value }); ``` -The `customProperties` on each action block prototype are configured in the Corti flow builder. Use them to encode your CAD's field names or codes as values. +#### Dispatcher fills in a flow value collector ---- +**Event:** `realtime.session.triage-flow.grouped-flow-value-collector-blocks-updated` +**Handler:** `src/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.ts` -### Comment created → write to your CAD notes +Fired when the dispatcher answers a question in a structured collector (demographics, incident details, clinical pathway, etc.). Corti sends the **full current state** of all collectors on every update — not just the changed field. -**Event:** `realtime.session.comments.comment-created` +Each collector block in `group[]` contains: +- `displayValues` — formatted, human-readable answer strings +- `collectedBlockValues` — structured values with `blockPrototypeID` for mapping +- `blockPrototype.customProperties` — key/value pairs you configure in the Corti flow builder -Fired when the dispatcher adds a free-text comment to the session. +``` +Terminal log: New Collector: Patient Age - 62 (External Session ID: CAD-12345) +Terminal log: Collector custom properties: 62 (External Session ID: CAD-12345) [{ key: 'fact_mapping', value: 'pt.age' }] +``` -**File:** `src/eventHandlers/handleCommentCreated.ts` +The handler deduplicates values by `blockPrototypeId` and surfaces any `customProperties` for blocks that have them. Configure a `customProperty` like `fact_mapping = pt.age` on each selectable option in the Corti flow builder to control field mapping from configuration rather than code: ```typescript -// What you receive -{ comment: { text: 'Patient is conscious and breathing.' }, session: { externalID: 'CAD-12345' } } +// TODO: replace with your CAD API call +console.log(`New Collector: ${block.blockPrototype.name} - ${textString} (External Session ID: ${session.externalID})`); -// Replace this: -console.log(`New Comment: ${comment.text} (External Session ID: ${session.externalID})`); - -// With this: -await yourCadApi.appendNote(session.externalID, comment.text); +// Example replacement using customProperties for field mapping: +const mapping = update.customProperties.find(p => p.key === 'fact_mapping'); +if (mapping) { + await yourCadApi.setField(session.externalID, mapping.value, update.text); +} ``` ---- - -### Flow value collector updated → update patient record in your CAD - -**Event:** `realtime.session.triage-flow.grouped-flow-value-collector-blocks-updated` - -This is the most data-rich event. It fires every time the dispatcher answers a question in a structured collector (demographics, clinical pathway, incident summary, etc.). Corti sends the **full current state** of every collector on each update — not just the changed field. - -**File:** `src/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.ts` +By default, one event is posted per value change. Set a `requireExplicitSendToLocalhostApi` custom property to `true` on a collector to only receive an event when the dispatcher clicks "Send". -Each collector block in the event has: -- `displayValues` — the formatted, human-readable answer strings (use these to display or log) -- `collectedBlockValues` — the structured values with their `blockPrototypeID` for mapping -- `blockPrototype.customProperties` — key/value pairs you configure in the Corti flow builder to control how this value maps to your CAD +#### Dispatcher adds a comment -**Using `customProperties` for field mapping:** +**Event:** `realtime.session.comments.comment-created` +**Handler:** `src/eventHandlers/handleCommentCreated.ts` -In the Corti flow builder, add a custom property to each selectable option's block prototype with the key `fact_mapping` (or any key your integration expects) and the value set to your CAD's field ID: +Fired when the dispatcher types a free-text comment. ``` -Block prototype: "Patient Age" -Custom property: fact_mapping = pt.age +Terminal log: New Comment: Patient is conscious and breathing. (External Session ID: CAD-12345) ``` -The handler surfaces these: - ```typescript -// What you receive (after dedup by blockPrototypeId) -{ text: '11', customProperties: [{ key: 'fact_mapping', value: 'pt.age' }] } - -// Replace the console.log with mapping logic: -for (const update of uniqueSelectUpdates) { - const mapping = update.customProperties.find(p => p.key === 'fact_mapping'); - if (mapping) { - await yourCadApi.setField(session.externalID, mapping.value, update.text); - } -} -``` - -This pattern means you control the CAD field mapping in the Corti flow configuration rather than in code. +// TODO: replace with your CAD API call +console.log(`New Comment: ${comment.text} (External Session ID: ${session.externalID})`); ---- +// Example replacement: +await yourCadApi.appendNote(session.externalID, comment.text); +``` -### Case ID linked → push CAD data to the case +#### A case ID is linked to the session -**Event:** `realtime.session.case-id-changed` +**Event:** `realtime.session.case-id-changed` +**Handler:** `src/eventHandlers/handleSessionCaseIDChanged.ts` -Fired when a Corti case ID is associated with the session (e.g. when Corti matches the call to an incoming call record). The handler calls `/backendproxy/cases/ensureCaseCustomProperties` to write properties onto the case. +Fired when Corti associates a case ID with the session (e.g. when it matches the call to an incoming call record). The handler calls `/backendproxy/cases/ensureCaseCustomProperties` to write properties onto the case. -**File:** `src/eventHandlers/handleSessionCaseIDChanged.ts` +``` +Terminal log: Case ID changed: case-abc-123 (External Session ID: CAD-12345) +``` ```typescript -// Current — hardcoded placeholder values +// TODO: replace the hardcoded values with a lookup from your CAD const customProperties = { "telephone": "1234567890", "location": "34 Elm St, Springfield, IL" } -// Replace with a lookup from your CAD using session.externalID: +// Example replacement: const call = await yourCadApi.getCall(session.externalID); -const customProperties = { - "telephone": call.callerNumber, - "location": call.incidentAddress, -} +const customProperties = { telephone: call.callerNumber, location: call.incidentAddress }; +``` + +#### Session opened / closed + +**Events:** `realtime.session-opened`, `realtime.session-closed` +**Handler:** `src/controllers/eventsController.ts` — add logic directly to the `case` blocks. + +Use these to track whether the dispatcher is actively working in Corti. + +``` +Terminal log: Event: realtime.session-opened (Session ID: 235caf94-…, External ID: CAD-12345) +Terminal log: Event: realtime.session-closed (Session ID: 235caf94-…, External ID: CAD-12345) ``` --- -### Session opened / closed +### Step 4 — Call ends -**Events:** `realtime.session-opened`, `realtime.session-closed` +When the call is complete, your CAD POSTs to `/leaveCortiSession`: -Use these to track session state in your CAD — for example to know when the dispatcher is actively working Corti versus when they've left the session. +```http +POST http://localhost:45002/leaveCortiSession +Content-Type: application/json + +{} +``` -**File:** `src/controllers/eventsController.ts` — add logic directly to the `case` blocks. +The integration calls `/realtime/leaveSession` on the desktop app. The `realtime.session-closed` event will arrive at `/events` shortly after. --- @@ -297,7 +287,7 @@ Use these to track session state in your CAD — for example to know when the di ### The callMethod RPC -The Corti desktop app exposes a local HTTP RPC server at `http://localhost:45001/callMethod`. Every call is a `POST`: +The Corti desktop app exposes a local HTTP RPC server at `http://localhost:45001/callMethod`. All calls are POSTs: ```json { "method": "/some/method", "params": { "key": "value" } } @@ -309,7 +299,7 @@ Response: { "result": } ``` -This integration wraps it in `cortiCallMethod(method, params?)` in `src/services/cortiServices.ts`. Use this function for all desktop app interactions — never call the Corti REST API directly for session control. +This integration wraps it in `cortiCallMethod(method, params?)` in `src/services/cortiServices.ts`. Use this function for all desktop app interactions. **Endpoints used by this integration:** @@ -317,11 +307,11 @@ This integration wraps it in `cortiCallMethod(method, params?)` in `src/services |--------|--------|--------------| | `/realtime/activeSessions` | — | Returns all currently active sessions | | `/realtime/startSession` | `externalID?, caseID?` | Creates a new session | -| `/realtime/enterSession` | `sessionID` | Navigates the desktop app to an existing session | +| `/realtime/enterSession` | `sessionID` | Navigates the desktop app to a session | | `/realtime/leaveSession` | — | Leaves the current session view | -| `/realtime/session/setFactValues` | `sessionID, facts[]` | Sets fact values on the active session | +| `/realtime/session/setFactValues` | `sessionID, facts[]` | Writes fact values onto a session | | `/window/unhideAllAndFocus` | — | Brings the Corti window to the foreground | -| `/app/getApiHost` | — | Returns the current Corti API base URL | +| `/app/getApiHost` | — | Returns the Corti REST API base URL | | `/app/getCurrentUser` | — | Returns the authenticated user | | `/backendproxy/cases/ensureCaseCustomProperties` | `caseID, customProperties` | Merges custom properties onto a case | @@ -331,28 +321,29 @@ This integration wraps it in `cortiCallMethod(method, params?)` in `src/services ### Manual end-to-end test -Simulates a CAD dispatching a call and walks you through the actions to verify each event type. +Simulates a full dispatch cycle and walks you through verifying each event type in the live Corti UI. ```bash -# Terminal 1 — run the integration -npm run build && node dist/index.js +# Terminal 1 +npm run dev -# Terminal 2 — simulate a CAD dispatch +# Terminal 2 node test-manual-flow.js ``` -Interact with the Corti flow in the desktop app and watch Terminal 1 for the event logs. +The script opens a session, prints a checklist of actions to perform in the Corti UI, and waits. Press Ctrl+C to trigger the leave step. ### Automated FVC handler test -Posts synthetic payloads directly to `/events` to verify the grouped flow value collector handler — no live session needed. +Posts synthetic payloads directly to `/events` to verify the grouped flow value collector handler — no live session or desktop app required. ```bash # Terminal 1 — integration must be running -node dist/index.js +npm run dev # Terminal 2 node test-grouped-flow-value-collector-blocks-updated.js +# or: npm run test:grouped-fvc ``` --- @@ -365,15 +356,15 @@ src/ eventsController.ts # Routes incoming events by name to handlers sessionController.ts # Handles /openCortiSession and /leaveCortiSession eventHandlers/ - handleActionBlockTriggered.ts # Typecode / protocol buttons - handleCommentCreated.ts # Free-text comments + handleActionBlockTriggered.ts # Typecode / protocol buttons + handleCommentCreated.ts # Free-text comments handleGroupedFlowValueCollectorBlocksUpdated.ts # Structured flow answers - handleSessionCaseIDChanged.ts # Case ID linking + handleSessionCaseIDChanged.ts # Case ID linking routes/ events.ts # POST /events session.ts # POST /openCortiSession, POST /leaveCortiSession services/ - cortiServices.ts # callMethod wrapper + Corti REST API calls + cortiServices.ts # cortiCallMethod wrapper + Corti REST API calls types/ apiResponses.ts # Response type definitions events.ts # Event payload type definitions @@ -381,7 +372,7 @@ src/ utils.ts # getApiHost, getApiKey, enterSessionAndOpenWindow ``` -## Adding new handlers +### Adding a new event handler 1. Create `src/eventHandlers/handleMyEvent.ts` 2. Export it from `src/eventHandlers/index.ts` From 6d37b8b06973d8ad29ff14253bf2ca153bdb7894 Mon Sep 17 00:00:00 2001 From: Henrik Cullen Date: Tue, 30 Jun 2026 19:27:53 +0200 Subject: [PATCH 7/8] Harden integration, align types with desktop API, add tests Fixes and hardening - Fix facts write-back: forward CAD `factValues` to setFactValues as `{ sessionID, facts }` on all session paths (new + re-entry); previously the openSession path sent the wrong shape with no sessionID. - Guard openSession against malformed `activeSessions` responses and wrap both session handlers in try/catch (return 502 instead of crashing). - action-block handler: always log the trigger (falling back name -> content -> id), tolerate missing customProperties, only patch facts when present. - Implement GET /health liveness probe; split app.ts from index.ts so the app can be mounted in tests without binding a port. - leaveSession forwards the documented `sessionID` param when provided. Types - Add src/types/shared.ts mirroring the desktop API shared types (Session, CustomProperty, Fact, UserTypeSerialized) 1:1. - Fix collectedFactValues to Fact[]; CurrentUserResponse = UserTypeSerialized. Config - Replace per-host API_KEY_ derivation with a single API_KEY. - Drop unused node-fetch dependency. Tests - Add unit + integration tests (Node test runner); integration boots the app against a stub desktop app and asserts the setFactValues shape and the malformed-activeSessions regression. Add test:* npm scripts and a live e2e script. Fix the manual-flow script to stay alive until Ctrl+C. Docs - Add DESKTOP_APP_API.md reference; rewrite README to match (environments model, response codes, /health, single API_KEY, project structure, types). Co-Authored-By: Claude Opus 4.8 (1M context) --- DESKTOP_APP_API.md | 557 ++++++++++++++++++ package-lock.json | 87 +-- package.json | 10 +- readme.md | 100 +++- src/app.ts | 22 + src/controllers/sessionController.ts | 115 ++-- .../handleActionBlockTriggered.ts | 41 +- ...eGroupedFlowValueCollectorBlocksUpdated.ts | 6 +- .../handleSessionCaseIDChanged.ts | 8 +- src/index.ts | 23 +- src/routes/health.ts | 11 + src/services/cortiServices.ts | 8 +- src/types/apiResponses.ts | 61 +- src/types/events.ts | 43 +- src/types/shared.ts | 31 + src/utils/utils.ts | 30 +- test-e2e-desktop.js | 93 +++ ...ped-flow-value-collector-blocks-updated.js | 30 +- test-manual-flow.js | 6 +- tests/integration.test.js | 247 ++++++++ tests/unit.test.js | 132 +++++ 21 files changed, 1377 insertions(+), 284 deletions(-) create mode 100644 DESKTOP_APP_API.md create mode 100644 src/app.ts create mode 100644 src/types/shared.ts create mode 100644 test-e2e-desktop.js create mode 100644 tests/integration.test.js create mode 100644 tests/unit.test.js diff --git a/DESKTOP_APP_API.md b/DESKTOP_APP_API.md new file mode 100644 index 0000000..347868a --- /dev/null +++ b/DESKTOP_APP_API.md @@ -0,0 +1,557 @@ +# Desktop App Public API + +The desktop (Electron) app exposes a JSON-RPC style API via the `publicapi` framework. Methods are called via `callMethod` and events are subscribed via `subscribeAll`. + +- **Renderer:** `window.publicapi.callMethod(method, params?)` / `window.publicapi.subscribeAll(cb)` +- **HTTP (launcher):** `POST /callMethod` with `{ method, params? }` body; events are POSTed to a configured webhook URL. +- **IPC:** Events and method calls are bridged between main and renderer via channels `core/publicapi:method-call` and `core/publicapi:push-event`. + +--- + +## Shared Types + +```typescript +interface Session { + id: string; + caseID?: string; + externalID?: string; + owner?: { + id: string; + name: string; + }; +} + +type NotificationType = 'info' | 'error' | 'success' | 'warning'; + +type ShowNotificationInput = PermanentNotificationInput | TemporaryNotificationInput; + +interface BaseNotificationInput { + id?: string; + message: string; + detailText?: string; + type?: NotificationType; + closable?: boolean; + permanent?: boolean; +} + +interface PermanentNotificationInput extends BaseNotificationInput { + permanent: true; +} + +interface TemporaryNotificationInput extends BaseNotificationInput { + permanent?: false; + duration?: number; + showDurationTimer?: boolean; +} + +interface UserTypeSerialized { + id: string; + name: string; + organizationID: string; + extension?: string; + externalID?: string; +} + +interface CustomProperty { + key: string; + value: string; +} + +type Fact = { + id: string; + value: string | boolean; +}; +``` + +--- + +## Methods (`callMethod`) + +### Auth + +#### `/app/getCurrentUserToken` + +Returns the token of the currently logged-in user. + +```typescript +// params: none + +// result: +{ + userToken: string | undefined; +} +``` + +#### `/app/getCurrentUser` + +Returns the current authenticated user. + +```typescript +// params: none + +// result: UserTypeSerialized | undefined +{ + id: string; + name: string; + organizationID: string; + extension?: string; + externalID?: string; +} +``` + +#### `/app/logout` + +Logs out the current user. + +```typescript +// params: none +// result: void +``` + +#### `/app/signInWithToken` + +Determines the owner of a token and signs them in. + +```typescript +// params: +{ + token: string; + options?: { + rememberUser?: boolean; + }; +} + +// result: void +``` + +--- + +### Navigation + +#### `/router/push` + +Changes the app route by adding an entry to history. + +```typescript +// params: +{ + path: string; +} + +// result: void +``` + +#### `/router/replace` + +Changes the app route without adding to history. + +```typescript +// params: +{ + path: string; +} + +// result: void +``` + +#### `/app/changeApp` + +Switches the active app/module. + +```typescript +// params: +{ + app: 'triage' | 'review' | 'editor'; +} + +// result: void +``` + +--- + +### Configuration / Backend + +#### `/app/getApiHost` + +Returns the current API host. + +```typescript +// params: none + +// result: +{ + apiHost?: string; +} +``` + +#### `/app/changeApiHost` + +Changes the host of the main backend API server. + +```typescript +// params: +{ + apiHost: string; +} + +// result: void +``` + +#### `/backendproxy/cases/ensureCaseCustomProperties` + +Merges provided custom properties with existing ones of a case. + +```typescript +// params: +{ + caseID: string; + customProperties: Record; +} + +// result: boolean +``` + +--- + +### Notifications + +#### `/app/showNotification` + +Shows a UI notification. Returns a notification ID. + +```typescript +// params: ShowNotificationInput +{ + message: string; + id?: string; + detailText?: string; + type?: 'info' | 'error' | 'success' | 'warning'; + closable?: boolean; + permanent?: boolean; + // if permanent is false/omitted: + duration?: number; + showDurationTimer?: boolean; +} + +// result: +{ + notificationID: string; +} +``` + +#### `/app/closeNotification` + +Closes a notification. + +```typescript +// params: +{ + notificationID: string; +} + +// result: void +``` + +--- + +### Realtime + +#### `/realtime/activeSessions` + +Returns all active/ongoing sessions. + +```typescript +// params: none + +// result: +{ + activeSessions: Session[]; +} +``` + +#### `/realtime/enterSession` + +Enters the view of an existing session. + +```typescript +// params: +{ + sessionID: string; +} + +// result: void +``` + +#### `/realtime/startSession` + +Starts a new session. If `externalID` matches an active session, it refocuses instead of creating new. + +```typescript +// params: +{ + externalID?: string; + startTime?: string; // ISO 8601, syncs start time to account for network delay + caseID?: string; // attaches session to an existing case; creates new case if omitted +} + +// result: +{ + session: { + id: string; + externalID?: string; + }; +} +``` + +#### `/realtime/leaveSession` + +Leaves the current session view. + +```typescript +// params: +{ + sessionID: string; +} + +// result: void +``` + +--- + +### Triage Session + +#### `/realtime/session/setFactValues` + +Sets fact values on the currently active triage session. Only updates provided facts; existing unprovided facts are unchanged. + +```typescript +// params: +{ + sessionID: string; + facts: Fact[]; +} + +// result: void +``` + +#### `/realtime/session/getFactValues` + +Gets values of all facts in the triage session. Only facts with values set are returned. + +```typescript +// params: +{ + sessionID: string; +} + +// result: Fact[] +{ + id: string; + value: string | boolean; +}[] +``` + +--- + +### Window (launcher-side) + +#### `/window/hideAll` + +Hides the app window(s). + +```typescript +// params: none +// result: void +``` + +#### `/window/unhideAllAndFocus` + +Unhides all windows and focuses the app. + +```typescript +// params: none +// result: void +``` + +--- + +## Events (`subscribeAll`) + +All events are emitted via `fireEvent` and received by `subscribeAll` callbacks as `{ name, data }`. Realtime events are only fired for the current session owner. + +--- + +### `app.login` + +Fired when the auth store has loaded (user is logged in / auth state initialized). + +```typescript +{ + name: 'app.login'; + data: void; +} +``` + +### `app.logout` + +Fired when the current user logs out. + +```typescript +{ + name: 'app.logout'; + data: void; +} +``` + +### `shell.quit` + +Fired when the Electron application is quitting. + +```typescript +{ + name: 'shell.quit'; + data: void; +} +``` + +### `realtime.session-opened` + +Fired when a triage session view is opened by the current user. + +```typescript +{ + name: 'realtime.session-opened'; + data: { + session: Session; + }; +} +``` + +### `realtime.session-closed` + +Fired when a triage session view is closed by the current user. + +```typescript +{ + name: 'realtime.session-closed'; + data: { + session: Session; + }; +} +``` + +### `realtime.session.case-id-changed` + +Fired when the session's `caseID` changes (becomes non-null and differs from previous). + +```typescript +{ + name: 'realtime.session.case-id-changed'; + data: { + session: Session; + }; +} +``` + +### `realtime.session.comments.comment-created` + +Fired when a comment is created by the current user in a triage session. + +```typescript +{ + name: 'realtime.session.comments.comment-created'; + data: { + session: Session; + comment: { + text: string; + createdBy: { + id: string; + }; + }; + }; +} +``` + +### `realtime.session.triage-flow.action-block-triggered` + +Fired when an action block is triggered in the triage flow. + +```typescript +{ + name: 'realtime.session.triage-flow.action-block-triggered'; + data: { + session: Session; + nodeID?: string; + blockInstance?: { + id: string; + customProperties: CustomProperty[]; + }; + blockPrototype: { + id: string; + content: string; + name: string; + customProperties: CustomProperty[]; + }; + }; +} +``` + +### `realtime.session.triage-flow.grouped-flow-value-collector-blocks-updated` + +Fired when grouped flow value collector blocks are updated. Auto-emission is suppressed if any collector has `requireExplicitSendToLocalhostApi=true` custom property; in that case it must be explicitly emitted via `emitGroupedCollectorBlocksToLocalhost`. + +```typescript +{ + name: 'realtime.session.triage-flow.grouped-flow-value-collector-blocks-updated'; + data: { + session: Session; + group: FlowValueCollectorPayload[]; + }; +} + +interface FlowValueCollectorPayload { + displayValues: Array<{ + text: string; + }>; + blockPrototype: { + id: string; + name: string; + customProperties: CustomProperty[]; + }; + customValues: Array<{ + value: string; + }>; + collectedFactValues: Fact[]; + collectedBlockValues: { + blockPrototypes: Array<{ + id: string; + label: string; + customProperties: CustomProperty[]; + }>; + values: Array<{ + blockPrototypeID: string; + value?: string; + text: string; + customProperties?: CustomProperty[]; + }>; + }; +} +``` + +--- + +## Type Definitions Source Files + +| Domain | Type definitions | Implementation | +|---|---|---| +| Auth | `src/core/auth/auth.publicapi.ts` | `src/core/auth/auth.publicapi.impl.ts` | +| Navigation | `src/browser/navigation.publicapi.ts` | `src/browser/navigation.ts` | +| Config | `src/core/configuration/browser/config.publicapi.ts` | `src/core/configuration/browser/initConfig.ts` | +| Remote request | `src/browser/stores/remoterequest.publicapi.ts` | `src/browser/stores/remoterequest.publicapi.impl.ts` | +| Notifications | `src/browser/stores/notifications.publicapi.ts` | `src/browser/stores/notifications.publicapi.impl.ts` | +| Backend proxy | `src/browser/backendproxy.publicapi.ts` | `src/browser/backendproxy.publicapi.impl.ts` | +| Realtime | `src/modules/RealTimeApp/publicapi/realtime.publicapi.ts` | `src/modules/RealTimeApp/publicapi/RealTimePublicApi.ts` | +| Triage session | `src/modules/RealTimeApp/publicapi/triageSession.publicapi.ts` | `src/modules/RealTimeApp/publicapi/TriageSessionPublicApi.ts` | +| Window/shell | `packages/launcher/src/main/apiv2.publicapi.ts` | `packages/launcher/src/main/apiv2.publicapi.impl.ts` | +| Framework | `src/core/publicapi/shared/framework.ts` | — | +| Shared types | `src/core/api/shared/types.ts` | — | diff --git a/package-lock.json b/package-lock.json index 7383014..050977b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,7 @@ "dependencies": { "body-parser": "^1.20.2", "dotenv": "^16.3.1", - "express": "^4.18.2", - "node-fetch": "^3.3.2" + "express": "^4.18.2" }, "devDependencies": { "@types/express": "^4.17.19", @@ -423,14 +422,6 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "engines": { - "node": ">= 12" - } - }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -602,28 +593,6 @@ "node": ">= 0.8" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -653,17 +622,6 @@ "node": ">= 0.8" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -966,41 +924,6 @@ "node": ">= 0.6" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/nodemon": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.1.tgz", @@ -1506,14 +1429,6 @@ "node": ">= 0.8" } }, - "node_modules/web-streams-polyfill": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", - "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", - "engines": { - "node": ">= 8" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 4caf482..e8746b0 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,19 @@ "scripts": { "build": "npx tsc", "start": "node dist/index.js", - "dev": "concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\"" + "dev": "concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\"", + "test": "npm run build && node --test tests/unit.test.js tests/integration.test.js", + "test:unit": "npm run build && node --test tests/unit.test.js", + "test:integration": "npm run build && node --test tests/integration.test.js", + "test:grouped-fvc": "node test-grouped-flow-value-collector-blocks-updated.js", + "test:e2e": "node test-e2e-desktop.js" }, "author": "Corti ApS", "license": "UNLICENSED", "dependencies": { "body-parser": "^1.20.2", "dotenv": "^16.3.1", - "express": "^4.18.2", - "node-fetch": "^3.3.2" + "express": "^4.18.2" }, "devDependencies": { "@types/express": "^4.17.19", diff --git a/readme.md b/readme.md index e3a9aad..e023b13 100644 --- a/readme.md +++ b/readme.md @@ -35,7 +35,7 @@ POST /leaveCortiSession ─────▶│──/realtime/leaveSession── | Port | Owner | Purpose | |------|-------|---------| -| `45002` | This integration | Receives commands from your CAD (`/openCortiSession`, `/leaveCortiSession`) and events pushed by Corti (`/events`) | +| `45002` | This integration | Receives commands from your CAD (`/openCortiSession`, `/leaveCortiSession`), events pushed by Corti (`/events`), and a `GET /health` liveness probe | | `45001` | Corti Desktop App | Receives `callMethod` RPC calls from this integration to control sessions and the window | Your CAD calls port `45002` only. The integration handles all Corti communication. @@ -69,12 +69,19 @@ PORT=45002 # Corti desktop app RPC server — don't change this CLIENTHOST=http://localhost:45001 -# API key for your Corti environment. -# Derive the variable name from your API host: -# https://api.myenv.motocorti.io → API_KEY_MYENV -API_KEY_MYENV=your-api-key-here +# API key for your Corti environment +API_KEY=your-api-key-here ``` +#### Environments + +There is no shared "prod" or "dev" — **each customer has their own isolated Corti environment**, identified by a short name (the examples in this repo use one called `try`). For an environment named ``: + +- **Web frontend:** `https://.corti.app` +- **API:** `https://api..motocorti.io` + +A single integration instance serves one environment, so set `API_KEY` to that environment's key. The integration still discovers the API host at runtime from the desktop app (`/app/getApiHost`) to build its REST calls — but the key itself comes straight from `API_KEY`. To target a different environment, log the desktop app into it and swap `API_KEY`; no code changes needed. + ### Run ```bash @@ -126,7 +133,7 @@ Content-Type: application/json ``` - **`externalId`** — your unique identifier for this call or incident. Used to prevent duplicates and to match events back to the right record. -- **`facts`** — data you already have. When re-entering an existing session these are written back to the session so the dispatcher sees up-to-date information. +- **`facts`** — data you already have, shaped as `{ factValues: [{ id, value }] }`. Whenever provided, they're written to the session — both when starting a new session and when re-entering an existing one — so the dispatcher sees up-to-date information. The integration forwards `factValues` to the desktop app's `setFactValues` RPC as `{ sessionID, facts }`. --- @@ -137,7 +144,7 @@ Content-Type: application/json 1. **Check active sessions** — calls `/realtime/activeSessions` on the desktop app. If a session with the same `externalId` is already open, navigates to it and returns immediately. 2. **Check the database** — calls the Corti REST API to look up a past session by `externalId`. If one exists, re-enters it. 3. **Start a new session** — calls the Corti REST API to find an active call for the current user, then calls `/realtime/startSession`. If a matching call is found the session is linked to its case ID; otherwise a standalone session is created. -4. **Enter and focus** — calls `/realtime/enterSession` to navigate the desktop app to the session, then `/window/unhideAllAndFocus` to bring it to the foreground. +4. **Enter, focus, and write facts** — calls `/realtime/enterSession` to navigate the desktop app to the session, `/window/unhideAllAndFocus` to bring it to the foreground, and (when `facts` were supplied) `/realtime/session/setFactValues` to write them onto the session. The response is returned to your CAD: @@ -147,6 +154,14 @@ The response is returned to your CAD: `message` will be `"Session New"`, `"Session Opened"` (existing active session), or `"Session from Db"` (found in database). Store the `sessionId` to correlate incoming events. +**Response codes:** + +| Status | Body | When | +|--------|------|------| +| `200` | `{ message, sessionId }` | Session opened — `message` is `Session New`, `Session Opened`, or `Session from Db` | +| `400` | `{ message: "Missing externalId" }` | `data.externalId` was missing from the request | +| `502` | `{ message: "Failed to reach the Corti desktop app" }` | The desktop app couldn't be reached (e.g. not running) | + --- ### Step 3 — Events arrive as the dispatcher works @@ -164,9 +179,10 @@ While the dispatcher interacts with the Corti flow, the desktop app pushes event **Event:** `realtime.session.triage-flow.action-block-triggered` **Handler:** `src/eventHandlers/handleActionBlockTriggered.ts` -Fired when the dispatcher clicks a protocol or typecode button. The handler merges `customProperties` from the block prototype and block instance (instance values win on conflict) into a flat map, logs each value, and writes them back to the session as facts via `/realtime/session/setFactValues`. +Fired when the dispatcher clicks a protocol or typecode button. The handler always logs the trigger (falling back to the block's `content` or `id` when it has no `name`), then merges any `customProperties` from the block prototype and block instance (instance values win on conflict) into a flat map, logs each value, and — when the block carried any — writes them back to the session as facts via `/realtime/session/setFactValues`. A block with no name or custom properties still logs a useful trigger line. ``` +Terminal log: Action block triggered: Chest Pain (External Session ID: CAD-12345) Terminal log: New Typecode: typecode - CHEST_PAIN (External Session ID: CAD-12345) ``` @@ -266,20 +282,27 @@ Terminal log: Event: realtime.session-opened (Session ID: 235caf94-…, External Terminal log: Event: realtime.session-closed (Session ID: 235caf94-…, External ID: CAD-12345) ``` +#### User logs in / out + +**Events:** `app.login`, `app.logout` +**Handler:** `src/controllers/eventsController.ts` — currently logged only. + +The desktop app also emits these auth lifecycle events. They're logged so you can see them in the stream; add logic if your CAD needs to react to the dispatcher signing in or out. + --- ### Step 4 — Call ends -When the call is complete, your CAD POSTs to `/leaveCortiSession`: +When the call is complete, your CAD POSTs to `/leaveCortiSession`. Include the `sessionId` you stored from `/openCortiSession` so the integration can tell the desktop app which session to leave: ```http POST http://localhost:45002/leaveCortiSession Content-Type: application/json -{} +{ "sessionId": "235caf94-bcb6-41f4-b663-c99e09e38aff" } ``` -The integration calls `/realtime/leaveSession` on the desktop app. The `realtime.session-closed` event will arrive at `/events` shortly after. +The integration forwards it to `/realtime/leaveSession` as `{ sessionID }`. An empty body (`{}`) is also accepted and leaves the current session view. It returns `200` on success, or `502` if the desktop app can't be reached. The `realtime.session-closed` event will arrive at `/events` shortly after. --- @@ -301,6 +324,8 @@ Response: This integration wraps it in `cortiCallMethod(method, params?)` in `src/services/cortiServices.ts`. Use this function for all desktop app interactions. +The full method, event, and type catalog is in [`DESKTOP_APP_API.md`](DESKTOP_APP_API.md). The types in `src/types/` mirror it 1:1 — `shared.ts` holds the shared types (`Session`, `Fact`, `CustomProperty`, …), `apiResponses.ts` the `callMethod` result types, and `events.ts` the event payloads. + **Endpoints used by this integration:** | Method | Params | What it does | @@ -308,7 +333,7 @@ This integration wraps it in `cortiCallMethod(method, params?)` in `src/services | `/realtime/activeSessions` | — | Returns all currently active sessions | | `/realtime/startSession` | `externalID?, caseID?` | Creates a new session | | `/realtime/enterSession` | `sessionID` | Navigates the desktop app to a session | -| `/realtime/leaveSession` | — | Leaves the current session view | +| `/realtime/leaveSession` | `sessionID?` | Leaves the session view (the current one if omitted) | | `/realtime/session/setFactValues` | `sessionID, facts[]` | Writes fact values onto a session | | `/window/unhideAllAndFocus` | — | Brings the Corti window to the foreground | | `/app/getApiHost` | — | Returns the Corti REST API base URL | @@ -319,39 +344,63 @@ This integration wraps it in `cortiCallMethod(method, params?)` in `src/services ## Testing -### Manual end-to-end test +### Automated tests (no desktop app required) -Simulates a full dispatch cycle and walks you through verifying each event type in the live Corti UI. +Unit tests cover the handler/util logic. Integration tests boot the real Express app against an in-process stub that impersonates the desktop app's `callMethod` RPC, then assert the full request flow (including that facts are forwarded to `setFactValues` as `{ sessionID, facts }`). ```bash -# Terminal 1 +npm test # build, then unit + integration +npm run test:unit +npm run test:integration +``` + +### Automated FVC handler test (integration must be running) + +Posts synthetic payloads directly to `/events` to verify the grouped flow value collector handler — no live session needed. + +```bash +# Terminal 1 — integration must be running npm run dev # Terminal 2 -node test-manual-flow.js +npm run test:grouped-fvc +# or: node test-grouped-flow-value-collector-blocks-updated.js ``` -The script opens a session, prints a checklist of actions to perform in the Corti UI, and waits. Press Ctrl+C to trigger the leave step. +### End-to-end against the desktop app -### Automated FVC handler test +With the desktop app running and logged in, this drives the open → write-facts → leave flow through the real RPC. -Posts synthetic payloads directly to `/events` to verify the grouped flow value collector handler — no live session or desktop app required. +```bash +# Terminal 1 +npm run dev + +# Terminal 2 +npm run test:e2e +``` + +### Manual end-to-end walkthrough + +Simulates a full dispatch cycle and walks you through verifying each event type in the live Corti UI. ```bash -# Terminal 1 — integration must be running +# Terminal 1 npm run dev # Terminal 2 -node test-grouped-flow-value-collector-blocks-updated.js -# or: npm run test:grouped-fvc +node test-manual-flow.js ``` +The script opens a session, prints a checklist of actions to perform in the Corti UI, and waits. Press Ctrl+C to trigger the leave step. + --- ## Project structure ``` src/ + app.ts # Builds the Express app (middleware + routes) + index.ts # Entry point — loads .env and starts the server controllers/ eventsController.ts # Routes incoming events by name to handlers sessionController.ts # Handles /openCortiSession and /leaveCortiSession @@ -361,15 +410,20 @@ src/ handleGroupedFlowValueCollectorBlocksUpdated.ts # Structured flow answers handleSessionCaseIDChanged.ts # Case ID linking routes/ + health.ts # GET /health events.ts # POST /events session.ts # POST /openCortiSession, POST /leaveCortiSession services/ cortiServices.ts # cortiCallMethod wrapper + Corti REST API calls types/ - apiResponses.ts # Response type definitions + shared.ts # Shared types (Session, Fact, CustomProperty, …) — 1:1 with DESKTOP_APP_API.md + apiResponses.ts # callMethod result types + Corti REST API types events.ts # Event payload type definitions utils/ utils.ts # getApiHost, getApiKey, enterSessionAndOpenWindow +tests/ + unit.test.js # Handler/util logic (no network) + integration.test.js # Full app against a stub desktop app ``` ### Adding a new event handler diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..aab7556 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,22 @@ +import express from 'express' +import bodyParser from 'body-parser' +import healthRoute from './routes/health' +import sessionRoutes from './routes/session' +import eventsRoute from './routes/events' +// Continue importing other routes as required + +// Builds the Express app. Kept separate from index.ts so tests can mount +// the app without binding to a port or starting the listener. +export const createApp = () => { + const app = express() + + app.use(bodyParser.json()) + app.use(bodyParser.urlencoded({ extended: false })) + + app.use(healthRoute) + app.use(sessionRoutes) + app.use(eventsRoute) + // Continue using other imported routes as required + + return app +} diff --git a/src/controllers/sessionController.ts b/src/controllers/sessionController.ts index 26d90e4..bb35afd 100644 --- a/src/controllers/sessionController.ts +++ b/src/controllers/sessionController.ts @@ -9,13 +9,13 @@ import { ActiveSessionsResponse, CurrentUserResponse, DBSession, - IFactUpdate, + FactUpdatePayload, StartSessionResponse, } from "../types/apiResponses"; interface OpenSessionParams { externalId: string; - facts?: IFactUpdate; + facts?: FactUpdatePayload; } export const openSession = async (req: Request, res: Response) => { @@ -25,61 +25,82 @@ export const openSession = async (req: Request, res: Response) => { } const { externalId } = data; - const { activeSessions } = - ((await cortiCallMethod( + try { + // Be defensive: the desktop app may return an unexpected shape (e.g. while + // shutting down), so default to an empty list rather than crashing. + const activeSessionsResult = (await cortiCallMethod( "/realtime/activeSessions" - )) as ActiveSessionsResponse) || []; + )) as ActiveSessionsResponse | undefined; + const activeSessions = activeSessionsResult?.activeSessions ?? []; - const activeSession = activeSessions.find( - (session) => session.externalID === externalId - ); + const activeSession = activeSessions.find( + (session) => session.externalID === externalId + ); - // Check if session is already open - if (activeSession) { - enterSessionAndOpenWindow(activeSession.id, data?.facts); - return res - .status(200) - .send({ message: "Session Opened", sessionId: activeSession.id }); - } + // Check if session is already open + if (activeSession) { + enterSessionAndOpenWindow(activeSession.id, data?.facts); + return res + .status(200) + .send({ message: "Session Opened", sessionId: activeSession.id }); + } - // Check if session already exists in database - const sessionFromDb = (await checkSessionExists(externalId)) as DBSession; - if (sessionFromDb) { - enterSessionAndOpenWindow(sessionFromDb.id); - return res - .status(200) - .send({ message: "Session from Db", sessionId: sessionFromDb.id }); - } + // Check if session already exists in database + const sessionFromDb = (await checkSessionExists(externalId)) as DBSession; + if (sessionFromDb) { + enterSessionAndOpenWindow(sessionFromDb.id, data?.facts); + return res + .status(200) + .send({ message: "Session from Db", sessionId: sessionFromDb.id }); + } - // Find appropriate call to match - const calls = await getMatchingCalls(); - // return first call that matches (calls are sorted by start time) - // Note, you may want to introduce more complex logic here to ensure you are matching - const currentUser = (await cortiCallMethod( - "/app/getCurrentUser" - )) as CurrentUserResponse; - const matchingCall = calls.find((call) => call.user_id === currentUser.id); + // Find appropriate call to match + const calls = await getMatchingCalls(); + // return first call that matches (calls are sorted by start time) + // Note, you may want to introduce more complex logic here to ensure you are matching + const currentUser = (await cortiCallMethod( + "/app/getCurrentUser" + )) as CurrentUserResponse; + const matchingCall = calls.find((call) => call.user_id === currentUser.id); - let newSession: StartSessionResponse; + let newSession: StartSessionResponse; - if (matchingCall) { - newSession = (await cortiCallMethod("/realtime/startSession", { - externalID: externalId, - caseID: matchingCall.case_id, - })) as StartSessionResponse; - } else { - newSession = (await cortiCallMethod("/realtime/startSession", { - externalID: externalId, - })) as StartSessionResponse; - } + if (matchingCall) { + newSession = (await cortiCallMethod("/realtime/startSession", { + externalID: externalId, + caseID: matchingCall.case_id, + })) as StartSessionResponse; + } else { + newSession = (await cortiCallMethod("/realtime/startSession", { + externalID: externalId, + })) as StartSessionResponse; + } - enterSessionAndOpenWindow(newSession.session.id); + enterSessionAndOpenWindow(newSession.session.id, data?.facts); - return res - .status(200) - .send({ message: "Session New", sessionId: newSession.session.id }); + return res + .status(200) + .send({ message: "Session New", sessionId: newSession.session.id }); + } catch (error) { + // A thrown error here (e.g. desktop app unreachable) would otherwise become + // an unhandled rejection and crash the whole process. + console.error("openSession failed:", error); + return res + .status(502) + .send({ message: "Failed to reach the Corti desktop app" }); + } }; export const leaveSession = async (req: Request, res: Response) => { - cortiCallMethod("/realtime/leaveSession").then(() => res.sendStatus(200)); + // The desktop app's /realtime/leaveSession takes the sessionID to leave. + // The CAD stored it from /openCortiSession; forward it when provided. + const sessionId = req.body?.sessionId as string | undefined; + const params = sessionId ? { sessionID: sessionId } : undefined; + try { + await cortiCallMethod("/realtime/leaveSession", params); + res.sendStatus(200); + } catch (error) { + console.error("leaveSession failed:", error); + res.sendStatus(502); + } }; diff --git a/src/eventHandlers/handleActionBlockTriggered.ts b/src/eventHandlers/handleActionBlockTriggered.ts index f0e83a3..baf7335 100644 --- a/src/eventHandlers/handleActionBlockTriggered.ts +++ b/src/eventHandlers/handleActionBlockTriggered.ts @@ -2,21 +2,33 @@ import { cortiCallMethod } from "../services/cortiServices"; import { ActionBlockTriggered } from "../types/events"; const handleActionBlockTriggered = async (data: ActionBlockTriggered) => { - let { session, blockPrototype, blockInstance } = data; + const { session, blockPrototype, blockInstance } = data; - // merge key/value pair objects in customProperties of blockPrototype and blockInstance. - // In case of clash, choose the instance value. - let customProperties: Record = {}; + // Always log that an action block was triggered — even one carrying no + // custom properties — so the event is never silent. Real blocks may have an + // empty `name`, so use `||` to fall through to content / id (never blank). + const blockLabel = + blockPrototype?.name || + blockPrototype?.content || + blockPrototype?.id || + "(unknown)"; + console.log( + `Action block triggered: ${blockLabel} (External Session ID: ${session?.externalID})` + ); + + // Merge customProperties from blockPrototype and blockInstance. + // In case of a clash, the instance value wins. A block may carry none, so + // default to an empty list rather than assuming the field is present. + const customProperties: Record = {}; - blockPrototype.customProperties.forEach((item, i) => { + (blockPrototype?.customProperties ?? []).forEach((item) => { customProperties[item.key] = item.value; }); - blockInstance?.customProperties.forEach((item, i) => { + (blockInstance?.customProperties ?? []).forEach((item) => { customProperties[item.key] = item.value; }); // convert an object to an array of key value pairs - // https://stackoverflow.com/questions/43118692/typescript-convert-object-to-array-of-key-value-pairs const factUpdateBody = Object.entries(customProperties).reduce( (acc, [key, value]) => { acc.push({ id: key, value: value }); @@ -32,11 +44,16 @@ const handleActionBlockTriggered = async (data: ActionBlockTriggered) => { } }); - // Patch session with new facts - cortiCallMethod('/realtime/session/setFactValues', { - sessionID: session.id, - facts: factUpdateBody - }) + // Patch session with new facts (only when the block carried any). + if (factUpdateBody.length) { + cortiCallMethod('/realtime/session/setFactValues', { + sessionID: session.id, + facts: factUpdateBody, + }); + } + + // Returned for testability; the controller ignores the return value. + return factUpdateBody; }; export default handleActionBlockTriggered; diff --git a/src/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.ts b/src/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.ts index 9151e9b..962543d 100644 --- a/src/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.ts +++ b/src/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.ts @@ -1,4 +1,5 @@ -import { GroupedFlowValueCollectorBlocksUpdated, CustomProperty } from "../types/events"; +import { GroupedFlowValueCollectorBlocksUpdated } from "../types/events"; +import { CustomProperty } from "../types/shared"; interface ISelectUpdates { blockPrototypeId: string; @@ -56,6 +57,9 @@ const handleGroupedFlowValueCollectorBlocksUpdated = async ( update.customProperties ); }); + + // Returned for testability; the controller ignores the return value. + return uniqueSelectUpdates; }; export default handleGroupedFlowValueCollectorBlocksUpdated; diff --git a/src/eventHandlers/handleSessionCaseIDChanged.ts b/src/eventHandlers/handleSessionCaseIDChanged.ts index b2415c7..4b8fef2 100644 --- a/src/eventHandlers/handleSessionCaseIDChanged.ts +++ b/src/eventHandlers/handleSessionCaseIDChanged.ts @@ -1,11 +1,7 @@ import { updateCaseCustomProperties } from "../services/cortiServices"; -import { Session } from "../types/events"; +import { SessionCaseIDChanged } from "../types/events"; -interface SessionCaseIDChangedBody { - session: Session; -} - -const handleSessionCaseIDChanged = async (data: SessionCaseIDChangedBody) => { +const handleSessionCaseIDChanged = async (data: SessionCaseIDChanged) => { const { session } = data; if (session.caseID) { console.log(`Case ID changed: ${session.caseID} (External Session ID: ${session.externalID})`); diff --git a/src/index.ts b/src/index.ts index e8dbc37..17a7dad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,10 @@ -import dotenv from 'dotenv'; -import express from 'express' -import bodyParser from 'body-parser' -import sessionRoutes from './routes/session' -import eventsRoute from './routes/events' -// Continue importing other routes as required +import dotenv from 'dotenv' +dotenv.config() -// setup express app -const app = express() -dotenv.config(); -const port = process.env.PORT; +import { createApp } from './app' -// bodyParser setup -app.use(bodyParser.json()) -app.use(bodyParser.urlencoded({ extended: false })) - -// use imported routes -app.use(sessionRoutes) -app.use(eventsRoute) -// Continue using other imported routes as required +const port = process.env.PORT || 45002 +const app = createApp() // set the app to listen on the port app.listen(port, () => { diff --git a/src/routes/health.ts b/src/routes/health.ts index e69de29..01cb3cc 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -0,0 +1,11 @@ +import express from 'express' + +const router = express.Router() + +// Liveness probe — lets a CAD (or the test scripts) confirm the +// integration is up without opening a session or touching the desktop app. +router.get('/health', (_req, res) => { + res.status(200).json({ status: 'ok' }) +}) + +export default router diff --git a/src/services/cortiServices.ts b/src/services/cortiServices.ts index f0dbb2d..8a9a71d 100644 --- a/src/services/cortiServices.ts +++ b/src/services/cortiServices.ts @@ -43,9 +43,9 @@ export const updateCaseCustomProperties = async ( export const checkSessionExists = async (externalSessionId: string): Promise => { const apiHost = await getApiHost(); - const apiKey = getApiKey(apiHost); + const apiKey = getApiKey(); if (!apiKey) { - console.error(`No API key found for ${apiHost}`); + console.error("No API key found — set API_KEY in .env"); return null; } @@ -68,9 +68,9 @@ export const checkSessionExists = async (externalSessionId: string): Promise => { const apiHost = await getApiHost(); - const apiKey = getApiKey(apiHost); + const apiKey = getApiKey(); if (!apiKey) { - console.error(`No API key found for ${apiHost}`); + console.error("No API key found — set API_KEY in .env"); return [] } diff --git a/src/types/apiResponses.ts b/src/types/apiResponses.ts index 7e4f46b..0951734 100644 --- a/src/types/apiResponses.ts +++ b/src/types/apiResponses.ts @@ -1,30 +1,20 @@ -export interface Session { - id: string; - caseID?: string; - externalID?: string; - owner?: { - id: string; - name: string; - }; -} +import { Fact, Session, UserTypeSerialized } from "./shared"; + +// ── Desktop app callMethod result types (1:1 with DESKTOP_APP_API.md) ── +// Result of /realtime/activeSessions export interface ActiveSessionsResponse { activeSessions: Session[]; } -export interface CurrentUserResponse { - id: string; - name: string; - organizationID: string; - extension?: string; - externalID?: string; -} +// Result of /app/getCurrentUser +export type CurrentUserResponse = UserTypeSerialized; /** - * Starts new session. + * Result of /realtime/startSession. * - * If external ID is provided and matches current session that is active - * it will not create a new session, but will bring the existing one into focus + * If externalID matches an active session, the desktop app refocuses the + * existing session instead of creating a new one. */ export interface StartSessionResponse { session: { @@ -33,8 +23,20 @@ export interface StartSessionResponse { }; } +/** + * Facts supplied by the CAD on /openCortiSession. + * + * The CAD sends `{ factValues: [...] }`. The desktop app's + * `/realtime/session/setFactValues` RPC expects `{ sessionID, facts: Fact[] }`, + * so the `factValues` array is forwarded as `facts` (see enterSessionAndOpenWindow). + */ +export interface FactUpdatePayload { + factValues: Fact[]; +} + +// ── Corti REST API types (separate public REST API, not the desktop app) ── -export interface CallsResponse{ +export interface CallsResponse { continuation_token: number | null; data: Call[]; } @@ -54,16 +56,11 @@ export interface DBSessionsResponse { } export interface DBSession { - id: string; - user_id: string; - owner_user_id: string; - case_id: string; - external_id: string | null; - started_at: string; - call_id: string | null; -} - -export interface IFactUpdate { id: string; - value: string; -} \ No newline at end of file + user_id: string; + owner_user_id: string; + case_id: string; + external_id: string | null; + started_at: string; + call_id: string | null; +} diff --git a/src/types/events.ts b/src/types/events.ts index fbd8a7f..207970c 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -1,3 +1,9 @@ +import { CustomProperty, Fact, Session } from "./shared"; + +// Event payload types — the `data` field of each `{ name, data }` envelope. +// Kept 1:1 with the Events section of DESKTOP_APP_API.md. + +// realtime.session.comments.comment-created export interface CommentCreated { session: Session; comment: { @@ -8,6 +14,7 @@ export interface CommentCreated { }; } +// realtime.session.triage-flow.action-block-triggered export interface ActionBlockTriggered { session: Session; nodeID?: string; @@ -23,14 +30,26 @@ export interface ActionBlockTriggered { }; } +// realtime.session.case-id-changed +export interface SessionCaseIDChanged { + session: Session; +} + +// realtime.session-opened / realtime.session-closed +export interface SessionOpenedOrClosed { + session: Session; +} + +// realtime.session.triage-flow.grouped-flow-value-collector-blocks-updated export interface GroupedFlowValueCollectorBlocksUpdated { session: Session; group: FlowValueCollectorPayload[]; } -interface FlowValueCollectorPayload { +export interface FlowValueCollectorPayload { /** - * Display Values: Ordered and formatted (custom format evaluated) collector values as they are displayed in the UI + * Display Values: Ordered and formatted (custom format evaluated) collector + * values as they are displayed in the UI. */ displayValues: Array<{ text: string; @@ -43,10 +62,7 @@ interface FlowValueCollectorPayload { customValues: Array<{ value: string; }>; - collectedFactValues: Array<{ - factID: string; - value: string; - }>; + collectedFactValues: Fact[]; collectedBlockValues: { blockPrototypes: Array<{ id: string; @@ -61,18 +77,3 @@ interface FlowValueCollectorPayload { }>; }; } - -export interface CustomProperty { - key: string; - value: string; -} - -export interface Session { - id: string; - caseID?: string; - externalID?: string; - owner?: { - id: string; - name: string; - }; -} diff --git a/src/types/shared.ts b/src/types/shared.ts new file mode 100644 index 0000000..77d9542 --- /dev/null +++ b/src/types/shared.ts @@ -0,0 +1,31 @@ +// Shared types — kept 1:1 with the desktop app public API "Shared Types". +// See DESKTOP_APP_API.md. Only the types this integration actually uses are +// mirrored here; notification/router/etc. types are intentionally omitted. + +export interface Session { + id: string; + caseID?: string; + externalID?: string; + owner?: { + id: string; + name: string; + }; +} + +export interface CustomProperty { + key: string; + value: string; +} + +export type Fact = { + id: string; + value: string | boolean; +}; + +export interface UserTypeSerialized { + id: string; + name: string; + organizationID: string; + extension?: string; + externalID?: string; +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 8d3a78d..0da2f47 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,23 +1,14 @@ import dotenv from "dotenv"; import { cortiCallMethod } from "../services/cortiServices"; -import { IFactUpdate } from "../types/apiResponses"; +import { FactUpdatePayload } from "../types/apiResponses"; // load the .env file dotenv.config(); -export const getApiKey = (apiHost: string) => { - // format the API host to match the naming convention in the .env file - - // convert format https://api.ENVIRONMENTID.motocorti.io to ENVIRONMENTID in a single line - const environmentID = apiHost - .replace(/https:\/\/api\.(.*?)\.motocorti\.io/, "$1") - .toLocaleUpperCase(); - - const envVariableName = `API_KEY_${environmentID}`; - - // get and return the matching API key from the environment variables - return process.env[envVariableName]; -}; +// The API key for the Corti environment this integration serves. +// Each customer has their own environment (see README "Environments"), so a +// single integration instance only ever needs one key. +export const getApiKey = () => process.env.API_KEY; export const getApiHost = async () => { const response = await cortiCallMethod("/app/getApiHost"); @@ -26,15 +17,20 @@ export const getApiHost = async () => { export const enterSessionAndOpenWindow = async ( sessionID: string, - facts?: IFactUpdate + facts?: FactUpdatePayload ) => { console.log(`Entering Session: ${sessionID}`); cortiCallMethod("/realtime/enterSession", { sessionID, }).then(() => { cortiCallMethod("/window/unhideAllAndFocus"); - if (facts) { - cortiCallMethod("/realtime/session/setFactValues", facts); + // The CAD sends facts as `{ factValues: [...] }`, but the desktop app's + // setFactValues RPC expects `{ sessionID, facts: [...] }`. Forward accordingly. + if (facts?.factValues?.length) { + cortiCallMethod("/realtime/session/setFactValues", { + sessionID, + facts: facts.factValues, + }); } }); }; diff --git a/test-e2e-desktop.js b/test-e2e-desktop.js new file mode 100644 index 0000000..571cfd1 --- /dev/null +++ b/test-e2e-desktop.js @@ -0,0 +1,93 @@ +/** + * End-to-end smoke test against the REAL Corti desktop app. + * + * Requires: + * - the desktop app running and logged in (RPC on CLIENTHOST, default :45001) + * - this integration running (npm run dev / npm start) on PORT (default :45002) + * + * Verifies the open → (facts write-back) → leave flow end to end: + * GET /health + * POST /openCortiSession → 200 + sessionId, opens/focuses the desktop app + * POST /leaveCortiSession → 200, leaves the session view + * + * Run: npm run test:e2e + */ +const PORT = process.env.PORT || 45002; +const BASE_URL = `http://localhost:${PORT}`; +const EXTERNAL_ID = `E2E-${Date.now()}`; + +async function req(method, endpoint, body) { + const res = await fetch(`${BASE_URL}${endpoint}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + let data; + try { data = JSON.parse(text); } catch { data = text; } + return { status: res.status, data }; +} + +let failed = 0; +function check(label, cond, detail) { + if (cond) { + console.log(` ✅ ${label}`); + } else { + failed++; + console.log(` ❌ ${label}${detail ? ' — ' + detail : ''}`); + } +} + +async function run() { + console.log('='.repeat(60)); + console.log(' E2E against real desktop app'); + console.log('='.repeat(60)); + console.log(` Integration: ${BASE_URL}`); + console.log(` External ID: ${EXTERNAL_ID}\n`); + + // 1. Health + const health = await req('GET', '/health').catch((e) => ({ status: 0, data: e.message })); + check('GET /health returns 200', health.status === 200, `status=${health.status}`); + if (health.status !== 200) { + console.error('\n Integration is not running. Start it with `npm run dev` or `npm start`.\n'); + process.exit(1); + } + + // 2. Open a session (with facts, exercising the setFactValues write-back path) + const open = await req('POST', '/openCortiSession', { + data: { + externalId: EXTERNAL_ID, + facts: { + factValues: [ + { id: 'patient.name', value: 'E2E Test' }, + { id: 'patient.age', value: '62' }, + ], + }, + }, + }); + console.log(` → /openCortiSession HTTP ${open.status}:`, open.data); + check('POST /openCortiSession returns 200', open.status === 200, `status=${open.status}`); + check('response includes a sessionId', Boolean(open.data && open.data.sessionId), JSON.stringify(open.data)); + check( + 'message is one of Session New/Opened/from Db', + ['Session New', 'Session Opened', 'Session from Db'].includes(open.data && open.data.message), + open.data && open.data.message + ); + + const sessionId = open.data && open.data.sessionId; + + // 3. Leave the session (forwarding the sessionID, per the desktop API) + const leave = await req('POST', '/leaveCortiSession', sessionId ? { sessionId } : {}); + console.log(` → /leaveCortiSession HTTP ${leave.status}`); + check('POST /leaveCortiSession returns 200', leave.status === 200, `status=${leave.status}`); + + console.log('\n' + '='.repeat(60)); + console.log(failed === 0 ? ' ✅ E2E PASSED' : ` ❌ E2E FAILED (${failed} check(s))`); + console.log('='.repeat(60) + '\n'); + process.exit(failed === 0 ? 0 : 1); +} + +run().catch((err) => { + console.error('Unexpected error:', err.message); + process.exit(1); +}); diff --git a/test-grouped-flow-value-collector-blocks-updated.js b/test-grouped-flow-value-collector-blocks-updated.js index 39822d3..08c04e4 100644 --- a/test-grouped-flow-value-collector-blocks-updated.js +++ b/test-grouped-flow-value-collector-blocks-updated.js @@ -115,18 +115,26 @@ async function runTests() { console.log(''); // ---------------------------------------- - // 2. FVC with requireExplicitSendToLocalhostApi – no events on value change + // 2. FVC carrying requireExplicitSendToLocalhostApi is still accepted by the integration // ---------------------------------------- - console.log('Test 2: FVC with requireExplicitSendToLocalhostApi – no events on value change'); + console.log('Test 2: FVC with requireExplicitSendToLocalhostApi is accepted by the integration'); console.log('-'.repeat(60)); + console.log(' Note: auto-emit suppression happens in the desktop app, not here.'); + console.log(' Once an event IS emitted, the integration must process it normally.'); try { - // Simulate “value changes”: we do not send any events. So the API receives 0. - // Assertion: after “value changes” we have sent 0 grouped-flow-value-collector-blocks-updated events. - // We can’t assert “API received 0” without changing the app. We assert our test behaviour: - // we did not post any events for value changes → then we post one “Send” and it’s the first one. - pass('Value changes send 0 events (simulated by not posting); nothing to assert on API.'); + const payload = buildFvcEventPayload({ + fvcName: 'ExplicitSendCollector', + customProperties: [{ key: 'requireExplicitSendToLocalhostApi', value: 'true' }], + displayValue: 'pending-value', + }); + const { status, ok } = await postEvent(payload); + if (ok && status === 200) { + pass('Integration accepted an FVC event that carries requireExplicitSendToLocalhostApi.'); + } else { + fail('Integration rejected the requireExplicitSendToLocalhostApi event', `status=${status}`); + } } catch (e) { - fail('Explicit-send “no events on change” threw', e.message); + fail('requireExplicitSendToLocalhostApi acceptance test threw', e.message); } console.log(''); @@ -202,11 +210,7 @@ async function runTests() { // Allow running standalone (node test-grouped-flow-value-collector-blocks-updated.js) async function main() { try { - const r = await fetch(BASE_URL + '/events', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'app.login', data: {} }), - }).catch(() => null); + const r = await fetch(BASE_URL + '/health').catch(() => null); if (!r || r.status !== 200) { console.error('Cannot reach API at', BASE_URL, '- ensure the app is running (e.g. npm run dev).'); process.exit(1); diff --git a/test-manual-flow.js b/test-manual-flow.js index 1c487f3..6059170 100644 --- a/test-manual-flow.js +++ b/test-manual-flow.js @@ -88,11 +88,15 @@ async function run() { // ── Step 3: Graceful shutdown ───────────────────────────── process.on('SIGINT', async () => { step(3, 'leaveCortiSession — cleaning up'); - const leaveResult = await post('/leaveCortiSession', {}); + const leaveResult = await post('/leaveCortiSession', { sessionId }); console.log(` → HTTP ${leaveResult.status}:`, leaveResult.data); console.log('\n Done. Check integration logs for "Event: realtime.session-closed".\n'); process.exit(0); }); + + // Keep the process alive so the dispatcher can interact with Corti and the + // events stream in. Ctrl+C triggers the SIGINT handler above to leave. + process.stdin.resume(); } run().catch(err => { diff --git a/tests/integration.test.js b/tests/integration.test.js new file mode 100644 index 0000000..157096d --- /dev/null +++ b/tests/integration.test.js @@ -0,0 +1,247 @@ +/** + * Integration tests. Boots the real Express app against an in-process stub + * that impersonates the Corti desktop app's /callMethod RPC, so the full + * request flow runs without the real desktop app. Run: npm run test:integration + */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); + +let stub, stubCalls, server, baseUrl, stubUrl; +// Overridable so a test can simulate a malformed/empty desktop-app response. +let activeSessionsResult = { activeSessions: [] }; + +function startServer(handler) { + return new Promise((resolve) => { + const s = http.createServer(handler); + s.listen(0, '127.0.0.1', () => resolve(s)); + }); +} + +function readBody(req) { + return new Promise((resolve) => { + let b = ''; + req.on('data', (c) => (b += c)); + req.on('end', () => { + try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } + }); + }); +} + +async function waitFor(fn, timeout = 2000, interval = 25) { + const start = Date.now(); + for (;;) { + const v = fn(); + if (v) return v; + if (Date.now() - start >= timeout) return v; + await new Promise((r) => setTimeout(r, interval)); + } +} + +function callsSince(index, method) { + return stubCalls.find((c, i) => i >= index && c.method === method); +} + +test.before(async () => { + stubCalls = []; + + // Stub desktop app: answers the callMethod RPCs this integration makes. + stub = await startServer(async (req, res) => { + const body = await readBody(req); + res.setHeader('Content-Type', 'application/json'); + + if (req.method === 'POST' && req.url === '/callMethod') { + const { method, params } = body; + stubCalls.push({ method, params }); + let result = {}; + switch (method) { + case '/app/getApiHost': + result = { apiHost: stubUrl }; + break; + case '/app/getCurrentUser': + result = { id: 'user-1', name: 'Test', organizationID: 'org-1' }; + break; + case '/realtime/activeSessions': + result = activeSessionsResult; + break; + case '/realtime/startSession': + result = { session: { id: 'new-session-1', externalID: params?.externalID } }; + break; + default: + result = {}; + } + res.end(JSON.stringify({ result })); + return; + } + res.statusCode = 404; + res.end('{}'); + }); + stubUrl = `http://127.0.0.1:${stub.address().port}`; + + // Point the integration at the stub BEFORE requiring it (clientHost is read at import). + process.env.CLIENTHOST = stubUrl; + const { createApp } = require('../dist/app.js'); + const app = createApp(); + server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +test.after(async () => { + if (server) await new Promise((r) => server.close(r)); + if (stub) await new Promise((r) => stub.close(r)); +}); + +test('GET /health returns ok', async () => { + const r = await fetch(`${baseUrl}/health`); + assert.equal(r.status, 200); + assert.deepEqual(await r.json(), { status: 'ok' }); +}); + +test('POST /openCortiSession with no externalId returns 400', async () => { + const r = await fetch(`${baseUrl}/openCortiSession`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: {} }), + }); + assert.equal(r.status, 400); +}); + +test('POST /openCortiSession starts a session and forwards facts as { sessionID, facts }', async () => { + const before = stubCalls.length; + const r = await fetch(`${baseUrl}/openCortiSession`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: { + externalId: 'EXT-INT-1', + facts: { factValues: [{ id: 'patient.age', value: '62' }] }, + }, + }), + }); + assert.equal(r.status, 200); + const json = await r.json(); + assert.equal(json.message, 'Session New'); + assert.equal(json.sessionId, 'new-session-1'); + + // enterSessionAndOpenWindow runs fire-and-forget after the response. + const setCall = await waitFor(() => callsSince(before, '/realtime/session/setFactValues')); + assert.ok(setCall, 'setFactValues should be called'); + assert.equal(setCall.params.sessionID, 'new-session-1', 'sessionID is included'); + assert.deepEqual( + setCall.params.facts, + [{ id: 'patient.age', value: '62' }], + 'factValues are forwarded under the `facts` key' + ); +}); + +test('POST /openCortiSession survives a malformed activeSessions response (regression)', async () => { + // Reproduces the crash where the desktop app (e.g. while shutting down) + // returned no usable activeSessions and `.find` threw, killing the process. + activeSessionsResult = null; + try { + const r = await fetch(`${baseUrl}/openCortiSession`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: { externalId: 'EXT-MALFORMED' } }), + }); + assert.equal(r.status, 200, 'falls back to creating a session instead of crashing'); + const json = await r.json(); + assert.equal(json.message, 'Session New'); + } finally { + activeSessionsResult = { activeSessions: [] }; + } + + // Prove the server is still alive afterwards. + const health = await fetch(`${baseUrl}/health`); + assert.equal(health.status, 200); +}); + +test('POST /events action-block-triggered patches merged facts via setFactValues', async () => { + const before = stubCalls.length; + const r = await fetch(`${baseUrl}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'realtime.session.triage-flow.action-block-triggered', + data: { + session: { id: 's-int', externalID: 'EXT-INT-1' }, + blockPrototype: { + id: 'bp', + content: '', + name: 'Chest Pain', + customProperties: [{ key: 'typecode', value: 'PROTO' }], + }, + blockInstance: { id: 'bi', customProperties: [{ key: 'typecode', value: 'CHEST_PAIN' }] }, + }, + }), + }); + assert.equal(r.status, 200); + + const setCall = await waitFor(() => callsSince(before, '/realtime/session/setFactValues')); + assert.ok(setCall); + assert.equal(setCall.params.sessionID, 's-int'); + assert.deepEqual(setCall.params.facts, [{ id: 'typecode', value: 'CHEST_PAIN' }]); +}); + +test('POST /events case-id-changed writes case custom properties', async () => { + const before = stubCalls.length; + const r = await fetch(`${baseUrl}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'realtime.session.case-id-changed', + data: { session: { id: 's', caseID: 'case-1', externalID: 'EXT-INT-1' } }, + }), + }); + assert.equal(r.status, 200); + + const call = await waitFor(() => callsSince(before, '/backendproxy/cases/ensureCaseCustomProperties')); + assert.ok(call); + assert.equal(call.params.caseID, 'case-1'); + assert.equal(typeof call.params.customProperties, 'object'); +}); + +test('POST /events comment-created / grouped-fvc / unknown all return 200', async () => { + const events = [ + { name: 'realtime.session.comments.comment-created', data: { session: { id: 's', externalID: 'E' }, comment: { text: 'hi', createdBy: { id: 'u' } } } }, + { name: 'realtime.session.triage-flow.grouped-flow-value-collector-blocks-updated', data: { session: { id: 's', externalID: 'E' }, group: [] } }, + { name: 'realtime.session-opened', data: { session: { id: 's', externalID: 'E' } } }, + { name: 'some.unknown.event', data: {} }, + ]; + for (const ev of events) { + const r = await fetch(`${baseUrl}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ev), + }); + assert.equal(r.status, 200, `${ev.name} returns 200`); + } +}); + +test('POST /leaveCortiSession forwards sessionID when provided', async () => { + const before = stubCalls.length; + const r = await fetch(`${baseUrl}/leaveCortiSession`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: 'leave-me' }), + }); + assert.equal(r.status, 200); + const leaveCall = await waitFor(() => callsSince(before, '/realtime/leaveSession')); + assert.ok(leaveCall); + assert.deepEqual(leaveCall.params, { sessionID: 'leave-me' }); +}); + +test('POST /leaveCortiSession with empty body still leaves (no params)', async () => { + const before = stubCalls.length; + const r = await fetch(`${baseUrl}/leaveCortiSession`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + assert.equal(r.status, 200); + const leaveCall = await waitFor(() => callsSince(before, '/realtime/leaveSession')); + assert.ok(leaveCall); + assert.equal(leaveCall.params, undefined); +}); diff --git a/tests/unit.test.js b/tests/unit.test.js new file mode 100644 index 0000000..5c7014d --- /dev/null +++ b/tests/unit.test.js @@ -0,0 +1,132 @@ +/** + * Unit tests for pure handler/util logic. No network, no desktop app. + * Run against the compiled output: npm run test:unit + */ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { getApiKey } = require('../dist/utils/utils.js'); +const handleGroupedFvc = require('../dist/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.js').default; +const services = require('../dist/services/cortiServices.js'); +const handleActionBlock = require('../dist/eventHandlers/handleActionBlockTriggered.js').default; + +test('getApiKey returns process.env.API_KEY', () => { + process.env.API_KEY = 'secret-key'; + assert.equal(getApiKey(), 'secret-key'); +}); + +test('getApiKey returns undefined when API_KEY is unset', () => { + delete process.env.API_KEY; + assert.equal(getApiKey(), undefined); +}); + +test('grouped FVC: dedupes by blockPrototypeId and keeps only blocks with customProperties', async () => { + const data = { + session: { id: 's1', externalID: 'EXT-1' }, + group: [ + { + displayValues: [{ text: '62' }], + blockPrototype: { id: 'bp-age', name: 'Patient Age', customProperties: [] }, + customValues: [], + collectedFactValues: [], + collectedBlockValues: { + blockPrototypes: [ + { id: 'opt-age', label: '62', customProperties: [{ key: 'fact_mapping', value: 'pt.age' }] }, + ], + values: [ + { blockPrototypeID: 'opt-age', text: '62' }, + { blockPrototypeID: 'opt-age', text: '62' }, // duplicate → deduped + { blockPrototypeID: 'missing-prototype', text: 'orphan' }, // no prototype → skipped + ], + }, + }, + { + displayValues: [{ text: 'Male' }], + blockPrototype: { id: 'bp-sex', name: 'Sex', customProperties: [] }, + customValues: [], + collectedFactValues: [], + collectedBlockValues: { + blockPrototypes: [{ id: 'opt-sex', label: 'Male', customProperties: [] }], + values: [{ blockPrototypeID: 'opt-sex', text: 'Male' }], // no customProps → filtered out + }, + }, + ], + }; + + const result = await handleGroupedFvc(data); + assert.equal(result.length, 1, 'only the block with customProperties survives'); + assert.equal(result[0].blockPrototypeId, 'opt-age'); + assert.deepEqual(result[0].customProperties, [{ key: 'fact_mapping', value: 'pt.age' }]); +}); + +test('grouped FVC: tolerates a collector with no values', async () => { + const data = { + session: { id: 's1', externalID: 'EXT-1' }, + group: [ + { + displayValues: [], + blockPrototype: { id: 'bp-empty', name: 'Empty', customProperties: [] }, + customValues: [], + collectedFactValues: [], + collectedBlockValues: { blockPrototypes: [], values: [] }, + }, + ], + }; + const result = await handleGroupedFvc(data); + assert.deepEqual(result, []); +}); + +test('action-block: merges prototype + instance customProperties (instance wins) and patches facts', async (t) => { + const calls = []; + t.mock.method(services, 'cortiCallMethod', async (method, params) => { + calls.push({ method, params }); + return {}; + }); + + const data = { + session: { id: 's1', externalID: 'EXT-1' }, + blockPrototype: { + id: 'bp', + content: '', + name: 'Chest Pain', + customProperties: [ + { key: 'typecode', value: 'PROTO' }, + { key: 'priority', value: 'P2' }, + ], + }, + blockInstance: { id: 'bi', customProperties: [{ key: 'typecode', value: 'CHEST_PAIN' }] }, + }; + + const facts = await handleActionBlock(data); + + assert.equal(facts.find((f) => f.id === 'typecode').value, 'CHEST_PAIN', 'instance value wins'); + assert.equal(facts.find((f) => f.id === 'priority').value, 'P2'); + + const setCall = calls.find((c) => c.method === '/realtime/session/setFactValues'); + assert.ok(setCall, 'setFactValues is called'); + assert.equal(setCall.params.sessionID, 's1'); + assert.deepEqual(setCall.params.facts, facts); +}); + +test('action-block: a block with no custom properties is handled without throwing or patching facts', async (t) => { + const calls = []; + t.mock.method(services, 'cortiCallMethod', async (method, params) => { + calls.push({ method, params }); + return {}; + }); + + // blockPrototype.customProperties omitted entirely, no blockInstance — + // this is the silent/throwing case that produced no console log. + const data = { + session: { id: 's1', externalID: 'EXT-1' }, + blockPrototype: { id: 'bp', content: '', name: 'Cardiac Arrest' }, + }; + + const facts = await handleActionBlock(data); + assert.deepEqual(facts, [], 'no facts derived'); + assert.equal( + calls.find((c) => c.method === '/realtime/session/setFactValues'), + undefined, + 'setFactValues is not called when there is nothing to write' + ); +}); From 24bdcac47eb2da6068325f27953bb827451af1b5 Mon Sep 17 00:00:00 2001 From: Henrik Cullen Date: Tue, 30 Jun 2026 19:31:22 +0200 Subject: [PATCH 8/8] Address PR review: propagate case-update failures, avoid logging PII MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updateCaseCustomProperties now returns the cortiCallMethod promise so callers can await completion and observe failures; guard the caller in handleSessionCaseIDChanged with try/catch to avoid unhandled rejections. - Default event handler no longer logs the full event data (potential PII) — logs only the event name and session/external IDs. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/controllers/eventsController.ts | 4 +++- src/eventHandlers/handleSessionCaseIDChanged.ts | 6 +++++- src/services/cortiServices.ts | 4 +++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/controllers/eventsController.ts b/src/controllers/eventsController.ts index a76ed4d..7a092e9 100644 --- a/src/controllers/eventsController.ts +++ b/src/controllers/eventsController.ts @@ -30,7 +30,9 @@ export const handleEvent = async (req: Request, res: Response) => { console.log(`Event: ${event.name}`); break; default: - console.log(`Unhandled event: ${event.name}`, data); + // Avoid logging the full payload — event data can contain session facts + // and other PII. Log only the name and identifiers. + console.log(`Unhandled event: ${event.name} (Session ID: ${data?.session?.id ?? 'N/A'}, External ID: ${data?.session?.externalID ?? 'N/A'})`); break; } res.sendStatus(200); diff --git a/src/eventHandlers/handleSessionCaseIDChanged.ts b/src/eventHandlers/handleSessionCaseIDChanged.ts index 4b8fef2..97a7ff6 100644 --- a/src/eventHandlers/handleSessionCaseIDChanged.ts +++ b/src/eventHandlers/handleSessionCaseIDChanged.ts @@ -10,7 +10,11 @@ const handleSessionCaseIDChanged = async (data: SessionCaseIDChanged) => { "telephone": "1234567890", "location": "34 Elm St, Springfield, IL" } - await updateCaseCustomProperties(session.caseID, customProperties); + try { + await updateCaseCustomProperties(session.caseID, customProperties); + } catch (error) { + console.error("Failed to update case custom properties:", error); + } } }; diff --git a/src/services/cortiServices.ts b/src/services/cortiServices.ts index 8a9a71d..ec6370f 100644 --- a/src/services/cortiServices.ts +++ b/src/services/cortiServices.ts @@ -35,7 +35,9 @@ export const updateCaseCustomProperties = async ( caseId: string, customPropertiesBody: CaseCustomProperties ) => { - cortiCallMethod("/backendproxy/cases/ensureCaseCustomProperties", { + // Return the promise so callers can await completion and observe failures + // rather than treating a fire-and-forget call as if it had succeeded. + return cortiCallMethod("/backendproxy/cases/ensureCaseCustomProperties", { caseID: caseId, customProperties: customPropertiesBody, });