221 lines
7.5 KiB
TypeScript
221 lines
7.5 KiB
TypeScript
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<ConversationMessageView> => {
|
|
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<ConversationMessageView> => {
|
|
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<ConversationMessageView> => {
|
|
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<ConversationMessageView | null> => {
|
|
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<ConversationMessageView[]> => {
|
|
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);
|
|
},
|
|
});
|