Skip to content

Commit 0278712

Browse files
authored
feat: data provenance, clinic-tz appts, slot-conflict recovery, import validation (#1012)
- medicationService: expose interaction-warning provenance (source/version/ publish date), an update policy, an unavailable state and a freshness-aware disclaimer so stale warnings are not shown as clinically authoritative. - appointmentTime util: resolve/validate appointment slots in the clinic's IANA timezone, transporting UTC + clinic zone; DST-safe, and flags when the device timezone would shift the displayed slot. - appointmentService: bookAppointmentWithConflictHandling() refreshes alternative slots on HTTP 409, preserves safe form inputs (never the dead slot), and sends an Idempotency-Key so a retry cannot double-book. - medicalFileValidation util + ImportRecordScreen: enforce type/size limits and reject malformed or password-protected PDFs with safe, non-leaking errors before any parsing happens. Each change is covered by focused unit tests (synthetic data only). closes #959 closes #960 closes #961 closes #962
1 parent f64dfca commit 0278712

9 files changed

Lines changed: 993 additions & 0 deletions

src/screens/ImportRecordScreen.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
Treatment,
1919
VaccinationRecord,
2020
} from '../models/MedicalRecord';
21+
import { validateMedicalImportFile } from '../utils/medicalFileValidation';
2122

2223
// ─── Types ────────────────────────────────────────────────────────────────────
2324

@@ -111,6 +112,20 @@ const ImportRecordScreen: React.FC<Props> = ({ petId, petName, onBack, onImporte
111112
return;
112113
}
113114

115+
// Validate the document before it reaches the parser: enforce type/size
116+
// limits and reject encrypted or malformed PDFs with a safe error. (#962)
117+
const approxBytes = Math.floor((pdfBase64.replace(/\s/g, '').length * 3) / 4);
118+
const validation = validateMedicalImportFile({
119+
name: 'import.pdf',
120+
size: approxBytes,
121+
mimeType: 'application/pdf',
122+
base64: pdfBase64,
123+
});
124+
if (!validation.ok) {
125+
Alert.alert('Cannot import file', validation.message);
126+
return;
127+
}
128+
114129
setLoading(true);
115130
try {
116131
const response = await fetch('/api/import/medical-records/parse-pdf', {
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {
2+
bookAppointmentWithConflictHandling,
3+
type BookAppointmentRequest,
4+
} from '../appointmentService';
5+
import { upsertAppointment } from '../localDB';
6+
import apiClient from '../apiClient';
7+
8+
jest.mock('../apiClient', () => ({
9+
__esModule: true,
10+
default: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() },
11+
}));
12+
13+
jest.mock('../localDB', () => ({
14+
getAllLocalAppointments: jest.fn(),
15+
getAllAppointmentsByPetId: jest.fn(),
16+
getAppointmentsInWindow: jest.fn(),
17+
upsertAppointment: jest.fn(),
18+
deleteAppointmentById: jest.fn(),
19+
}));
20+
21+
jest.mock('expo-notifications', () => ({
22+
scheduleNotificationAsync: jest.fn(),
23+
cancelScheduledNotificationAsync: jest.fn(),
24+
SchedulableTriggerInputTypes: { DATE: 'date' },
25+
}));
26+
27+
const mockPost = jest.mocked(apiClient).post as jest.Mock;
28+
const mockGet = jest.mocked(apiClient).get as jest.Mock;
29+
const mockUpsert = upsertAppointment as jest.Mock;
30+
31+
const baseReq: BookAppointmentRequest = {
32+
petId: 'pet-1',
33+
vetId: 'vet-1',
34+
date: '2026-09-01',
35+
time: '10:00',
36+
title: 'Dental cleaning',
37+
location: 'Downtown Clinic',
38+
vetName: 'Dr. Synthetic',
39+
notes: 'Nervous around strangers',
40+
durationMinutes: 30,
41+
idempotencyKey: 'attempt-abc-123',
42+
};
43+
44+
const conflict409 = Object.assign(new Error('Conflict'), { response: { status: 409 } });
45+
46+
beforeEach(() => jest.clearAllMocks());
47+
48+
describe('bookAppointmentWithConflictHandling (#961)', () => {
49+
it('books normally and persists locally when the slot is free', async () => {
50+
mockPost.mockResolvedValueOnce({ data: { data: { id: 'a1', ...baseReq } } });
51+
52+
const result = await bookAppointmentWithConflictHandling(baseReq);
53+
54+
expect(result.status).toBe('booked');
55+
expect(mockUpsert).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
56+
});
57+
58+
it('sends the idempotency key as a header so retries cannot double-book', async () => {
59+
mockPost.mockResolvedValueOnce({ data: { data: { id: 'a1' } } });
60+
61+
await bookAppointmentWithConflictHandling(baseReq);
62+
63+
expect(mockPost).toHaveBeenCalledWith(
64+
expect.any(String),
65+
expect.any(Object),
66+
expect.objectContaining({ headers: { 'Idempotency-Key': 'attempt-abc-123' } }),
67+
);
68+
});
69+
70+
it('on 409 refreshes alternative slots and preserves safe form inputs', async () => {
71+
mockPost.mockRejectedValueOnce(conflict409);
72+
mockGet.mockResolvedValueOnce({
73+
data: {
74+
data: { vetId: 'vet-1', date: '2026-09-01', availableSlots: ['10:00', '10:30', '11:00'] },
75+
},
76+
});
77+
78+
const result = await bookAppointmentWithConflictHandling(baseReq);
79+
80+
if (result.status !== 'conflict') throw new Error('expected conflict');
81+
expect(result.alternatives).toEqual(['10:30', '11:00']); // conflicted 10:00 removed
82+
expect(result.preservedInput).toMatchObject({
83+
petId: 'pet-1',
84+
title: 'Dental cleaning',
85+
location: 'Downtown Clinic',
86+
vetName: 'Dr. Synthetic',
87+
notes: 'Nervous around strangers',
88+
date: '2026-09-01',
89+
});
90+
// The dead time is not carried back into the form.
91+
expect((result.preservedInput as Record<string, unknown>).time).toBeUndefined();
92+
expect(mockUpsert).not.toHaveBeenCalled();
93+
});
94+
95+
it('still returns a usable conflict result when availability lookup fails', async () => {
96+
mockPost.mockRejectedValueOnce(conflict409);
97+
mockGet.mockRejectedValueOnce(new Error('offline'));
98+
99+
const result = await bookAppointmentWithConflictHandling(baseReq);
100+
101+
expect(result.status).toBe('conflict');
102+
if (result.status === 'conflict') expect(result.alternatives).toEqual([]);
103+
});
104+
105+
it('rethrows non-409 errors so existing handling applies', async () => {
106+
mockPost.mockRejectedValueOnce(
107+
Object.assign(new Error('Server error'), { response: { status: 500 } }),
108+
);
109+
110+
await expect(bookAppointmentWithConflictHandling(baseReq)).rejects.toThrow('Server error');
111+
});
112+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {
2+
INTERACTION_DATA_PROVENANCE,
3+
INTERACTION_DATA_STALE_AFTER_DAYS,
4+
INTERACTION_DATA_UNAVAILABLE_AFTER_DAYS,
5+
assessInteractionDataFreshness,
6+
presentInteractionWarning,
7+
} from '../medicationService';
8+
9+
jest.mock('@react-native-async-storage/async-storage', () => ({
10+
getItem: jest.fn(),
11+
setItem: jest.fn(),
12+
removeItem: jest.fn(),
13+
}));
14+
15+
jest.mock('expo-notifications', () => ({
16+
scheduleNotificationAsync: jest.fn(),
17+
cancelScheduledNotificationAsync: jest.fn(),
18+
SchedulableTriggerInputTypes: { DATE: 'date' },
19+
}));
20+
21+
const published = new Date(INTERACTION_DATA_PROVENANCE.publishedAt).getTime();
22+
const daysAfterPublish = (n: number) => new Date(published + n * 24 * 60 * 60 * 1000);
23+
24+
describe('medication interaction data provenance & freshness (#959)', () => {
25+
// Characterises the current/target behaviour before the UI consumes it.
26+
it('exposes source, version and publish date as provenance', () => {
27+
expect(INTERACTION_DATA_PROVENANCE.source).toEqual(expect.any(String));
28+
expect(INTERACTION_DATA_PROVENANCE.version).toEqual(expect.any(String));
29+
expect(() => new Date(INTERACTION_DATA_PROVENANCE.publishedAt).toISOString()).not.toThrow();
30+
});
31+
32+
it('treats recent data as fresh and authoritative', () => {
33+
const status = assessInteractionDataFreshness(daysAfterPublish(10));
34+
expect(status.freshness).toBe('fresh');
35+
expect(status.authoritative).toBe(true);
36+
expect(status.unavailable).toBe(false);
37+
expect(status.ageDays).toBe(10);
38+
expect(status.disclaimer).toMatch(/veterinar/i);
39+
});
40+
41+
it('flags data past the stale threshold as non-authoritative but still shown', () => {
42+
const status = assessInteractionDataFreshness(
43+
daysAfterPublish(INTERACTION_DATA_STALE_AFTER_DAYS + 1),
44+
);
45+
expect(status.freshness).toBe('stale');
46+
expect(status.authoritative).toBe(false);
47+
expect(status.unavailable).toBe(false);
48+
expect(status.disclaimer).toMatch(/out of date|old/i);
49+
});
50+
51+
it('marks data past the unavailable threshold as expired and withheld', () => {
52+
const status = assessInteractionDataFreshness(
53+
daysAfterPublish(INTERACTION_DATA_UNAVAILABLE_AFTER_DAYS + 1),
54+
);
55+
expect(status.freshness).toBe('expired');
56+
expect(status.authoritative).toBe(false);
57+
expect(status.unavailable).toBe(true);
58+
expect(status.updatePolicy).toMatch(/withheld|stale/i);
59+
});
60+
61+
it('handles a malformed publish date as expired rather than crashing', () => {
62+
const status = assessInteractionDataFreshness(new Date(), {
63+
...INTERACTION_DATA_PROVENANCE,
64+
publishedAt: 'not-a-date',
65+
});
66+
expect(status.unavailable).toBe(true);
67+
expect(status.ageDays).toBe(Number.POSITIVE_INFINITY);
68+
});
69+
70+
describe('presentInteractionWarning', () => {
71+
it('attaches an attribution line and keeps the message when fresh', () => {
72+
const p = presentInteractionWarning('Do not combine drug A and drug B.', daysAfterPublish(5));
73+
expect(p.message).toContain('drug A');
74+
expect(p.attribution).toContain(INTERACTION_DATA_PROVENANCE.source);
75+
expect(p.attribution).toContain('5 days old');
76+
expect(p.authoritative).toBe(true);
77+
expect(p.suppressed).toBe(false);
78+
});
79+
80+
it('suppresses the warning body once the data is expired', () => {
81+
const p = presentInteractionWarning(
82+
'Do not combine drug A and drug B.',
83+
daysAfterPublish(INTERACTION_DATA_UNAVAILABLE_AFTER_DAYS + 5),
84+
);
85+
expect(p.message).toBe('');
86+
expect(p.suppressed).toBe(true);
87+
expect(p.disclaimer).toMatch(/unavailable/i);
88+
});
89+
});
90+
});

src/services/appointmentService.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,3 +422,101 @@ export async function checkConflicts(
422422
};
423423
}
424424
}
425+
426+
// ─── Slot-conflict-safe booking (issue #961) ─────────────────────────────────
427+
428+
/**
429+
* Fields the user typed that are safe to carry back into the form after a slot
430+
* conflict — never the (now-invalid) date/time, never anything server-assigned.
431+
*/
432+
export type PreservableBookingInput = Pick<
433+
Appointment,
434+
'petId' | 'title' | 'location' | 'vetName' | 'notes' | 'durationMinutes'
435+
> & { vetId?: string };
436+
437+
export interface BookAppointmentRequest extends PreservableBookingInput {
438+
/** Requested slot, `YYYY-MM-DD`. */
439+
date: string;
440+
/** Requested slot, `HH:mm`. */
441+
time: string;
442+
vetId: string;
443+
/**
444+
* Stable key identifying this booking attempt. Re-sent verbatim on retry so
445+
* the server can dedupe and never create two appointments for one intent.
446+
*/
447+
idempotencyKey: string;
448+
}
449+
450+
export type BookAppointmentResult =
451+
| { status: 'booked'; appointment: Appointment }
452+
| {
453+
status: 'conflict';
454+
/** Fresh alternative slots (`HH:mm`) for the same vet/date, if any. */
455+
alternatives: string[];
456+
/** The user's inputs minus the conflicted slot, ready to re-seed the form. */
457+
preservedInput: PreservableBookingInput & { date: string };
458+
message: string;
459+
};
460+
461+
function pickPreservable(req: BookAppointmentRequest): PreservableBookingInput {
462+
const { petId, title, location, vetName, notes, durationMinutes, vetId } = req;
463+
return { petId, title, location, vetName, notes, durationMinutes, vetId };
464+
}
465+
466+
function httpStatusOf(error: unknown): number | undefined {
467+
const resp = (error as { response?: { status?: number } })?.response;
468+
return typeof resp?.status === 'number' ? resp.status : undefined;
469+
}
470+
471+
/**
472+
* Book an appointment, tolerating the slot being taken between selection and
473+
* submit:
474+
* - On HTTP 409 the form state is preserved (minus the dead slot) and a fresh
475+
* list of alternative slots is fetched so the user can re-pick in place.
476+
* - The caller-supplied `idempotencyKey` is sent as a header so a retry of the
477+
* same intent can never double-book.
478+
*
479+
* Throws for non-409 errors so existing error handling still applies.
480+
*/
481+
export async function bookAppointmentWithConflictHandling(
482+
req: BookAppointmentRequest,
483+
): Promise<BookAppointmentResult> {
484+
try {
485+
const response = await apiClient.post<{ data: Appointment }>(
486+
BASE_URL,
487+
{
488+
petId: req.petId,
489+
vetId: req.vetId,
490+
date: req.date,
491+
time: req.time,
492+
durationMinutes: req.durationMinutes,
493+
title: req.title,
494+
location: req.location,
495+
vetName: req.vetName,
496+
notes: req.notes,
497+
},
498+
{ headers: { 'Idempotency-Key': req.idempotencyKey } },
499+
);
500+
const appointment = response.data.data;
501+
await upsertAppointment(appointment);
502+
return { status: 'booked', appointment };
503+
} catch (error) {
504+
if (httpStatusOf(error) !== 409) throw error;
505+
506+
let alternatives: string[] = [];
507+
try {
508+
const availability = await getAvailability(req.vetId, req.date);
509+
alternatives = (availability.availableSlots ?? []).filter((slot) => slot !== req.time);
510+
} catch {
511+
alternatives = [];
512+
}
513+
514+
return {
515+
status: 'conflict',
516+
alternatives,
517+
preservedInput: { ...pickPreservable(req), date: req.date },
518+
message:
519+
'That time was just booked by someone else. Your details are saved — pick another slot below.',
520+
};
521+
}
522+
}

0 commit comments

Comments
 (0)