Files
Jyotisha/frontend/src/lib/rectification-in-chat-server.ts
T

166 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}