diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index 1ebe65a..e2ec136 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -36,6 +36,7 @@ import onboardingAdminRoutes from './routes/onboarding-admin-routes.js'; import profileRoutes from './routes/profile-routes.js'; import settingsRoutes from './routes/settings-routes.js'; import pushTokenRoutes from './routes/push-token-routes.js'; +import mileageRoutes from './routes/mileage-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'; @@ -348,6 +349,7 @@ export function createApp(options: { mobileSessionStore?: MobileSessionStore } = app.use(`${prefix}/profile`, profileRoutes); app.use(`${prefix}/settings`, settingsRoutes); app.use(`${prefix}/notifications`, pushTokenRoutes); + app.use(`${prefix}/mileage`, mileageRoutes); 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__/mileage-routes.test.ts b/packages/app/src/routes/__tests__/mileage-routes.test.ts new file mode 100644 index 0000000..ca3795b --- /dev/null +++ b/packages/app/src/routes/__tests__/mileage-routes.test.ts @@ -0,0 +1,214 @@ +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-0000000000f1'; +const userId = '00000000-0000-4000-8000-0000000000f2'; +const caregiverId = '00000000-0000-4000-8000-0000000000f3'; +const entryId = '00000000-0000-4000-8000-0000000000f4'; + +function mockRepo(overrides: Record) { + vi.spyOn(core, 'MileageRepository').mockImplementation( + () => overrides as unknown as core.MileageRepository, + ); +} + +function caregiverAuth() { + return `Bearer ${makeToken('caregiver', agencyId, userId, caregiverId)}`; +} +function adminAuth() { + return `Bearer ${makeToken('admin', agencyId, userId)}`; +} + +const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + +describe('POST /mileage', () => { + it('stores miles as hundredths, scoped to the calling caregiver', async () => { + const create = vi.fn().mockResolvedValue({ id: entryId }); + mockRepo({ create }); + + const res = await request(createApp()) + .post('/mileage') + .set('Authorization', caregiverAuth()) + .send({ tripDate: yesterday, miles: 12.34, purpose: 'Between visits' }); + + expect(res.status).toBe(201); + expect(create).toHaveBeenCalledWith({ + agencyId, + caregiverId, + visitId: null, + tripDate: yesterday, + milesHundredths: 1234, + purpose: 'Between visits', + }); + }); + + it('rounds at the boundary so only the integer form reaches storage', async () => { + const create = vi.fn().mockResolvedValue({ id: entryId }); + mockRepo({ create }); + + await request(createApp()) + .post('/mileage') + .set('Authorization', caregiverAuth()) + .send({ tripDate: yesterday, miles: 0.125 }); + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ milesHundredths: 13 })); + }); + + it('refuses a trip dated in the future', async () => { + const create = vi.fn(); + mockRepo({ create }); + const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); + + const res = await request(createApp()) + .post('/mileage') + .set('Authorization', caregiverAuth()) + .send({ tripDate: tomorrow, miles: 5 }); + + expect(res.status).toBe(400); + expect(create).not.toHaveBeenCalled(); + }); + + it('rejects zero, negative, and absurd distances', async () => { + const create = vi.fn(); + mockRepo({ create }); + + for (const miles of [0, -3, 501]) { + const res = await request(createApp()) + .post('/mileage') + .set('Authorization', caregiverAuth()) + .send({ tripDate: yesterday, miles }); + expect(res.status).toBe(400); + } + expect(create).not.toHaveBeenCalled(); + }); + + it('will not let a non-caregiver log mileage', async () => { + mockRepo({ create: vi.fn() }); + const res = await request(createApp()) + .post('/mileage') + .set('Authorization', adminAuth()) + .send({ tripDate: yesterday, miles: 5 }); + expect(res.status).toBe(403); + }); +}); + +describe('GET /mileage', () => { + it('gives a caregiver only their own trips', async () => { + const listForCaregiver = vi.fn().mockResolvedValue([]); + const listForAgency = vi.fn().mockResolvedValue([]); + mockRepo({ listForCaregiver, listForAgency }); + + await request(createApp()).get('/mileage').set('Authorization', caregiverAuth()); + + expect(listForCaregiver).toHaveBeenCalledWith(caregiverId, agencyId, {}); + expect(listForAgency).not.toHaveBeenCalled(); + }); + + it('gives staff the agency review queue', async () => { + const listForCaregiver = vi.fn().mockResolvedValue([]); + const listForAgency = vi.fn().mockResolvedValue([]); + mockRepo({ listForCaregiver, listForAgency }); + + await request(createApp()) + .get('/mileage?status=submitted') + .set('Authorization', adminAuth()); + + expect(listForAgency).toHaveBeenCalledWith(agencyId, { status: 'submitted' }); + expect(listForCaregiver).not.toHaveBeenCalled(); + }); + + it('rejects a malformed filter', async () => { + mockRepo({ listForCaregiver: vi.fn(), listForAgency: vi.fn() }); + const res = await request(createApp()) + .get('/mileage?from=08-2026') + .set('Authorization', caregiverAuth()); + expect(res.status).toBe(400); + }); +}); + +describe('PATCH /mileage/:id/review', () => { + it('lets staff approve a submitted trip', async () => { + const review = vi.fn().mockResolvedValue({ id: entryId, status: 'approved' }); + mockRepo({ review }); + + const res = await request(createApp()) + .patch(`/mileage/${entryId}/review`) + .set('Authorization', adminAuth()) + .send({ status: 'approved', note: 'ok' }); + + expect(res.status).toBe(200); + expect(review).toHaveBeenCalledWith(entryId, agencyId, 'approved', userId, 'ok'); + }); + + it('will not let a caregiver approve their own reimbursement', async () => { + const review = vi.fn(); + mockRepo({ review }); + + const res = await request(createApp()) + .patch(`/mileage/${entryId}/review`) + .set('Authorization', caregiverAuth()) + .send({ status: 'approved' }); + + expect(res.status).toBe(403); + expect(review).not.toHaveBeenCalled(); + }); + + it('404s on an already-reviewed or foreign entry, without saying which', async () => { + // The repository only matches status='submitted', so a second reviewer + // cannot silently overwrite the first decision. + mockRepo({ review: vi.fn().mockResolvedValue(null) }); + + const res = await request(createApp()) + .patch(`/mileage/${entryId}/review`) + .set('Authorization', adminAuth()) + .send({ status: 'rejected' }); + + expect(res.status).toBe(404); + }); + + it('rejects a status outside the workflow', async () => { + mockRepo({ review: vi.fn() }); + const res = await request(createApp()) + .patch(`/mileage/${entryId}/review`) + .set('Authorization', adminAuth()) + .send({ status: 'paid' }); + expect(res.status).toBe(400); + }); +}); + +describe('DELETE /mileage/:id', () => { + it('withdraws the caregiver own not-yet-reviewed trip', async () => { + const deleteOwnSubmitted = vi.fn().mockResolvedValue(true); + mockRepo({ deleteOwnSubmitted }); + + const res = await request(createApp()) + .delete(`/mileage/${entryId}`) + .set('Authorization', caregiverAuth()); + + expect(res.status).toBe(204); + expect(deleteOwnSubmitted).toHaveBeenCalledWith(entryId, caregiverId, agencyId); + }); + + it('404s once the agency has ruled on the trip', async () => { + // The decision record is not the caregiver's to erase. + mockRepo({ deleteOwnSubmitted: vi.fn().mockResolvedValue(false) }); + + const res = await request(createApp()) + .delete(`/mileage/${entryId}`) + .set('Authorization', caregiverAuth()); + + expect(res.status).toBe(404); + }); + + it('requires authentication', async () => { + mockRepo({ deleteOwnSubmitted: vi.fn() }); + const res = await request(createApp()).delete(`/mileage/${entryId}`); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/app/src/routes/mileage-routes.ts b/packages/app/src/routes/mileage-routes.ts new file mode 100644 index 0000000..008bdd0 --- /dev/null +++ b/packages/app/src/routes/mileage-routes.ts @@ -0,0 +1,175 @@ +/** + * Caregiver mileage. + * + * Caregivers log trips; agency staff approve or reject them. The split is + * strict: a caregiver may only ever see, create, or withdraw their own + * entries, and only staff with `staff.write` may rule on one. A caregiver + * approving their own reimbursement would be the whole point of the workflow + * defeated. + * + * `purpose` is caregiver-authored free text and is treated as potentially + * PHI, because somebody will eventually type a client's name into it. It is + * returned only inside agency-scoped responses and never copied into an audit + * payload or a notification body. + */ +import { Router, type Request, type Response } from 'express'; +import type { Knex } from 'knex'; +import { z } from 'zod'; +import { MileageRepository } from '@rayhealth/core'; +import { requireCapability } from '../middleware/require-capability.js'; +import { safeError } from '../security/safe-log.js'; + +const router = Router(); + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Miles arrive as a decimal from the UI and are stored as hundredths. + * + * The 500-mile ceiling is a typo guard, not a policy: a home-care caregiver + * does not drive 500 miles between clients in a day, and catching a slipped + * decimal here beats explaining a four-figure reimbursement later. Zero is + * rejected because a zero-mile trip is a mistake, not a claim. + */ +const createSchema = z.object({ + tripDate: z.string().regex(DATE_RE, 'tripDate must be YYYY-MM-DD'), + miles: z.number().positive().max(500), + purpose: z.string().max(500).optional(), + visitId: z.string().uuid().optional(), +}); + +const reviewSchema = z.object({ + status: z.enum(['approved', 'rejected']), + note: z.string().max(500).optional(), +}); + +const listQuerySchema = z.object({ + from: z.string().regex(DATE_RE).optional(), + to: z.string().regex(DATE_RE).optional(), + status: z.enum(['submitted', 'approved', 'rejected']).optional(), +}); + +/** Trips cannot be logged in the future; a trip either happened or it didn't. */ +function isFutureDate(tripDate: string): boolean { + return tripDate > new Date().toISOString().slice(0, 10); +} + +// GET /mileage, the caller's own trips (caregiver) or the agency queue (staff) +router.get('/', requireCapability('evv.read'), async (req: Request, res: Response) => { + const parsed = listQuerySchema.safeParse(req.query); + if (!parsed.success) { + return res.status(400).json({ message: 'from/to must be YYYY-MM-DD and status must be valid' }); + } + try { + const db = req.app.get('db') as Knex; + const repo = new MileageRepository(db); + // A caregiver sees only their own trips. Anyone else with evv.read sees + // the agency queue, which is what the review screen needs. + const entries = + req.auth.role === 'caregiver' && req.auth.caregiverId + ? await repo.listForCaregiver(req.auth.caregiverId, req.auth.agencyId, parsed.data) + : await repo.listForAgency(req.auth.agencyId, parsed.data); + res.json({ entries }); + } catch (error) { + safeError('mileage list failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// POST /mileage, log a trip +router.post('/', requireCapability('evv.write'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Only caregivers can log mileage' }); + } + const parsed = createSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ + message: parsed.error.issues[0]?.message ?? 'Invalid mileage entry', + issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), + }); + } + if (isFutureDate(parsed.data.tripDate)) { + return res.status(400).json({ message: 'tripDate cannot be in the future' }); + } + + try { + const db = req.app.get('db') as Knex; + const entry = await new MileageRepository(db).create({ + agencyId: req.auth.agencyId, + caregiverId: req.auth.caregiverId, + visitId: parsed.data.visitId ?? null, + tripDate: parsed.data.tripDate, + // Rounded at the boundary so the stored integer is the only + // representation the rest of the system ever sees. + milesHundredths: Math.round(parsed.data.miles * 100), + purpose: parsed.data.purpose ?? null, + }); + res.status(201).json(entry); + } catch (error) { + safeError('mileage create failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// PATCH /mileage/:id/review, approve or reject a submitted trip +router.patch('/:id/review', requireCapability('staff.write'), async (req: Request, res: Response) => { + const id = typeof req.params.id === 'string' ? req.params.id : ''; + if (!/^[0-9a-f-]{36}$/i.test(id)) { + return res.status(400).json({ message: 'valid mileage entry id required' }); + } + const parsed = reviewSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ message: 'status must be approved or rejected' }); + } + + try { + const db = req.app.get('db') as Knex; + const updated = await new MileageRepository(db).review( + id, + req.auth.agencyId, + parsed.data.status, + req.auth.userId, + parsed.data.note ?? null, + ); + // Either the entry is not in this agency, or it has already been ruled on. + // A single 404 for both keeps the row from being probed across tenants and + // stops a second reviewer silently overwriting the first decision. + if (!updated) { + return res.status(404).json({ message: 'No submitted mileage entry with that id' }); + } + res.json(updated); + } catch (error) { + safeError('mileage review failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// DELETE /mileage/:id, withdraw one's own not-yet-reviewed trip +router.delete('/:id', requireCapability('evv.write'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Only caregivers can withdraw mileage' }); + } + const id = typeof req.params.id === 'string' ? req.params.id : ''; + if (!/^[0-9a-f-]{36}$/i.test(id)) { + return res.status(400).json({ message: 'valid mileage entry id required' }); + } + try { + const db = req.app.get('db') as Knex; + const removed = await new MileageRepository(db).deleteOwnSubmitted( + id, + req.auth.caregiverId, + req.auth.agencyId, + ); + // Once the agency has ruled on a trip, the record of that decision is not + // the caregiver's to erase. + if (!removed) { + return res.status(404).json({ message: 'No withdrawable mileage entry with that id' }); + } + res.status(204).end(); + } catch (error) { + safeError('mileage delete 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 4047a59..186c50d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -77,3 +77,4 @@ export * from './integrations/index.js'; export * from './domain/user-agency.js'; export * from './repositories/user-agency-repository.js'; export * from './repositories/push-token-repository.js'; +export * from './repositories/mileage-repository.js'; diff --git a/packages/core/src/migrations/2026-08-04-add-mileage-entries.ts b/packages/core/src/migrations/2026-08-04-add-mileage-entries.ts new file mode 100644 index 0000000..bb8fe70 --- /dev/null +++ b/packages/core/src/migrations/2026-08-04-add-mileage-entries.ts @@ -0,0 +1,55 @@ +/** + * Migration: caregiver mileage entries. + * + * Home-care caregivers drive between clients all day and are commonly + * reimbursed for it. Until now there was nowhere to record that, so agencies + * were collecting it on paper or not at all. + * + * Shape notes: + * - `miles_hundredths` is an integer: 12.34 miles stores as 1234. Same + * reason money is stored in cents, a float odometer difference summed + * over a month drifts. + * - `visit_id` is nullable. Not every trip attaches to a visit (a supply + * run, a drive to the office), and a visit-linked trip should survive the + * visit being reviewed, so there is no cascade from it. + * - `status` is the approval workflow: submitted, approved, rejected. An + * agency pays on approved rows; the caregiver sees the state of each. + * - `purpose` is free text and IS potentially PHI, because a caregiver may + * type a client's name into it. It is treated as PHI everywhere it is + * read: agency-scoped queries only, never in a notification body, never + * in an audit payload. + * + * 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('mileage_entries'))) { + await knex.schema.createTable('mileage_entries', (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') + // Optional visit linkage. No FK cascade: a trip that happened still + // happened even if the visit row is later removed. + table.uuid('visit_id').nullable() + table.date('trip_date').notNullable() + // Miles to two decimal places, stored as hundredths. + table.integer('miles_hundredths').notNullable() + table.string('purpose', 500).nullable() + table.string('status', 16).notNullable().defaultTo('submitted') + table.uuid('reviewed_by').nullable() + table.timestamp('reviewed_at', { useTz: true }).nullable() + table.string('review_note', 500).nullable() + table.timestamps(true, true) + table.index(['agency_id', 'trip_date']) + table.index(['caregiver_id', 'trip_date']) + table.index(['status']) + }) + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('mileage_entries') +} diff --git a/packages/core/src/migrations/runner.ts b/packages/core/src/migrations/runner.ts index 1bed253..69c7eed 100644 --- a/packages/core/src/migrations/runner.ts +++ b/packages/core/src/migrations/runner.ts @@ -35,6 +35,7 @@ import * as addDenialWorklist from './2026-07-21-add-denial-worklist.js'; import * as addCourseResumeState from './2026-08-04-add-course-resume-state.js'; 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'; async function run(): Promise { const db = createDb(); @@ -53,6 +54,7 @@ async function run(): Promise { await addCourseResumeState.up(db); await addPushTokens.up(db); await addCaregiverPayRate.up(db); + await addMileageEntries.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/mileage-repository.ts b/packages/core/src/repositories/mileage-repository.ts new file mode 100644 index 0000000..800cdbe --- /dev/null +++ b/packages/core/src/repositories/mileage-repository.ts @@ -0,0 +1,157 @@ +/** + * Repository for `mileage_entries`. + * + * Tenancy: every read and write takes an agencyId and filters on it. Caregiver + * reads additionally filter on caregiver_id, because a caregiver may see only + * their own trips. + * + * `purpose` is caregiver-authored free text and must be treated as potentially + * PHI: somebody will eventually type a client's name into it. It never leaves + * an agency-scoped query and never lands in a notification or audit payload. + */ + +import type { Knex } from 'knex' + +export type MileageStatus = 'submitted' | 'approved' | 'rejected' + +export interface MileageEntry { + id: string + agencyId: string + caregiverId: string + visitId: string | null + tripDate: string + /** Miles to two decimals, stored as hundredths (12.34 mi = 1234). */ + milesHundredths: number + purpose: string | null + status: MileageStatus + reviewedAt: string | null + reviewNote: string | null + createdAt: string | null +} + +export interface NewMileageEntry { + agencyId: string + caregiverId: string + visitId?: string | null + tripDate: string + milesHundredths: number + purpose?: string | null +} + +function toIso(value: unknown): string | null { + if (!value) return null + return value instanceof Date ? value.toISOString() : String(value) +} + +/** Dates come back as Date or string depending on driver; normalize to YYYY-MM-DD. */ +function toYmd(value: unknown): string { + if (value instanceof Date) return value.toISOString().slice(0, 10) + return String(value).slice(0, 10) +} + +function mapRow(row: Record): MileageEntry { + return { + id: String(row.id), + agencyId: String(row.agency_id), + caregiverId: String(row.caregiver_id), + visitId: row.visit_id ? String(row.visit_id) : null, + tripDate: toYmd(row.trip_date), + milesHundredths: Number(row.miles_hundredths), + purpose: row.purpose ? String(row.purpose) : null, + status: (row.status as MileageStatus) ?? 'submitted', + reviewedAt: toIso(row.reviewed_at), + reviewNote: row.review_note ? String(row.review_note) : null, + createdAt: toIso(row.created_at), + } +} + +export class MileageRepository { + constructor(private readonly db: Knex) {} + + async create(input: NewMileageEntry): Promise { + const [row] = await this.db('mileage_entries') + .insert({ + agency_id: input.agencyId, + caregiver_id: input.caregiverId, + visit_id: input.visitId ?? null, + trip_date: input.tripDate, + miles_hundredths: input.milesHundredths, + purpose: input.purpose ?? null, + status: 'submitted', + }) + .returning('*') + return mapRow(row as Record) + } + + /** One caregiver's own trips, newest first, bounded by an optional range. */ + async listForCaregiver( + caregiverId: string, + agencyId: string, + options: { from?: string; to?: string; limit?: number } = {}, + ): Promise { + let q = this.db('mileage_entries') + .where({ caregiver_id: caregiverId, agency_id: agencyId }) + .orderBy('trip_date', 'desc') + .orderBy('created_at', 'desc') + if (options.from) q = q.andWhere('trip_date', '>=', options.from) + if (options.to) q = q.andWhere('trip_date', '<=', options.to) + const rows = await q.limit(Math.min(options.limit ?? 200, 500)).select('*') + return (rows as Record[]).map(mapRow) + } + + /** Agency-wide review queue, optionally filtered by status. */ + async listForAgency( + agencyId: string, + options: { status?: MileageStatus; from?: string; to?: string; limit?: number } = {}, + ): Promise { + let q = this.db('mileage_entries').where({ agency_id: agencyId }) + if (options.status) q = q.andWhere('status', options.status) + if (options.from) q = q.andWhere('trip_date', '>=', options.from) + if (options.to) q = q.andWhere('trip_date', '<=', options.to) + const rows = await q + .orderBy('trip_date', 'desc') + .limit(Math.min(options.limit ?? 500, 1000)) + .select('*') + return (rows as Record[]).map(mapRow) + } + + /** + * Approve or reject a submitted entry. + * + * Only a `submitted` row can be reviewed. That is what makes the transition + * idempotent-safe: two coordinators clicking approve at once cannot produce + * two approvals, and a rejected entry cannot be quietly flipped to approved + * without the caregiver resubmitting. + */ + async review( + id: string, + agencyId: string, + status: 'approved' | 'rejected', + reviewerId: string, + note?: string | null, + ): Promise { + const [row] = await this.db('mileage_entries') + .where({ id, agency_id: agencyId, status: 'submitted' }) + .update({ + status, + reviewed_by: reviewerId, + reviewed_at: this.db.fn.now(), + review_note: note ?? null, + updated_at: this.db.fn.now(), + }) + .returning('*') + return row ? mapRow(row as Record) : null + } + + /** + * Delete a caregiver's own entry. Scoped to the caregiver AND to + * `submitted`: once an agency has ruled on a trip, the record of that + * decision is not the caregiver's to erase. + */ + async deleteOwnSubmitted(id: string, caregiverId: string, agencyId: string): Promise { + const deleted = await this.db('mileage_entries') + .where({ id, caregiver_id: caregiverId, agency_id: agencyId, status: 'submitted' }) + .del() + return deleted > 0 + } +} diff --git a/packages/mobile/app/mileage.tsx b/packages/mobile/app/mileage.tsx new file mode 100644 index 0000000..fb3c4f0 --- /dev/null +++ b/packages/mobile/app/mileage.tsx @@ -0,0 +1,2 @@ +import MileageScreen from '../src/features/mileage/MileageScreen'; +export default MileageScreen; diff --git a/packages/mobile/src/features/mileage/MileageScreen.tsx b/packages/mobile/src/features/mileage/MileageScreen.tsx new file mode 100644 index 0000000..6c4b1d7 --- /dev/null +++ b/packages/mobile/src/features/mileage/MileageScreen.tsx @@ -0,0 +1,365 @@ +import React, { useCallback, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +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 { alpha, colors, radii, shadow, typography } from '../common/tokens'; +import { + formatMiles, + parseMiles, + summarize, + todayYmd, + type MileageEntry, +} from '../../lib/mileage'; + +/** + * Mileage screen. + * + * Caregivers log the driving they do between clients and watch it move + * through agency review. Approved and still-pending totals are shown + * separately, because agencies pay on approved trips and one blended number + * would overstate what is actually coming. + */ + +const STATUS_META: Record< + MileageEntry['status'], + { label: string; color: string; icon: keyof typeof Ionicons.glyphMap } +> = { + submitted: { label: 'Pending review', color: colors.amber, icon: 'time-outline' }, + approved: { label: 'Approved', color: colors.success, icon: 'checkmark-circle-outline' }, + rejected: { label: 'Not approved', color: colors.danger, icon: 'close-circle-outline' }, +}; + +function formatTripDate(ymd: string): string { + const d = new Date(`${ymd}T00:00:00.000Z`); + return Number.isFinite(d.getTime()) + ? d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }) + : ymd; +} + +export default function MileageScreen() { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const [miles, setMiles] = useState(''); + const [purpose, setPurpose] = useState(''); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(null); + + const load = useCallback(async () => { + try { + const res = await apiClient.get<{ entries: MileageEntry[] }>('/api/mileage'); + setEntries(res.data?.entries ?? []); + setError(null); + } catch { + setError('Could not load your mileage.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useFocusEffect( + useCallback(() => { + void load(); + }, [load]), + ); + + const totals = summarize(entries); + + const submit = async () => { + const parsed = parseMiles(miles); + if (!parsed.ok) { + setFormError(parsed.error); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + return; + } + setFormError(null); + setSaving(true); + try { + await apiClient.post('/api/mileage', { + tripDate: todayYmd(new Date()), + miles: parsed.miles, + ...(purpose.trim() ? { purpose: purpose.trim() } : {}), + }); + setMiles(''); + setPurpose(''); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + await load(); + } catch { + showAppAlert( + 'Could not save that trip', + 'Please check your connection and try again.', + undefined, + { variant: 'error' }, + ); + } finally { + setSaving(false); + } + }; + + const withdraw = (entry: MileageEntry) => { + showAppAlert( + 'Remove this trip?', + `${formatMiles(entry.milesHundredths)} on ${formatTripDate(entry.tripDate)} will be deleted.`, + [ + { text: 'Keep it' }, + { + text: 'Remove', + onPress: () => { + void (async () => { + try { + await apiClient.delete(`/api/mileage/${entry.id}`); + await load(); + } catch { + showAppAlert('Could not remove that trip', 'Please try again.', undefined, { + variant: 'error', + }); + } + })(); + }, + }, + ], + { variant: 'warning' }, + ); + }; + + return ( + + + { + setRefreshing(true); + void load(); + }} + tintColor={colors.brandBlue} + /> + } + > + + + {formatMiles(totals.approvedHundredths)} + Approved + + + + {formatMiles(totals.submittedHundredths)} + + Awaiting review + + + + + Log today's driving + + Miles + { + setMiles(t); + if (formError) setFormError(null); + }} + placeholder="12.4" + placeholderTextColor={colors.textMuted} + keyboardType="decimal-pad" + style={styles.input} + accessibilityLabel="Miles driven" + /> + + + Purpose (optional) + + + {formError ? {formError} : null} + void submit()} + disabled={saving} + style={({ pressed }) => [styles.submitBtn, pressed && !saving && { opacity: 0.9 }]} + accessibilityRole="button" + accessibilityLabel="Save trip" + accessibilityState={{ disabled: saving }} + > + {saving ? ( + + ) : ( + Save trip + )} + + + + Recent trips + + {loading ? ( + + ) : error ? ( + { setLoading(true); void load(); }} /> + ) : entries.length === 0 ? ( + + + + No trips logged yet. Add the driving you do between clients and your agency will + review it. + + + ) : ( + entries.map((entry, index) => { + const meta = STATUS_META[entry.status]; + return ( + + + {formatMiles(entry.milesHundredths)} + + + {meta.label} + + + {formatTripDate(entry.tripDate)} + {entry.purpose ? ( + + {entry.purpose} + + ) : null} + {entry.status === 'rejected' && entry.reviewNote ? ( + {entry.reviewNote} + ) : null} + {entry.status === 'submitted' ? ( + withdraw(entry)} + hitSlop={8} + style={styles.withdrawBtn} + accessibilityRole="button" + accessibilityLabel="Remove this trip" + > + Remove + + ) : null} + + ); + }) + )} + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: colors.screenBg }, + body: { flex: 1 }, + bodyContent: { padding: 16, paddingBottom: 48, gap: 12 }, + totalsRow: { flexDirection: 'row', gap: 12 }, + totalCard: { + backgroundColor: colors.cardBg, + borderRadius: radii.lg, + padding: 16, + gap: 2, + ...shadow.card, + }, + totalValue: { fontSize: 20, fontWeight: '800', color: colors.textPrimary }, + totalLabel: { ...typography.caption, color: colors.textMuted }, + formCard: { + backgroundColor: colors.cardBg, + borderRadius: radii.lg, + padding: 16, + gap: 12, + ...shadow.card, + }, + formTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, + field: { gap: 5 }, + fieldLabel: { ...typography.caption, color: colors.textSecondary }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: radii.md, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + color: colors.textPrimary, + backgroundColor: colors.screenBg, + }, + formError: { ...typography.caption, color: colors.danger }, + submitBtn: { + backgroundColor: colors.brandBlue, + borderRadius: radii.md, + paddingVertical: 13, + alignItems: 'center', + }, + submitText: { color: colors.onGradient, fontWeight: '700', fontSize: 15 }, + sectionLabel: { + ...typography.caption, + color: colors.textMuted, + textTransform: 'uppercase', + marginTop: 4, + }, + emptyCard: { + backgroundColor: colors.cardBg, + borderRadius: radii.lg, + padding: 24, + alignItems: 'center', + gap: 10, + ...shadow.card, + }, + emptyText: { ...typography.caption, color: colors.textSecondary, textAlign: 'center', lineHeight: 17 }, + entryCard: { + backgroundColor: colors.cardBg, + borderRadius: radii.lg, + padding: 14, + gap: 4, + ...shadow.card, + }, + entryTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + entryMiles: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, + statusPill: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: radii.pill, + }, + statusText: { ...typography.caption }, + entryDate: { ...typography.caption, color: colors.textMuted }, + entryPurpose: { fontSize: 13, color: colors.textSecondary }, + reviewNote: { ...typography.caption, color: colors.danger, marginTop: 2 }, + withdrawBtn: { alignSelf: 'flex-start', marginTop: 6 }, + withdrawText: { ...typography.caption, color: colors.brandBlue, fontWeight: '700' }, +}); diff --git a/packages/mobile/src/features/profile/ProfileScreen.tsx b/packages/mobile/src/features/profile/ProfileScreen.tsx index 791801a..7475d7f 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('/mileage')} + /> { + it('accepts whole and decimal miles', () => { + expect(parseMiles('12')).toEqual({ ok: true, miles: 12 }); + expect(parseMiles('12.4')).toEqual({ ok: true, miles: 12.4 }); + expect(parseMiles('12.45')).toEqual({ ok: true, miles: 12.45 }); + expect(parseMiles(' 8.5 ')).toEqual({ ok: true, miles: 8.5 }); + }); + + it('asks for a number rather than failing silently', () => { + const result = parseMiles(''); + expect(result.ok).toBe(false); + // Actionable, not a generic "invalid input". + if (!result.ok) expect(result.error).toMatch(/Enter how many miles/); + }); + + it('rejects non-numeric and over-precise input', () => { + expect(parseMiles('twelve').ok).toBe(false); + expect(parseMiles('12.456').ok).toBe(false); + expect(parseMiles('1,200').ok).toBe(false); + }); + + it('rejects zero and negative trips, which are mistakes not claims', () => { + expect(parseMiles('0').ok).toBe(false); + expect(parseMiles('0.00').ok).toBe(false); + expect(parseMiles('-5').ok).toBe(false); + }); + + it('catches a slipped decimal before the server has to', () => { + expect(parseMiles('500').ok).toBe(true); + const tooHigh = parseMiles('501'); + expect(tooHigh.ok).toBe(false); + if (!tooHigh.ok) expect(tooHigh.error).toMatch(/too high/); + }); +}); + +describe('formatMiles', () => { + it('always shows two decimals', () => { + expect(formatMiles(1234)).toBe('12.34 mi'); + expect(formatMiles(1200)).toBe('12.00 mi'); + expect(formatMiles(0)).toBe('0.00 mi'); + expect(formatMiles(5)).toBe('0.05 mi'); + }); +}); + +describe('totalHundredths', () => { + it('sums integrally, with no float drift', () => { + const entries = Array.from({ length: 10 }, () => entry('approved', 1010)); + expect(totalHundredths(entries)).toBe(10100); + }); + + it('is zero for no entries', () => { + expect(totalHundredths([])).toBe(0); + }); +}); + +describe('summarize', () => { + it('keeps approved and pending apart rather than blending them', () => { + // A blended total would overstate what is actually coming. + const result = summarize([ + entry('approved', 1000), + entry('approved', 500), + entry('submitted', 2000), + entry('rejected', 900), + ]); + expect(result).toEqual({ + approvedHundredths: 1500, + submittedHundredths: 2000, + rejectedCount: 1, + }); + }); + + it('handles an empty list', () => { + expect(summarize([])).toEqual({ + approvedHundredths: 0, + submittedHundredths: 0, + rejectedCount: 0, + }); + }); +}); + +describe('todayYmd', () => { + it('formats the date portion only', () => { + expect(todayYmd(new Date('2026-08-04T22:15:00.000Z'))).toBe('2026-08-04'); + }); +}); diff --git a/packages/mobile/src/lib/mileage.ts b/packages/mobile/src/lib/mileage.ts new file mode 100644 index 0000000..f7d2571 --- /dev/null +++ b/packages/mobile/src/lib/mileage.ts @@ -0,0 +1,76 @@ +/** + * Pure helpers for the mileage screen: input parsing and display formatting. + * No React Native imports, so it is unit-testable. + */ + +export type MileageStatus = 'submitted' | 'approved' | 'rejected'; + +export interface MileageEntry { + id: string; + tripDate: string; + milesHundredths: number; + purpose: string | null; + status: MileageStatus; + reviewNote: string | null; +} + +export type ParsedMiles = { ok: true; miles: number } | { ok: false; error: string }; + +/** + * Parse what a caregiver typed into the miles box. + * + * Deliberately strict about the failure messages: "Enter miles" and "That + * looks too high" are actionable, where a generic "invalid input" leaves + * somebody staring at a form they cannot submit. The 500 ceiling matches the + * server's typo guard, so the client never sends something the API will + * bounce. + */ +export function parseMiles(raw: string): ParsedMiles { + const trimmed = raw.trim(); + if (trimmed === '') return { ok: false, error: 'Enter how many miles you drove.' }; + if (!/^\d{0,4}(\.\d{1,2})?$/.test(trimmed)) { + return { ok: false, error: 'Enter miles as a number, like 12.4.' }; + } + const miles = Number(trimmed); + if (!Number.isFinite(miles) || miles <= 0) { + return { ok: false, error: 'Miles must be more than zero.' }; + } + if (miles > 500) return { ok: false, error: 'That looks too high. Check the number.' }; + return { ok: true, miles }; +} + +/** Hundredths of a mile to a display string, e.g. 1234 becomes "12.34 mi". */ +export function formatMiles(hundredths: number): string { + return `${(hundredths / 100).toFixed(2)} mi`; +} + +/** Total of a set of entries, in hundredths, so callers stay integral. */ +export function totalHundredths(entries: MileageEntry[]): number { + return entries.reduce((sum, e) => sum + e.milesHundredths, 0); +} + +/** + * Totals split by review state. Agencies pay on approved trips, so a + * caregiver needs to see approved and still-pending as separate numbers + * rather than one blended figure that overstates what is actually coming. + */ +export function summarize(entries: MileageEntry[]): { + approvedHundredths: number; + submittedHundredths: number; + rejectedCount: number; +} { + let approvedHundredths = 0; + let submittedHundredths = 0; + let rejectedCount = 0; + for (const e of entries) { + if (e.status === 'approved') approvedHundredths += e.milesHundredths; + else if (e.status === 'submitted') submittedHundredths += e.milesHundredths; + else rejectedCount += 1; + } + return { approvedHundredths, submittedHundredths, rejectedCount }; +} + +/** Today as YYYY-MM-DD, the default trip date. */ +export function todayYmd(now: Date): string { + return now.toISOString().slice(0, 10); +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 4d350f9..75efd35 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -16,6 +16,7 @@ import { RouteErrorBoundary } from './components/RouteErrorBoundary.js'; const AgencySetupPage = lazy(() => import('./features/agency/AgencySetupPage.js').then((m) => ({ default: m.AgencySetupPage }))); const GoLiveReadinessPage = lazy(() => import('./features/agency/GoLiveReadinessPage.js').then((m) => ({ default: m.GoLiveReadinessPage }))); 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 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 }))); @@ -301,6 +302,7 @@ const navGroupDefs: NavGroupDef[] = [ { to: '/admin/readiness', label: 'Go-Live Checklist', icon: icons.dashboard }, { to: '/admin/agency', label: 'Agency Setup', icon: icons.agency }, { to: '/admin/staff', label: 'Staff', icon: icons.staff }, + { to: '/admin/mileage', label: 'Mileage', 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 }, @@ -576,6 +578,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/web/src/features/staff/MileageReviewPage.tsx b/packages/web/src/features/staff/MileageReviewPage.tsx new file mode 100644 index 0000000..6240d8d --- /dev/null +++ b/packages/web/src/features/staff/MileageReviewPage.tsx @@ -0,0 +1,207 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { getJson, patchJson } from '../../lib/api-client.js'; +import { EmptyState, LoadingSkeleton, ErrorRetry } from '../../components/state/index.js'; + +/** + * Mileage review. + * + * The agency side of caregiver mileage: approve or reject what caregivers + * logged. Defaults to the pending queue, because that is the only list with + * work in it; approved and rejected are there for looking something up, not + * for daily use. + */ + +type MileageStatus = 'submitted' | 'approved' | 'rejected'; + +interface MileageEntry { + id: string; + caregiverId: string; + tripDate: string; + milesHundredths: number; + purpose: string | null; + status: MileageStatus; + reviewNote: string | null; + createdAt: string | null; +} + +const STATUS_TABS: Array<{ key: MileageStatus; label: string }> = [ + { key: 'submitted', label: 'Pending' }, + { key: 'approved', label: 'Approved' }, + { key: 'rejected', label: 'Not approved' }, +]; + +const STATUS_COLOR: Record = { + submitted: 'var(--color-warning)', + approved: 'var(--color-success)', + rejected: 'var(--color-danger-text)', +}; + +function formatMiles(hundredths: number): string { + return `${(hundredths / 100).toFixed(2)} mi`; +} + +function formatDate(ymd: string): string { + const d = new Date(`${ymd}T00:00:00.000Z`); + return Number.isFinite(d.getTime()) + ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) + : ymd; +} + +export function MileageReviewPage() { + const [status, setStatus] = useState('submitted'); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState>({}); + const [notes, setNotes] = useState>({}); + + const load = useCallback(() => { + setLoading(true); + setError(null); + getJson<{ entries: MileageEntry[] }>(`/api/mileage?status=${status}`) + .then((data) => setEntries(data?.entries ?? [])) + .catch((err: Error) => setError(err.message || 'Failed to load mileage')) + .finally(() => setLoading(false)); + }, [status]); + + useEffect(() => { load(); }, [load]); + + const totalHundredths = useMemo( + () => entries.reduce((sum, e) => sum + e.milesHundredths, 0), + [entries], + ); + + const review = async (entry: MileageEntry, next: 'approved' | 'rejected') => { + setBusy((prev) => ({ ...prev, [entry.id]: true })); + try { + await patchJson(`/api/mileage/${encodeURIComponent(entry.id)}/review`, { + status: next, + ...(notes[entry.id]?.trim() ? { note: notes[entry.id].trim() } : {}), + }); + // Drop the row from the current tab rather than refetching: it no + // longer belongs to the list being viewed. + setEntries((prev) => prev.filter((e) => e.id !== entry.id)); + setNotes((prev) => { const n = { ...prev }; delete n[entry.id]; return n; }); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to update that trip'); + } finally { + setBusy((prev) => { const n = { ...prev }; delete n[entry.id]; return n; }); + } + }; + + return ( +
+
+

Mileage

+

+ Driving logged by caregivers between clients. Approved trips are what your payroll or + reimbursement run should pay. +

+
+ +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ + {loading ? ( + + ) : error ? ( + + ) : entries.length === 0 ? ( + + ) : ( + <> +
+ {entries.length} {entries.length === 1 ? 'trip' : 'trips'} · {formatMiles(totalHundredths)} total +
+
+ {entries.map((entry) => ( +
+
+
+
{formatMiles(entry.milesHundredths)}
+
+ {formatDate(entry.tripDate)} +
+
+ + {STATUS_TABS.find((t) => t.key === entry.status)?.label ?? entry.status} + +
+ + {entry.purpose ? ( +
{entry.purpose}
+ ) : null} + + {entry.reviewNote ? ( +
+ Note: {entry.reviewNote} +
+ ) : null} + + {entry.status === 'submitted' ? ( +
+ setNotes((prev) => ({ ...prev, [entry.id]: e.target.value }))} + placeholder="Note (optional, shown to the caregiver if not approved)" + className="input-field" + style={{ fontSize: '0.8125rem', padding: '0.3rem 0.6rem', flex: '1 1 240px', minWidth: 0 }} + maxLength={500} + /> + + +
+ ) : null} +
+ ))} +
+ + )} +
+ ); +} + +export default MileageReviewPage;