import "server-only"; import type { SupabaseClient } from "@supabase/supabase-js"; import { resolveFeaturePricing } from "@/lib/feature-pricing"; import { isProductEnabled } from "@/lib/product-access"; import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service"; import { loadV9CaseDossier } from "@/lib/rectification-agentic/v9/tool-service"; import { appendRectificationReturn, attachOpenedOfferToFreeMessage, bindOfferToCase, decideRectificationOfferWith, offerForCase, sourceFromSessionRows, type RectificationOfferDecision, type RectificationOfferRecord, type RectificationReturnRecord, type RectificationSource, } from "@/lib/rectification-in-chat"; /** * Server side of rectification reached from a consultation (BUG-1247~1251). * Reads and writes the consultation session's messages with the caller's own * client (owner RLS; `messages` is an owner-updatable column), never the * admin client. */ /** C3: whether the offer card may be shown, decided by the server and never by the model. */ export async function decideRectificationOffer(input: { accounting: SupabaseClient; userId: string; subjectRole: "self" | "other"; modelId: string; }): Promise { return decideRectificationOfferWith({ subjectRole: input.subjectRole, productEnabled: () => isProductEnabled("rectification"), hasResumableCase: async () => (await getRectificationEntrySummary(input.accounting, input.userId)).hasResumableCase, creditCost: async () => (await resolveFeaturePricing(input.accounting, "rectification", input.modelId)).credit_cost, }); } type SessionRow = { id: string; messages: unknown; session_type?: string | null }; async function readConsultationSession(client: SupabaseClient, userId: string, sessionId: string): Promise { const { data, error } = await client .from("chat_sessions") .select("id,messages,session_type") .eq("id", sessionId) .eq("user_id", userId) .maybeSingle(); if (error || !data) return null; const row = data as SessionRow; return (row.session_type ?? "consultation") === "consultation" ? row : null; } async function writeMessages(client: SupabaseClient, userId: string, sessionId: string, messages: unknown[]): Promise { const { error } = await client .from("chat_sessions") .update({ messages }) .eq("id", sessionId) .eq("user_id", userId); return !error; } /** * After a free direct-entry reply is appended, hang the opened offer on that * message. Idempotent: an offer already on the message (including one a later * bind gave a case id) is left as it is. Does not touch consultation_requests. */ export async function persistOpenedDirectOffer(input: { client: SupabaseClient; userId: string; sessionId: string; sentence: string; offer: RectificationOfferRecord; }): Promise { const { data, error } = await input.client .from("chat_sessions") .select("messages") .eq("id", input.sessionId) .eq("user_id", input.userId) .maybeSingle(); if (error || !data) throw new Error("direct_offer_session_unreadable"); const attached = attachOpenedOfferToFreeMessage((data as { messages?: unknown }).messages, { sentence: input.sentence, offer: input.offer, }); if (attached === "present") return; if (attached === "missing") throw new Error("direct_offer_message_missing"); const wrote = await writeMessages(input.client, input.userId, input.sessionId, attached); if (!wrote) throw new Error("direct_offer_write_failed"); } /** C4: after a case opens from a consultation, bind the offer card to it. Best effort. */ export async function bindRectificationSource(input: { client: SupabaseClient; userId: string; sourceSessionId: string; caseId: string; rectificationSessionId: string; }): Promise { const row = await readConsultationSession(input.client, input.userId, input.sourceSessionId); if (!row) return false; const next = bindOfferToCase(row.messages, { caseId: input.caseId, rectificationSessionId: input.rectificationSessionId }); if (!next) return Boolean(offerForCase(row.messages, input.caseId)); return writeMessages(input.client, input.userId, row.id, next); } /** How many recent consultations are searched for the session that started a case. */ const SOURCE_SEARCH_LIMIT = 20; /** * The consultation that started this case. Binding writes that session, so it * is among the most recently updated ones; a case started from the homepage or * from history has none. */ export async function findRectificationSource( client: SupabaseClient, userId: string, caseId: string, ): Promise { const { data, error } = await client .from("chat_sessions") .select("id,messages,session_type") .eq("user_id", userId) .eq("session_type", "consultation") .order("updated_at", { ascending: false }) .limit(SOURCE_SEARCH_LIMIT); if (error || !Array.isArray(data)) return null; return sourceFromSessionRows(data as SessionRow[], caseId); } /** * C5/C6: going back appends one divider to the consultation (idempotent) and * marks the offer returned. Whether the time changed is read from the case, * never from the browser. */ export async function returnToRectificationSource(input: { client: SupabaseClient; accounting: SupabaseClient; userId: string; caseId: string; now?: () => Date; }): Promise<(RectificationSource & { record: RectificationReturnRecord }) | null> { const source = await findRectificationSource(input.client, input.userId, input.caseId); if (!source) return null; if (source.returned) return { ...source, record: source.returned }; const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); const accepted = dossier.case.acceptedTime?.slice(0, 5) ?? null; const row = await readConsultationSession(input.client, input.userId, source.sessionId); if (!row) return null; const appended = appendRectificationReturn(row.messages, { caseId: input.caseId, adopted: Boolean(accepted), clock: accepted, returnedAt: (input.now?.() ?? new Date()).toISOString(), }); if (!appended) { const again = await findRectificationSource(input.client, input.userId, input.caseId); return again?.returned ? { ...again, record: again.returned } : null; } if (!await writeMessages(input.client, input.userId, row.id, appended.messages)) return null; return { ...source, returned: appended.record, record: appended.record }; }