mirror of
https://github.com/temetro/temetro.git
synced 2026-08-19 14:46:29 +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,6 +1,8 @@
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import type { MessageAttachment } from "../../types/messaging.js";
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
// A conversation between clinic staff, scoped to a clinic (organization). `name`
|
||||
@@ -64,8 +67,31 @@ export const messages = pgTable(
|
||||
senderId: text("sender_id")
|
||||
.notNull()
|
||||
.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(),
|
||||
},
|
||||
(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 "*".
|
||||
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) => {
|
||||
res.json({ status: "ok" });
|
||||
|
||||
+12
-2
@@ -7,6 +7,7 @@ import { auth } from "./auth.js";
|
||||
import { env } from "./env.js";
|
||||
import * as messaging from "./services/messaging.js";
|
||||
import { createNotification } from "./services/notifications.js";
|
||||
import type { MessageAttachment } from "./types/messaging.js";
|
||||
|
||||
let io: Server | null = null;
|
||||
|
||||
@@ -79,13 +80,21 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
socket.on(
|
||||
"message:send",
|
||||
async (
|
||||
payload: { conversationId?: string; body?: string },
|
||||
payload: {
|
||||
conversationId?: string;
|
||||
body?: string;
|
||||
attachments?: MessageAttachment[];
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
const conversationId = String(payload?.conversationId ?? "");
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
@@ -95,6 +104,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
userName,
|
||||
conversationId,
|
||||
body,
|
||||
attachments,
|
||||
);
|
||||
emitToConversation(conversationId, "message:new", message);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import { requireAuth, requireOrg } from "../middleware/auth.js";
|
||||
import { emitToConversation, emitToUser } from "../realtime.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).
|
||||
conversationsRouter.use(requireAuth, requireOrg);
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; // 10mb
|
||||
|
||||
const createSchema = z.object({
|
||||
participantIds: z.array(z.string().min(1)).min(1),
|
||||
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({
|
||||
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)
|
||||
@@ -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)
|
||||
conversationsRouter.post("/", async (req, res, next) => {
|
||||
try {
|
||||
@@ -75,7 +148,7 @@ conversationsRouter.get("/:id/messages", async (req, res, next) => {
|
||||
// POST /api/conversations/:id/messages — send (REST fallback; also broadcasts)
|
||||
conversationsRouter.post("/:id/messages", async (req, res, next) => {
|
||||
try {
|
||||
const { body } = messageSchema.parse(req.body);
|
||||
const { body, attachments } = messageSchema.parse(req.body);
|
||||
const conversationId = req.params.id as string;
|
||||
const { message, recipientIds } = await service.createMessage(
|
||||
req.organizationId!,
|
||||
@@ -83,6 +156,7 @@ conversationsRouter.post("/:id/messages", async (req, res, next) => {
|
||||
req.user!.name,
|
||||
conversationId,
|
||||
body,
|
||||
attachments,
|
||||
);
|
||||
emitToConversation(conversationId, "message:new", message);
|
||||
for (const recipientId of recipientIds) {
|
||||
|
||||
@@ -5,12 +5,14 @@ import { member, user } from "../db/schema/auth.js";
|
||||
import {
|
||||
conversationParticipants,
|
||||
conversations,
|
||||
messageAttachments,
|
||||
messages,
|
||||
} from "../db/schema/messaging.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import type {
|
||||
ConversationMessage,
|
||||
ConversationSummary,
|
||||
MessageAttachment,
|
||||
Participant,
|
||||
} from "../types/messaging.js";
|
||||
|
||||
@@ -106,6 +108,7 @@ async function buildSummaries(
|
||||
senderId: messages.senderId,
|
||||
senderName: user.name,
|
||||
body: messages.body,
|
||||
attachments: messages.attachments,
|
||||
createdAt: messages.createdAt,
|
||||
})
|
||||
.from(messages)
|
||||
@@ -231,6 +234,7 @@ export async function getMessages(
|
||||
senderId: messages.senderId,
|
||||
senderName: user.name,
|
||||
body: messages.body,
|
||||
attachments: messages.attachments,
|
||||
createdAt: messages.createdAt,
|
||||
})
|
||||
.from(messages)
|
||||
@@ -351,6 +355,7 @@ export async function createMessage(
|
||||
senderName: string,
|
||||
conversationId: string,
|
||||
body: string,
|
||||
attachments?: MessageAttachment[] | null,
|
||||
): Promise<{ message: ConversationMessage; recipientIds: string[] }> {
|
||||
if (!(await conversationInOrg(orgId, conversationId))) {
|
||||
throw new HttpError(404, "Conversation not found.");
|
||||
@@ -358,10 +363,14 @@ export async function createMessage(
|
||||
if (!(await isParticipant(conversationId, userId))) {
|
||||
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 [row] = await db
|
||||
.insert(messages)
|
||||
.values({ conversationId, senderId: userId, body })
|
||||
.values({ conversationId, senderId: userId, body, attachments: list })
|
||||
.returning();
|
||||
// Bump conversation recency and mark the sender's own read pointer.
|
||||
await Promise.all([
|
||||
@@ -388,12 +397,74 @@ export async function createMessage(
|
||||
senderId: userId,
|
||||
senderName,
|
||||
body: row!.body,
|
||||
attachments: row!.attachments,
|
||||
createdAt: row!.createdAt.toISOString(),
|
||||
},
|
||||
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(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
|
||||
@@ -5,12 +5,35 @@ export type Participant = {
|
||||
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 = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
body: string;
|
||||
attachments?: MessageAttachment[] | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user