Split Travellers data into Trips; Added 'Risk Audit' screen; Added follow-up checks; connected 'Trips' to Juvonno.

This commit is contained in:
2026-09-03 15:18:32 -04:00
parent 7085a478fb
commit 8e14bb438d
21 changed files with 3958 additions and 226 deletions
+55
View File
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
import path from 'node:path'
import {
fetchEntries,
fetchTravellers,
createEntry,
updateEntry,
deleteEntry,
@@ -11,6 +12,7 @@ import {
saveVaccineRecord,
updateVaccineRecord,
} from './pocketbase.js'
import { JUVONNO_API_URL, JUVONNO_API_KEY } from './juvonno-config.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -99,6 +101,9 @@ ipcMain.handle('app:get-version', () => app.getVersion())
ipcMain.handle('pb:fetch-entries', async () => {
return await fetchEntries()
})
ipcMain.handle('pb:fetch-travellers', async () => {
return await fetchTravellers()
})
ipcMain.handle('pb:create-entry', async (_event, trip) => {
return await createEntry(trip)
})
@@ -125,4 +130,54 @@ ipcMain.handle('pb:update-vaccine-record', async (_event, id, recordData) => {
return await updateVaccineRecord(id, recordData)
})
// ─── Health Risk Audit IPC Handler (Node-side to avoid CORS) ────────────────
ipcMain.handle('audit:run-health-audit', async (_event, payload) => {
const AUDIT_WEBHOOK_URL = 'https://n8n.wirediv.dev/webhook/travel-health-audit'
const AUDIT_API_KEY = '63350077-643e-40d8-8d34-378495c81203'
const response = await fetch(AUDIT_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': AUDIT_API_KEY,
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(`Audit webhook returned HTTP ${response.status} ${response.statusText}`)
}
return await response.json()
})
// ─── Juvonno EMR IPC Handler (Node-side to avoid CORS) ──────────────────────
ipcMain.handle('juvonno:fetch-chart', async (_event, emrId) => {
if (!emrId) {
throw new Error('EMR ID is required.')
}
const url = `${JUVONNO_API_URL}/customers/chart/${encodeURIComponent(emrId)}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-API-Key': JUVONNO_API_KEY,
},
})
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Customer chart with EMR ID "${emrId}" not found in Juvonno.`)
}
if (response.status === 403 || response.status === 401) {
throw new Error(`Juvonno authentication failed (HTTP ${response.status}). Please verify the X-API-Key in juvonno-config.js.`)
}
throw new Error(`Juvonno API error: HTTP ${response.status} ${response.statusText}`)
}
return await response.json()
})
+6
View File
@@ -0,0 +1,6 @@
/**
* Configuration for Juvonno EMR Integration.
* Replace JUVONNO_API_KEY with the actual API key provided by Juvonno.
*/
export const JUVONNO_API_URL = 'https://canadatravelmed.juvonno.com/api'
export const JUVONNO_API_KEY = '4f664679e83c89ac76b2db0a1855804cbe38d7cf'
+228 -64
View File
@@ -1,10 +1,25 @@
import PocketBase from 'pocketbase'
import { POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } from './pocketbase-config.js'
const COLLECTION = 'travellers'
const TRAVELLERS_COLLECTION = 'travellers'
const TRIPS_COLLECTION = 'trips'
const VACCINE_RECORDS_COLLECTION = 'records'
const VACCINES_COLLECTION = 'vaccines'
let pb = null
let authPromise = null
function getClient() {
if (!pb) {
pb = new PocketBase(POCKETBASE_URL)
// Main process may receive overlapping IPC calls (e.g. React Strict Mode).
// Default auto-cancel would abort duplicate in-flight PocketBase requests.
pb.autoCancellation(false)
}
return pb
}
function parseVaccineRecord(r) {
return {
id: r.id,
@@ -48,15 +63,17 @@ export async function fetchVaccineRecords() {
export async function saveVaccineRecord(data) {
await ensureAuth()
const client = getClient()
const travellerId = data.traveller_id
const travellerId = data.traveller_id || data.travellerId || data.traveller
let existing = null
try {
const list = await client.collection(VACCINE_RECORDS_COLLECTION).getList(1, 50, {
filter: `traveller_id = "${travellerId}"`
})
if (list.items && list.items.length > 0) {
existing = list.items[0]
if (travellerId) {
const list = await client.collection(VACCINE_RECORDS_COLLECTION).getList(1, 50, {
filter: `traveller_id = "${travellerId}"`
})
if (list.items && list.items.length > 0) {
existing = list.items[0]
}
}
} catch (e) {
// ignore filter error
@@ -69,52 +86,76 @@ export async function saveVaccineRecord(data) {
timelines: data.timelines || data.vaccineTimelines || data.vaccine_timelines || [],
}
let record
if (existing) {
const record = await client.collection(VACCINE_RECORDS_COLLECTION).update(existing.id, payload)
return parseVaccineRecord(record)
record = await client.collection(VACCINE_RECORDS_COLLECTION).update(existing.id, payload)
} else {
const record = await client.collection(VACCINE_RECORDS_COLLECTION).create(payload)
return parseVaccineRecord(record)
record = await client.collection(VACCINE_RECORDS_COLLECTION).create(payload)
}
// Also link record.id into travellers collection record_id
if (travellerId && record?.id) {
try {
await client.collection(TRAVELLERS_COLLECTION).update(travellerId, {
record_id: record.id,
})
} catch (e) {
console.warn(`[pocketbase] Could not link record_id to traveller ${travellerId}:`, e.message)
}
}
return parseVaccineRecord(record)
}
export async function updateVaccineRecord(id, data) {
await ensureAuth()
const travellerId = data.traveller_id
const client = getClient()
const travellerId = data.traveller_id || data.travellerId || data.traveller
const payload = {
traveller_id: travellerId,
traveller_name: data.travellerName || data.traveller_name || '',
emr_id: data.emrID || data.emr_id || null,
timelines: data.timelines || data.vaccineTimelines || data.vaccine_timelines || [],
}
const record = await getClient().collection(VACCINE_RECORDS_COLLECTION).update(id, payload)
const record = await client.collection(VACCINE_RECORDS_COLLECTION).update(id, payload)
if (travellerId && record?.id) {
try {
await client.collection(TRAVELLERS_COLLECTION).update(travellerId, {
record_id: record.id,
})
} catch (e) {
console.warn(`[pocketbase] Could not link record_id to traveller ${travellerId}:`, e.message)
}
}
return parseVaccineRecord(record)
}
let pb = null
let authPromise = null
function getClient() {
if (!pb) {
pb = new PocketBase(POCKETBASE_URL)
// Main process may receive overlapping IPC calls (e.g. React Strict Mode).
// Default auto-cancel would abort duplicate in-flight PocketBase requests.
pb.autoCancellation(false)
}
return pb
}
function parseEntry(r) {
const traveller = r.expand?.traveller_id
? (Array.isArray(r.expand.traveller_id) ? r.expand.traveller_id[0] : r.expand.traveller_id)
: null
const travellerId = typeof r.traveller_id === 'string'
? r.traveller_id
: (Array.isArray(r.traveller_id) ? r.traveller_id[0] : (traveller?.id || ''))
return {
id: r.id,
recordID: r.record_id ?? r.recordID ?? '',
emrID: r.emr_id ?? '',
name: r.name ?? '',
location: r.location ?? '',
tripId: r.id,
travellerId: travellerId,
name: traveller?.name ?? r.name ?? '',
emrID: traveller?.emr_id ?? r.emr_id ?? '',
recordID: traveller?.record_id ?? r.record_id ?? r.recordID ?? '',
location: r.locations ?? r.location ?? '',
locations: r.locations ?? r.location ?? '',
countryISO: r.country_iso ?? '',
countriesISO: r.countries_iso ?? [],
status: r.status ?? '',
followUpStatus: r.follow_up_status ?? [],
isAtRisk: r.is_at_risk ?? false,
archived: r.archived ?? false,
departure: r.departure ?? '',
arrival: r.arrival ?? '',
created: r.created,
@@ -145,61 +186,184 @@ export async function ensureAuth() {
export async function fetchEntries() {
await ensureAuth()
const list = await getClient().collection(COLLECTION).getFullList({ sort: '-created' })
const list = await getClient().collection(TRIPS_COLLECTION).getFullList({
sort: '-created',
filter: 'archived != true',
expand: 'traveller_id',
})
return list.map(parseEntry)
}
function parseTraveller(r) {
return {
id: r.id,
travellerId: r.id,
name: r.name ?? '',
emrID: r.emr_id ?? '',
recordID: r.record_id ?? '',
created: r.created,
updated: r.updated,
}
}
export async function fetchTravellers() {
await ensureAuth()
const list = await getClient().collection(TRAVELLERS_COLLECTION).getFullList({
sort: 'name',
})
return list.map(parseTraveller)
}
export async function createEntry(trip) {
await ensureAuth()
const payload = {
name: trip.name,
location: trip.location,
countries_iso: trip.countriesISO,
departure: trip.departure,
arrival: trip.arrival,
const client = getClient()
let traveller = null
// 1. Validate mandatory emrID and search existing traveller by emr_id (string)
const emrStr = trip.emrID !== undefined && trip.emrID !== null ? String(trip.emrID).trim() : ''
if (!emrStr) {
throw new Error('EMR ID is required to create a trip.')
}
if (trip.status !== undefined) {
payload.status = trip.status
try {
const filter = client.filter
? client.filter('emr_id = {:emrId}', { emrId: emrStr })
: `emr_id = "${emrStr.replace(/"/g, '\\"')}"`
const list = await client.collection(TRAVELLERS_COLLECTION).getList(1, 1, { filter })
if (list.items && list.items.length > 0) {
traveller = list.items[0]
}
} catch (e) {
console.warn(`[pocketbase] Failed looking up traveller with emr_id "${emrStr}":`, e.message)
}
if (trip.recordID || trip.record_id) {
payload.record_id = trip.recordID || trip.record_id
// 2. If no traveller found with this emr_id, create a new record in travellers collection
if (!traveller) {
const travellerPayload = {
emr_id: emrStr,
name: trip.name ? trip.name.trim() : '',
}
traveller = await client.collection(TRAVELLERS_COLLECTION).create(travellerPayload)
}
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
const emrNum = Number(trip.emrID)
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID
// 3. Create the trip record in trips collection linked to the traveller
const tripPayload = {
traveller_id: traveller.id,
locations: trip.location ?? trip.locations ?? '',
countries_iso: trip.countriesISO ?? trip.countries_iso ?? [],
departure: trip.departure ?? '',
arrival: trip.arrival ?? '',
follow_up_status: trip.followUpStatus ?? trip.follow_up_status ?? [],
is_at_risk: trip.isAtRisk ?? trip.is_at_risk ?? false,
}
const record = await getClient().collection(COLLECTION).create(payload)
const record = await client.collection(TRIPS_COLLECTION).create(tripPayload, {
expand: 'traveller_id',
})
// Ensure expand is attached for parseEntry
if (!record.expand || !record.expand.traveller_id) {
record.expand = { ...record.expand, traveller_id: traveller }
}
return parseEntry(record)
}
export async function updateEntry(id, trip) {
await ensureAuth()
const payload = {
name: trip.name,
location: trip.location,
countries_iso: trip.countriesISO,
departure: trip.departure,
arrival: trip.arrival,
const client = getClient()
// 1. Fetch current trip to identify the linked traveller
let existingTrip = null
try {
existingTrip = await client.collection(TRIPS_COLLECTION).getOne(id, {
expand: 'traveller_id',
})
} catch (e) {
// ignore
}
if (trip.status !== undefined) {
payload.status = trip.status
// 2. Update trip in trips collection
const tripPayload = {}
if (trip.location !== undefined || trip.locations !== undefined) {
tripPayload.locations = trip.locations ?? trip.location ?? ''
}
if (trip.recordID !== undefined || trip.record_id !== undefined) {
payload.record_id = trip.recordID || trip.record_id || null
if (trip.countriesISO !== undefined || trip.countries_iso !== undefined) {
tripPayload.countries_iso = trip.countriesISO ?? trip.countries_iso ?? []
}
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
const emrNum = Number(trip.emrID)
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID
} else if (trip.emrID === '') {
payload.emr_id = null
if (trip.departure !== undefined) {
tripPayload.departure = trip.departure
}
const record = await getClient().collection(COLLECTION).update(id, payload)
return parseEntry(record)
if (trip.arrival !== undefined) {
tripPayload.arrival = trip.arrival
}
if (trip.followUpStatus !== undefined || trip.follow_up_status !== undefined) {
tripPayload.follow_up_status = trip.followUpStatus ?? trip.follow_up_status ?? []
}
if (trip.isAtRisk !== undefined || trip.is_at_risk !== undefined) {
tripPayload.is_at_risk = trip.isAtRisk ?? trip.is_at_risk ?? false
}
const updatedTrip = await client.collection(TRIPS_COLLECTION).update(id, tripPayload)
// 3. Update associated traveller record if traveller fields changed
const emrStr = trip.emrID !== undefined && trip.emrID !== null ? String(trip.emrID).trim() : ''
let travellerId = trip.travellerId || trip.traveller_id || existingTrip?.traveller_id || existingTrip?.expand?.traveller_id?.id
let updatedTraveller = existingTrip?.expand?.traveller_id || null
// If emrID was provided, check if it matches an existing traveller to re-link
if (emrStr) {
try {
const filter = client.filter
? client.filter('emr_id = {:emrId}', { emrId: emrStr })
: `emr_id = "${emrStr.replace(/"/g, '\\"')}"`
const list = await client.collection(TRAVELLERS_COLLECTION).getList(1, 1, { filter })
if (list.items && list.items.length > 0) {
const matched = list.items[0]
if (matched.id !== travellerId) {
travellerId = matched.id
updatedTraveller = matched
await client.collection(TRIPS_COLLECTION).update(id, { traveller_id: travellerId })
}
}
} catch (e) {
console.warn(`[pocketbase] Failed looking up traveller with emr_id "${emrStr}" in updateEntry:`, e.message)
}
}
if (travellerId) {
const travellerPayload = {}
if (trip.name !== undefined) {
travellerPayload.name = typeof trip.name === 'string' ? trip.name.trim() : trip.name
}
if (emrStr) {
travellerPayload.emr_id = emrStr
}
if (trip.recordID !== undefined || trip.record_id !== undefined) {
travellerPayload.record_id = trip.recordID || trip.record_id || null
}
if (Object.keys(travellerPayload).length > 0) {
try {
updatedTraveller = await client.collection(TRAVELLERS_COLLECTION).update(travellerId, travellerPayload)
} catch (e) {
console.warn(`[pocketbase] Could not update traveller ${travellerId}:`, e.message)
}
}
}
updatedTrip.expand = { ...updatedTrip.expand, traveller_id: updatedTraveller }
return parseEntry(updatedTrip)
}
export async function deleteEntry(id) {
await ensureAuth()
await getClient().collection(COLLECTION).delete(id)
return await getClient().collection(TRIPS_COLLECTION).update(id, {
archived: true,
})
}