import { ConvexError, v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import { mutation, query } from "./_generated/server"; import { requireOrganizationMember } from "./authz"; export type ConversationMessageStatus = "admitting" | "admitted" | "failed"; interface ConversationMessageView { readonly _id: Id<"conversationMessages">; readonly _creationTime: number; readonly organizationId: Id<"organizations">; readonly conversationId: string; readonly messageId: string; readonly submissionId: string | null; readonly clientRequestId: string; readonly role: "user"; readonly rawText: string; readonly status: ConversationMessageStatus; readonly createdAt: number; } const toView = (doc: Doc<"conversationMessages">): ConversationMessageView => ({ _creationTime: doc._creationTime, _id: doc._id, clientRequestId: doc.clientRequestId, conversationId: doc.conversationId, createdAt: doc.createdAt, messageId: doc.messageId, organizationId: doc.organizationId, rawText: doc.rawText, role: doc.role, status: doc.status, submissionId: doc.submissionId ?? null, }); /** * The global Zopu conversation uses the organization ID as its conversation * identity (and agent instance ID). This resolves the conversation ID for the * authenticated user's current personal organization, so callers never supply * tenancy themselves. */ const resolveConversationId = (organizationId: Id<"organizations">): string => organizationId; /** * Idempotently begin a user message at the conversation ingress. If a row with * the same (organizationId, clientRequestId) already exists, return it instead * of creating a duplicate — Flue retries that replay the same request id must * not produce duplicate evidence. The raw text is stored verbatim; it is never * trimmed, collapsed, or rewritten. * * Authorization: the authenticated identity must be a member of the target * organization. The conversation ID, message ID, and timestamp are all * generated server-side; none are trusted from the caller. */ export const beginUserMessage = mutation({ args: { organizationId: v.id("organizations"), clientRequestId: v.string(), rawText: v.string(), }, handler: async (ctx, args): Promise => { await requireOrganizationMember(ctx, args.organizationId); const existing = await ctx.db .query("conversationMessages") .withIndex("by_organization_and_request", (q) => q .eq("organizationId", args.organizationId) .eq("clientRequestId", args.clientRequestId) ) .unique(); if (existing) { return toView(existing); } const createdAt = Date.now(); const messageId = crypto.randomUUID(); const conversationId = resolveConversationId(args.organizationId); const rowId = await ctx.db.insert("conversationMessages", { clientRequestId: args.clientRequestId, conversationId, createdAt, messageId, organizationId: args.organizationId, rawText: args.rawText, role: "user", status: "admitting", }); const created = await ctx.db.get(rowId); if (!created) { throw new Error("Failed to read created conversation message"); } return toView(created); }, }); /** * Mark a begun message as admitted after Flue accepts the submission. The * submission id links the normalized evidence to the Flue transcript. Only an * `admitting` message may transition to `admitted`; an already-admitted or * failed message is returned unchanged so a retry is idempotent. */ export const markAdmitted = mutation({ args: { organizationId: v.id("organizations"), clientRequestId: v.string(), submissionId: v.string(), }, handler: async (ctx, args): Promise => { await requireOrganizationMember(ctx, args.organizationId); const existing = await ctx.db .query("conversationMessages") .withIndex("by_organization_and_request", (q) => q .eq("organizationId", args.organizationId) .eq("clientRequestId", args.clientRequestId) ) .unique(); if (!existing) { throw new ConvexError("Conversation message not found"); } if (existing.status === "admitting") { await ctx.db.patch(existing._id, { status: "admitted", submissionId: args.submissionId, }); } const updated = await ctx.db.get(existing._id); if (!updated) { throw new Error("Failed to read updated conversation message"); } return toView(updated); }, }); /** * Mark a begun message as failed when Flue admission rejects it. Failed * messages remain as evidence with an explicit status; they are never silently * deleted. Only an `admitting` message may transition to `failed`; an * already-admitted message is never overwritten (admission won), and an * already-failed message is returned unchanged. */ export const markFailed = mutation({ args: { organizationId: v.id("organizations"), clientRequestId: v.string(), }, handler: async (ctx, args): Promise => { await requireOrganizationMember(ctx, args.organizationId); const existing = await ctx.db .query("conversationMessages") .withIndex("by_organization_and_request", (q) => q .eq("organizationId", args.organizationId) .eq("clientRequestId", args.clientRequestId) ) .unique(); if (!existing) { throw new ConvexError("Conversation message not found"); } if (existing.status === "admitting") { await ctx.db.patch(existing._id, { status: "failed" }); } const updated = await ctx.db.get(existing._id); if (!updated) { throw new Error("Failed to read updated conversation message"); } return toView(updated); }, }); /** * Read a single message by its client request id. Authorization requires * membership in the message's organization; cross-organization observation is * denied. Returns null when no message matches so observers cannot probe for * another organization's request ids. */ export const getByRequest = query({ args: { organizationId: v.id("organizations"), clientRequestId: v.string(), }, handler: async (ctx, args): Promise => { await requireOrganizationMember(ctx, args.organizationId); const existing = await ctx.db .query("conversationMessages") .withIndex("by_organization_and_request", (q) => q .eq("organizationId", args.organizationId) .eq("clientRequestId", args.clientRequestId) ) .unique(); return existing ? toView(existing) : null; }, }); /** * List the user messages for the authenticated user's current organization * conversation, newest first. This is the authorized observation path used by * GET/SSE routes and product clients. Cross-organization access is denied. */ export const listForCurrentOrganization = query({ args: { organizationId: v.id("organizations"), }, handler: async (ctx, args): Promise => { await requireOrganizationMember(ctx, args.organizationId); const conversationId = resolveConversationId(args.organizationId); const rows = await ctx.db .query("conversationMessages") .withIndex("by_organization_and_message", (q) => q .eq("organizationId", args.organizationId) .eq("conversationId", conversationId) ) .order("desc") .take(100); return rows.map(toView); }, });