fix(consult): mark rectification opened only after the case opens (BUG-1306)

This commit is contained in:
jesse-ux
2026-10-12 15:36:36 +08:00
committed by Jesse_Chen
parent 798e15effe
commit 805932addd
14 changed files with 228 additions and 27 deletions
@@ -26,6 +26,8 @@ export function RectificationOfferCard({
onStart,
}: Readonly<{ offer: RectificationOfferRecord; disabled: boolean; onStart: () => void }>) {
if (offer.status === "returned") return null;
// 「已经开始」 / 继续核对 only after the case is bound. autoOpen still shows
// the ordinary offer and 「开始核对出生时间」 until status is opened.
const opened = offer.status === "opened";
return (
<div className="rectification-offer-card" role="group" aria-label="核对出生时间">
+8 -2
View File
@@ -156,7 +156,7 @@ export type ConsultationRunParams = {
setOnboardingPaywallOpen?: (open: boolean) => void;
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
/** Direct entry from this consultation (BUG-1306). Same opener as the offer card. */
openRectificationFromChat: (sourceSessionId: string) => Promise<void> | void;
openRectificationFromChat: (sourceSessionId: string) => Promise<unknown> | void;
};
export function useConsultationRun(params: ConsultationRunParams) {
@@ -1077,7 +1077,13 @@ export function useConsultationRun(params: ConsultationRunParams) {
if (rectificationDirectOpen?.mode === "resume" && rectificationDirectOpen.resumeSessionId) {
void openRectificationSession(rectificationDirectOpen.resumeSessionId);
} else if (rectificationDirectOpen?.mode === "new") {
void openRectificationFromChat(rectificationDirectOpen.sourceSessionId);
// Stored offer stays offered. bindOfferToCase marks opened only after
// the case opens. A failed open must not mark it opened here.
void Promise.resolve(openRectificationFromChat(rectificationDirectOpen.sourceSessionId)).then((opened) => {
if (opened) return;
}, () => {
// The opener threw. Leave the stored offer offered.
});
}
try {
await persistSession(completedSession);
@@ -369,8 +369,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
/** C4: the offer card under a consultation answer starts (or resumes) the case. */
async function openRectificationFromChat(sourceSessionId: string) {
if (rectificationLimitedToSelf()) return;
await openRectificationCase("homepage", null, sourceSessionId);
if (rectificationLimitedToSelf()) return null;
return openRectificationCase("homepage", null, sourceSessionId);
}
async function openRectificationSession(exactSessionId: string) {
@@ -64,9 +64,11 @@ async function writeMessages(client: SupabaseClient, userId: string, sessionId:
}
/**
* 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.
* 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: SupabaseClient;
+11 -3
View File
@@ -28,6 +28,12 @@ export const rectificationOfferRecordSchema = z.object({
offeredAt: z.string().min(1).max(40),
caseId: z.string().uuid().optional(),
rectificationSessionId: z.string().uuid().optional(),
/**
* Present only when this turn should open the case without a click.
* Writers omit it unless it is true. Older stored offers omit it too.
* The card stays an ordinary offer until status is opened (BUG-1306 F3).
*/
autoOpen: z.boolean().optional(),
}).strict();
export type RectificationOfferRecord = z.infer<typeof rectificationOfferRecordSchema>;
@@ -149,8 +155,9 @@ export function bindOfferToCase(
for (let index = stored.length - 1; index >= 0; index -= 1) {
const offer = parseRectificationOfferRecord(stored[index]!.rectificationOffer);
if (!offer || offer.caseId) continue;
// A direct open records `opened` before the case id exists (BUG-1306).
// An offer already bound to another case is left alone.
// Offered cards, including a direct open that is still offered (autoOpen).
// An older direct-open row stored as opened before it had a case id still binds.
// Returned rows, and a card already bound to another case, are left alone.
if (offer.status !== "offered" && offer.status !== "opened") continue;
const next = [...stored];
next[index] = {
@@ -164,7 +171,8 @@ export function bindOfferToCase(
/**
* After `complete_consultation_free` appends the one-sentence reply, hang the
* opened offer on that message so a reload can still find the source. Returns
* offer on that message so a reload can still find the source. The record is
* not opened yet (still `offered`, with `autoOpen` on a direct open). Returns
* the next array, "present" when that message already has an offer (do not
* overwrite a later case id), or "missing" when the free reply is not there yet.
*/
+7 -3
View File
@@ -1073,6 +1073,7 @@ function offerRecord(
hook: RectificationOfferHook,
decision: Extract<RectificationOfferDecision, { ok: true }>,
status: RectificationOfferRecord["status"],
autoOpen = false,
): RectificationOfferRecord {
return {
status,
@@ -1080,13 +1081,16 @@ function offerRecord(
questionEstimate: decision.questionEstimate,
sourceQuestion: hook.revisitQuestion,
offeredAt: (hook.now?.() ?? new Date()).toISOString(),
// Only when true. Ordinary offers and refusals omit the key.
...(autoOpen ? { autoOpen: true } : {}),
};
}
/**
* Explicit request (BUG-1306): the server already decided. A pass records an
* opened offer with no case id yet and tells the stream to open. A case
* already in progress resumes that case. Anything else is one sentence.
* offered card with autoOpen and tells the stream to open. Status stays
* offered until the case is bound. A case already in progress resumes that
* case. Anything else is one sentence and writes no offer.
*/
async function finishExplicitRectificationRequest(
state: ConsultationRuntimeState,
@@ -1094,7 +1098,7 @@ async function finishExplicitRectificationRequest(
decision: RectificationOfferDecision,
) {
if (decision.ok) {
state.rectificationOffer = offerRecord(hook, decision, "opened");
state.rectificationOffer = offerRecord(hook, decision, "offered", true);
state.rectificationDirectEntry = {
kind: "new",
sentence: RECTIFICATION_DIRECT_NEW_COPY,