From c56bb18ca65f82c278260b59041f91d32512fcba Mon Sep 17 00:00:00 2001 From: Sishir P Date: Tue, 4 Aug 2026 19:34:11 -0400 Subject: [PATCH] feat(messages): agency to caregiver messaging inside the platform Coordinators and caregivers were talking by personal text message, which put work conversation, and sometimes client detail, on personal phones outside any retention, audit, or BAA the agency holds. One thread per (agency, caregiver), not per topic. A caregiver has one conversation with their office the way a text thread works, and making somebody pick a category on a phone would just push them back to SMS. The unique constraint makes opening a thread idempotent, so two concurrent opens cannot split a conversation in half. Threads are agency-scoped, so a caregiver working at two agencies has two separate conversations and neither agency can observe the other. Staff address a caregiver by id, but the thread is always resolved inside the caller's own agency, so an id belonging to another tenant reaches a local thread that will simply never have messages rather than the other agency's conversation. Message bodies are PHI: people will discuss clients here. Bodies never enter a notification payload or an audit payload. The push says only that a message arrived, and the caregiver opens the app to read it. Unread counts are computed as aggregates rather than a conditional join. The join has to express "newer than the read mark, or all of them when it is null", and a null comparison in SQL quietly yields no rows, which would report zero unread on a thread nobody has ever opened, the exact opposite of the truth. Posting a message and stamping the thread's activity time happen in one transaction, so a thread can never show a message it does not list or list a message without appearing active. The unread-count endpoint answers zero rather than failing, because a badge is not worth breaking a screen over. --- packages/app/src/app.ts | 2 + .../routes/__tests__/message-routes.test.ts | 188 ++++++++++++++ packages/app/src/routes/message-routes.ts | 166 ++++++++++++ packages/core/src/index.ts | 1 + .../migrations/2026-08-04-add-messaging.ts | 80 ++++++ packages/core/src/migrations/runner.ts | 2 + .../src/repositories/message-repository.ts | 239 +++++++++++++++++ packages/mobile/app/messages.tsx | 2 + .../src/features/messages/MessagesScreen.tsx | 242 ++++++++++++++++++ .../src/features/profile/ProfileScreen.tsx | 7 + packages/web/src/App.tsx | 3 + .../web/src/features/staff/MessagesPage.tsx | 239 +++++++++++++++++ 12 files changed, 1171 insertions(+) create mode 100644 packages/app/src/routes/__tests__/message-routes.test.ts create mode 100644 packages/app/src/routes/message-routes.ts create mode 100644 packages/core/src/migrations/2026-08-04-add-messaging.ts create mode 100644 packages/core/src/repositories/message-repository.ts create mode 100644 packages/mobile/app/messages.tsx create mode 100644 packages/mobile/src/features/messages/MessagesScreen.tsx create mode 100644 packages/web/src/features/staff/MessagesPage.tsx diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index 0ccdb4f..b9f5f13 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -38,6 +38,7 @@ import settingsRoutes from './routes/settings-routes.js'; import pushTokenRoutes from './routes/push-token-routes.js'; import mileageRoutes from './routes/mileage-routes.js'; import availabilityRoutes from './routes/availability-routes.js'; +import messageRoutes from './routes/message-routes.js'; import agencySandataConfigRoutes from './routes/agency-sandata-config-routes.js'; import agencyHhaexchangeConfigRoutes from './routes/agency-hhaexchange-config-routes.js'; import agencyClearinghouseConfigRoutes from './routes/agency-clearinghouse-config-routes.js'; @@ -352,6 +353,7 @@ export function createApp(options: { mobileSessionStore?: MobileSessionStore } = app.use(`${prefix}/notifications`, pushTokenRoutes); app.use(`${prefix}/mileage`, mileageRoutes); app.use(`${prefix}/availability`, availabilityRoutes); + app.use(`${prefix}/messages`, messageRoutes); app.use(`${prefix}/compliance-engine`, complianceEngineRoutes); app.use(`${prefix}/command-center`, copilotLimiter, commandCenterRoutes); app.use(`${prefix}/documents`, documentRoutes); diff --git a/packages/app/src/routes/__tests__/message-routes.test.ts b/packages/app/src/routes/__tests__/message-routes.test.ts new file mode 100644 index 0000000..ee1d986 --- /dev/null +++ b/packages/app/src/routes/__tests__/message-routes.test.ts @@ -0,0 +1,188 @@ +import request from 'supertest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import * as core from '@rayhealth/core'; +import { createApp } from '../../app.js'; +import { makeToken, setTestJwtSecret } from './test-helpers.js'; + +beforeAll(() => setTestJwtSecret()); +afterEach(() => vi.restoreAllMocks()); + +const agencyId = '00000000-0000-4000-8000-00000000b001'; +const userId = '00000000-0000-4000-8000-00000000b002'; +const caregiverId = '00000000-0000-4000-8000-00000000b003'; +const threadId = '00000000-0000-4000-8000-00000000b004'; + +function mockRepo(overrides: Record = {}) { + const base = { + ensureThread: vi.fn().mockResolvedValue({ id: threadId, agencyId, caregiverId }), + listMessages: vi.fn().mockResolvedValue([]), + listThreadsForAgency: vi.fn().mockResolvedValue([]), + postMessage: vi.fn().mockResolvedValue({ id: 'm-1', body: 'hi' }), + markRead: vi.fn().mockResolvedValue(undefined), + unreadForCaregiver: vi.fn().mockResolvedValue(0), + ...overrides, + }; + vi.spyOn(core, 'MessageRepository').mockImplementation( + () => base as unknown as core.MessageRepository, + ); + return base; +} + +const caregiverAuth = () => `Bearer ${makeToken('caregiver', agencyId, userId, caregiverId)}`; +const adminAuth = () => `Bearer ${makeToken('admin', agencyId, userId)}`; + +describe('GET /messages', () => { + it('gives a caregiver their own thread and marks it read', async () => { + const repo = mockRepo(); + + const res = await request(createApp()).get('/messages').set('Authorization', caregiverAuth()); + + expect(res.status).toBe(200); + // The thread is resolved from the session, never from a parameter. + expect(repo.ensureThread).toHaveBeenCalledWith(agencyId, caregiverId); + expect(repo.markRead).toHaveBeenCalledWith(threadId, agencyId, 'caregiver'); + expect(repo.listThreadsForAgency).not.toHaveBeenCalled(); + }); + + it('gives staff the agency inbox instead of a single thread', async () => { + const repo = mockRepo(); + + await request(createApp()).get('/messages').set('Authorization', adminAuth()); + + expect(repo.listThreadsForAgency).toHaveBeenCalledWith(agencyId); + expect(repo.ensureThread).not.toHaveBeenCalled(); + }); + + it('requires authentication', async () => { + mockRepo(); + const res = await request(createApp()).get('/messages'); + expect(res.status).toBe(401); + }); +}); + +describe('POST /messages', () => { + it('posts as the caregiver and marks their own thread read', async () => { + const repo = mockRepo(); + + const res = await request(createApp()) + .post('/messages') + .set('Authorization', caregiverAuth()) + .send({ body: 'Running ten minutes late' }); + + expect(res.status).toBe(201); + expect(repo.postMessage).toHaveBeenCalledWith({ + threadId, + agencyId, + senderType: 'caregiver', + senderUserId: userId, + body: 'Running ten minutes late', + }); + expect(repo.markRead).toHaveBeenCalledWith(threadId, agencyId, 'caregiver'); + }); + + it('rejects an empty or whitespace-only message', async () => { + const repo = mockRepo(); + + expect( + (await request(createApp()).post('/messages').set('Authorization', caregiverAuth()).send({ body: '' })) + .status, + ).toBe(400); + expect( + (await request(createApp()).post('/messages').set('Authorization', caregiverAuth()).send({ body: ' ' })) + .status, + ).toBe(400); + expect(repo.postMessage).not.toHaveBeenCalled(); + }); + + it('trims the message before storing it', async () => { + const repo = mockRepo(); + + await request(createApp()) + .post('/messages') + .set('Authorization', caregiverAuth()) + .send({ body: ' hello ' }); + + expect(repo.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: 'hello' })); + }); + + it('points a non-caregiver at the staff endpoint', async () => { + const repo = mockRepo(); + const res = await request(createApp()) + .post('/messages') + .set('Authorization', adminAuth()) + .send({ body: 'hi' }); + expect(res.status).toBe(403); + expect(repo.postMessage).not.toHaveBeenCalled(); + }); +}); + +describe('POST /messages/staff', () => { + it('posts as staff into the addressed caregiver thread', async () => { + const repo = mockRepo(); + + const res = await request(createApp()) + .post('/messages/staff') + .set('Authorization', adminAuth()) + .send({ caregiverId, body: 'Can you cover Thursday?' }); + + expect(res.status).toBe(201); + // Thread is resolved inside the caller's agency, so a caregiver id from + // another tenant cannot reach that tenant's conversation. + expect(repo.ensureThread).toHaveBeenCalledWith(agencyId, caregiverId); + expect(repo.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ senderType: 'staff', senderUserId: userId }), + ); + }); + + it('will not let a caregiver post as staff', async () => { + const repo = mockRepo(); + const res = await request(createApp()) + .post('/messages/staff') + .set('Authorization', caregiverAuth()) + .send({ caregiverId, body: 'hi' }); + expect(res.status).toBe(403); + expect(repo.postMessage).not.toHaveBeenCalled(); + }); + + it('requires a caregiver id', async () => { + const repo = mockRepo(); + const res = await request(createApp()) + .post('/messages/staff') + .set('Authorization', adminAuth()) + .send({ body: 'hi' }); + expect(res.status).toBe(400); + expect(repo.postMessage).not.toHaveBeenCalled(); + }); +}); + +describe('GET /messages/unread-count', () => { + it('returns the caregiver unread count', async () => { + mockRepo({ unreadForCaregiver: vi.fn().mockResolvedValue(3) }); + + const res = await request(createApp()) + .get('/messages/unread-count') + .set('Authorization', caregiverAuth()); + + expect(res.body).toEqual({ count: 3 }); + }); + + it('reports zero rather than failing when the count cannot be computed', async () => { + // A badge is not worth breaking a screen over. + mockRepo({ unreadForCaregiver: vi.fn().mockRejectedValue(new Error('db down')) }); + + const res = await request(createApp()) + .get('/messages/unread-count') + .set('Authorization', caregiverAuth()); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ count: 0 }); + }); + + it('reports zero for a non-caregiver', async () => { + mockRepo(); + const res = await request(createApp()) + .get('/messages/unread-count') + .set('Authorization', adminAuth()); + expect(res.body).toEqual({ count: 0 }); + }); +}); diff --git a/packages/app/src/routes/message-routes.ts b/packages/app/src/routes/message-routes.ts new file mode 100644 index 0000000..e3fc5b7 --- /dev/null +++ b/packages/app/src/routes/message-routes.ts @@ -0,0 +1,166 @@ +/** + * Agency to caregiver messaging. + * + * Replaces the personal text messages coordinators and caregivers were using, + * which put work conversation (and sometimes client detail) on personal phones + * outside any retention, audit, or BAA the agency holds. + * + * One thread per (agency, caregiver). A caregiver sees exactly one thread, + * their own; staff see the agency inbox. A caregiver working at two agencies + * has two threads and neither agency can observe the other. + * + * MESSAGE BODIES ARE PHI. People discuss clients here. Bodies are never put + * into a push notification, which says only that a message arrived, and never + * into an audit payload. + */ +import { Router, type Request, type Response } from 'express'; +import type { Knex } from 'knex'; +import { z } from 'zod'; +import { MessageRepository } from '@rayhealth/core'; +import { requireCapability } from '../middleware/require-capability.js'; +import { safeError } from '../security/safe-log.js'; +import { notifyCaregivers } from '../services/notification-service.js'; + +const router = Router(); + +const UUID_RE = /^[0-9a-f-]{36}$/i; + +const postSchema = z.object({ + body: z.string().trim().min(1, 'Message cannot be empty').max(4000), +}); + +const staffPostSchema = postSchema.extend({ + caregiverId: z.string().uuid(), +}); + +// GET /messages, the caregiver's own thread, or the agency inbox for staff +router.get('/', requireCapability('evv.read'), async (req: Request, res: Response) => { + try { + const db = req.app.get('db') as Knex; + const repo = new MessageRepository(db); + + if (req.auth.role === 'caregiver' && req.auth.caregiverId) { + const thread = await repo.ensureThread(req.auth.agencyId, req.auth.caregiverId); + const messages = await repo.listMessages(thread.id, req.auth.agencyId); + // Opening the thread is what "read" means here. + await repo.markRead(thread.id, req.auth.agencyId, 'caregiver'); + return res.json({ thread, messages }); + } + + const threads = await repo.listThreadsForAgency(req.auth.agencyId); + res.json({ threads }); + } catch (error) { + safeError('message list failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// GET /messages/unread-count, for the mobile badge +router.get('/unread-count', requireCapability('evv.read'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.json({ count: 0 }); + } + try { + const db = req.app.get('db') as Knex; + const count = await new MessageRepository(db).unreadForCaregiver( + req.auth.caregiverId, + req.auth.agencyId, + ); + res.json({ count }); + } catch (error) { + safeError('unread count failed', error); + // A badge is not worth a failed screen; report zero and move on. + res.json({ count: 0 }); + } +}); + +// GET /messages/:caregiverId, one caregiver's thread, staff view +router.get('/:caregiverId', requireCapability('staff.read'), async (req: Request, res: Response) => { + const caregiverId = typeof req.params.caregiverId === 'string' ? req.params.caregiverId : ''; + if (!UUID_RE.test(caregiverId)) { + return res.status(400).json({ message: 'valid caregiver id required' }); + } + try { + const db = req.app.get('db') as Knex; + const repo = new MessageRepository(db); + // ensureThread is agency-scoped, so a caregiver id from another tenant + // creates a thread in THIS agency that will simply never have messages, + // rather than exposing the other agency's conversation. + const thread = await repo.ensureThread(req.auth.agencyId, caregiverId); + const messages = await repo.listMessages(thread.id, req.auth.agencyId); + await repo.markRead(thread.id, req.auth.agencyId, 'staff'); + res.json({ thread, messages }); + } catch (error) { + safeError('staff thread load failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// POST /messages, caregiver sends to their agency +router.post('/', requireCapability('evv.write'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Use the staff endpoint to message a caregiver' }); + } + const parsed = postSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ message: parsed.error.issues[0]?.message ?? 'Invalid message' }); + } + try { + const db = req.app.get('db') as Knex; + const repo = new MessageRepository(db); + const thread = await repo.ensureThread(req.auth.agencyId, req.auth.caregiverId); + const message = await repo.postMessage({ + threadId: thread.id, + agencyId: req.auth.agencyId, + senderType: 'caregiver', + senderUserId: req.auth.userId, + body: parsed.data.body, + }); + // Sending is also reading: the caregiver has plainly seen the thread. + await repo.markRead(thread.id, req.auth.agencyId, 'caregiver'); + res.status(201).json(message); + } catch (error) { + safeError('caregiver message send failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// POST /messages/staff, staff sends to a caregiver +router.post('/staff', requireCapability('staff.write'), async (req: Request, res: Response) => { + const parsed = staffPostSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ message: parsed.error.issues[0]?.message ?? 'Invalid message' }); + } + try { + const db = req.app.get('db') as Knex; + const repo = new MessageRepository(db); + const thread = await repo.ensureThread(req.auth.agencyId, parsed.data.caregiverId); + const message = await repo.postMessage({ + threadId: thread.id, + agencyId: req.auth.agencyId, + senderType: 'staff', + senderUserId: req.auth.userId, + body: parsed.data.body, + }); + await repo.markRead(thread.id, req.auth.agencyId, 'staff'); + + // The notification deliberately carries no part of the message. Bodies are + // PHI and a push renders on a locked screen; the caregiver opens the app + // to read it. + void notifyCaregivers(db, { + agencyId: req.auth.agencyId, + caregiverIds: [parsed.data.caregiverId], + category: 'scheduleChanges', + title: 'New message', + body: 'Your agency sent you a message. Open RayHealth to read it.', + data: { kind: 'message.received', threadId: thread.id }, + }); + + res.status(201).json(message); + } catch (error) { + safeError('staff message send failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +export default router; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8307f60..d5e5465 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -80,3 +80,4 @@ export * from './repositories/user-agency-repository.js'; export * from './repositories/push-token-repository.js'; export * from './repositories/mileage-repository.js'; export * from './repositories/availability-repository.js'; +export * from './repositories/message-repository.js'; diff --git a/packages/core/src/migrations/2026-08-04-add-messaging.ts b/packages/core/src/migrations/2026-08-04-add-messaging.ts new file mode 100644 index 0000000..1041ad5 --- /dev/null +++ b/packages/core/src/migrations/2026-08-04-add-messaging.ts @@ -0,0 +1,80 @@ +/** + * Migration: agency to caregiver messaging. + * + * Coordinators and caregivers were communicating by personal text message, + * which puts work conversation (and sometimes client detail) on personal + * phones outside any retention, audit, or BAA the agency holds. + * + * Shape notes: + * - One thread per (agency, caregiver). Not per topic: a caregiver has one + * conversation with their office, the way a text thread works, and + * forcing topic selection on a phone would just push people back to SMS. + * The unique constraint makes "open the thread" idempotent. + * - A thread is agency-scoped, so a caregiver working at two agencies has + * two separate threads and neither agency can observe the other. That + * preserves the standing cross-agency privacy rule. + * - `sender_type` is 'staff' or 'caregiver'. `sender_user_id` is nullable + * because a staff account may later be deleted while the message stays. + * - MESSAGE BODIES ARE PHI. People will discuss clients here. Bodies are + * never included in a notification payload, an audit payload, or any + * cross-agency query; the push says only that a message arrived. + * - `read_at` on the thread rather than per message: unread counts only + * need a high-water mark, and per-message receipts would be a lot of + * write traffic for a feature nobody asked for. + * + * Idempotent via hasTable guards, safe to re-run. Callbacks are synchronous + * on purpose: an async callback is silently dropped by knex. + */ + +import type { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable('message_threads'))) { + await knex.schema.createTable('message_threads', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')) + table.uuid('agency_id').references('id').inTable('agencies').notNullable().onDelete('CASCADE') + table + .uuid('caregiver_id') + .references('id') + .inTable('caregivers') + .notNullable() + .onDelete('CASCADE') + table.timestamp('last_message_at', { useTz: true }).nullable() + // High-water marks for unread counts, one per side of the conversation. + table.timestamp('caregiver_read_at', { useTz: true }).nullable() + table.timestamp('staff_read_at', { useTz: true }).nullable() + table.timestamps(true, true) + // One conversation per caregiver per agency: opening a thread is + // idempotent, and there is never a second place a message could land. + table.unique(['agency_id', 'caregiver_id']) + table.index(['agency_id', 'last_message_at']) + }) + } + + if (!(await knex.schema.hasTable('messages'))) { + await knex.schema.createTable('messages', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')) + table + .uuid('thread_id') + .references('id') + .inTable('message_threads') + .notNullable() + .onDelete('CASCADE') + // Denormalized so every read can be tenant-filtered without a join. + table.uuid('agency_id').references('id').inTable('agencies').notNullable().onDelete('CASCADE') + // 'staff' | 'caregiver' + table.string('sender_type', 16).notNullable() + // Nullable: a staff account may be deleted while the message remains. + table.uuid('sender_user_id').nullable() + table.text('body').notNullable() + table.timestamps(true, true) + table.index(['thread_id', 'created_at']) + table.index(['agency_id']) + }) + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('messages') + await knex.schema.dropTableIfExists('message_threads') +} diff --git a/packages/core/src/migrations/runner.ts b/packages/core/src/migrations/runner.ts index 105fe7b..31952f6 100644 --- a/packages/core/src/migrations/runner.ts +++ b/packages/core/src/migrations/runner.ts @@ -37,6 +37,7 @@ import * as addPushTokens from './2026-08-04-add-push-tokens.js'; import * as addCaregiverPayRate from './2026-08-04-add-caregiver-pay-rate.js'; import * as addMileageEntries from './2026-08-04-add-mileage-entries.js'; import * as addAvailabilityAndTimeOff from './2026-08-04-add-availability-and-time-off.js'; +import * as addMessaging from './2026-08-04-add-messaging.js'; async function run(): Promise { const db = createDb(); @@ -57,6 +58,7 @@ async function run(): Promise { await addCaregiverPayRate.up(db); await addMileageEntries.up(db); await addAvailabilityAndTimeOff.up(db); + await addMessaging.up(db); process.stderr.write('Migrations complete.\n'); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'unknown error'; diff --git a/packages/core/src/repositories/message-repository.ts b/packages/core/src/repositories/message-repository.ts new file mode 100644 index 0000000..1de666d --- /dev/null +++ b/packages/core/src/repositories/message-repository.ts @@ -0,0 +1,239 @@ +/** + * Repository for `message_threads` and `messages`. + * + * Tenancy: every method takes an agencyId and filters on it, including the + * message reads, which is why `messages.agency_id` is denormalized. A + * caregiver working at two agencies has two separate threads and neither + * agency can observe the other, preserving the standing cross-agency rule. + * + * MESSAGE BODIES ARE PHI. People discuss clients here. Bodies never leave an + * agency-scoped query and are never copied into a notification payload or an + * audit payload. + */ + +import type { Knex } from 'knex' + +export type MessageSender = 'staff' | 'caregiver' + +export interface MessageThread { + id: string + agencyId: string + caregiverId: string + lastMessageAt: string | null + caregiverReadAt: string | null + staffReadAt: string | null +} + +export interface Message { + id: string + threadId: string + senderType: MessageSender + senderUserId: string | null + body: string + createdAt: string +} + +function toIso(value: unknown): string | null { + if (!value) return null + return value instanceof Date ? value.toISOString() : String(value) +} + +function mapThread(row: Record): MessageThread { + return { + id: String(row.id), + agencyId: String(row.agency_id), + caregiverId: String(row.caregiver_id), + lastMessageAt: toIso(row.last_message_at), + caregiverReadAt: toIso(row.caregiver_read_at), + staffReadAt: toIso(row.staff_read_at), + } +} + +function mapMessage(row: Record): Message { + return { + id: String(row.id), + threadId: String(row.thread_id), + senderType: (row.sender_type as MessageSender) ?? 'staff', + senderUserId: row.sender_user_id ? String(row.sender_user_id) : null, + body: String(row.body), + createdAt: toIso(row.created_at) ?? '', + } +} + +export class MessageRepository { + constructor(private readonly db: Knex) {} + + /** + * The thread for one caregiver at one agency, creating it on first use. + * + * Idempotent through the (agency_id, caregiver_id) unique constraint, so two + * concurrent opens cannot produce two threads and split a conversation in + * half. + */ + async ensureThread(agencyId: string, caregiverId: string): Promise { + const existing = await this.db('message_threads') + .where({ agency_id: agencyId, caregiver_id: caregiverId }) + .first() + if (existing) return mapThread(existing as Record) + + await this.db('message_threads') + .insert({ agency_id: agencyId, caregiver_id: caregiverId }) + .onConflict(['agency_id', 'caregiver_id']) + .ignore() + + const row = await this.db('message_threads') + .where({ agency_id: agencyId, caregiver_id: caregiverId }) + .first() + return mapThread(row as Record) + } + + /** + * Threads for an agency, most recently active first, for the staff inbox. + * + * Unread counts are a second aggregate query rather than a conditional join. + * The join version has to express "newer than staff_read_at, or all of them + * when it is null", and a null comparison in SQL quietly yields no rows, + * which would report zero unread on a thread nobody has ever opened, the + * exact opposite of the truth. + */ + async listThreadsForAgency( + agencyId: string, + limit = 200, + ): Promise> { + const rows = (await this.db('message_threads') + .where({ agency_id: agencyId }) + .orderByRaw('last_message_at desc nulls last') + .limit(Math.min(limit, 500)) + .select('*')) as Record[] + if (rows.length === 0) return [] + + const threads = rows.map(mapThread) + const counts = await this.countUnread( + agencyId, + threads.map((t) => ({ threadId: t.id, since: t.staffReadAt })), + 'caregiver', + ) + return threads.map((t) => ({ ...t, unreadForStaff: counts.get(t.id) ?? 0 })) + } + + /** + * Unread messages per thread from one side of the conversation. + * + * A null `since` means that side has never opened the thread, so every + * message from the other side counts. Grouped in a single query rather than + * one per thread so an agency with a hundred caregivers does not pay a + * hundred round trips to render its inbox. + */ + private async countUnread( + agencyId: string, + threads: Array<{ threadId: string; since: string | null }>, + senderType: MessageSender, + ): Promise> { + const counts = new Map() + if (threads.length === 0) return counts + + const rows = (await this.db('messages') + .where({ agency_id: agencyId, sender_type: senderType }) + .whereIn( + 'thread_id', + threads.map((t) => t.threadId), + ) + .groupBy('thread_id') + .select('thread_id') + .count({ total: '*' })) as Array<{ thread_id: string; total: string | number }> + + // Totals per thread, then subtract what was already seen. Two small + // queries keep the null-read case correct without conditional SQL. + const totals = new Map(rows.map((r) => [String(r.thread_id), Number(r.total)])) + + const readThreads = threads.filter((t) => t.since) + const seenRows = readThreads.length + ? ((await this.db('messages') + .where({ agency_id: agencyId, sender_type: senderType }) + .whereIn( + 'thread_id', + readThreads.map((t) => t.threadId), + ) + .where((builder) => { + for (const t of readThreads) { + void builder.orWhere((inner) => { + void inner + .where('thread_id', t.threadId) + .andWhere('created_at', '<=', t.since as string) + }) + } + }) + .groupBy('thread_id') + .select('thread_id') + .count({ total: '*' })) as Array<{ thread_id: string; total: string | number }>) + : [] + const seen = new Map(seenRows.map((r) => [String(r.thread_id), Number(r.total)])) + + for (const t of threads) { + const total = totals.get(t.threadId) ?? 0 + counts.set(t.threadId, Math.max(0, total - (seen.get(t.threadId) ?? 0))) + } + return counts + } + + /** Messages in a thread, oldest first. Tenant-scoped on the message rows. */ + async listMessages(threadId: string, agencyId: string, limit = 200): Promise { + const rows = await this.db('messages') + .where({ thread_id: threadId, agency_id: agencyId }) + .orderBy('created_at', 'asc') + .limit(Math.min(limit, 500)) + .select('*') + return (rows as Record[]).map(mapMessage) + } + + /** + * Append a message and stamp the thread's activity time in one transaction, + * so a thread can never show a message it does not list, or list a message + * without appearing active. + */ + async postMessage(input: { + threadId: string + agencyId: string + senderType: MessageSender + senderUserId: string | null + body: string + }): Promise { + return this.db.transaction(async (trx) => { + const [row] = await trx('messages') + .insert({ + thread_id: input.threadId, + agency_id: input.agencyId, + sender_type: input.senderType, + sender_user_id: input.senderUserId, + body: input.body, + }) + .returning('*') + await trx('message_threads') + .where({ id: input.threadId, agency_id: input.agencyId }) + .update({ last_message_at: trx.fn.now(), updated_at: trx.fn.now() }) + return mapMessage(row as Record) + }) + } + + /** Move one side's read high-water mark to now. */ + async markRead(threadId: string, agencyId: string, side: MessageSender): Promise { + const column = side === 'caregiver' ? 'caregiver_read_at' : 'staff_read_at' + await this.db('message_threads') + .where({ id: threadId, agency_id: agencyId }) + .update({ [column]: this.db.fn.now(), updated_at: this.db.fn.now() }) + } + + /** Count of messages the caregiver has not seen, for the mobile badge. */ + async unreadForCaregiver(caregiverId: string, agencyId: string): Promise { + const thread = await this.db('message_threads') + .where({ agency_id: agencyId, caregiver_id: caregiverId }) + .first() + if (!thread) return 0 + const row = thread as Record + let q = this.db('messages') + .where({ thread_id: String(row.id), agency_id: agencyId, sender_type: 'staff' }) + if (row.caregiver_read_at) q = q.andWhere('created_at', '>', row.caregiver_read_at as string) + const result = (await q.count({ count: '*' }).first()) as { count?: string | number } | undefined + return Number(result?.count ?? 0) + } +} diff --git a/packages/mobile/app/messages.tsx b/packages/mobile/app/messages.tsx new file mode 100644 index 0000000..0bc5bfa --- /dev/null +++ b/packages/mobile/app/messages.tsx @@ -0,0 +1,2 @@ +import MessagesScreen from '../src/features/messages/MessagesScreen'; +export default MessagesScreen; diff --git a/packages/mobile/src/features/messages/MessagesScreen.tsx b/packages/mobile/src/features/messages/MessagesScreen.tsx new file mode 100644 index 0000000..f97f3d3 --- /dev/null +++ b/packages/mobile/src/features/messages/MessagesScreen.tsx @@ -0,0 +1,242 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import { useFocusEffect } from 'expo-router'; +import apiClient from '../../lib/api-client'; +import ScreenHeader from '../common/ScreenHeader'; +import ErrorRetry from '../common/ErrorRetry'; +import { SkeletonList } from '../common/Skeleton'; +import { showAppAlert } from '../common/alerts/appAlert'; +import { colors, radii, shadow, typography } from '../common/tokens'; + +/** + * Messages. + * + * One conversation between a caregiver and their agency's office. Deliberately + * a single thread rather than per-topic: this replaces the personal text + * thread people were already using, and making somebody pick a category on a + * phone would just push them back to SMS. + */ + +interface Message { + id: string; + senderType: 'staff' | 'caregiver'; + body: string; + createdAt: string; +} + +function formatTime(iso: string): string { + const d = new Date(iso); + if (!Number.isFinite(d.getTime())) return ''; + const today = new Date(); + const sameDay = d.toDateString() === today.toDateString(); + return sameDay + ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + : d.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +export default function MessagesScreen() { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const scrollRef = useRef(null); + + const load = useCallback(async () => { + try { + const res = await apiClient.get<{ messages: Message[] }>('/api/messages'); + setMessages(res.data?.messages ?? []); + setError(null); + } catch { + setError('Could not load your messages.'); + } finally { + setLoading(false); + } + }, []); + + useFocusEffect( + useCallback(() => { + void load(); + }, [load]), + ); + + const send = async () => { + const body = draft.trim(); + if (!body) return; + setSending(true); + try { + await apiClient.post('/api/messages', { body }); + setDraft(''); + void Haptics.selectionAsync(); + await load(); + // Land on the newest message, the way any chat behaves. + requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true })); + } catch { + showAppAlert('Could not send that message', 'Please check your connection and try again.', undefined, { + variant: 'error', + }); + } finally { + setSending(false); + } + }; + + return ( + + + + {loading ? ( + + + + ) : error ? ( + { setLoading(true); void load(); }} /> + ) : ( + scrollRef.current?.scrollToEnd({ animated: false })} + keyboardShouldPersistTaps="handled" + > + {messages.length === 0 ? ( + + + No messages yet + + Send your office a message and it will appear here. Keep client details out of + anything you would not want on a lock screen. + + + ) : ( + messages.map((m) => { + const mine = m.senderType === 'caregiver'; + return ( + + + {m.body} + + {formatTime(m.createdAt)} + + + + ); + }) + )} + + )} + + + + void send()} + disabled={sending || draft.trim().length === 0} + style={({ pressed }) => [ + styles.sendBtn, + (sending || draft.trim().length === 0) && styles.sendBtnDisabled, + pressed && { opacity: 0.9 }, + ]} + accessibilityRole="button" + accessibilityLabel="Send message" + > + {sending ? ( + + ) : ( + + )} + + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: colors.screenBg }, + body: { flex: 1 }, + bodyContent: { padding: 16, gap: 8, paddingBottom: 20 }, + emptyCard: { + backgroundColor: colors.cardBg, + borderRadius: radii.lg, + padding: 26, + alignItems: 'center', + gap: 8, + marginTop: 24, + ...shadow.card, + }, + emptyTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, + emptyText: { + ...typography.caption, + color: colors.textSecondary, + textAlign: 'center', + lineHeight: 17, + }, + bubbleRow: { flexDirection: 'row' }, + bubbleRowMine: { justifyContent: 'flex-end' }, + bubbleRowTheirs: { justifyContent: 'flex-start' }, + bubble: { maxWidth: '82%', borderRadius: radii.lg, paddingHorizontal: 13, paddingVertical: 9, gap: 3 }, + bubbleMine: { backgroundColor: colors.brandBlue, borderBottomRightRadius: 4 }, + bubbleTheirs: { backgroundColor: colors.cardBg, borderBottomLeftRadius: 4, ...shadow.card }, + bubbleText: { fontSize: 15, color: colors.textPrimary, lineHeight: 20 }, + bubbleTextMine: { color: colors.onGradient }, + bubbleTime: { ...typography.caption, color: colors.textMuted, alignSelf: 'flex-end' }, + bubbleTimeMine: { color: colors.onGradientSoft }, + composer: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: 8, + padding: 12, + paddingBottom: 22, + backgroundColor: colors.cardBg, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + composerInput: { + flex: 1, + maxHeight: 120, + borderWidth: 1, + borderColor: colors.border, + borderRadius: radii.lg, + paddingHorizontal: 14, + paddingTop: 10, + paddingBottom: 10, + fontSize: 15, + color: colors.textPrimary, + backgroundColor: colors.screenBg, + }, + sendBtn: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.brandBlue, + alignItems: 'center', + justifyContent: 'center', + }, + sendBtnDisabled: { backgroundColor: colors.disabled }, +}); diff --git a/packages/mobile/src/features/profile/ProfileScreen.tsx b/packages/mobile/src/features/profile/ProfileScreen.tsx index e82ae02..9eba409 100644 --- a/packages/mobile/src/features/profile/ProfileScreen.tsx +++ b/packages/mobile/src/features/profile/ProfileScreen.tsx @@ -208,6 +208,13 @@ export default function ProfileScreen() { subtitle="Estimated pay from verified visits" onPress={() => router.push('/earnings')} /> + router.push('/messages')} + /> import('./features/agency/GoLiveReadiness const StaffPage = lazy(() => import('./features/staff/StaffPage.js').then((m) => ({ default: m.StaffPage }))); const MileageReviewPage = lazy(() => import('./features/staff/MileageReviewPage.js').then((m) => ({ default: m.MileageReviewPage }))); const TimeOffReviewPage = lazy(() => import('./features/staff/TimeOffReviewPage.js').then((m) => ({ default: m.TimeOffReviewPage }))); +const MessagesPage = lazy(() => import('./features/staff/MessagesPage.js').then((m) => ({ default: m.MessagesPage }))); const CaregiverActivityPage = lazy(() => import('./features/staff/CaregiverActivityPage.js').then((m) => ({ default: m.CaregiverActivityPage }))); const ClientsPage = lazy(() => import('./features/clients/ClientsPage.js').then((m) => ({ default: m.ClientsPage }))); const AuthorizationsPage = lazy(() => import('./features/authorizations/AuthorizationsPage.js').then((m) => ({ default: m.AuthorizationsPage }))); @@ -305,6 +306,7 @@ const navGroupDefs: NavGroupDef[] = [ { to: '/admin/staff', label: 'Staff', icon: icons.staff }, { to: '/admin/mileage', label: 'Mileage', icon: icons.staff }, { to: '/admin/time-off', label: 'Time Off', icon: icons.staff }, + { to: '/admin/messages', label: 'Messages', icon: icons.staff }, { to: '/admin/clients', label: 'Clients', icon: icons.clients }, { to: '/admin/authorizations', label: 'Authorizations', icon: icons.auth }, { to: '/admin/import', label: 'Data Import', icon: icons.agency }, @@ -582,6 +584,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/web/src/features/staff/MessagesPage.tsx b/packages/web/src/features/staff/MessagesPage.tsx new file mode 100644 index 0000000..de3223d --- /dev/null +++ b/packages/web/src/features/staff/MessagesPage.tsx @@ -0,0 +1,239 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { getJson, postJson } from '../../lib/api-client.js'; +import { EmptyState, LoadingSkeleton, ErrorRetry } from '../../components/state/index.js'; + +/** + * Agency message inbox. + * + * One thread per caregiver, newest activity first. This exists so work + * conversation stops happening on coordinators' personal phones, where it sits + * outside any retention, audit, or BAA the agency holds. + */ + +interface Thread { + id: string; + caregiverId: string; + lastMessageAt: string | null; + unreadForStaff: number; +} + +interface Message { + id: string; + senderType: 'staff' | 'caregiver'; + body: string; + createdAt: string; +} + +interface StaffMember { + id: string; + email: string; + role: string; + /** 'active' for real caregivers; 'pending' rows are unaccepted invites. */ + status: string; +} + +function formatWhen(iso: string | null): string { + if (!iso) return 'No messages yet'; + const d = new Date(iso); + if (!Number.isFinite(d.getTime())) return ''; + const sameDay = d.toDateString() === new Date().toDateString(); + return sameDay + ? d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +export function MessagesPage() { + const [threads, setThreads] = useState([]); + const [caregivers, setCaregivers] = useState([]); + const [selected, setSelected] = useState(null); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const listRef = useRef(null); + + const loadThreads = useCallback(() => { + setLoading(true); + setError(null); + Promise.all([ + getJson<{ threads: Thread[] }>('/api/messages'), + getJson('/api/staff'), + ]) + .then(([threadData, staffData]) => { + setThreads(threadData?.threads ?? []); + setCaregivers((staffData ?? []).filter((s) => s.role === 'caregiver' && s.status !== 'pending')); + }) + .catch((err: Error) => setError(err.message || 'Failed to load messages')) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { loadThreads(); }, [loadThreads]); + + const openThread = useCallback((caregiverId: string) => { + setSelected(caregiverId); + getJson<{ messages: Message[] }>(`/api/messages/${encodeURIComponent(caregiverId)}`) + .then((data) => { + setMessages(data?.messages ?? []); + // Opening clears the unread badge server-side; mirror it here rather + // than refetching the whole thread list. + setThreads((prev) => + prev.map((t) => (t.caregiverId === caregiverId ? { ...t, unreadForStaff: 0 } : t)), + ); + requestAnimationFrame(() => { + if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight; + }); + }) + .catch(() => setMessages([])); + }, []); + + const send = async () => { + const body = draft.trim(); + if (!body || !selected) return; + setSending(true); + try { + await postJson('/api/messages/staff', { caregiverId: selected, body }); + setDraft(''); + openThread(selected); + loadThreads(); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to send that message'); + } finally { + setSending(false); + } + }; + + const emailFor = (caregiverId: string) => + caregivers.find((c) => c.id === caregiverId)?.email ?? 'Caregiver'; + + return ( +
+
+

Messages

+

+ One conversation per caregiver. Keeps work conversation inside RayHealth instead of on + personal phones. +

+
+ + {loading ? ( + + ) : error ? ( + + ) : ( +
+
+
+ Caregivers +
+
+ {caregivers.length === 0 ? ( +
+ No active caregivers yet. +
+ ) : ( + caregivers.map((c) => { + const thread = threads.find((t) => t.caregiverId === c.id); + const active = selected === c.id; + return ( + + ); + }) + )} +
+
+ +
+ {!selected ? ( +
+ +
+ ) : ( + <> +
+ {emailFor(selected)} +
+
+ {messages.length === 0 ? ( +
+ No messages yet. Say hello. +
+ ) : ( + messages.map((m) => { + const mine = m.senderType === 'staff'; + return ( +
+
+ {m.body} +
+ {formatWhen(m.createdAt)} +
+
+
+ ); + }) + )} +
+
+