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

@@ -1,12 +1,14 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import multipart from "@fastify/multipart"; import multipart from "@fastify/multipart";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { success, error } from "../../utils/response"; import { success, error, parseId } from "../../utils/response";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { import {
AiChatSchema, AiChatSchema,
AiBudgetSchema, AiBudgetSchema,
AiHistoryAppendSchema, AiHistoryAppendSchema,
AiCreateConversationSchema,
AiRenameConversationSchema,
} from "../../schemas/ai.schema"; } from "../../schemas/ai.schema";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { config } from "../../config/env"; import { config } from "../../config/env";
@@ -18,9 +20,12 @@ import {
setBudgetUsd, setBudgetUsd,
chat, chat,
extractInvoice, extractInvoice,
getChatHistory, listConversations,
appendChatMessages, createConversation,
clearChatHistory, getConversationMessages,
appendConversationMessages,
renameConversation,
deleteConversation,
type ExtractedInvoice, type ExtractedInvoice,
} from "../../services/ai.service"; } from "../../services/ai.service";
@@ -131,35 +136,95 @@ export default async function aiRoutes(app: FastifyInstance): Promise<void> {
}, },
); );
// GET /api/admin/ai/history — this user's stored chat thread // GET /conversations — this user's conversations (stable order)
app.get( app.get(
"/history", "/conversations",
{ preHandler: requirePermission("ai.use") }, { preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => { async (request: FastifyRequest, reply: FastifyReply) => {
const messages = await getChatHistory(request.authData!.userId); const conversations = await listConversations(request.authData!.userId);
return success(reply, { messages }); return success(reply, { conversations });
}, },
); );
// POST /api/admin/ai/history — append turns to this user's thread // POST /conversations — create
app.post( app.post(
"/history", "/conversations",
{ preHandler: requirePermission("ai.use") }, { preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => { async (request: FastifyRequest, reply: FastifyReply) => {
const body = parseBody(AiCreateConversationSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const conversation = await createConversation(
request.authData!.userId,
body.data.title,
);
return success(reply, { conversation });
},
);
// GET /conversations/:id/messages
app.get(
"/conversations/:id/messages",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const result = await getConversationMessages(
request.authData!.userId,
id,
);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, { messages: result.data });
},
);
// POST /conversations/:id/messages — append turns
app.post(
"/conversations/:id/messages",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(AiHistoryAppendSchema, request.body); const body = parseBody(AiHistoryAppendSchema, request.body);
if ("error" in body) return error(reply, body.error, 400); if ("error" in body) return error(reply, body.error, 400);
await appendChatMessages(request.authData!.userId, body.data.messages); const result = await appendConversationMessages(
return success(reply, { ok: true }); request.authData!.userId,
id,
body.data.messages,
);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, result.data);
}, },
); );
// DELETE /api/admin/ai/history — clear this user's thread // PATCH /conversations/:id — rename
app.delete( app.patch(
"/history", "/conversations/:id",
{ preHandler: requirePermission("ai.use") }, { preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => { async (request: FastifyRequest, reply: FastifyReply) => {
await clearChatHistory(request.authData!.userId); const id = parseId((request.params as { id: string }).id, reply);
return success(reply, { ok: true }); if (id === null) return;
const body = parseBody(AiRenameConversationSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const result = await renameConversation(
request.authData!.userId,
id,
body.data.title,
);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, { conversation: result.data });
},
);
// DELETE /conversations/:id
app.delete(
"/conversations/:id",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const result = await deleteConversation(request.authData!.userId, id);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, result.data);
}, },
); );
} }

View File

@@ -24,6 +24,14 @@ export const AiHistoryAppendSchema = z.object({
.max(20, "Příliš mnoho zpráv"), .max(20, "Příliš mnoho zpráv"),
}); });
export const AiCreateConversationSchema = z.object({
title: z.string().max(255).optional(),
});
export const AiRenameConversationSchema = z.object({
title: z.string().min(1, "Název je povinný").max(255),
});
export const AiBudgetSchema = z.object({ export const AiBudgetSchema = z.object({
budget_usd: z budget_usd: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])

View File

@@ -104,48 +104,121 @@ export async function assertBudgetAvailable(): Promise<{
return null; return null;
} }
// ── Chat history (server-side, permanent, per user) ─────────────────────── // ── Conversations (server-side, per user) ─────────────────────────────────
export interface StoredChatMessage { export interface StoredChatMessage {
role: string; role: string;
content: string; content: string;
created_at: Date; 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; const DEFAULT_CONVERSATION_TITLE = "Nová konverzace";
// this only bounds a single GET payload (and, on the client, the model context). const MESSAGE_LIMIT = 200; // cap a single thread read
const HISTORY_LIMIT = 200;
/** This user's chat thread, oldest → newest, capped at HISTORY_LIMIT. */ export async function listConversations(
export async function getChatHistory(
userId: number, userId: number,
): Promise<StoredChatMessage[]> { ): Promise<ConversationSummary[]> {
const rows = await prisma.ai_chat_messages.findMany({ return prisma.ai_conversations.findMany({
where: { user_id: userId }, 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" }, orderBy: { id: "desc" },
take: HISTORY_LIMIT, take: MESSAGE_LIMIT,
select: { role: true, content: true, created_at: true }, 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 appendConversationMessages(
export async function appendChatMessages(
userId: number, userId: number,
convId: number,
messages: { role: string; content: string }[], messages: { role: string; content: string }[],
): Promise<void> { ): Promise<{ data: { ok: true } } | { error: string; status: number }> {
if (messages.length === 0) return; const conv = await ownConversation(userId, convId);
await prisma.ai_chat_messages.createMany({ if (!conv) return { error: "Konverzace nenalezena", status: 404 };
data: messages.map((m) => ({ // The route validates messages with min(1), so this is normally non-empty;
user_id: userId, // the guard is defensive (and still bumps updated_at below for an empty call).
role: m.role, if (messages.length > 0) {
content: m.content, 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 renameConversation(
export async function clearChatHistory(userId: number): Promise<void> { userId: number,
await prisma.ai_chat_messages.deleteMany({ where: { user_id: userId } }); 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 { export interface ChatMessage {