mirror of
https://github.com/temetro/temetro.git
synced 2026-08-11 11:09:04 +00:00
15fc7fdf26
- 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>
98 lines
3.2 KiB
TypeScript
98 lines
3.2 KiB
TypeScript
import {
|
|
boolean,
|
|
index,
|
|
integer,
|
|
jsonb,
|
|
pgTable,
|
|
text,
|
|
timestamp,
|
|
uniqueIndex,
|
|
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`
|
|
// is set for named/group conversations; a 1:1 DM leaves it null and the display
|
|
// name is derived from the other participant.
|
|
export const conversations = pgTable(
|
|
"conversations",
|
|
{
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
organizationId: text("organization_id")
|
|
.notNull()
|
|
.references(() => organization.id, { onDelete: "cascade" }),
|
|
name: text("name"),
|
|
isGroup: boolean("is_group").notNull().default(false),
|
|
createdBy: text("created_by").references(() => user.id, {
|
|
onDelete: "set null",
|
|
}),
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
// Bumped on each new message so the inbox can sort by recency.
|
|
updatedAt: timestamp("updated_at")
|
|
.defaultNow()
|
|
.$onUpdate(() => new Date())
|
|
.notNull(),
|
|
},
|
|
(t) => [index("conversations_org_idx").on(t.organizationId)],
|
|
);
|
|
|
|
export const conversationParticipants = pgTable(
|
|
"conversation_participants",
|
|
{
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
conversationId: uuid("conversation_id")
|
|
.notNull()
|
|
.references(() => conversations.id, { onDelete: "cascade" }),
|
|
userId: text("user_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
// Last time this participant read the conversation; drives unread state.
|
|
lastReadAt: timestamp("last_read_at"),
|
|
},
|
|
(t) => [
|
|
uniqueIndex("conv_participant_uidx").on(t.conversationId, t.userId),
|
|
index("conv_participant_user_idx").on(t.userId),
|
|
],
|
|
);
|
|
|
|
export const messages = pgTable(
|
|
"messages",
|
|
{
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
conversationId: uuid("conversation_id")
|
|
.notNull()
|
|
.references(() => conversations.id, { onDelete: "cascade" }),
|
|
senderId: text("sender_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
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)],
|
|
);
|