mirror of
https://github.com/temetro/temetro.git
synced 2026-08-26 02:17:22 +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:
@@ -1,7 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Mail, Plus, Search, SendHorizonal } from "lucide-react";
|
||||
import {
|
||||
CalendarClock,
|
||||
Download,
|
||||
FileText,
|
||||
Mail,
|
||||
Plus,
|
||||
Search,
|
||||
SendHorizonal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
Fragment,
|
||||
useEffect,
|
||||
@@ -30,20 +40,27 @@ import {
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu";
|
||||
import { type Appointment, listAppointments } from "@/lib/appointments";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import {
|
||||
type ConversationMessage,
|
||||
type ConversationSummary,
|
||||
type MessageAttachment,
|
||||
type Participant,
|
||||
createConversation,
|
||||
downloadAttachment,
|
||||
getMessages,
|
||||
listClinicMembers,
|
||||
listConversations,
|
||||
uploadAttachment,
|
||||
} from "@/lib/messages";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
// Up to two-letter initials from a display name.
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
@@ -69,6 +86,47 @@ function sameDay(a: string, b: string): boolean {
|
||||
// one sender label, one timestamp, tighter spacing.
|
||||
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
// One sent attachment rendered in the thread: a downloadable file chip or a
|
||||
// shared-appointment card. Alignment (left/right) comes from the parent column.
|
||||
function SentAttachment({ att }: { att: MessageAttachment }) {
|
||||
const { t } = useTranslation();
|
||||
if (att.kind === "file") {
|
||||
return (
|
||||
<button
|
||||
className="flex max-w-[75%] items-center gap-2 rounded-2xl border bg-card px-3 py-2 text-left text-foreground text-sm transition-colors hover:bg-accent"
|
||||
onClick={() => {
|
||||
void downloadAttachment(att.attachmentId, att.fileName).catch(() => {
|
||||
/* ignore — surfaced by the browser */
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 max-w-48 flex-1 truncate">{att.fileName}</span>
|
||||
<Download className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const a = att.appointment;
|
||||
return (
|
||||
<div className="max-w-[75%] rounded-2xl border bg-card p-3 text-sm">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<CalendarClock className="size-3.5" />
|
||||
{t("messages.attach.apptCardLabel")}
|
||||
</div>
|
||||
<p className="mt-1 font-medium text-foreground">{a.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{[a.date, a.time].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
{[a.type, a.provider].filter(Boolean).length > 0 && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{[a.type, a.provider].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessagesView() {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = authClient.useSession();
|
||||
@@ -101,6 +159,14 @@ export function MessagesView() {
|
||||
const [members, setMembers] = useState<Participant[]>([]);
|
||||
const [memberQuery, setMemberQuery] = useState("");
|
||||
|
||||
// Pending attachments staged for the next message + the attach UI.
|
||||
const [pending, setPending] = useState<MessageAttachment[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [apptOpen, setApptOpen] = useState(false);
|
||||
const [appts, setAppts] = useState<Appointment[]>([]);
|
||||
const [apptQuery, setApptQuery] = useState("");
|
||||
|
||||
// Refs so the socket handler (registered once) reads current values.
|
||||
const selectedIdRef = useRef<string | null>(null);
|
||||
const myIdRef = useRef<string>("");
|
||||
@@ -187,6 +253,7 @@ export function MessagesView() {
|
||||
setSelectedId(id);
|
||||
selectedIdRef.current = id;
|
||||
setDraft("");
|
||||
setPending([]);
|
||||
setMessages([]);
|
||||
getMessages(id)
|
||||
.then(setMessages)
|
||||
@@ -204,14 +271,79 @@ export function MessagesView() {
|
||||
const send = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!(text && selected)) return;
|
||||
if (!((text || pending.length > 0) && selected)) return;
|
||||
getSocket().emit("message:send", {
|
||||
conversationId: selected.id,
|
||||
body: text,
|
||||
attachments: pending.length > 0 ? pending : undefined,
|
||||
});
|
||||
setDraft("");
|
||||
setPending([]);
|
||||
};
|
||||
|
||||
// Open the file picker; on select, upload each and stage it.
|
||||
const onPickFiles = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
event.target.value = "";
|
||||
if (!files?.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
notify.error(
|
||||
t("messages.attach.tooLargeTitle"),
|
||||
t("messages.attach.tooLargeBody"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const att = await uploadAttachment(file);
|
||||
setPending((prev) => [...prev, att]);
|
||||
}
|
||||
} catch {
|
||||
notify.error(
|
||||
t("messages.attach.uploadFailedTitle"),
|
||||
t("messages.attach.uploadFailedBody"),
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openApptPicker = () => {
|
||||
setApptQuery("");
|
||||
setApptOpen(true);
|
||||
listAppointments()
|
||||
.then(setAppts)
|
||||
.catch(() => setAppts([]));
|
||||
};
|
||||
|
||||
const attachAppointment = (a: Appointment) => {
|
||||
setPending((prev) => [
|
||||
...prev,
|
||||
{
|
||||
kind: "appointment",
|
||||
appointment: {
|
||||
fileNumber: a.fileNumber,
|
||||
name: a.name,
|
||||
date: a.date,
|
||||
time: a.time,
|
||||
type: a.type,
|
||||
provider: a.provider,
|
||||
status: a.status,
|
||||
},
|
||||
},
|
||||
]);
|
||||
setApptOpen(false);
|
||||
};
|
||||
|
||||
const removePending = (index: number) =>
|
||||
setPending((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
const visibleAppts = useMemo(() => {
|
||||
const q = apptQuery.trim().toLowerCase();
|
||||
return q ? appts.filter((a) => a.name.toLowerCase().includes(q)) : appts;
|
||||
}, [appts, apptQuery]);
|
||||
|
||||
const openCompose = () => {
|
||||
setComposeOpen(true);
|
||||
setMemberQuery("");
|
||||
@@ -426,20 +558,28 @@ export function MessagesView() {
|
||||
{m.senderName}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
||||
out
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
!startsGroup &&
|
||||
(out ? "rounded-tr-md" : "rounded-tl-md"),
|
||||
!endsGroup &&
|
||||
(out ? "rounded-br-md" : "rounded-bl-md"),
|
||||
)}
|
||||
>
|
||||
{m.body}
|
||||
</div>
|
||||
{m.body && (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
||||
out
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
!startsGroup &&
|
||||
(out ? "rounded-tr-md" : "rounded-tl-md"),
|
||||
!endsGroup &&
|
||||
(out ? "rounded-br-md" : "rounded-bl-md"),
|
||||
)}
|
||||
>
|
||||
{m.body}
|
||||
</div>
|
||||
)}
|
||||
{m.attachments?.map((att, ai) => (
|
||||
<SentAttachment
|
||||
att={att}
|
||||
key={`${m.id}-att-${ai}`}
|
||||
/>
|
||||
))}
|
||||
{endsGroup && (
|
||||
<span className="px-1 text-muted-foreground text-[11px]">
|
||||
{formatTime(m.createdAt)}
|
||||
@@ -453,26 +593,92 @@ export function MessagesView() {
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex items-center gap-2 rounded-2xl border bg-card/30 p-2"
|
||||
className="flex flex-col gap-2 rounded-2xl border bg-card/30 p-2"
|
||||
onSubmit={send}
|
||||
>
|
||||
<Input
|
||||
aria-label={t("messages.newMessage")}
|
||||
className="border-0 bg-transparent shadow-none before:hidden"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={t("messages.messagePlaceholder", {
|
||||
name: selected.name,
|
||||
})}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t("messages.send")}
|
||||
disabled={!draft.trim()}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizonal className="size-4" />
|
||||
</Button>
|
||||
{pending.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-1 pt-1">
|
||||
{pending.map((att, i) => (
|
||||
<span
|
||||
className="flex items-center gap-1.5 rounded-lg bg-muted px-2 py-1 text-foreground text-xs"
|
||||
key={`pending-${i}`}
|
||||
>
|
||||
{att.kind === "file" ? (
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<CalendarClock className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="max-w-40 truncate">
|
||||
{att.kind === "file"
|
||||
? att.fileName
|
||||
: att.appointment.name}
|
||||
</span>
|
||||
<button
|
||||
aria-label={t("messages.attach.remove")}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => removePending(i)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
aria-label={t("messages.attach.file")}
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={onPickFiles}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("messages.attach.menu")}
|
||||
disabled={uploading}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align="start" side="top">
|
||||
<MenuItem onClick={() => fileInputRef.current?.click()}>
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
{t("messages.attach.file")}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={openApptPicker}>
|
||||
<CalendarClock className="size-4 text-muted-foreground" />
|
||||
{t("messages.attach.appointment")}
|
||||
</MenuItem>
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
<Input
|
||||
aria-label={t("messages.newMessage")}
|
||||
className="border-0 bg-transparent shadow-none before:hidden"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={
|
||||
uploading
|
||||
? t("messages.attach.uploading")
|
||||
: t("messages.messagePlaceholder", { name: selected.name })
|
||||
}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t("messages.send")}
|
||||
disabled={!draft.trim() && pending.length === 0}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizonal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
@@ -549,6 +755,60 @@ export function MessagesView() {
|
||||
</DialogPanel>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
|
||||
{/* Share an appointment: search by patient, pick one to attach */}
|
||||
<Dialog onOpenChange={setApptOpen} open={apptOpen}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("messages.attach.apptDialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("messages.attach.apptDialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("messages.attach.apptSearchPlaceholder")}
|
||||
className="pl-9"
|
||||
onChange={(e) => setApptQuery(e.target.value)}
|
||||
placeholder={t("messages.attach.apptSearchPlaceholder")}
|
||||
size="sm"
|
||||
value={apptQuery}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex max-h-80 flex-col gap-1 overflow-y-auto">
|
||||
{appts.length === 0 ? (
|
||||
<p className="px-1 py-4 text-center text-muted-foreground text-sm">
|
||||
{t("messages.attach.apptEmpty")}
|
||||
</p>
|
||||
) : visibleAppts.length === 0 ? (
|
||||
<p className="px-1 py-4 text-center text-muted-foreground text-sm">
|
||||
{t("messages.attach.apptNoMatches")}
|
||||
</p>
|
||||
) : (
|
||||
visibleAppts.map((a) => (
|
||||
<button
|
||||
className="flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
|
||||
key={a.id}
|
||||
onClick={() => attachAppointment(a)}
|
||||
type="button"
|
||||
>
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
{a.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{[a.date, a.time, a.type, a.provider]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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