mirror of
https://github.com/temetro/temetro.git
synced 2026-08-30 12:19:07 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user