mirror of
https://github.com/temetro/temetro.git
synced 2026-08-10 18:49:29 +00:00
3aa699aefe
real Live card, persistent chat history Analytics & earnings: - Analytics now carries real money computed from invoices (billed/paid/ outstanding + by-month); new Earnings section on the Analysis page drawn with the project's Bklit chart components (shared EarningsChart). AI agent reaches the whole clinic: - new read tools getClinicInfo / getAnalytics / listInventory render clinic, analytics (with a Bklit earnings chart) and inventory cards in chat - proposeInvoice turns an uploaded purchase/medication list into an invoice (new "invoice" action-preview kind → createInvoice); invoices/appointments auto-create/link a patient (ensurePatient) so they hit the Patients page Live card: - plots real data — patients checked in today — via GET /api/analytics/live (polled); value pill clamped and margins widened so nothing spills the card Persistent AI chat history (Claude-style): - ai_chat_threads + ai_chat_messages (migration 0016); per-user, org-scoped thread CRUD under /api/chat/threads - chat panel owns a thread id, loads /?thread=<id>, and auto-saves after each exchange; sidebar lists past chats (open/delete), "New chat" starts fresh Verified with backend typecheck + frontend tsc + next build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
3.4 KiB
TypeScript
133 lines
3.4 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
|
|
import { and, asc, desc, eq } from "drizzle-orm";
|
|
|
|
import { db } from "../db/index.js";
|
|
import { aiChatMessages, aiChatThreads } from "../db/schema/ai-chat.js";
|
|
import { HttpError } from "../lib/http-error.js";
|
|
|
|
export type StoredMessage = { role: string; parts: unknown };
|
|
|
|
export type ThreadSummary = {
|
|
id: string;
|
|
title: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export async function listThreads(
|
|
orgId: string,
|
|
userId: string,
|
|
): Promise<ThreadSummary[]> {
|
|
const rows = await db
|
|
.select({
|
|
id: aiChatThreads.id,
|
|
title: aiChatThreads.title,
|
|
updatedAt: aiChatThreads.updatedAt,
|
|
})
|
|
.from(aiChatThreads)
|
|
.where(
|
|
and(
|
|
eq(aiChatThreads.organizationId, orgId),
|
|
eq(aiChatThreads.userId, userId),
|
|
),
|
|
)
|
|
.orderBy(desc(aiChatThreads.updatedAt))
|
|
.limit(50);
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
title: r.title,
|
|
updatedAt: r.updatedAt.toISOString(),
|
|
}));
|
|
}
|
|
|
|
export async function getThread(
|
|
orgId: string,
|
|
userId: string,
|
|
threadId: string,
|
|
): Promise<{ id: string; title: string; messages: StoredMessage[] } | null> {
|
|
const [thread] = await db
|
|
.select()
|
|
.from(aiChatThreads)
|
|
.where(
|
|
and(
|
|
eq(aiChatThreads.id, threadId),
|
|
eq(aiChatThreads.organizationId, orgId),
|
|
eq(aiChatThreads.userId, userId),
|
|
),
|
|
);
|
|
if (!thread) return null;
|
|
const rows = await db
|
|
.select({ role: aiChatMessages.role, parts: aiChatMessages.parts })
|
|
.from(aiChatMessages)
|
|
.where(eq(aiChatMessages.threadId, threadId))
|
|
.orderBy(asc(aiChatMessages.position));
|
|
return {
|
|
id: thread.id,
|
|
title: thread.title,
|
|
messages: rows.map((r) => ({ role: r.role, parts: r.parts })),
|
|
};
|
|
}
|
|
|
|
// Upsert a thread and replace its messages with the supplied snapshot. The
|
|
// thread id is client-generated; ownership is enforced (you can only write your
|
|
// own threads within your clinic).
|
|
export async function saveThread(
|
|
orgId: string,
|
|
userId: string,
|
|
threadId: string,
|
|
messages: StoredMessage[],
|
|
title: string,
|
|
): Promise<void> {
|
|
await db.transaction(async (tx) => {
|
|
const [existing] = await tx
|
|
.select({ userId: aiChatThreads.userId })
|
|
.from(aiChatThreads)
|
|
.where(eq(aiChatThreads.id, threadId));
|
|
if (existing && existing.userId !== userId) {
|
|
throw new HttpError(403, "Not your conversation.");
|
|
}
|
|
if (existing) {
|
|
await tx
|
|
.update(aiChatThreads)
|
|
.set({ title, updatedAt: new Date() })
|
|
.where(eq(aiChatThreads.id, threadId));
|
|
await tx
|
|
.delete(aiChatMessages)
|
|
.where(eq(aiChatMessages.threadId, threadId));
|
|
} else {
|
|
await tx
|
|
.insert(aiChatThreads)
|
|
.values({ id: threadId, organizationId: orgId, userId, title });
|
|
}
|
|
if (messages.length > 0) {
|
|
await tx.insert(aiChatMessages).values(
|
|
messages.map((m, i) => ({
|
|
id: randomUUID(),
|
|
threadId,
|
|
position: i,
|
|
role: m.role,
|
|
parts: m.parts,
|
|
})),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function deleteThread(
|
|
orgId: string,
|
|
userId: string,
|
|
threadId: string,
|
|
): Promise<boolean> {
|
|
const deleted = await db
|
|
.delete(aiChatThreads)
|
|
.where(
|
|
and(
|
|
eq(aiChatThreads.id, threadId),
|
|
eq(aiChatThreads.organizationId, orgId),
|
|
eq(aiChatThreads.userId, userId),
|
|
),
|
|
)
|
|
.returning({ id: aiChatThreads.id });
|
|
return deleted.length > 0;
|
|
}
|