Files
Jyotisha/frontend/src/lib/rectification-in-chat-server.ts
T
jesse-uxandGrok (agent loop) 4a9ad6a1bd fix(rectification): open a new case when an accepted one blocks chat
An accepted rectification no longer counts as in progress at the chat entry, so an explicit request starts a new case and the offer card can return. An unfinished case still resumes through the case RPC. A failed lookup is logged without personal data and opens a new case.

Self-test: tsc 0, lint 0 errors, 47 targeted tests, test:db failures match the Windows list, next build with / static.

Co-Authored-By: Grok (agent loop)
2026-10-23 01:55:59 +08:00

172 lines
6.6 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 { DataClient } from "@/lib/db/data-client";
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,
blocksNewRectification,
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: DataClient;
userId: string;
subjectRole: "self" | "other";
modelId: string;
}): Promise<RectificationOfferDecision> {
return decideRectificationOfferWith({
subjectRole: input.subjectRole,
productEnabled: () => isProductEnabled("rectification"),
hasResumableCase: async () => {
const summary = await getRectificationEntrySummary(input.accounting, input.userId);
return blocksNewRectification(summary.latestResumable?.status ?? "");
},
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: DataClient, 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: DataClient, 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 offer on that message.
* The record is not opened yet (`offered` + `autoOpen`); bindOfferToCase sets
* opened after the case actually opens. 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: DataClient;
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: DataClient;
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: DataClient,
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: DataClient;
accounting: DataClient;
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 };
}