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:
@@ -1,12 +1,14 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { success, error } from "../../utils/response";
|
||||
import { success, error, parseId } from "../../utils/response";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import {
|
||||
AiChatSchema,
|
||||
AiBudgetSchema,
|
||||
AiHistoryAppendSchema,
|
||||
AiCreateConversationSchema,
|
||||
AiRenameConversationSchema,
|
||||
} from "../../schemas/ai.schema";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { config } from "../../config/env";
|
||||
@@ -18,9 +20,12 @@ import {
|
||||
setBudgetUsd,
|
||||
chat,
|
||||
extractInvoice,
|
||||
getChatHistory,
|
||||
appendChatMessages,
|
||||
clearChatHistory,
|
||||
listConversations,
|
||||
createConversation,
|
||||
getConversationMessages,
|
||||
appendConversationMessages,
|
||||
renameConversation,
|
||||
deleteConversation,
|
||||
type ExtractedInvoice,
|
||||
} 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(
|
||||
"/history",
|
||||
"/conversations",
|
||||
{ preHandler: requirePermission("ai.use") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const messages = await getChatHistory(request.authData!.userId);
|
||||
return success(reply, { messages });
|
||||
const conversations = await listConversations(request.authData!.userId);
|
||||
return success(reply, { conversations });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/ai/history — append turns to this user's thread
|
||||
// POST /conversations — create
|
||||
app.post(
|
||||
"/history",
|
||||
"/conversations",
|
||||
{ preHandler: requirePermission("ai.use") },
|
||||
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);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
await appendChatMessages(request.authData!.userId, body.data.messages);
|
||||
return success(reply, { ok: true });
|
||||
const result = await appendConversationMessages(
|
||||
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
|
||||
app.delete(
|
||||
"/history",
|
||||
// PATCH /conversations/:id — rename
|
||||
app.patch(
|
||||
"/conversations/:id",
|
||||
{ preHandler: requirePermission("ai.use") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
await clearChatHistory(request.authData!.userId);
|
||||
return success(reply, { ok: true });
|
||||
const id = parseId((request.params as { id: string }).id, reply);
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ export const AiHistoryAppendSchema = z.object({
|
||||
.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({
|
||||
budget_usd: z
|
||||
.union([z.number(), z.string()])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user