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)} +
+
+
+ ); + }) + )} +
+
+