Files
temetro/frontend/lib/messages.ts
T
Khalid Abdi 90e6ec4cc0 feat: email provider, admin password reset, portal new-patient, chat pill
Chat: history pill now shows a History icon + a Start-new-chat (SquarePen)
button; removed the duplicate chat-history list from the sidebar.

Email: deployment-wide email provider config (Resend/Postmark/SendGrid/SMTP) in
Settings → Developers, with encrypted API key and a Send-test action. sendEmail
dispatches via the chosen provider (REST via fetch; SMTP via nodemailer).

Forgot password with no provider: alert the clinic admin(s) via a "System"
message card in Messages + a bell notification (seeded system user + per-clinic
System conversation); clicking deep-links to /settings?tab=careTeam&member=<id>.
Admins can set a member's password directly from the employee dialog
(PATCH /api/staff/:id/password via Better Auth's internal context — no admin
plugin needed).

Patient Portal: "New patient" booking path registers a demographics-only patient
then books; bookings reject double-booked slots (409).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:52:55 +03:00

160 lines
4.3 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 }
| {
kind: "passwordReset";
userId: string;
userName: string;
userEmail: string;
};
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",
});
}