diff --git a/apps/freya-backend/src/conversations/errors.ts b/apps/freya-backend/src/conversations/errors.ts new file mode 100644 index 0000000..1546088 --- /dev/null +++ b/apps/freya-backend/src/conversations/errors.ts @@ -0,0 +1,11 @@ +export class ConversationNotFoundError extends Error { + readonly conversationId: string + readonly userId: string + + constructor(conversationId: string, userId: string) { + super(`Conversation "${conversationId}" not found for user "${userId}"`) + this.name = "ConversationNotFoundError" + this.conversationId = conversationId + this.userId = userId + } +} diff --git a/apps/freya-backend/src/conversations/http.test.ts b/apps/freya-backend/src/conversations/http.test.ts index 678c8ba..6e51f66 100644 --- a/apps/freya-backend/src/conversations/http.test.ts +++ b/apps/freya-backend/src/conversations/http.test.ts @@ -2,20 +2,54 @@ import { beforeEach, describe, expect, mock, test } from "bun:test" import { Hono } from "hono" import type { Database } from "../db/index.ts" -import type { ConversationRow } from "./storage.ts" +import type { + ConversationEntryRow, + ConversationRow, + ListConversationEntriesParams, +} from "./storage.ts" import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts" +import { ConversationNotFoundError } from "./errors.ts" import { registerConversationsHttpHandlers } from "./http.ts" +import { ConversationEntryKind, ConversationEntryVisibility } from "./types.ts" const MockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn" +const ConversationId = "11111111-1111-4111-8111-111111111111" +const MissingConversationId = "22222222-2222-4222-8222-222222222222" const conversationRowsByUser = new Map() +const conversationEntryRowsByUserAndConversation = new Map() +const listEntriesCalls: Array<{ + userId: string + conversationId: string + params: ListConversationEntriesParams +}> = [] mock.module("./storage.ts", () => ({ conversations: (_db: Database, userId: string) => ({ async listConversations(): Promise { return conversationRowsByUser.get(userId) ?? [] }, + + async listEntries( + conversationId: string, + params: ListConversationEntriesParams = {}, + ): Promise { + listEntriesCalls.push({ userId, conversationId, params }) + + const rows = conversationEntryRowsByUserAndConversation.get( + conversationEntriesKey(userId, conversationId), + ) + if (!rows) { + throw new ConversationNotFoundError(conversationId, userId) + } + + if (params.visibility) { + return rows.filter((row) => row.visibility === params.visibility) + } + + return rows + }, }), })) @@ -44,9 +78,39 @@ function createConversationRow( } } +function createConversationEntryRow( + id: string, + conversationId: string, + sequence: number, + kind: ConversationEntryRow["kind"], + visibility: ConversationEntryRow["visibility"], + payload: ConversationEntryRow["payload"], + createdAt: string, + metadata: ConversationEntryRow["metadata"] = {}, + fileId: string | null = null, +): ConversationEntryRow { + return { + id, + conversationId, + sequence, + kind, + visibility, + fileId, + payload, + metadata, + createdAt: new Date(createdAt), + } +} + +function conversationEntriesKey(userId: string, conversationId: string): string { + return `${userId}:${conversationId}` +} + describe("GET /api/conversations", () => { beforeEach(() => { conversationRowsByUser.clear() + conversationEntryRowsByUserAndConversation.clear() + listEntriesCalls.length = 0 }) test("returns 401 without auth", async () => { @@ -108,3 +172,162 @@ describe("GET /api/conversations", () => { }) }) }) + +describe("GET /api/conversations/:id/entries", () => { + beforeEach(() => { + conversationRowsByUser.clear() + conversationEntryRowsByUserAndConversation.clear() + listEntriesCalls.length = 0 + }) + + test("returns 401 without auth", async () => { + const app = buildTestApp() + + const res = await app.request("/api/conversations/conversation-1/entries") + + expect(res.status).toBe(401) + }) + + test("returns user-visible entries for the authenticated user", async () => { + conversationEntryRowsByUserAndConversation.set( + conversationEntriesKey(MockUserId, ConversationId), + [ + createConversationEntryRow( + "entry-user", + ConversationId, + 1, + ConversationEntryKind.UserMessage, + ConversationEntryVisibility.UserVisible, + { + role: "user", + parts: [{ type: "text", text: "What is on today?" }], + }, + "2026-06-17T09:30:00.000Z", + ), + createConversationEntryRow( + "entry-tool", + ConversationId, + 2, + ConversationEntryKind.ToolCall, + ConversationEntryVisibility.Internal, + { + toolName: "freya_list_context", + input: {}, + }, + "2026-06-17T09:30:01.000Z", + ), + createConversationEntryRow( + "entry-assistant", + ConversationId, + 3, + ConversationEntryKind.AssistantMessage, + ConversationEntryVisibility.UserVisible, + { + role: "assistant", + parts: [{ type: "text", text: "You have two calendar events." }], + }, + "2026-06-17T09:30:02.000Z", + { runId: "run-1" }, + ), + ], + ) + const app = buildTestApp("user-1") + + const res = await app.request(`/api/conversations/${ConversationId}/entries`) + + expect(res.status).toBe(200) + expect(listEntriesCalls).toEqual([ + { + userId: MockUserId, + conversationId: ConversationId, + params: { visibility: ConversationEntryVisibility.UserVisible }, + }, + ]) + + const body = (await res.json()) as { entries: unknown[] } + expect(body).toEqual({ + entries: [ + { + id: "entry-user", + conversationId: ConversationId, + sequence: 1, + kind: ConversationEntryKind.UserMessage, + visibility: ConversationEntryVisibility.UserVisible, + fileId: null, + payload: { + role: "user", + parts: [{ type: "text", text: "What is on today?" }], + }, + metadata: {}, + createdAt: "2026-06-17T09:30:00.000Z", + }, + { + id: "entry-assistant", + conversationId: ConversationId, + sequence: 3, + kind: ConversationEntryKind.AssistantMessage, + visibility: ConversationEntryVisibility.UserVisible, + fileId: null, + payload: { + role: "assistant", + parts: [{ type: "text", text: "You have two calendar events." }], + }, + metadata: { runId: "run-1" }, + createdAt: "2026-06-17T09:30:02.000Z", + }, + ], + }) + }) + + test("returns an empty list when the conversation has no user-visible entries", async () => { + conversationEntryRowsByUserAndConversation.set( + conversationEntriesKey(MockUserId, ConversationId), + [ + createConversationEntryRow( + "entry-tool", + ConversationId, + 1, + ConversationEntryKind.ToolResult, + ConversationEntryVisibility.Internal, + { toolCallId: "call-1", output: { ok: true } }, + "2026-06-17T09:30:00.000Z", + ), + ], + ) + const app = buildTestApp("user-1") + + const res = await app.request(`/api/conversations/${ConversationId}/entries`) + + expect(res.status).toBe(200) + const body = (await res.json()) as { entries: unknown[] } + expect(body).toEqual({ entries: [] }) + }) + + test("returns 404 for malformed conversation ids without querying storage", async () => { + const app = buildTestApp("user-1") + + const res = await app.request("/api/conversations/missing-conversation/entries") + + expect(res.status).toBe(404) + expect(listEntriesCalls).toEqual([]) + const body = (await res.json()) as { error: string } + expect(body).toEqual({ error: "Conversation not found" }) + }) + + test("returns 404 when the conversation does not exist for the user", async () => { + const app = buildTestApp("user-1") + + const res = await app.request(`/api/conversations/${MissingConversationId}/entries`) + + expect(res.status).toBe(404) + expect(listEntriesCalls).toEqual([ + { + userId: MockUserId, + conversationId: MissingConversationId, + params: { visibility: ConversationEntryVisibility.UserVisible }, + }, + ]) + const body = (await res.json()) as { error: string } + expect(body).toEqual({ error: "Conversation not found" }) + }) +}) diff --git a/apps/freya-backend/src/conversations/http.ts b/apps/freya-backend/src/conversations/http.ts index e036307..cd534ee 100644 --- a/apps/freya-backend/src/conversations/http.ts +++ b/apps/freya-backend/src/conversations/http.ts @@ -1,11 +1,15 @@ import type { Context, Hono } from "hono" +import { type } from "arktype" import { createMiddleware } from "hono/factory" import type { AuthSessionMiddleware } from "../auth/session-middleware.ts" import type { Database } from "../db/index.ts" +import type { ConversationRow } from "./storage.ts" +import { ConversationNotFoundError } from "./errors.ts" import { conversations } from "./storage.ts" +import { ConversationEntryVisibility } from "./types.ts" type Env = { Variables: { @@ -13,11 +17,19 @@ type Env = { } } +interface ConversationSummaryResponse { + id: string + createdAt: string + updatedAt: string +} + interface ConversationsHttpHandlersDeps { db: Database authSessionMiddleware: AuthSessionMiddleware } +const ConversationIdParam = type("string.uuid") + export function registerConversationsHttpHandlers( app: Hono, { db, authSessionMiddleware }: ConversationsHttpHandlersDeps, @@ -28,6 +40,7 @@ export function registerConversationsHttpHandlers( }) app.get("/api/conversations", inject, authSessionMiddleware, handleListConversations) + app.get("/api/conversations/:id/entries", inject, authSessionMiddleware, handleListEntries) } async function handleListConversations(c: Context) { @@ -35,10 +48,54 @@ async function handleListConversations(c: Context) { const db = c.get("db") return c.json({ - conversations: (await conversations(db, user.id).listConversations()).map((row) => ({ - id: row.id, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - })), + conversations: (await conversations(db, user.id).listConversations()).map( + serializeConversation, + ), }) } + +async function handleListEntries(c: Context) { + const user = c.get("user")! + const db = c.get("db") + const conversationId = c.req.param("id") + if (!conversationId) { + return c.json({ error: "Conversation not found" }, 404) + } + const parsedConversationId = ConversationIdParam(conversationId) + if (parsedConversationId instanceof type.errors) { + return c.json({ error: "Conversation not found" }, 404) + } + + try { + const entries = await conversations(db, user.id).listEntries(parsedConversationId, { + visibility: ConversationEntryVisibility.UserVisible, + }) + + return c.json({ + entries: entries.map((row) => ({ + id: row.id, + conversationId: row.conversationId, + sequence: row.sequence, + kind: row.kind, + visibility: row.visibility, + fileId: row.fileId, + payload: row.payload, + metadata: row.metadata, + createdAt: row.createdAt.toISOString(), + })), + }) + } catch (err) { + if (err instanceof ConversationNotFoundError) { + return c.json({ error: "Conversation not found" }, 404) + } + throw err + } +} + +function serializeConversation(row: ConversationRow): ConversationSummaryResponse { + return { + id: row.id, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} diff --git a/apps/freya-backend/src/conversations/storage.ts b/apps/freya-backend/src/conversations/storage.ts index 5b0b831..0480107 100644 --- a/apps/freya-backend/src/conversations/storage.ts +++ b/apps/freya-backend/src/conversations/storage.ts @@ -19,6 +19,7 @@ import { files, user, } from "../db/schema.ts" +import { ConversationNotFoundError } from "./errors.ts" import { ConversationEntryMetadata as ConversationEntryMetadataSchema, AssistantMessagePayload as AssistantMessagePayloadSchema, @@ -96,7 +97,7 @@ export interface ListConversationEntriesParams { } export function conversations(db: Database, userId: string) { - return { + const storage = { async createConversation(): Promise { return insertConversation(db, userId) }, @@ -109,6 +110,18 @@ export function conversations(db: Database, userId: string) { .orderBy(desc(conversationsTable.updatedAt), desc(conversationsTable.createdAt)) }, + async getConversation(conversationId: string): Promise { + const rows = await db + .select() + .from(conversationsTable) + .where( + and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)), + ) + .limit(1) + + return rows[0] ?? null + }, + async getOrCreateConversation(): Promise { return db.transaction(async (tx) => { await requireUserForUpdate(tx, userId) @@ -141,7 +154,9 @@ export function conversations(db: Database, userId: string) { } const rows = await db.transaction(async (tx) => { - await requireConversationForUpdate(tx, userId, conversationId) + if (!(await findConversationForUpdate(tx, userId, conversationId))) { + throw new ConversationNotFoundError(conversationId, userId) + } const sequence = await nextSequence(tx, conversationId) const rows = await tx @@ -175,7 +190,9 @@ export function conversations(db: Database, userId: string) { const metadata = ConversationEntryMetadataSchema.assert(input.metadata ?? {}) return db.transaction(async (tx) => { - await requireConversationForUpdate(tx, userId, conversationId) + if (!(await findConversationForUpdate(tx, userId, conversationId))) { + throw new ConversationNotFoundError(conversationId, userId) + } const file = await insertFile(tx, userId, input.file) const sequence = await nextSequence(tx, conversationId) @@ -204,7 +221,9 @@ export function conversations(db: Database, userId: string) { conversationId: string, params: ListConversationEntriesParams = {}, ): Promise { - await requireConversation(db, userId, conversationId) + if (!(await storage.getConversation(conversationId))) { + throw new ConversationNotFoundError(conversationId, userId) + } if (params.visibility) { return db @@ -226,6 +245,8 @@ export function conversations(db: Database, userId: string) { .orderBy(asc(conversationEntries.sequence)) }, } + + return storage } function payloadForKind( @@ -259,25 +280,11 @@ async function requireUserForUpdate(db: Database, userId: string): Promise requireRow(rows, `User not found: ${userId}`) } -async function requireConversation( +async function findConversationForUpdate( db: Database, userId: string, conversationId: string, -): Promise { - const rows = await db - .select() - .from(conversationsTable) - .where(and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId))) - .limit(1) - - return requireRow(rows, `Conversation not found: ${conversationId}`) -} - -async function requireConversationForUpdate( - db: Database, - userId: string, - conversationId: string, -): Promise { +): Promise { const rows = await db .select() .from(conversationsTable) @@ -285,7 +292,7 @@ async function requireConversationForUpdate( .limit(1) .for("update") - return requireRow(rows, `Conversation not found: ${conversationId}`) + return rows[0] ?? null } async function latestConversation(db: Database, userId: string): Promise { diff --git a/apps/freya-backend/src/server.ts b/apps/freya-backend/src/server.ts index 20fe884..34ab18d 100644 --- a/apps/freya-backend/src/server.ts +++ b/apps/freya-backend/src/server.ts @@ -11,6 +11,7 @@ import { registerAuthHandlers } from "./auth/http.ts" import { createAuth } from "./auth/index.ts" import { createRequireSession } from "./auth/session-middleware.ts" import { CalDavSourceProvider } from "./caldav/provider.ts" +import { registerConversationsHttpHandlers } from "./conversations/http.ts" import { createDatabase } from "./db/index.ts" import { registerFeedHttpHandlers } from "./engine/http.ts" import { createFeedEnhancer } from "./enhancement/enhance-feed.ts" @@ -129,6 +130,7 @@ function main() { sessionManager, authSessionMiddleware, }) + registerConversationsHttpHandlers(app, { db, authSessionMiddleware }) if (isDebugMode) { registerDebugAgentHttpHandlers(app, { authSessionMiddleware,