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 19ec467..e023b13 100644 --- a/readme.md +++ b/readme.md @@ -1,82 +1,433 @@ -# Corti Triage Sample Integration Application (Node.js) +# Corti Triage — CAD 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 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. -## 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. +## Architecture -## Configuration -Before running the integration, you need to configure the following settings in the .env file: +Two local HTTP servers run side by side on the dispatcher's machine: -- `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. +``` +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──────▶│ +``` + +| Port | Owner | Purpose | +|------|-------|---------| +| `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. + +--- + +## Getting started + +### Prerequisites + +- Node.js 18+ +- Corti Triage desktop app installed and running with a user logged in +- A Corti API key for your environment + +### Install -### Example .env File +```bash +git clone https://github.com/corticph/sample-node-integration.git +cd sample-node-integration +npm install ``` -PORT=5173 -CLIENTHOST=http://localhost:3000 -API_KEY_ENGAGESTAGING=xxxxxx -API_KEY_OTHERENV=yyyyyyy + +### Configure + +Create a `.env` file in the project root: + ``` +# Port this integration listens on +PORT=45002 -## 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. +# Corti desktop app RPC server — don't change this +CLIENTHOST=http://localhost:45001 + +# API key for your Corti environment +API_KEY=your-api-key-here +``` -### 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: +#### Environments -#### Payload -The payload should be an object with the following properties: +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 ``: -- `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. +- **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 +# Development — TypeScript watch mode with auto-restart +npm run dev + +# Production +npm run build && npm start ``` -POST /openCortiSession HTTP/1.1 + +--- + +## Typical flow walkthrough + +The script below simulates exactly what your CAD system will do. Run it to verify the full flow end to end: + +```bash +# Terminal 1 — start the integration +npm run dev + +# Terminal 2 — simulate a CAD dispatch +node test-manual-flow.js +``` + +Here is what happens at each step. + +--- + +### Step 1 — CAD dispatches a call + +Your CAD POSTs to `/openCortiSession` when an incoming call is answered: + +```http +POST http://localhost:45002/openCortiSession Content-Type: application/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" } + ] + } + } +} +``` + +- **`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, 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 }`. + +--- + +### Step 2 — Integration opens the session + +`src/controllers/sessionController.ts` runs through this logic on every `/openCortiSession` call: + +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, 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: + +```json +{ "message": "Session New", "sessionId": "235caf94-bcb6-41f4-b663-c99e09e38aff" } +``` + +`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 + +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": { ... } } +``` + +`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) + +**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 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) +``` + +The `customProperties` keys and values are configured in the Corti flow builder. Use them to encode your CAD's field names: + +```typescript +// TODO: replace with your CAD API call +console.log(`New Typecode: ${fact.id} - ${fact.value} (External Session ID: ${session.externalID})`); + +// Example replacement: +await yourCadApi.updateIncidentType(session.externalID, { [fact.id]: fact.value }); +``` + +#### Dispatcher fills in a flow value collector + +**Event:** `realtime.session.triage-flow.grouped-flow-value-collector-blocks-updated` +**Handler:** `src/eventHandlers/handleGroupedFlowValueCollectorBlocksUpdated.ts` + +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. + +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 + +``` +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' }] +``` + +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 +// TODO: replace with your CAD API call +console.log(`New Collector: ${block.blockPrototype.name} - ${textString} (External Session ID: ${session.externalID})`); + +// 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); +} +``` + +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". + +#### Dispatcher adds a comment + +**Event:** `realtime.session.comments.comment-created` +**Handler:** `src/eventHandlers/handleCommentCreated.ts` + +Fired when the dispatcher types a free-text comment. + +``` +Terminal log: New Comment: Patient is conscious and breathing. (External Session ID: CAD-12345) +``` + +```typescript +// 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); +``` + +#### A case ID is linked to the session + +**Event:** `realtime.session.case-id-changed` +**Handler:** `src/eventHandlers/handleSessionCaseIDChanged.ts` + +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. + +``` +Terminal log: Case ID changed: case-abc-123 (External Session ID: CAD-12345) +``` + +```typescript +// TODO: replace the hardcoded values with a lookup from your CAD +const customProperties = { + "telephone": "1234567890", + "location": "34 Elm St, Springfield, IL" } +// Example replacement: +const call = await yourCadApi.getCall(session.externalID); +const customProperties = { telephone: call.callerNumber, location: call.incidentAddress }; ``` -### 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. +#### Session opened / closed -### 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. +**Events:** `realtime.session-opened`, `realtime.session-closed` +**Handler:** `src/controllers/eventsController.ts` — add logic directly to the `case` blocks. -### Comment Created: -When a new comment is created in a session, it is logged with the associated session ID. +Use these to track whether the dispatcher is actively working in Corti. -### Grouped Flow Value Collector Blocks Updated: -This event logs updates to grouped flow value collectors such as reports and demographics. +``` +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) +``` + +#### 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`. 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 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. + +--- + +## Architecture reference + +### The callMethod RPC + +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" } } +``` + +Response: + +```json +{ "result": } +``` + +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 | +|--------|--------|--------------| +| `/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` | `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 | +| `/app/getCurrentUser` | — | Returns the authenticated user | +| `/backendproxy/cases/ensureCaseCustomProperties` | `caseID, customProperties` | Merges custom properties onto a case | + +--- + +## Testing + +### Automated tests (no desktop app required) + +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 +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 +npm run test:grouped-fvc +# or: node test-grouped-flow-value-collector-blocks-updated.js +``` + +### End-to-end against the desktop app + +With the desktop app running and logged in, this drives the open → write-facts → leave flow through the real RPC. + +```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 +npm run dev + +# Terminal 2 +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 + eventHandlers/ + handleActionBlockTriggered.ts # Typecode / protocol buttons + handleCommentCreated.ts # Free-text comments + 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/ + 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 +``` -## Contact -For any queries or suggestions regarding this integration, please contact [your email address]. +### Adding a new event handler -We hope this sample integration helps you get started with integrating \ No newline at end of file +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` 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/eventsController.ts b/src/controllers/eventsController.ts index b8ac0f9..7a092e9 100644 --- a/src/controllers/eventsController.ts +++ b/src/controllers/eventsController.ts @@ -18,12 +18,21 @@ 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); + // 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/controllers/sessionController.ts b/src/controllers/sessionController.ts index cef407a..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/enterSession").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 35746d9..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; @@ -15,7 +16,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; @@ -53,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 2abf80c..97a7ff6 100644 --- a/src/eventHandlers/handleSessionCaseIDChanged.ts +++ b/src/eventHandlers/handleSessionCaseIDChanged.ts @@ -1,19 +1,20 @@ 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})`); // fetch custom properties for the case, either from the CAD or in memory const customProperties = { "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/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 a319cb8..ec6370f 100644 --- a/src/services/cortiServices.ts +++ b/src/services/cortiServices.ts @@ -35,28 +35,19 @@ 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), - } - ); + // 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, + }); }; 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; } @@ -79,9 +70,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 ca62960..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("/app/unhideAllAndFocus"); - if (facts) { - cortiCallMethod("/realtime/session/setFactValues", facts); + cortiCallMethod("/window/unhideAllAndFocus"); + // 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 new file mode 100644 index 0000000..08c04e4 --- /dev/null +++ b/test-grouped-flow-value-collector-blocks-updated.js @@ -0,0 +1,225 @@ +/** + * 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 carrying requireExplicitSendToLocalhostApi is still accepted by the integration + // ---------------------------------------- + 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 { + 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('requireExplicitSendToLocalhostApi acceptance test 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 + '/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); + } + } catch (e) { + console.error('Cannot reach API at', BASE_URL, '- ensure the app is running.'); + process.exit(1); + } + await runTests(); +} + +main(); diff --git a/test-manual-flow.js b/test-manual-flow.js new file mode 100644 index 0000000..6059170 --- /dev/null +++ b/test-manual-flow.js @@ -0,0 +1,105 @@ +/** + * 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', { 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 => { + console.error('Unexpected error:', err.message); + process.exit(1); +}); 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' + ); +});