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 { 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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()])
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user