mirror of
https://github.com/temetro/temetro.git
synced 2026-09-04 14:55:28 +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:
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "message_attachments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"uploader_id" text,
|
||||||
|
"file_name" text NOT NULL,
|
||||||
|
"mime_type" text NOT NULL,
|
||||||
|
"size" integer NOT NULL,
|
||||||
|
"data" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "messages" ALTER COLUMN "body" SET DEFAULT '';--> statement-breakpoint
|
||||||
|
ALTER TABLE "messages" ADD COLUMN "attachments" jsonb;--> statement-breakpoint
|
||||||
|
ALTER TABLE "message_attachments" ADD CONSTRAINT "message_attachments_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "message_attachments" ADD CONSTRAINT "message_attachments_uploader_id_user_id_fk" FOREIGN KEY ("uploader_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "message_attachments_org_idx" ON "message_attachments" USING btree ("organization_id");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,13 @@
|
|||||||
"when": 1781537902724,
|
"when": 1781537902724,
|
||||||
"tag": "0018_clean_doctor_strange",
|
"tag": "0018_clean_doctor_strange",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781538797034,
|
||||||
|
"tag": "0019_black_mole_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
index,
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -8,6 +10,7 @@ import {
|
|||||||
uuid,
|
uuid,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import type { MessageAttachment } from "../../types/messaging.js";
|
||||||
import { organization, user } from "./auth.js";
|
import { organization, user } from "./auth.js";
|
||||||
|
|
||||||
// A conversation between clinic staff, scoped to a clinic (organization). `name`
|
// A conversation between clinic staff, scoped to a clinic (organization). `name`
|
||||||
@@ -64,8 +67,31 @@ export const messages = pgTable(
|
|||||||
senderId: text("sender_id")
|
senderId: text("sender_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
body: text("body").notNull(),
|
body: text("body").notNull().default(""),
|
||||||
|
// File references / shared appointment snapshots. Null when none.
|
||||||
|
attachments: jsonb("attachments").$type<MessageAttachment[]>(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(t) => [index("messages_conv_idx").on(t.conversationId, t.createdAt)],
|
(t) => [index("messages_conv_idx").on(t.conversationId, t.createdAt)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stored uploaded files referenced by a message attachment. Bytes are kept as
|
||||||
|
// base64 text (simple, dependency-free; fine for the app's scale). Org-scoped.
|
||||||
|
export const messageAttachments = pgTable(
|
||||||
|
"message_attachments",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: text("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
|
uploaderId: text("uploader_id").references(() => user.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
fileName: text("file_name").notNull(),
|
||||||
|
mimeType: text("mime_type").notNull(),
|
||||||
|
size: integer("size").notNull(),
|
||||||
|
data: text("data").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index("message_attachments_org_idx").on(t.organizationId)],
|
||||||
|
);
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ app.use((req, _res, next) => {
|
|||||||
// named wildcard ("*splat") rather than a bare "*".
|
// named wildcard ("*splat") rather than a bare "*".
|
||||||
app.all("/api/auth/*splat", toNodeHandler(auth));
|
app.all("/api/auth/*splat", toNodeHandler(auth));
|
||||||
|
|
||||||
app.use(express.json());
|
// 15mb accommodates base64-encoded message attachments (capped at 10mb of bytes
|
||||||
|
// in the conversations route, which is ~13.3mb once base64-encoded).
|
||||||
|
app.use(express.json({ limit: "15mb" }));
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ status: "ok" });
|
res.json({ status: "ok" });
|
||||||
|
|||||||
+12
-2
@@ -7,6 +7,7 @@ import { auth } from "./auth.js";
|
|||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import * as messaging from "./services/messaging.js";
|
import * as messaging from "./services/messaging.js";
|
||||||
import { createNotification } from "./services/notifications.js";
|
import { createNotification } from "./services/notifications.js";
|
||||||
|
import type { MessageAttachment } from "./types/messaging.js";
|
||||||
|
|
||||||
let io: Server | null = null;
|
let io: Server | null = null;
|
||||||
|
|
||||||
@@ -79,13 +80,21 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|||||||
socket.on(
|
socket.on(
|
||||||
"message:send",
|
"message:send",
|
||||||
async (
|
async (
|
||||||
payload: { conversationId?: string; body?: string },
|
payload: {
|
||||||
|
conversationId?: string;
|
||||||
|
body?: string;
|
||||||
|
attachments?: MessageAttachment[];
|
||||||
|
},
|
||||||
ack?: Ack,
|
ack?: Ack,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const conversationId = String(payload?.conversationId ?? "");
|
const conversationId = String(payload?.conversationId ?? "");
|
||||||
const body = String(payload?.body ?? "").trim();
|
const body = String(payload?.body ?? "").trim();
|
||||||
if (!(conversationId && body && orgId)) {
|
const attachments = Array.isArray(payload?.attachments)
|
||||||
|
? payload.attachments
|
||||||
|
: undefined;
|
||||||
|
// Allow attachment-only messages; the service re-validates.
|
||||||
|
if (!(conversationId && orgId && (body || attachments?.length))) {
|
||||||
ack?.({ ok: false });
|
ack?.({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -95,6 +104,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|||||||
userName,
|
userName,
|
||||||
conversationId,
|
conversationId,
|
||||||
body,
|
body,
|
||||||
|
attachments,
|
||||||
);
|
);
|
||||||
emitToConversation(conversationId, "message:new", message);
|
emitToConversation(conversationId, "message:new", message);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { HttpError } from "../lib/http-error.js";
|
||||||
import { requireAuth, requireOrg } from "../middleware/auth.js";
|
import { requireAuth, requireOrg } from "../middleware/auth.js";
|
||||||
import { emitToConversation, emitToUser } from "../realtime.js";
|
import { emitToConversation, emitToUser } from "../realtime.js";
|
||||||
import * as service from "../services/messaging.js";
|
import * as service from "../services/messaging.js";
|
||||||
@@ -11,13 +12,48 @@ export const conversationsRouter = Router();
|
|||||||
// Conversations are participant-scoped within the active clinic (no extra RBAC).
|
// Conversations are participant-scoped within the active clinic (no extra RBAC).
|
||||||
conversationsRouter.use(requireAuth, requireOrg);
|
conversationsRouter.use(requireAuth, requireOrg);
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; // 10mb
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
participantIds: z.array(z.string().min(1)).min(1),
|
participantIds: z.array(z.string().min(1)).min(1),
|
||||||
name: z.string().trim().max(120).nullish(),
|
name: z.string().trim().max(120).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const appointmentSnapshotSchema = z.object({
|
||||||
|
fileNumber: z.string().max(120).default(""),
|
||||||
|
name: z.string().max(200),
|
||||||
|
date: z.string().max(40),
|
||||||
|
time: z.string().max(40),
|
||||||
|
type: z.string().max(120).default(""),
|
||||||
|
provider: z.string().max(200).default(""),
|
||||||
|
status: z.string().max(40).default(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachmentSchema = z.discriminatedUnion("kind", [
|
||||||
|
z.object({
|
||||||
|
kind: z.literal("file"),
|
||||||
|
attachmentId: z.string().uuid(),
|
||||||
|
fileName: z.string().max(255),
|
||||||
|
mimeType: z.string().max(120),
|
||||||
|
size: z.number().int().nonnegative(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
kind: z.literal("appointment"),
|
||||||
|
appointment: appointmentSnapshotSchema,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const messageSchema = z.object({
|
const messageSchema = z.object({
|
||||||
body: z.string().trim().min(1, "Message can't be empty.").max(5000),
|
body: z.string().trim().max(5000).default(""),
|
||||||
|
attachments: z.array(attachmentSchema).max(10).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadSchema = z.object({
|
||||||
|
fileName: z.string().trim().min(1).max(255),
|
||||||
|
mimeType: z.string().trim().max(120).default("application/octet-stream"),
|
||||||
|
size: z.number().int().nonnegative().max(MAX_ATTACHMENT_BYTES),
|
||||||
|
// Raw base64 (no data: prefix).
|
||||||
|
data: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/conversations — the caller's conversations (with last message + unread)
|
// GET /api/conversations — the caller's conversations (with last message + unread)
|
||||||
@@ -42,6 +78,43 @@ conversationsRouter.get("/members", async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/conversations/attachments — upload a file, get its id back
|
||||||
|
conversationsRouter.post("/attachments", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const input = uploadSchema.parse(req.body);
|
||||||
|
if (Buffer.byteLength(input.data, "base64") > MAX_ATTACHMENT_BYTES) {
|
||||||
|
throw new HttpError(413, "File is too large (max 10MB).");
|
||||||
|
}
|
||||||
|
const meta = await service.createAttachment(
|
||||||
|
req.organizationId!,
|
||||||
|
req.user!.id,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
res.status(201).json(meta);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/conversations/attachments/:id — download a file (clinic-scoped)
|
||||||
|
conversationsRouter.get("/attachments/:id", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const file = await service.getAttachment(
|
||||||
|
req.organizationId!,
|
||||||
|
req.params.id as string,
|
||||||
|
);
|
||||||
|
if (!file) throw new HttpError(404, "Attachment not found.");
|
||||||
|
res.setHeader("Content-Type", file.mimeType);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`inline; filename="${encodeURIComponent(file.fileName)}"`,
|
||||||
|
);
|
||||||
|
res.send(Buffer.from(file.data, "base64"));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/conversations — create (or reuse an existing DM)
|
// POST /api/conversations — create (or reuse an existing DM)
|
||||||
conversationsRouter.post("/", async (req, res, next) => {
|
conversationsRouter.post("/", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -75,7 +148,7 @@ conversationsRouter.get("/:id/messages", async (req, res, next) => {
|
|||||||
// POST /api/conversations/:id/messages — send (REST fallback; also broadcasts)
|
// POST /api/conversations/:id/messages — send (REST fallback; also broadcasts)
|
||||||
conversationsRouter.post("/:id/messages", async (req, res, next) => {
|
conversationsRouter.post("/:id/messages", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { body } = messageSchema.parse(req.body);
|
const { body, attachments } = messageSchema.parse(req.body);
|
||||||
const conversationId = req.params.id as string;
|
const conversationId = req.params.id as string;
|
||||||
const { message, recipientIds } = await service.createMessage(
|
const { message, recipientIds } = await service.createMessage(
|
||||||
req.organizationId!,
|
req.organizationId!,
|
||||||
@@ -83,6 +156,7 @@ conversationsRouter.post("/:id/messages", async (req, res, next) => {
|
|||||||
req.user!.name,
|
req.user!.name,
|
||||||
conversationId,
|
conversationId,
|
||||||
body,
|
body,
|
||||||
|
attachments,
|
||||||
);
|
);
|
||||||
emitToConversation(conversationId, "message:new", message);
|
emitToConversation(conversationId, "message:new", message);
|
||||||
for (const recipientId of recipientIds) {
|
for (const recipientId of recipientIds) {
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { member, user } from "../db/schema/auth.js";
|
|||||||
import {
|
import {
|
||||||
conversationParticipants,
|
conversationParticipants,
|
||||||
conversations,
|
conversations,
|
||||||
|
messageAttachments,
|
||||||
messages,
|
messages,
|
||||||
} from "../db/schema/messaging.js";
|
} from "../db/schema/messaging.js";
|
||||||
import { HttpError } from "../lib/http-error.js";
|
import { HttpError } from "../lib/http-error.js";
|
||||||
import type {
|
import type {
|
||||||
ConversationMessage,
|
ConversationMessage,
|
||||||
ConversationSummary,
|
ConversationSummary,
|
||||||
|
MessageAttachment,
|
||||||
Participant,
|
Participant,
|
||||||
} from "../types/messaging.js";
|
} from "../types/messaging.js";
|
||||||
|
|
||||||
@@ -106,6 +108,7 @@ async function buildSummaries(
|
|||||||
senderId: messages.senderId,
|
senderId: messages.senderId,
|
||||||
senderName: user.name,
|
senderName: user.name,
|
||||||
body: messages.body,
|
body: messages.body,
|
||||||
|
attachments: messages.attachments,
|
||||||
createdAt: messages.createdAt,
|
createdAt: messages.createdAt,
|
||||||
})
|
})
|
||||||
.from(messages)
|
.from(messages)
|
||||||
@@ -231,6 +234,7 @@ export async function getMessages(
|
|||||||
senderId: messages.senderId,
|
senderId: messages.senderId,
|
||||||
senderName: user.name,
|
senderName: user.name,
|
||||||
body: messages.body,
|
body: messages.body,
|
||||||
|
attachments: messages.attachments,
|
||||||
createdAt: messages.createdAt,
|
createdAt: messages.createdAt,
|
||||||
})
|
})
|
||||||
.from(messages)
|
.from(messages)
|
||||||
@@ -351,6 +355,7 @@ export async function createMessage(
|
|||||||
senderName: string,
|
senderName: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
body: string,
|
body: string,
|
||||||
|
attachments?: MessageAttachment[] | null,
|
||||||
): Promise<{ message: ConversationMessage; recipientIds: string[] }> {
|
): Promise<{ message: ConversationMessage; recipientIds: string[] }> {
|
||||||
if (!(await conversationInOrg(orgId, conversationId))) {
|
if (!(await conversationInOrg(orgId, conversationId))) {
|
||||||
throw new HttpError(404, "Conversation not found.");
|
throw new HttpError(404, "Conversation not found.");
|
||||||
@@ -358,10 +363,14 @@ export async function createMessage(
|
|||||||
if (!(await isParticipant(conversationId, userId))) {
|
if (!(await isParticipant(conversationId, userId))) {
|
||||||
throw new HttpError(403, "You are not part of this conversation.");
|
throw new HttpError(403, "You are not part of this conversation.");
|
||||||
}
|
}
|
||||||
|
const list = attachments && attachments.length > 0 ? attachments : null;
|
||||||
|
if (!body.trim() && !list) {
|
||||||
|
throw new HttpError(400, "Message can't be empty.");
|
||||||
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(messages)
|
.insert(messages)
|
||||||
.values({ conversationId, senderId: userId, body })
|
.values({ conversationId, senderId: userId, body, attachments: list })
|
||||||
.returning();
|
.returning();
|
||||||
// Bump conversation recency and mark the sender's own read pointer.
|
// Bump conversation recency and mark the sender's own read pointer.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -388,12 +397,74 @@ export async function createMessage(
|
|||||||
senderId: userId,
|
senderId: userId,
|
||||||
senderName,
|
senderName,
|
||||||
body: row!.body,
|
body: row!.body,
|
||||||
|
attachments: row!.attachments,
|
||||||
createdAt: row!.createdAt.toISOString(),
|
createdAt: row!.createdAt.toISOString(),
|
||||||
},
|
},
|
||||||
recipientIds: ids.filter((id) => id !== userId),
|
recipientIds: ids.filter((id) => id !== userId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- File attachments ------------------------------------------------------
|
||||||
|
|
||||||
|
// Store an uploaded file (base64) and return its metadata. The bytes live in the
|
||||||
|
// message_attachments table; messages reference them by id.
|
||||||
|
export async function createAttachment(
|
||||||
|
orgId: string,
|
||||||
|
uploaderId: string,
|
||||||
|
input: { fileName: string; mimeType: string; size: number; data: string },
|
||||||
|
): Promise<{
|
||||||
|
attachmentId: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
}> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(messageAttachments)
|
||||||
|
.values({
|
||||||
|
organizationId: orgId,
|
||||||
|
uploaderId,
|
||||||
|
fileName: input.fileName,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
size: input.size,
|
||||||
|
data: input.data,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: messageAttachments.id,
|
||||||
|
fileName: messageAttachments.fileName,
|
||||||
|
mimeType: messageAttachments.mimeType,
|
||||||
|
size: messageAttachments.size,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
attachmentId: row!.id,
|
||||||
|
fileName: row!.fileName,
|
||||||
|
mimeType: row!.mimeType,
|
||||||
|
size: row!.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch an attachment's bytes for download. Scoped to the caller's clinic; ids
|
||||||
|
// are unguessable uuids, so org membership is a sufficient gate.
|
||||||
|
export async function getAttachment(
|
||||||
|
orgId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
): Promise<{ fileName: string; mimeType: string; data: string } | null> {
|
||||||
|
if (!UUID_RE.test(attachmentId)) return null;
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
fileName: messageAttachments.fileName,
|
||||||
|
mimeType: messageAttachments.mimeType,
|
||||||
|
data: messageAttachments.data,
|
||||||
|
})
|
||||||
|
.from(messageAttachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(messageAttachments.id, attachmentId),
|
||||||
|
eq(messageAttachments.organizationId, orgId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function markRead(
|
export async function markRead(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -5,12 +5,35 @@ export type Participant = {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A shared appointment is stored as a snapshot so the card renders without an
|
||||||
|
// extra fetch and survives the appointment later being changed/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 = {
|
export type ConversationMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
attachments?: MessageAttachment[] | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Mail, Plus, Search, SendHorizonal } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
|
CalendarClock,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Mail,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
SendHorizonal,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
type ChangeEvent,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
Fragment,
|
Fragment,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -30,20 +40,27 @@ import {
|
|||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@/components/ui/empty";
|
} from "@/components/ui/empty";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { authClient } from "@/lib/auth-client";
|
||||||
import {
|
import {
|
||||||
type ConversationMessage,
|
type ConversationMessage,
|
||||||
type ConversationSummary,
|
type ConversationSummary,
|
||||||
|
type MessageAttachment,
|
||||||
type Participant,
|
type Participant,
|
||||||
createConversation,
|
createConversation,
|
||||||
|
downloadAttachment,
|
||||||
getMessages,
|
getMessages,
|
||||||
listClinicMembers,
|
listClinicMembers,
|
||||||
listConversations,
|
listConversations,
|
||||||
|
uploadAttachment,
|
||||||
} from "@/lib/messages";
|
} from "@/lib/messages";
|
||||||
import { getSocket } from "@/lib/socket";
|
import { getSocket } from "@/lib/socket";
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from "@/lib/toast";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
// Up to two-letter initials from a display name.
|
// Up to two-letter initials from a display name.
|
||||||
function initials(name: string): string {
|
function initials(name: string): string {
|
||||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
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.
|
// one sender label, one timestamp, tighter spacing.
|
||||||
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
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() {
|
export function MessagesView() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
@@ -101,6 +159,14 @@ export function MessagesView() {
|
|||||||
const [members, setMembers] = useState<Participant[]>([]);
|
const [members, setMembers] = useState<Participant[]>([]);
|
||||||
const [memberQuery, setMemberQuery] = useState("");
|
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.
|
// Refs so the socket handler (registered once) reads current values.
|
||||||
const selectedIdRef = useRef<string | null>(null);
|
const selectedIdRef = useRef<string | null>(null);
|
||||||
const myIdRef = useRef<string>("");
|
const myIdRef = useRef<string>("");
|
||||||
@@ -187,6 +253,7 @@ export function MessagesView() {
|
|||||||
setSelectedId(id);
|
setSelectedId(id);
|
||||||
selectedIdRef.current = id;
|
selectedIdRef.current = id;
|
||||||
setDraft("");
|
setDraft("");
|
||||||
|
setPending([]);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
getMessages(id)
|
getMessages(id)
|
||||||
.then(setMessages)
|
.then(setMessages)
|
||||||
@@ -204,14 +271,79 @@ export function MessagesView() {
|
|||||||
const send = (event: FormEvent) => {
|
const send = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!(text && selected)) return;
|
if (!((text || pending.length > 0) && selected)) return;
|
||||||
getSocket().emit("message:send", {
|
getSocket().emit("message:send", {
|
||||||
conversationId: selected.id,
|
conversationId: selected.id,
|
||||||
body: text,
|
body: text,
|
||||||
|
attachments: pending.length > 0 ? pending : undefined,
|
||||||
});
|
});
|
||||||
setDraft("");
|
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 = () => {
|
const openCompose = () => {
|
||||||
setComposeOpen(true);
|
setComposeOpen(true);
|
||||||
setMemberQuery("");
|
setMemberQuery("");
|
||||||
@@ -426,20 +558,28 @@ export function MessagesView() {
|
|||||||
{m.senderName}
|
{m.senderName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div
|
{m.body && (
|
||||||
className={cn(
|
<div
|
||||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
className={cn(
|
||||||
out
|
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
||||||
? "bg-primary text-primary-foreground"
|
out
|
||||||
: "bg-muted text-foreground",
|
? "bg-primary text-primary-foreground"
|
||||||
!startsGroup &&
|
: "bg-muted text-foreground",
|
||||||
(out ? "rounded-tr-md" : "rounded-tl-md"),
|
!startsGroup &&
|
||||||
!endsGroup &&
|
(out ? "rounded-tr-md" : "rounded-tl-md"),
|
||||||
(out ? "rounded-br-md" : "rounded-bl-md"),
|
!endsGroup &&
|
||||||
)}
|
(out ? "rounded-br-md" : "rounded-bl-md"),
|
||||||
>
|
)}
|
||||||
{m.body}
|
>
|
||||||
</div>
|
{m.body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{m.attachments?.map((att, ai) => (
|
||||||
|
<SentAttachment
|
||||||
|
att={att}
|
||||||
|
key={`${m.id}-att-${ai}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{endsGroup && (
|
{endsGroup && (
|
||||||
<span className="px-1 text-muted-foreground text-[11px]">
|
<span className="px-1 text-muted-foreground text-[11px]">
|
||||||
{formatTime(m.createdAt)}
|
{formatTime(m.createdAt)}
|
||||||
@@ -453,26 +593,92 @@ export function MessagesView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<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}
|
onSubmit={send}
|
||||||
>
|
>
|
||||||
<Input
|
{pending.length > 0 && (
|
||||||
aria-label={t("messages.newMessage")}
|
<div className="flex flex-wrap items-center gap-1.5 px-1 pt-1">
|
||||||
className="border-0 bg-transparent shadow-none before:hidden"
|
{pending.map((att, i) => (
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
<span
|
||||||
placeholder={t("messages.messagePlaceholder", {
|
className="flex items-center gap-1.5 rounded-lg bg-muted px-2 py-1 text-foreground text-xs"
|
||||||
name: selected.name,
|
key={`pending-${i}`}
|
||||||
})}
|
>
|
||||||
value={draft}
|
{att.kind === "file" ? (
|
||||||
/>
|
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<Button
|
) : (
|
||||||
aria-label={t("messages.send")}
|
<CalendarClock className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
disabled={!draft.trim()}
|
)}
|
||||||
size="icon"
|
<span className="max-w-40 truncate">
|
||||||
type="submit"
|
{att.kind === "file"
|
||||||
>
|
? att.fileName
|
||||||
<SendHorizonal className="size-4" />
|
: att.appointment.name}
|
||||||
</Button>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -549,6 +755,60 @@ export function MessagesView() {
|
|||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
</DialogPopup>
|
</DialogPopup>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -638,7 +638,25 @@
|
|||||||
"noMembers": "No other clinic members yet. Invite colleagues from Settings → Care team."
|
"noMembers": "No other clinic members yet. Invite colleagues from Settings → Care team."
|
||||||
},
|
},
|
||||||
"startFailedTitle": "Couldn't start conversation",
|
"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": {
|
"analysis": {
|
||||||
"title": "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`.
|
// Messaging shapes. Mirror the backend `src/types/messaging.ts`.
|
||||||
export type Participant = {
|
export type Participant = {
|
||||||
@@ -6,12 +6,35 @@ export type Participant = {
|
|||||||
name: 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 = {
|
export type ConversationMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
attachments?: MessageAttachment[] | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,13 +80,72 @@ export function getMessages(
|
|||||||
export function sendMessageRest(
|
export function sendMessageRest(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
body: string,
|
body: string,
|
||||||
|
attachments?: MessageAttachment[],
|
||||||
): Promise<ConversationMessage> {
|
): Promise<ConversationMessage> {
|
||||||
return apiFetch<ConversationMessage>(
|
return apiFetch<ConversationMessage>(
|
||||||
`/api/conversations/${conversationId}/messages`,
|
`/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> {
|
export function markConversationRead(conversationId: string): Promise<void> {
|
||||||
return apiFetch<void>(`/api/conversations/${conversationId}/read`, {
|
return apiFetch<void>(`/api/conversations/${conversationId}/read`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
Reference in New Issue
Block a user