import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { getAuthUser } from "../auth/index.js"; import type { AppDatabase } from "../db/database.js"; import { createMessageRepository, createSessionRepository } from "./repository.js"; const createSessionBody = z.object({ title: z.string().min(1).max(120).optional(), }); const updateSessionBody = z.object({ title: z.string().trim().min(1).max(120), }); const updateSystemPromptBody = z.object({ // Empty / whitespace-only strings clear the override; null is a no-op. system_prompt: z.string().max(8_000).nullable().optional(), }); export async function registerSessionRoutes(app: FastifyInstance, db: AppDatabase) { const sessions = createSessionRepository(db); const messages = createMessageRepository(db); app.get("/api/sessions", async (request) => { const user = await getAuthUser(request); return { items: sessions.list(user.id) }; }); app.post("/api/sessions", async (request, reply) => { const user = await getAuthUser(request); const body = createSessionBody.parse(request.body ?? {}); const session = sessions.create(user.id, body.title ?? null); return reply.code(201).send(session); }); app.get<{ Params: { id: string } }>("/api/sessions/:id", async (request, reply) => { const user = await getAuthUser(request); const session = sessions.get(user.id, request.params.id); if (!session) { return reply.code(404).send({ error: "session_not_found" }); } return { ...session, messages: messages.listForSession(user.id, session.id), }; }); app.patch<{ Params: { id: string } }>("/api/sessions/:id", async (request, reply) => { const user = await getAuthUser(request); const body = updateSessionBody.parse(request.body); const session = sessions.get(user.id, request.params.id); if (!session) { return reply.code(404).send({ error: "session_not_found" }); } sessions.updateTitle(user.id, session.id, body.title); return sessions.get(user.id, session.id); }); // Per-session system prompt override. Inserted into the chat stream // immediately after the base identity prompt, before the docs/actions // context. Use to attach incident-specific context (runbook link, on-call // names, severity matrix) without polluting the global prompt. app.patch<{ Params: { id: string } }>( "/api/sessions/:id/system-prompt", async (request, reply) => { const user = await getAuthUser(request); const body = updateSystemPromptBody.parse(request.body ?? {}); const session = sessions.get(user.id, request.params.id); if (!session) { return reply.code(404).send({ error: "session_not_found" }); } sessions.updateSystemPrompt(user.id, session.id, body.system_prompt ?? null); return sessions.get(user.id, session.id); }, ); app.delete<{ Params: { id: string } }>("/api/sessions/:id", async (request, reply) => { const user = await getAuthUser(request); const deleted = sessions.delete(user.id, request.params.id); if (!deleted) { return reply.code(404).send({ error: "session_not_found" }); } return reply.code(204).send(); }); // Bulk delete: wipes every session owned by the current user. Cascade // removes the messages and webhook_runs that point at them. The frontend // requires the user to type the literal word "delete" before this fires. app.delete("/api/sessions", async (request, reply) => { const user = await getAuthUser(request); const removed = sessions.deleteAllForUser(user.id); return reply.code(200).send({ deleted: removed }); }); // Export: returns a JSON document with the session metadata and all its // messages. The shape is a stable contract so a `POST /api/sessions/import` // can read it back. webhook_runs are intentionally excluded from the // export — those are operational audit data, not conversation content. app.get<{ Params: { id: string } }>("/api/sessions/:id/export", async (request, reply) => { const user = await getAuthUser(request); const session = sessions.get(user.id, request.params.id); if (!session) { return reply.code(404).send({ error: "session_not_found" }); } return { version: 1, exported_at: new Date().toISOString(), session: { id: session.id, title: session.title, created_at: session.created_at, updated_at: session.updated_at, }, messages: messages.listForSession(user.id, session.id), }; }); // Import: accepts the export document above, creates a new session owned // by the caller, and writes the messages with fresh ids. Returns the new // session id and a count of imported messages. const importSessionBody = z.object({ session: z.object({ title: z.string().max(120).nullable().optional(), created_at: z.string().optional(), updated_at: z.string().optional(), }), messages: z.array( z.object({ role: z.enum(["user", "assistant", "system"]), content: z.string().min(1).max(50_000), metadata: z.record(z.unknown()).optional(), // Original created_at is preserved if present; otherwise "now" is // used. Used only to restore the timeline. created_at: z.string().optional(), }), ), }); app.post("/api/sessions/import", async (request, reply) => { const user = await getAuthUser(request); const body = importSessionBody.parse(request.body); const newSession = sessions.create(user.id, body.session.title ?? null); let imported = 0; for (const message of body.messages) { messages.create({ sessionId: newSession.id, userId: user.id, role: message.role, content: message.content, metadata: message.metadata, }); imported += 1; } sessions.touch(user.id, newSession.id); return reply.code(201).send({ session: newSession, imported_messages: imported, }); }); }