Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
188 changes: 188 additions & 0 deletions packages/app/src/routes/__tests__/message-routes.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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 });
});
});
166 changes: 166 additions & 0 deletions packages/app/src/routes/message-routes.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading