feat(messages): attach files and share appointments

- new message_attachments table + POST/GET /api/conversations/attachments
  (base64 upload, capped 10MB; authed clinic-scoped download)
- messages carry an attachments JSON column (file refs or appointment
  snapshots); realtime + REST send accept attachments and allow
  attachment-only messages; migration 0019
- composer "+" menu (Files / Appointments): file picker uploads and
  stages chips; appointment picker searches by patient and attaches a
  snapshot; both render in the thread (download chip / appointment card)
- raise express json limit to 15mb for base64 uploads

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-15 18:58:11 +03:00
parent addddc8972
commit 15fc7fdf26
12 changed files with 3971 additions and 44 deletions
+19 -1
View File
@@ -638,7 +638,25 @@
"noMembers": "No other clinic members yet. Invite colleagues from Settings → Care team."
},
"startFailedTitle": "Couldn't start conversation",
"startFailedBody": "Please try again."
"startFailedBody": "Please try again.",
"attach": {
"menu": "Attach",
"file": "Files",
"appointment": "Appointments",
"remove": "Remove",
"uploading": "Uploading…",
"uploadFailedTitle": "Couldn't attach file",
"uploadFailedBody": "The file may be too large (max 10MB). Please try again.",
"tooLargeTitle": "File too large",
"tooLargeBody": "Attachments are limited to 10MB.",
"download": "Download",
"apptDialogTitle": "Share an appointment",
"apptDialogDescription": "Search by patient name, then pick an appointment to attach.",
"apptSearchPlaceholder": "Patient name…",
"apptNoMatches": "No appointments match.",
"apptEmpty": "No appointments yet.",
"apptCardLabel": "Appointment"
}
},
"analysis": {
"title": "Analysis",
+84 -2
View File
@@ -1,4 +1,4 @@
import { apiFetch } from "@/lib/api-client";
import { API_BASE_URL, apiFetch } from "@/lib/api-client";
// Messaging shapes. Mirror the backend `src/types/messaging.ts`.
export type Participant = {
@@ -6,12 +6,35 @@ export type Participant = {
name: string;
};
// A shared appointment is stored as a snapshot (renders without a refetch and
// survives the appointment changing/being deleted).
export type AppointmentSnapshot = {
fileNumber: string;
name: string;
date: string;
time: string;
type: string;
provider: string;
status: string;
};
export type MessageAttachment =
| {
kind: "file";
attachmentId: string;
fileName: string;
mimeType: string;
size: number;
}
| { kind: "appointment"; appointment: AppointmentSnapshot };
export type ConversationMessage = {
id: string;
conversationId: string;
senderId: string;
senderName: string;
body: string;
attachments?: MessageAttachment[] | null;
createdAt: string;
};
@@ -57,13 +80,72 @@ export function getMessages(
export function sendMessageRest(
conversationId: string,
body: string,
attachments?: MessageAttachment[],
): Promise<ConversationMessage> {
return apiFetch<ConversationMessage>(
`/api/conversations/${conversationId}/messages`,
{ method: "POST", body: JSON.stringify({ body }) },
{ method: "POST", body: JSON.stringify({ body, attachments }) },
);
}
// Upload a file and get back a file attachment ready to send with a message.
export async function uploadAttachment(
file: File,
): Promise<Extract<MessageAttachment, { kind: "file" }>> {
const data = await fileToBase64(file);
const meta = await apiFetch<{
attachmentId: string;
fileName: string;
mimeType: string;
size: number;
}>("/api/conversations/attachments", {
method: "POST",
body: JSON.stringify({
fileName: file.name,
mimeType: file.type || "application/octet-stream",
size: file.size,
data,
}),
});
return { kind: "file", ...meta };
}
// Fetch an attachment with credentials and trigger a browser download. (A plain
// link wouldn't carry the auth cookie cross-origin.)
export async function downloadAttachment(
attachmentId: string,
fileName: string,
): Promise<void> {
const res = await fetch(
`${API_BASE_URL}/api/conversations/attachments/${attachmentId}`,
{ credentials: "include" },
);
if (!res.ok) throw new Error("Download failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// Read a File as raw base64 (without the `data:...;base64,` prefix).
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result);
const comma = result.indexOf(",");
resolve(comma === -1 ? result : result.slice(comma + 1));
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export function markConversationRead(conversationId: string): Promise<void> {
return apiFetch<void>(`/api/conversations/${conversationId}/read`, {
method: "POST",