166 lines
6.4 KiB
TypeScript
166 lines
6.4 KiB
TypeScript
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<RectificationOfferDecision> {
|
||
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<SessionRow | null> {
|
||
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<boolean> {
|
||
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<void> {
|
||
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<boolean> {
|
||
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<RectificationSource | null> {
|
||
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 };
|
||
}
|