Files
temetro/frontend/lib/messages.ts
T
Khalid Abdi 15fc7fdf26 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>
2026-06-15 18:58:11 +03:00

154 lines
4.2 KiB
TypeScript

import { API_BASE_URL, apiFetch } from "@/lib/api-client";
// Messaging shapes. Mirror the backend `src/types/messaging.ts`.
export type Participant = {
id: string;
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;
};
export type ConversationSummary = {
id: string;
name: string;
isGroup: boolean;
participants: Participant[];
lastMessage: ConversationMessage | null;
unread: boolean;
// Messages from others newer than the caller's read pointer.
unreadCount: number;
updatedAt: string;
};
export function listConversations(): Promise<ConversationSummary[]> {
return apiFetch<ConversationSummary[]>("/api/conversations");
}
export function listClinicMembers(): Promise<Participant[]> {
return apiFetch<Participant[]>("/api/conversations/members");
}
export function createConversation(input: {
participantIds: string[];
name?: string | null;
}): Promise<ConversationSummary> {
return apiFetch<ConversationSummary>("/api/conversations", {
method: "POST",
body: JSON.stringify(input),
});
}
export function getMessages(
conversationId: string,
): Promise<ConversationMessage[]> {
return apiFetch<ConversationMessage[]>(
`/api/conversations/${conversationId}/messages`,
);
}
// REST fallback for sending (the realtime path is the socket "message:send").
export function sendMessageRest(
conversationId: string,
body: string,
attachments?: MessageAttachment[],
): Promise<ConversationMessage> {
return apiFetch<ConversationMessage>(
`/api/conversations/${conversationId}/messages`,
{ 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",
});
}