feat(odin): conversation-scoped service + routes (replace /history)

Remove single-thread getChatHistory/appendChatMessages/clearChatHistory and the
three /history routes; add listConversations, createConversation, getConversation-
Messages, appendConversationMessages, renameConversation, deleteConversation with
ownership checks and auto-title; wire six /conversations[/:id] routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 19:19:03 +02:00
parent c5cfd855ba
commit 6664e3bc4d
3 changed files with 187 additions and 41 deletions

View File

@@ -104,48 +104,121 @@ export async function assertBudgetAvailable(): Promise<{
return null;
}
// ── Chat history (server-side, permanent, per user) ───────────────────────
// ── Conversations (server-side, per user) ─────────────────────────────────
export interface StoredChatMessage {
role: string;
content: string;
created_at: Date;
}
export interface ConversationSummary {
id: number;
title: string;
updated_at: Date;
}
// Cap how many turns we read back / display. The full thread stays in the DB;
// this only bounds a single GET payload (and, on the client, the model context).
const HISTORY_LIMIT = 200;
const DEFAULT_CONVERSATION_TITLE = "Nová konverzace";
const MESSAGE_LIMIT = 200; // cap a single thread read
/** This user's chat thread, oldest → newest, capped at HISTORY_LIMIT. */
export async function getChatHistory(
export async function listConversations(
userId: number,
): Promise<StoredChatMessage[]> {
const rows = await prisma.ai_chat_messages.findMany({
): Promise<ConversationSummary[]> {
return prisma.ai_conversations.findMany({
where: { user_id: userId },
orderBy: { id: "asc" }, // stable tab order
select: { id: true, title: true, updated_at: true },
});
}
export async function createConversation(
userId: number,
title?: string,
): Promise<ConversationSummary> {
return prisma.ai_conversations.create({
data: {
user_id: userId,
title: title?.trim() || DEFAULT_CONVERSATION_TITLE,
},
select: { id: true, title: true, updated_at: true },
});
}
/** Ownership-checked fetch; null when not found / not owned. */
async function ownConversation(userId: number, convId: number) {
return prisma.ai_conversations.findFirst({
where: { id: convId, user_id: userId },
select: { id: true, title: true },
});
}
export async function getConversationMessages(
userId: number,
convId: number,
): Promise<{ data: StoredChatMessage[] } | { error: string; status: number }> {
if (!(await ownConversation(userId, convId)))
return { error: "Konverzace nenalezena", status: 404 };
const rows = await prisma.ai_chat_messages.findMany({
where: { conversation_id: convId },
orderBy: { id: "desc" },
take: HISTORY_LIMIT,
take: MESSAGE_LIMIT,
select: { role: true, content: true, created_at: true },
});
return rows.reverse();
return { data: rows.reverse() };
}
/** Append turns to this user's thread (best-effort caller). */
export async function appendChatMessages(
export async function appendConversationMessages(
userId: number,
convId: number,
messages: { role: string; content: string }[],
): Promise<void> {
if (messages.length === 0) return;
await prisma.ai_chat_messages.createMany({
data: messages.map((m) => ({
user_id: userId,
role: m.role,
content: m.content,
})),
});
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
const conv = await ownConversation(userId, convId);
if (!conv) return { error: "Konverzace nenalezena", status: 404 };
// The route validates messages with min(1), so this is normally non-empty;
// the guard is defensive (and still bumps updated_at below for an empty call).
if (messages.length > 0) {
await prisma.ai_chat_messages.createMany({
data: messages.map((m) => ({
user_id: userId,
conversation_id: convId,
role: m.role,
content: m.content,
})),
});
}
// Auto-title from the first user message (only while still the default), and
// always bump updated_at.
const firstUser = messages.find((m) => m.role === "user");
const data: { updated_at: Date; title?: string } = { updated_at: new Date() };
if (conv.title === DEFAULT_CONVERSATION_TITLE && firstUser) {
const t = firstUser.content.replace(/\s+/g, " ").trim().slice(0, 60);
if (t) data.title = t;
}
await prisma.ai_conversations.update({ where: { id: convId }, data });
return { data: { ok: true } };
}
/** Wipe this user's thread. */
export async function clearChatHistory(userId: number): Promise<void> {
await prisma.ai_chat_messages.deleteMany({ where: { user_id: userId } });
export async function renameConversation(
userId: number,
convId: number,
title: string,
): Promise<{ data: ConversationSummary } | { error: string; status: number }> {
if (!(await ownConversation(userId, convId)))
return { error: "Konverzace nenalezena", status: 404 };
const updated = await prisma.ai_conversations.update({
where: { id: convId },
data: { title: title.trim() || DEFAULT_CONVERSATION_TITLE },
select: { id: true, title: true, updated_at: true },
});
return { data: updated };
}
export async function deleteConversation(
userId: number,
convId: number,
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
if (!(await ownConversation(userId, convId)))
return { error: "Konverzace nenalezena", status: 404 };
await prisma.ai_conversations.delete({ where: { id: convId } }); // cascade
return { data: { ok: true } };
}
export interface ChatMessage {