Merge pull request #3 from temetro/feature/clinical-fixes-and-chat

Feature/clinical fixes and chat
This commit is contained in:
Khalidabdi1
2026-06-15 19:10:53 +03:00
committed by GitHub
38 changed files with 11239 additions and 245 deletions
@@ -0,0 +1,3 @@
ALTER TABLE "tasks" ADD COLUMN "status" text DEFAULT 'todo' NOT NULL;
--> statement-breakpoint
UPDATE "tasks" SET "status" = 'done' WHERE "done" = true;
@@ -0,0 +1,8 @@
CREATE TABLE "org_ai_policy" (
"organization_id" text PRIMARY KEY NOT NULL,
"ai_enabled" boolean DEFAULT true NOT NULL,
"disabled_for_employees" boolean DEFAULT false NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "org_ai_policy" ADD CONSTRAINT "org_ai_policy_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
+16
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -120,6 +120,27 @@
"when": 1781464772532,
"tag": "0016_past_maestro",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1781537419038,
"tag": "0017_wealthy_northstar",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1781537902724,
"tag": "0018_clean_doctor_strange",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1781538797034,
"tag": "0019_black_mole_man",
"breakpoints": true
}
]
}
+1
View File
@@ -12,3 +12,4 @@ export * from "./notifications.js";
export * from "./settings.js";
export * from "./ai.js";
export * from "./ai-chat.js";
export * from "./org-ai-policy.js";
+27 -1
View File
@@ -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)],
);
+22
View File
@@ -0,0 +1,22 @@
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { organization } from "./auth.js";
// Clinic-wide AI availability, controlled by owners/admins. One row per clinic
// (organization). Absent row = AI enabled for everyone (the default).
// aiEnabled = false → AI hidden + blocked for the whole clinic.
// disabledForEmployees = true → AI hidden + blocked for non-admins; owners
// and admins keep access.
export const orgAiPolicy = pgTable("org_ai_policy", {
organizationId: text("organization_id")
.primaryKey()
.references(() => organization.id, { onDelete: "cascade" }),
aiEnabled: boolean("ai_enabled").notNull().default(true),
disabledForEmployees: boolean("disabled_for_employees")
.notNull()
.default(false),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
+4 -1
View File
@@ -7,7 +7,7 @@ import {
uuid,
} from "drizzle-orm/pg-core";
import type { TaskPriority } from "../../types/task.js";
import type { TaskPriority, TaskStatus } from "../../types/task.js";
import { organization, user } from "./auth.js";
// One row per care-team to-do, scoped to a clinic (organization). Shared across
@@ -27,6 +27,9 @@ export const tasks = pgTable(
assigneeRole: text("assignee_role"),
due: text("due").notNull().default("No due date"),
priority: text("priority").$type<TaskPriority>().notNull(),
// Board column: todo | in_progress | done. Kept in sync with `done`
// (done === status === "done") so the legacy toggle still works.
status: text("status").$type<TaskStatus>().notNull().default("todo"),
patient: text("patient"),
notes: text("notes"),
done: boolean("done").notNull().default(false),
+3 -1
View File
@@ -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" });
+1
View File
@@ -17,6 +17,7 @@ export const taskInputSchema = z.object({
assigneeRole: z.enum(TASK_DEPARTMENTS).nullish(),
due: z.string().trim().max(120).default("No due date"),
priority: z.enum(["high", "medium", "low"]).default("medium"),
status: z.enum(["todo", "in_progress", "done"]).default("todo"),
patient: z.string().trim().max(200).nullish(),
notes: z.string().max(5000).nullish(),
});
+12 -2
View File
@@ -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);
+45
View File
@@ -18,10 +18,55 @@ import {
saveAiConfig,
toAiConfig,
} from "../services/ai/config.js";
import { getPolicy, savePolicy } from "../services/ai/policy.js";
import * as patients from "../services/patients.js";
export const aiRouter = Router();
// --- Clinic-wide AI policy (admin-controlled kill-switch) -------------------
// Any member can READ the policy (the frontend needs it to gate nav/routes);
// only owners/admins can change it.
aiRouter.get("/policy", requireAuth, requireOrg, async (req, res, next) => {
try {
res.json(await getPolicy(req.organizationId!));
} catch (err) {
next(err);
}
});
aiRouter.put("/policy", requireAuth, requireOrg, async (req, res, next) => {
try {
const roles = String(req.memberRole ?? "")
.split(",")
.map((s) => s.trim());
const isAdmin = roles.some((r) => r === "owner" || r === "admin");
if (!isAdmin) {
throw new HttpError(403, "Only owners and admins can change this.");
}
const body = req.body as {
aiEnabled?: unknown;
disabledForEmployees?: unknown;
};
const saved = await savePolicy(req.organizationId!, {
aiEnabled: Boolean(body.aiEnabled),
disabledForEmployees: Boolean(body.disabledForEmployees),
});
void recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: saved.aiEnabled
? saved.disabledForEmployees
? "Restricted AI to owners and admins"
: "Enabled the AI assistant clinic-wide"
: "Disabled the AI assistant clinic-wide",
entityType: "patient",
});
res.json(saved);
} catch (err) {
next(err);
}
});
// --- Per-user AI config (no clinic/RBAC needed, like /api/settings) ---------
aiRouter.get("/config", requireAuth, async (req, res, next) => {
try {
+59 -2
View File
@@ -21,6 +21,7 @@ import {
import { recordActivity } from "../services/activity.js";
import * as aiChat from "../services/ai-chat.js";
import { getAiSettings } from "../services/ai/config.js";
import { aiAllowedFor, getPolicy } from "../services/ai/policy.js";
import { resolveModel } from "../services/ai/provider.js";
import { createChatTools } from "../services/ai/tools.js";
import { createVeil } from "../services/ai/veil.js";
@@ -33,6 +34,50 @@ export const chatRouter = Router();
chatRouter.use(requireAuth, requireOrg, requirePermission({ patient: ["read"] }));
// Text-like uploads (CSV/JSON/TXT/…) the model should read as parseable text
// rather than an opaque data URL. Images/PDFs are left as file parts so
// vision-capable providers read them directly.
const TEXT_LIKE_MEDIA = /^(text\/|application\/(json|xml|csv|x-ndjson))/i;
const TEXT_LIKE_EXT = /\.(csv|tsv|json|txt|md|xml|ndjson|tab)$/i;
function decodeDataUrl(url: string): string {
const comma = url.indexOf(",");
if (comma === -1) return "";
const meta = url.slice(0, comma);
const data = url.slice(comma + 1);
return meta.includes("base64")
? Buffer.from(data, "base64").toString("utf8")
: decodeURIComponent(data);
}
// Replace text-like file parts with a text part carrying the file's content
// (capped) so the agent can parse uploads (e.g. a medications list to add to
// inventory, or a database export to import). Display/storage are unaffected —
// this only shapes what the model sees.
function inlineTextFiles(messages: UIMessage[]): UIMessage[] {
return messages.map((message) => {
if (!Array.isArray(message.parts)) return message;
const parts = message.parts.flatMap((part) => {
if (
part.type === "file" &&
typeof part.url === "string" &&
(TEXT_LIKE_MEDIA.test(part.mediaType ?? "") ||
TEXT_LIKE_EXT.test(part.filename ?? ""))
) {
const content = decodeDataUrl(part.url).slice(0, 200_000);
return [
{
type: "text" as const,
text: `--- File: ${part.filename ?? "file"} ---\n${content}`,
},
];
}
return [part];
});
return { ...message, parts } as UIMessage;
});
}
function systemPrompt(veilActive: boolean, providerLabel: string): string {
return [
"You are temetro, a clinical assistant that helps clinicians retrieve,",
@@ -55,10 +100,15 @@ function systemPrompt(veilActive: boolean, providerLabel: string): string {
" asks to add/book/create one. They show an approval card; the record is only",
" written after the clinician clicks Add. NEVER say you added/booked/created",
" something — say you've drafted it for their approval.",
"- proposeInventory: when the clinician wants to ADD STOCK to the clinic's",
" inventory — e.g. they upload a list of medications/supplies with quantities",
" (and optionally prices) to stock. Parse it into items {name, form, strength,",
" unit, stockQuantity, reorderThreshold, expiresAt} and call proposeInventory.",
"- proposeInvoice: when the clinician wants to bill someone — e.g. they upload",
" a list of purchased medications/items. Parse it into line items",
" {description, quantity, unitPrice} (use the prices in the document) and call",
" proposeInvoice with the patient/client name.",
" proposeInvoice with the patient/client name. (Stocking inventory vs. billing a",
" patient are different — pick proposeInventory for the former.)",
"- previewImport: when the clinician wants to import/migrate an existing",
" patient database file, or add a single patient. Parse the uploaded content",
" into our patient shape and call previewImport.",
@@ -97,6 +147,13 @@ chatRouter.post("/", async (req, res, next) => {
return;
}
// Honour the clinic's AI kill-switch — employees can't reach the agent even
// by bypassing the (also-gated) UI.
const policy = await getPolicy(req.organizationId!);
if (!aiAllowedFor(policy, req.memberRole)) {
throw new HttpError(403, "The AI assistant is disabled for your account.");
}
const settings = await getAiSettings(req.user!.id);
const modelId = requestedModel || settings.defaultModel;
const resolved = resolveModel(settings, modelId);
@@ -113,7 +170,7 @@ chatRouter.post("/", async (req, res, next) => {
},
};
const modelMessages = await convertToModelMessages(messages);
const modelMessages = await convertToModelMessages(inlineTextFiles(messages));
const system = systemPrompt(veil.active, resolved.providerLabel);
const stream = createUIMessageStream({
+76 -2
View File
@@ -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) {
+53
View File
@@ -0,0 +1,53 @@
import { eq } from "drizzle-orm";
import { db } from "../../db/index.js";
import { orgAiPolicy } from "../../db/schema/org-ai-policy.js";
export type AiPolicy = {
aiEnabled: boolean;
disabledForEmployees: boolean;
};
const DEFAULT_POLICY: AiPolicy = {
aiEnabled: true,
disabledForEmployees: false,
};
// Read a clinic's AI policy, falling back to the permissive default when no row
// exists yet.
export async function getPolicy(orgId: string): Promise<AiPolicy> {
const [row] = await db
.select({
aiEnabled: orgAiPolicy.aiEnabled,
disabledForEmployees: orgAiPolicy.disabledForEmployees,
})
.from(orgAiPolicy)
.where(eq(orgAiPolicy.organizationId, orgId))
.limit(1);
return row ?? DEFAULT_POLICY;
}
export async function savePolicy(
orgId: string,
policy: AiPolicy,
): Promise<AiPolicy> {
await db
.insert(orgAiPolicy)
.values({ organizationId: orgId, ...policy })
.onConflictDoUpdate({
target: orgAiPolicy.organizationId,
set: { ...policy, updatedAt: new Date() },
});
return policy;
}
// Whether a member with `role` may use the AI under `policy`. Owners/admins keep
// access when AI is only disabled for employees; a full disable blocks everyone.
export function aiAllowedFor(policy: AiPolicy, role: string | undefined): boolean {
if (!policy.aiEnabled) return false;
if (!policy.disabledForEmployees) return true;
const names = String(role ?? "")
.split(",")
.map((s) => s.trim());
return names.some((r) => r === "owner" || r === "admin");
}
+72
View File
@@ -8,6 +8,7 @@ import { db } from "../../db/index.js";
import { organization } from "../../db/schema/auth.js";
import { appointmentInputSchema } from "../../lib/appointment-validation.js";
import { initialsFromName } from "../../lib/initials.js";
import { inventoryInputSchema } from "../../lib/inventory-validation.js";
import { invoiceInputSchema } from "../../lib/invoice-validation.js";
import { patientInputSchema } from "../../lib/patient-validation.js";
import { prescriptionInputSchema } from "../../lib/prescription-validation.js";
@@ -474,6 +475,77 @@ export function createChatTools(ctx: ToolContext) {
},
}),
proposeInventory: tool({
description:
"Propose adding one or more items to the clinic's inventory (medications/supplies) for the clinician to approve — e.g. parse an uploaded stock or purchase list into stock items. Does NOT save; it shows an approval card the clinician confirms. Each item needs a name; form/strength/unit/stockQuantity/reorderThreshold/location/expiresAt (YYYY-MM-DD)/notes are optional. Use this for STOCKING inventory; use proposeInvoice instead when billing a patient for purchased items.",
inputSchema: z.object({
items: z
.array(
z.object({
name: z.string().describe("Item / medication name"),
form: z
.string()
.optional()
.describe("Dosage form, e.g. Tablet, Capsule, Syrup"),
strength: z.string().optional().describe("e.g. 500mg"),
unit: z
.string()
.optional()
.describe("Dispensing unit, e.g. box, bottle"),
stockQuantity: z
.number()
.optional()
.describe("Units currently in stock"),
reorderThreshold: z
.number()
.optional()
.describe("Low-stock reorder level"),
location: z.string().optional().describe("Storage location"),
expiresAt: z
.string()
.nullish()
.describe("Expiry date, YYYY-MM-DD"),
notes: z.string().nullish(),
}),
)
.describe("Inventory items to add (prices/quantities from the document)"),
}),
execute: async ({ items }) => {
step(`Drafting ${items.length} inventory item(s)`);
// Inventory is non-PHI — no Veil resolution needed.
const validated: unknown[] = [];
const issues: string[] = [];
items.forEach((item, index) => {
const parsed = inventoryInputSchema.safeParse(item);
if (parsed.success) {
validated.push(parsed.data);
} else {
issues.push(
...parsed.error.issues.map(
(i) =>
`item ${index + 1} ${i.path.join(".") || "(root)"}: ${i.message}`,
),
);
}
});
writer.write({
type: "data-actionPreview",
data: {
token: `inventory-${stepSeq}`,
kind: "inventory" as const,
record: { items: validated.length ? validated : items },
issues,
},
});
return {
ok: issues.length === 0,
count: validated.length,
issues,
note: "Preview only — awaiting clinician approval before any write.",
};
},
}),
proposeInvoice: tool({
description:
"Propose a new invoice for the clinician to approve — e.g. parse an uploaded list of purchased medications into billable line items. Does NOT save; it shows an approval card the clinician confirms. Provide the patient/client name (a file number if known) and line items {description, quantity, unitPrice}; prices come from the uploaded document.",
+72 -1
View File
@@ -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,
+13 -1
View File
@@ -26,6 +26,7 @@ function toTask(row: TaskRow): Task {
assigneeRole: row.assigneeRole,
due: row.due,
priority: row.priority,
status: row.status,
patient: row.patient,
notes: row.notes,
done: row.done,
@@ -74,6 +75,9 @@ export async function createTask(
assigneeRole: input.assigneeRole ?? null,
due: input.due,
priority: input.priority,
status: input.status,
// Keep the legacy `done` flag in lock-step with the board column.
done: input.status === "done",
patient: input.patient ?? null,
notes: input.notes ?? null,
createdBy: creator.id,
@@ -100,7 +104,15 @@ export async function updateTask(
if (patch.priority !== undefined) set.priority = patch.priority;
if (patch.patient !== undefined) set.patient = patch.patient ?? null;
if (patch.notes !== undefined) set.notes = patch.notes ?? null;
if (patch.done !== undefined) set.done = patch.done;
// Keep `status` and the legacy `done` flag in sync: a status patch wins and
// sets done; a bare done toggle maps to done/todo.
if (patch.status !== undefined) {
set.status = patch.status;
set.done = patch.status === "done";
} else if (patch.done !== undefined) {
set.done = patch.done;
set.status = patch.done ? "done" : "todo";
}
if (Object.keys(set).length === 0) {
const [row] = await db
+23
View File
@@ -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;
};
+3
View File
@@ -2,6 +2,8 @@
// `lib/tasks.ts` Task type. Scoped to the active clinic (a shared care-team
// to-do board). `patient` is an optional free-text reference for context.
export type TaskPriority = "high" | "medium" | "low";
// Board column the task sits in. `done` mirrors `status === "done"`.
export type TaskStatus = "todo" | "in_progress" | "done";
export type Task = {
id: string;
@@ -11,6 +13,7 @@ export type Task = {
assigneeRole: string | null;
due: string;
priority: TaskPriority;
status: TaskStatus;
patient: string | null;
notes: string | null;
done: boolean;
+9 -1
View File
@@ -3,6 +3,7 @@
import { usePathname, useRouter } from "next/navigation";
import { type ReactNode, useEffect, useRef } from "react";
import { useAiAccess } from "@/lib/ai-policy";
import { authClient } from "@/lib/auth-client";
import { canAccessRoute, defaultLandingFor, useActiveRole } from "@/lib/roles";
@@ -14,6 +15,7 @@ export function AppAuthGuard({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const role = useActiveRole();
const { allowed: aiAllowed, loading: aiLoading } = useAiAccess();
const { data: session, isPending } = authClient.useSession();
const { data: orgs, isPending: orgsPending } =
authClient.useListOrganizations();
@@ -52,8 +54,14 @@ export function AppAuthGuard({ children }: { children: ReactNode }) {
if (!ready || role == null) return;
if (!canAccessRoute(pathname, role)) {
router.replace(defaultLandingFor(role));
return;
}
}, [ready, role, pathname, router]);
// AI kill-switch: the chat home ("/") is off for this user — send them to
// patients (clinical roles always have it; non-clinical never land on "/").
if (!aiLoading && !aiAllowed && pathname === "/") {
router.replace("/patients");
}
}, [ready, role, pathname, router, aiAllowed, aiLoading]);
if (!ready) {
return (
@@ -2,6 +2,7 @@
import {
AlertTriangle,
Boxes,
CalendarPlus,
Check,
ClipboardList,
@@ -22,6 +23,7 @@ import {
type InvoiceInput,
type InvoiceLineItem,
} from "@/lib/invoices";
import { type InventoryInput, createInventory } from "@/lib/inventory";
import { type PrescriptionInput, createPrescription } from "@/lib/prescriptions";
import { type TaskInput, createTask } from "@/lib/tasks";
import { notify } from "@/lib/toast";
@@ -33,6 +35,7 @@ export const ACTION_ICONS = {
task: ClipboardList,
prescription: Pill,
invoice: Receipt,
inventory: Boxes,
} as const;
const ICONS = ACTION_ICONS;
@@ -61,6 +64,18 @@ export function summarize(data: ActionPreviewData): string[] {
`${items.length} item${items.length === 1 ? "" : "s"} · ${formatMoney(total)}`,
].filter(Boolean);
}
if (data.kind === "inventory") {
const items = (r.items as InventoryInput[] | undefined) ?? [];
return [
`${items.length} item${items.length === 1 ? "" : "s"}`,
items
.map((it) =>
[it.name, it.strength].filter(Boolean).join(" ") +
(it.stockQuantity ? ` ×${it.stockQuantity}` : ""),
)
.join(", "),
].filter(Boolean);
}
// prescription
return [
[r.medication, r.dose].filter(Boolean).join(" "),
@@ -81,6 +96,12 @@ export async function commitAction(data: ActionPreviewData): Promise<void> {
await createTask(data.record as TaskInput);
} else if (data.kind === "invoice") {
await createInvoice({ ...(data.record as InvoiceInput), source: "ai" });
} else if (data.kind === "inventory") {
const { items = [] } = data.record as { items?: InventoryInput[] };
// Commit each proposed stock item via the RBAC-gated create endpoint.
for (const item of items) {
await createInventory(item);
}
} else {
await createPrescription({
...(data.record as PrescriptionInput),
+7 -27
View File
@@ -17,7 +17,7 @@ import type { Effort } from "@/lib/ai-models";
import { cn } from "@/lib/utils";
type ChatInputProps = {
onSubmit: (text: string) => void;
onSubmit: (text: string, files: File[]) => void;
status: ChatStatus;
onStop?: () => void;
model: string;
@@ -55,36 +55,16 @@ export function ChatInput({
const canSend =
(value.trim().length > 0 || files.length > 0) && !isGenerating;
const submit = useCallback(async () => {
const submit = useCallback(() => {
const trimmed = value.trim();
// Allow submitting while generating — the panel queues it (Claude-style).
if (!trimmed && files.length === 0) {
return;
}
const parts: string[] = [];
if (trimmed) {
parts.push(trimmed);
}
// Include the text of attached files so the agent can parse them (e.g. a
// database export to import). Read text-like files; cap each so a huge file
// can't blow the context. Binary files are referenced by name only.
for (const file of files) {
const textLike =
/\.(csv|tsv|json|txt|md|xml|ndjson|tab)$/i.test(file.name) ||
file.type.startsWith("text/") ||
file.type === "application/json";
if (textLike) {
try {
const content = (await file.text()).slice(0, 200_000);
parts.push(`--- File: ${file.name} ---\n${content}`);
} catch {
parts.push(`[Attached: ${file.name}]`);
}
} else {
parts.push(`[Attached: ${file.name}]`);
}
}
onSubmit(parts.join("\n\n"));
// Hand the raw files to the panel; it sends them as proper attachment parts
// (rendered as chips, not raw inlined text) and the backend extracts any
// text-like content for the model.
onSubmit(trimmed, files);
setValue("");
setFiles([]);
}, [value, files, onSubmit]);
@@ -234,7 +214,7 @@ export function ChatInput({
<PatientFormDialog
key={addKey}
mode="create"
onCreated={(fileNumber) => onSubmit(`/patient ${fileNumber}`)}
onCreated={(fileNumber) => onSubmit(`/patient ${fileNumber}`, [])}
onOpenChange={setAddOpen}
open={addOpen}
/>
+75 -18
View File
@@ -1,13 +1,23 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type ToolUIPart } from "ai";
import {
DefaultChatTransport,
type FileUIPart,
type ToolUIPart,
} from "ai";
import { AlertTriangle, Brain, ChevronDown, ShieldCheck, X } from "lucide-react";
import { nanoid } from "nanoid";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Attachment,
AttachmentInfo,
AttachmentPreview,
Attachments,
} from "@/components/ai-elements/attachments";
import {
ChainOfThought,
ChainOfThoughtContent,
@@ -83,6 +93,23 @@ import { getPatient } from "@/lib/patients";
// pulls records instantly without the LLM (also works offline).
const PATIENT_COMMAND = /^\/(?:patient\s+)?(\d+)$/i;
// Read a File into a FileUIPart (data URL). The backend extracts text-like
// content for the model; images/PDFs are read directly by vision providers.
function fileToPart(file: File): Promise<FileUIPart> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve({
type: "file",
mediaType: file.type || "application/octet-stream",
filename: file.name,
url: reader.result as string,
});
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export function ChatPanel() {
const { t } = useTranslation();
const [model, setModel] = useState<string>(DEFAULT_MODEL_ID);
@@ -92,11 +119,14 @@ export function ChatPanel() {
// per session before the first such send — inline (no modal). `pendingConsent`
// holds the message text waiting on that one-time approval.
const [consented, setConsented] = useState(false);
const [pendingConsent, setPendingConsent] = useState<string | null>(null);
const [pendingConsent, setPendingConsent] = useState<{
text: string;
files: File[];
} | null>(null);
// Claude-style message queue: messages submitted while the assistant is busy
// (or waiting on the Veil gate) wait here and auto-send when it goes idle.
const [queued, setQueued] = useState<string[]>([]);
const [queued, setQueued] = useState<{ text: string; files: File[] }[]>([]);
// Persisted conversation: a client-owned thread id (a fresh one per new chat),
// saved to the server after each exchange so history survives reloads.
@@ -151,9 +181,10 @@ export function ChatPanel() {
// Run the LLM agent for a message (after any Veil gate) on a given model.
const runAgentWith = useCallback(
(text: string, modelId: string) => {
async (text: string, modelId: string, files: File[] = []) => {
const fileParts = await Promise.all(files.map(fileToPart));
sendMessage(
{ text },
{ text, files: fileParts },
{ body: { model: modelId, effort, threadId: threadIdRef.current } },
);
},
@@ -161,13 +192,13 @@ export function ChatPanel() {
);
const send = useCallback(
async (text: string) => {
async (text: string, files: File[] = []) => {
const trimmed = text.trim();
if (!trimmed) return;
if (!trimmed && files.length === 0) return;
// Busy or awaiting the Veil gate → queue and auto-send when idle.
if (status === "submitted" || status === "streaming" || pendingConsent) {
setQueued((q) => [...q, trimmed]);
setQueued((q) => [...q, { text: trimmed, files }]);
return;
}
@@ -206,10 +237,10 @@ export function ChatPanel() {
// Cloud model → inline Veil consent once before sending externally.
if (isCloudModel && !consented) {
setPendingConsent(trimmed);
setPendingConsent({ text: trimmed, files });
return;
}
runAgentWith(trimmed, model);
void runAgentWith(trimmed, model, files);
},
[
consented,
@@ -228,22 +259,22 @@ export function ChatPanel() {
if (status !== "ready" || pendingConsent || queued.length === 0) return;
const [next, ...rest] = queued;
setQueued(rest);
if (next) void send(next);
if (next) void send(next.text, next.files);
}, [status, pendingConsent, queued, send]);
// Veil gate actions.
const confirmConsent = useCallback(() => {
setConsented(true);
const text = pendingConsent;
const pending = pendingConsent;
setPendingConsent(null);
if (text) runAgentWith(text, model);
if (pending) void runAgentWith(pending.text, model, pending.files);
}, [pendingConsent, runAgentWith, model]);
const useLocalInstead = useCallback(() => {
setModel("ollama");
const text = pendingConsent;
const pending = pendingConsent;
setPendingConsent(null);
if (text) runAgentWith(text, "ollama");
if (pending) void runAgentWith(pending.text, "ollama", pending.files);
}, [pendingConsent, runAgentWith]);
const cancelConsent = useCallback(() => setPendingConsent(null), []);
@@ -381,10 +412,13 @@ export function ChatPanel() {
</span>
<QueueList>
{queued.map((q, i) => (
<QueueItem key={`${i}-${q}`}>
<QueueItem key={`${i}-${q.text}`}>
<div className="flex items-center gap-2">
<QueueItemIndicator />
<QueueItemContent>{q}</QueueItemContent>
<QueueItemContent>
{q.text ||
t("chat.queue.attachmentsOnly", { count: q.files.length })}
</QueueItemContent>
<QueueItemActions>
<QueueItemAction
aria-label={t("chat.queue.remove")}
@@ -415,13 +449,16 @@ export function ChatPanel() {
const firstActionPreviewIdx = message.parts.findIndex(
(p) => p.type === "data-actionPreview",
);
// Attachments the clinician uploaded — rendered once as a chip group.
const fileParts = message.parts.filter((p) => p.type === "file");
const firstFileIdx = message.parts.findIndex((p) => p.type === "file");
return (
<Message from={message.role} key={message.id}>
<MessageContent className="w-full">
{steps.length > 0 ? (
<ChainOfThought
className="mb-1"
defaultOpen={isLast && isWorking}
defaultOpen={false}
key={`${message.id}-cot`}
>
<ChainOfThoughtHeader>{t("chat.steps")}</ChainOfThoughtHeader>
@@ -478,6 +515,26 @@ export function ChatPanel() {
<MessageResponse key={key}>{part.text}</MessageResponse>
);
}
if (part.type === "file") {
// Render the whole message's files as one chip group, once.
if (i !== firstFileIdx) return null;
return (
<Attachments className="w-full" key={key} variant="inline">
{fileParts.map((fp, fi) => (
<Attachment
data={{
...(fp as FileUIPart),
id: `${message.id}-file-${fi}`,
}}
key={`${message.id}-file-${fi}`}
>
<AttachmentPreview />
<AttachmentInfo />
</Attachment>
))}
</Attachments>
);
}
if (part.type === "data-patientCard") {
return (
<PatientResult
+8 -3
View File
@@ -27,6 +27,7 @@ import {
CommandPanel,
} from "@/components/ui/command";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { useAiAccess } from "@/lib/ai-policy";
import { useActiveRole, visibleNavItems } from "@/lib/roles";
type CommandPaletteContextValue = { open: () => void };
@@ -51,6 +52,7 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
const router = useRouter();
const { t } = useTranslation();
const role = useActiveRole();
const { allowed: aiAllowed } = useAiAccess();
const [open, setOpen] = useState(false);
useEffect(() => {
@@ -71,8 +73,11 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
value: "pages",
label: t("nav.commandGroup"),
// Flatten sub-pages so e.g. "Appointments & Schedule" is reachable.
// Filtered by role so reception can't jump to clinical pages.
items: visibleNavItems(role).flatMap((item) =>
// Filtered by role so reception can't jump to clinical pages, and by
// the AI kill-switch so the disabled chat isn't listed.
items: visibleNavItems(role)
.filter((item) => aiAllowed || item.id !== "new-chat")
.flatMap((item) =>
item.subs?.length
? item.subs.map((sub) => ({
id: sub.id,
@@ -91,7 +96,7 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
),
},
],
[t, role],
[t, role, aiAllowed],
);
type Group = (typeof groups)[number];
+294 -34
View File
@@ -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>
);
}
@@ -17,7 +17,13 @@ import {
FieldLabel,
SettingsCard,
SettingsSection,
ToggleRow,
} from "@/components/settings/settings-parts";
import {
type AiPolicy,
getAiPolicy,
saveAiPolicy,
} from "@/lib/ai-policy";
import { AI_MODELS, EFFORT_LEVELS, type Effort } from "@/lib/ai-models";
import {
type AiConfig,
@@ -28,6 +34,7 @@ import {
saveAiConfig,
testAiConnection,
} from "@/lib/ai-settings";
import { useActiveRole } from "@/lib/roles";
import { notify } from "@/lib/toast";
const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
@@ -52,6 +59,55 @@ export function AIPanel() {
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
// Clinic-wide AI availability (admin-controlled kill-switch).
const role = useActiveRole();
const isAdmin = role === "owner" || role === "admin";
const [policy, setPolicy] = useState<AiPolicy | null>(null);
const [policyBaseline, setPolicyBaseline] = useState<AiPolicy | null>(null);
const [savingPolicy, setSavingPolicy] = useState(false);
useEffect(() => {
let cancelled = false;
getAiPolicy()
.then((p) => {
if (cancelled) return;
setPolicy(p);
setPolicyBaseline(p);
})
.catch(() => {
/* leave null; section just won't render its controls */
});
return () => {
cancelled = true;
};
}, []);
const policyDirty =
policy != null &&
policyBaseline != null &&
JSON.stringify(policy) !== JSON.stringify(policyBaseline);
const savePolicy = async () => {
if (!policy) return;
setSavingPolicy(true);
try {
const saved = await saveAiPolicy(policy);
setPolicy(saved);
setPolicyBaseline(saved);
notify.success(
t("settings.ai.availability.savedTitle"),
t("settings.ai.availability.savedBody"),
);
} catch {
notify.error(
t("settings.ai.saveFailedTitle"),
t("settings.ai.saveFailedBody"),
);
} finally {
setSavingPolicy(false);
}
};
useEffect(() => {
let cancelled = false;
getAiConfig()
@@ -142,6 +198,63 @@ export function AIPanel() {
return (
<>
{policy ? (
<SettingsSection
description={t("settings.ai.availability.description")}
title={t("settings.ai.availability.title")}
>
{isAdmin ? (
<div className="space-y-3">
<ToggleRow
checked={policy.aiEnabled}
description={t("settings.ai.availability.enabledHint")}
onCheckedChange={(checked) =>
setPolicy((p) => (p ? { ...p, aiEnabled: checked } : p))
}
title={t("settings.ai.availability.enabled")}
/>
{policy.aiEnabled ? (
<ToggleRow
checked={policy.disabledForEmployees}
description={t(
"settings.ai.availability.employeesOnlyHint",
)}
onCheckedChange={(checked) =>
setPolicy((p) =>
p ? { ...p, disabledForEmployees: checked } : p,
)
}
title={t("settings.ai.availability.employeesOnly")}
/>
) : null}
{policyDirty ? (
<div className="flex justify-end">
<Button
disabled={savingPolicy}
onClick={savePolicy}
size="sm"
>
{savingPolicy
? t("settings.ai.saving")
: t("settings.ai.saveChanges")}
</Button>
</div>
) : null}
</div>
) : (
<SettingsCard className="px-4 py-3.5">
<p className="text-sm text-muted-foreground">
{policy.aiEnabled
? policy.disabledForEmployees
? t("settings.ai.availability.readonlyEmployeesOnly")
: t("settings.ai.availability.readonlyEnabled")
: t("settings.ai.availability.readonlyDisabled")}
</p>
</SettingsCard>
)}
</SettingsSection>
) : null}
<SettingsSection
description={t("settings.ai.modeDescription")}
title={t("settings.ai.modeTitle")}
@@ -14,6 +14,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useAiAccess } from "@/lib/ai-policy";
import { useActiveRole, visibleNavItems } from "@/lib/roles";
import { motion } from "framer-motion";
import Image from "next/image";
@@ -28,10 +29,14 @@ export function DashboardSidebar() {
const { state } = useSidebar();
const { t } = useTranslation();
const role = useActiveRole();
const { allowed: aiAllowed } = useAiAccess();
const isCollapsed = state === "collapsed";
// Hide clinical nav from non-clinical roles (e.g. reception). See lib/roles.ts.
const dashboardRoutes: Route[] = visibleNavItems(role).map((item) => ({
// Also drop the AI "New chat" entry when the clinic's AI kill-switch applies.
const dashboardRoutes: Route[] = visibleNavItems(role)
.filter((item) => aiAllowed || item.id !== "new-chat")
.map((item) => ({
id: item.id,
title: t(item.labelKey),
icon: <item.icon className="size-4" />,
@@ -92,7 +97,7 @@ export function DashboardSidebar() {
</SidebarHeader>
<SidebarContent className="gap-4 px-2 py-4">
<DashboardNavigation routes={dashboardRoutes} />
<NavChatHistory />
{aiAllowed && <NavChatHistory />}
</SidebarContent>
<SidebarFooter className="p-2">
<NavUser />
+23 -15
View File
@@ -12,9 +12,12 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { ROLE_LABELS } from "@/lib/access";
import type { TaskStatus } from "@/lib/tasks";
import { cn } from "@/lib/utils";
import type { Priority, Task } from "@/components/tasks/tasks-view";
const STATUSES: TaskStatus[] = ["todo", "in_progress", "done"];
const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline"> =
{
high: "destructive",
@@ -33,12 +36,12 @@ export function TaskDetailSheet({
task,
open,
onOpenChange,
onToggle,
onMove,
}: {
task: Task | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onToggle: (id: string) => void;
onMove: (id: string, status: TaskStatus) => void;
}) {
const { t } = useTranslation();
return (
@@ -67,9 +70,7 @@ export function TaskDetailSheet({
{t("tasks.detail.status")}
</dt>
<dd className="text-foreground">
{task.done
? t("tasks.detail.completed")
: t("tasks.detail.open")}
{t(`tasks.status.${task.status}`)}
</dd>
<dt className="text-muted-foreground">
{t("tasks.detail.assignedTo")}
@@ -110,16 +111,23 @@ export function TaskDetailSheet({
</div>
)}
<div>
<Button
onClick={() => onToggle(task.id)}
type="button"
variant={task.done ? "outline" : "default"}
>
{task.done
? t("tasks.detail.reopen")
: t("tasks.detail.complete")}
</Button>
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">
{t("tasks.detail.moveTo")}
</span>
<div className="flex flex-wrap gap-2">
{STATUSES.map((s) => (
<Button
key={s}
onClick={() => onMove(task.id, s)}
size="sm"
type="button"
variant={task.status === s ? "default" : "outline"}
>
{t(`tasks.status.${s}`)}
</Button>
))}
</div>
</div>
</div>
)}
+202 -126
View File
@@ -1,7 +1,8 @@
"use client";
import { Check, Plus } from "lucide-react";
import { CalendarClock, Plus } from "lucide-react";
import {
type DragEvent,
type FormEvent,
type ReactNode,
useEffect,
@@ -32,6 +33,7 @@ import {
type Priority,
type Task,
type TaskInput,
type TaskStatus,
createTask,
listTasks,
updateTask,
@@ -47,7 +49,8 @@ function deptLabel(role: string): string {
export type { Priority, Task } from "@/lib/tasks";
type Filter = "all" | "open" | "done";
// The board columns, left → right.
const COLUMNS: TaskStatus[] = ["todo", "in_progress", "done"];
const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline"> =
{
@@ -56,32 +59,12 @@ const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline">
low: "outline",
};
function CheckButton({
done,
onClick,
label,
}: {
done: boolean;
onClick: () => void;
label: string;
}) {
return (
<button
aria-label={label}
aria-pressed={done}
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded-md border transition-colors",
done
? "border-primary bg-primary text-primary-foreground"
: "border-input hover:border-ring",
)}
onClick={onClick}
type="button"
>
{done && <Check className="size-3.5" />}
</button>
);
}
// A subtle accent dot per column so the three are quick to tell apart.
const columnDot: Record<TaskStatus, string> = {
todo: "bg-muted-foreground",
in_progress: "bg-primary",
done: "bg-success",
};
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
@@ -99,10 +82,12 @@ function AddTaskDialog({
open,
onOpenChange,
onAdd,
initialStatus,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (task: TaskInput) => void;
initialStatus: TaskStatus;
}) {
const { t } = useTranslation();
const [title, setTitle] = useState("");
@@ -111,6 +96,12 @@ function AddTaskDialog({
const [department, setDepartment] = useState<string>("reception");
const [due, setDue] = useState("");
const [priority, setPriority] = useState<Priority>("medium");
const [status, setStatus] = useState<TaskStatus>(initialStatus);
// Re-seed the column when the dialog is opened from a specific column.
useEffect(() => {
if (open) setStatus(initialStatus);
}, [open, initialStatus]);
const reset = () => {
setTitle("");
@@ -119,6 +110,7 @@ function AddTaskDialog({
setDepartment("reception");
setDue("");
setPriority("medium");
setStatus(initialStatus);
};
const submit = (event: FormEvent) => {
@@ -137,6 +129,7 @@ function AddTaskDialog({
assigneeRole: assigneeMode === "self" ? null : department,
due: due.trim() || "No due date",
priority,
status,
});
notify.success(t("tasks.toast.addedTitle"), title.trim());
reset();
@@ -208,7 +201,7 @@ function AddTaskDialog({
</TabsPanel>
</Tabs>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
<Field label={t("tasks.dialog.due")}>
<Input
onChange={(e) => setDue(e.target.value)}
@@ -227,6 +220,19 @@ function AddTaskDialog({
<option value="low">{t("tasks.priority.low")}</option>
</select>
</Field>
<Field label={t("tasks.dialog.statusLabel")}>
<select
className={controlClass}
onChange={(e) => setStatus(e.target.value as TaskStatus)}
value={status}
>
{COLUMNS.map((s) => (
<option key={s} value={s}>
{t(`tasks.status.${s}`)}
</option>
))}
</select>
</Field>
</div>
</DialogPanel>
@@ -242,13 +248,68 @@ function AddTaskDialog({
);
}
// One draggable task card on the board.
function TaskCard({
task,
onOpen,
onDragStart,
}: {
task: Task;
onOpen: () => void;
onDragStart: () => void;
}) {
const { t } = useTranslation();
return (
<div
className="group flex cursor-pointer flex-col gap-2 rounded-2xl border bg-card p-3 transition-colors hover:border-ring"
draggable
onClick={onOpen}
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", task.id);
e.dataTransfer.effectAllowed = "move";
onDragStart();
}}
>
<span
className={cn(
"text-sm",
task.done
? "text-muted-foreground line-through"
: "font-medium text-foreground",
)}
>
{task.title}
</span>
<span className="truncate text-muted-foreground text-xs">
{task.assigneeRole
? t("tasks.list.forDept", { dept: deptLabel(task.assigneeRole) })
: t("tasks.list.personal")}
{task.createdByName
? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}`
: ""}
</span>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-1 text-muted-foreground text-xs">
<CalendarClock className="size-3.5 shrink-0" />
<span className="truncate">{task.due}</span>
</span>
<Badge className="shrink-0" variant={priorityVariant[task.priority]}>
{t(`tasks.priority.${task.priority}`)}
</Badge>
</div>
</div>
);
}
export function TasksView() {
const { t } = useTranslation();
const [tasks, setTasks] = useState<Task[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [filter, setFilter] = useState<Filter>("all");
const [addOpen, setAddOpen] = useState(false);
const [addStatus, setAddStatus] = useState<TaskStatus>("todo");
const [dragId, setDragId] = useState<string | null>(null);
const [dragOver, setDragOver] = useState<TaskStatus | null>(null);
useEffect(() => {
let active = true;
@@ -266,26 +327,29 @@ export function TasksView() {
const selected = tasks.find((task) => task.id === selectedId) ?? null;
const visible = useMemo(() => {
if (filter === "open") return tasks.filter((task) => !task.done);
if (filter === "done") return tasks.filter((task) => task.done);
return tasks;
}, [tasks, filter]);
const byStatus = useMemo(() => {
const groups: Record<TaskStatus, Task[]> = {
todo: [],
in_progress: [],
done: [],
};
for (const task of tasks) groups[task.status]?.push(task);
return groups;
}, [tasks]);
// Optimistically flip done, then persist; roll back on failure.
const toggle = async (id: string) => {
// Optimistically move a task to a new column, then persist; roll back on fail.
const moveTask = async (id: string, status: TaskStatus) => {
const current = tasks.find((task) => task.id === id);
if (!current) return;
const next = !current.done;
if (!current || current.status === status) return;
setTasks((prev) =>
prev.map((row) => (row.id === id ? { ...row, done: next } : row)),
prev.map((row) =>
row.id === id ? { ...row, status, done: status === "done" } : row,
),
);
try {
await updateTask(id, { done: next });
await updateTask(id, { status });
} catch {
setTasks((prev) =>
prev.map((row) => (row.id === id ? { ...row, done: current.done } : row)),
);
setTasks((prev) => prev.map((row) => (row.id === id ? current : row)));
notify.error(
t("tasks.toast.updateFailedTitle"),
t("tasks.toast.updateFailedBody"),
@@ -310,97 +374,109 @@ export function TasksView() {
}
};
const openAdd = (status: TaskStatus) => {
setAddStatus(status);
setAddOpen(true);
};
const handleDrop = (event: DragEvent, status: TaskStatus) => {
event.preventDefault();
setDragOver(null);
const id = dragId ?? event.dataTransfer.getData("text/plain");
setDragId(null);
if (id) void moveTask(id, status);
};
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="font-semibold text-2xl tracking-tight">
{t("tasks.title")}
</h1>
<p className="text-muted-foreground text-sm">{t("tasks.subtitle")}</p>
</div>
<Button
className="rounded-3xl"
onClick={() => setAddOpen(true)}
type="button"
>
<Plus className="size-4" />
{t("tasks.new")}
</Button>
<div className="flex h-full w-full flex-col gap-6 px-6 py-8">
<div className="flex flex-col gap-1">
<h1 className="font-semibold text-2xl tracking-tight">
{t("tasks.title")}
</h1>
<p className="text-muted-foreground text-sm">{t("tasks.subtitle")}</p>
</div>
<div className="flex w-full items-center gap-1 rounded-2xl border bg-card/30 p-1 sm:w-fit">
{(["all", "open", "done"] as Filter[]).map((f) => (
<Button
className="flex-1 sm:flex-none"
key={f}
onClick={() => setFilter(f)}
size="sm"
type="button"
variant={filter === f ? "secondary" : "ghost"}
>
{t(`tasks.filters.${f}`)}
</Button>
))}
</div>
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{visible.length === 0 ? (
<p className="px-4 py-10 text-center text-muted-foreground text-sm">
{t("tasks.empty")}
</p>
) : (
visible.map((task) => (
<div className="flex items-center gap-3 px-4 py-3" key={task.id}>
<CheckButton
done={task.done}
label={
task.done ? t("tasks.markNotDone") : t("tasks.markDone")
}
onClick={() => toggle(task.id)}
/>
<button
className="flex min-w-0 flex-1 flex-col text-left"
onClick={() => openTask(task.id)}
type="button"
>
<span
className={cn(
"truncate text-sm",
task.done
? "text-muted-foreground line-through"
: "font-medium text-foreground",
)}
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-3">
{COLUMNS.map((status) => {
const column = byStatus[status];
return (
<section
className={cn(
"flex min-h-0 flex-col gap-3 rounded-2xl border bg-card/30 p-3 transition-colors",
dragOver === status && "border-ring bg-accent/40",
)}
key={status}
onDragLeave={() => setDragOver((s) => (s === status ? null : s))}
onDragOver={(e) => {
e.preventDefault();
setDragOver(status);
}}
onDrop={(e) => handleDrop(e, status)}
>
<div className="flex items-center justify-between gap-2 px-1">
<div className="flex items-center gap-2">
<span
className={cn("size-2 rounded-full", columnDot[status])}
/>
<span className="font-medium text-sm">
{t(`tasks.status.${status}`)}
</span>
<span className="text-muted-foreground text-xs">
{column.length}
</span>
</div>
<Button
aria-label={t("tasks.board.addTask")}
onClick={() => openAdd(status)}
size="icon-sm"
type="button"
variant="ghost"
>
{task.title}
</span>
<span className="truncate text-muted-foreground text-xs">
{task.assigneeRole
? t("tasks.list.forDept", {
dept: deptLabel(task.assigneeRole),
})
: t("tasks.list.personal")}
{task.createdByName
? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}`
: ""}
</span>
</button>
<Badge
className="shrink-0"
variant={priorityVariant[task.priority]}
>
{t(`tasks.priority.${task.priority}`)}
</Badge>
</div>
))
)}
<Plus className="size-4" />
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{column.length === 0 ? (
<p className="px-1 py-6 text-center text-muted-foreground text-xs">
{t("tasks.board.emptyColumn")}
</p>
) : (
column.map((task) => (
<TaskCard
key={task.id}
onDragStart={() => setDragId(task.id)}
onOpen={() => openTask(task.id)}
task={task}
/>
))
)}
<Button
className="justify-start text-muted-foreground"
onClick={() => openAdd(status)}
size="sm"
type="button"
variant="ghost"
>
<Plus className="size-4" />
{t("tasks.board.addTask")}
</Button>
</div>
</section>
);
})}
</div>
<AddTaskDialog onAdd={addTask} onOpenChange={setAddOpen} open={addOpen} />
<AddTaskDialog
initialStatus={addStatus}
onAdd={addTask}
onOpenChange={setAddOpen}
open={addOpen}
/>
<TaskDetailSheet
onMove={moveTask}
onOpenChange={setSheetOpen}
onToggle={toggle}
open={sheetOpen}
task={selected}
/>
+2 -1
View File
@@ -56,7 +56,8 @@ export type ActionPreviewKind =
| "appointment"
| "task"
| "prescription"
| "invoice";
| "invoice"
| "inventory";
export type ActionPreviewData = {
token: string;
kind: ActionPreviewKind;
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api-client";
import { useActiveRole } from "@/lib/roles";
// Mirrors backend/src/services/ai/policy.ts. Clinic-wide AI availability, set by
// owners/admins. Absent/default = AI enabled for everyone.
export type AiPolicy = {
aiEnabled: boolean;
disabledForEmployees: boolean;
};
export function getAiPolicy(): Promise<AiPolicy> {
return apiFetch<AiPolicy>("/api/ai/policy");
}
export function saveAiPolicy(policy: AiPolicy): Promise<AiPolicy> {
return apiFetch<AiPolicy>("/api/ai/policy", {
method: "PUT",
body: JSON.stringify(policy),
});
}
function isAdminRole(role: string | null): boolean {
return String(role ?? "")
.split(",")
.map((s) => s.trim())
.some((r) => r === "owner" || r === "admin");
}
// Whether a member with `role` may use the AI under `policy`. Owners/admins keep
// access when AI is only disabled for employees.
export function aiAllowedFor(
policy: AiPolicy | null,
role: string | null,
): boolean {
if (!policy) return true; // optimistic while loading — avoids nav flicker
if (!policy.aiEnabled) return false;
if (!policy.disabledForEmployees) return true;
return isAdminRole(role);
}
// Whether the current user may use the AI (clinic policy + their role). Returns
// `allowed: true` while loading so the AI nav doesn't flash out then back in.
export function useAiAccess(): { allowed: boolean; loading: boolean } {
const role = useActiveRole();
const [policy, setPolicy] = useState<AiPolicy | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
getAiPolicy()
.then((p) => {
if (active) setPolicy(p);
})
.catch(() => {
/* leave permissive default; backend still enforces */
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
return { allowed: aiAllowedFor(policy, role), loading };
}
+52 -5
View File
@@ -542,6 +542,16 @@
"empty": "No tasks here.",
"markDone": "Mark as done",
"markNotDone": "Mark as not done",
"status": {
"todo": "To Do",
"in_progress": "In Progress",
"done": "Done"
},
"board": {
"addTask": "Add task",
"moveTo": "Move to",
"emptyColumn": "No tasks."
},
"filters": {
"all": "All",
"open": "Open",
@@ -566,6 +576,7 @@
"due": "Due",
"duePlaceholder": "e.g. Today",
"priorityLabel": "Priority",
"statusLabel": "Status",
"cancel": "Cancel",
"add": "Add task"
},
@@ -588,7 +599,8 @@
"patient": "Patient",
"details": "Details",
"reopen": "Reopen task",
"complete": "Mark complete"
"complete": "Mark complete",
"moveTo": "Move to"
},
"toast": {
"needSubjectTitle": "Add a subject",
@@ -626,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",
@@ -795,7 +825,8 @@
},
"queue": {
"label": "Queued · {{count}}",
"remove": "Remove from queue"
"remove": "Remove from queue",
"attachmentsOnly": "{{count}} attachment(s)"
},
"veil": {
"title": "Veil",
@@ -810,14 +841,17 @@
"appointment": "Proposed appointment",
"task": "Proposed task",
"prescription": "Proposed prescription",
"invoice": "Proposed invoice"
"invoice": "Proposed invoice",
"inventory": "Proposed inventory"
},
"kind": {
"appointment": "Appointment added.",
"task": "Task added.",
"prescription": "Prescription added.",
"invoice": "Invoice added."
"invoice": "Invoice added.",
"inventory": "Inventory updated."
},
"inventoryItems": "{{count}} item(s)",
"approve": "Add",
"adding": "Adding…",
"discard": "Discard",
@@ -1258,6 +1292,19 @@
"backupDesc": "Export an encrypted backup of your signing key to restore it on a new device."
},
"ai": {
"availability": {
"title": "Availability",
"description": "Control who in your clinic can use the AI assistant. When disabled, the AI page and sidebar entry are hidden and the assistant cannot be reached.",
"enabled": "Enable AI assistant",
"enabledHint": "Turn the AI assistant on for your clinic. Off hides it for everyone.",
"employeesOnly": "Disable for employees only",
"employeesOnlyHint": "Hide the AI from staff; owners and admins keep access.",
"savedTitle": "AI availability updated",
"savedBody": "The change applies across your clinic.",
"readonlyEnabled": "The AI assistant is enabled for your clinic.",
"readonlyEmployeesOnly": "The AI assistant is restricted to owners and admins.",
"readonlyDisabled": "The AI assistant is disabled for your clinic."
},
"modeTitle": "Inference mode",
"modeDescription": "Choose how temetro runs the AI. A cloud API key sends data off your infrastructure; a local model keeps everything on your machine.",
"mode": "Mode",
+84 -2
View File
@@ -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",
+4
View File
@@ -3,6 +3,8 @@ import { apiFetch } from "@/lib/api-client";
// A care-team task. Mirrors the backend `src/types/task.ts`. Scoped to the active
// clinic (shared across the care team).
export type Priority = "high" | "medium" | "low";
// Board column the task sits in. `done` mirrors `status === "done"`.
export type TaskStatus = "todo" | "in_progress" | "done";
export type Task = {
id: string;
@@ -12,6 +14,7 @@ export type Task = {
assigneeRole: string | null;
due: string;
priority: Priority;
status: TaskStatus;
patient: string | null;
notes: string | null;
done: boolean;
@@ -28,6 +31,7 @@ export type TaskInput = {
assigneeRole?: string | null;
due?: string;
priority?: Priority;
status?: TaskStatus;
patient?: string | null;
notes?: string | null;
};