fix: block unconfirmed birth-time candidate adoption
This commit is contained in:
@@ -1,9 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { candidateWorkingTime } from "@/lib/birth-time-candidate-completion";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -13,57 +9,12 @@ const requestSchema = z.object({
|
||||
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||
}).strict();
|
||||
|
||||
/** Compatibility endpoint for stale clients; unconfirmed candidates never write profiles. */
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "候选时间格式不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data: stored, error: caseError } = await admin
|
||||
.from("birth_time_rectification_cases")
|
||||
.select("id,user_id,status,candidate_result_id,candidate_result,turn_state")
|
||||
.eq("id", parsed.data.caseId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
const time = candidateWorkingTime(stored, { ...parsed.data, userId: user.id });
|
||||
if (caseError || !time) {
|
||||
return NextResponse.json(
|
||||
{ error: "候选结果已变化", message: "请使用当前评估结果继续。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await admin
|
||||
.from("profiles")
|
||||
.update({
|
||||
active_birth_time: time,
|
||||
birth_time_status: "candidate",
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", user.id)
|
||||
.eq("rectification_case_id", parsed.data.caseId)
|
||||
.select("id")
|
||||
.maybeSingle();
|
||||
if (profileError || !profile) {
|
||||
return NextResponse.json(
|
||||
{ error: "候选时间暂时无法保存", message: "当前评估结果仍已保留,请稍后重试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, activeTime: time, birthTimeStatus: "candidate" });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: "候选时间暂时无法保存" }, { status: 500 });
|
||||
}
|
||||
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "候选时间格式不正确" }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "候选时间不能直接采用", message: "候选范围已保留;请补充资料,或在高置信结果出现后通过正式确认继续。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,20 +121,10 @@ function TerminalAction({ controller, error, path }: {
|
||||
readonly error: string;
|
||||
readonly path: NonNullable<ReturnType<typeof guidedTerminalPath>>;
|
||||
}) {
|
||||
if (path.kind === "complete_with_candidate") {
|
||||
return (
|
||||
<div className="birth-time-next-step">
|
||||
<b>评估已完成,下一步</b>
|
||||
<p>点击后将使用 {path.time} 作为<span className="phrase-nowrap">当前排盘时间</span>并进入对话;<span className="phrase-nowrap">原始填报</span>和本次<span className="phrase-nowrap">候选结果</span><span className="phrase-nowrap">仍会保留</span>。</p>
|
||||
<button className="button-primary birth-time-guided-action" disabled={controller.pending} onClick={() => controller.completeCandidate(path.time)} type="button">
|
||||
{controller.pending ? `正在采用 ${path.time}…` : `采用 ${path.time} 并进入对话`}
|
||||
</button>
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="birth-time-new-assessment">
|
||||
<b>尚未达到采用条件</b>
|
||||
<p>候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。</p>
|
||||
<button className="button-secondary birth-time-guided-action" disabled={controller.pending} onClick={controller.editBirthTimeDetails} type="button">开始新的评估</button>
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
<small>会建立新的记录,当前结果仍会保留。</small>
|
||||
|
||||
@@ -5,37 +5,10 @@ type CandidateCompletionRequest = {
|
||||
readonly time: string;
|
||||
};
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object"
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Direct adoption was superseded by versioned high-confidence confirmation. */
|
||||
export function candidateWorkingTime(
|
||||
stored: unknown,
|
||||
request: CandidateCompletionRequest,
|
||||
_stored: unknown,
|
||||
_request: CandidateCompletionRequest,
|
||||
): string | null {
|
||||
const assessment = record(stored);
|
||||
const candidate = record(assessment?.candidate_result);
|
||||
const winner = record(candidate?.winningSegment);
|
||||
const turn = record(assessment?.turn_state);
|
||||
const action = record(turn?.nextAction);
|
||||
const actionKind = action?.kind;
|
||||
const terminal = actionKind === "present_low_result"
|
||||
|| actionKind === "present_medium_result"
|
||||
|| actionKind === "candidate_saved";
|
||||
const terminalStatusMatches = actionKind === "present_low_result"
|
||||
? assessment?.status === "rectifying"
|
||||
: (actionKind === "present_medium_result" || actionKind === "candidate_saved")
|
||||
&& assessment?.status === "candidate";
|
||||
|
||||
return assessment?.id === request.caseId
|
||||
&& assessment?.user_id === request.userId
|
||||
&& terminalStatusMatches
|
||||
&& assessment.candidate_result_id === request.resultId
|
||||
&& action?.resultId === request.resultId
|
||||
&& terminal
|
||||
&& winner?.representativeTime === request.time
|
||||
? request.time
|
||||
: null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,14 @@
|
||||
import type { JourneyClientResponse } from "./birth-time-journey-response-schema.ts";
|
||||
|
||||
export type GuidedTerminalPath =
|
||||
| {
|
||||
readonly kind: "edit_birth_time_details";
|
||||
readonly preservesCase: true;
|
||||
readonly appliesCandidateTime: false;
|
||||
}
|
||||
| {
|
||||
readonly kind: "complete_with_candidate";
|
||||
readonly time: string;
|
||||
readonly preservesCase: true;
|
||||
readonly appliesCandidateTime: true;
|
||||
};
|
||||
export type GuidedTerminalPath = {
|
||||
readonly kind: "edit_birth_time_details";
|
||||
readonly preservesCase: true;
|
||||
readonly appliesCandidateTime: false;
|
||||
};
|
||||
|
||||
export function guidedTerminalPath(journey: JourneyClientResponse): GuidedTerminalPath | null {
|
||||
const kind = journey.nextAction.kind;
|
||||
const winner = journey.candidateResult?.winningSegment;
|
||||
if (journey.journeyProtocol === "dynamic-choice-v2"
|
||||
&& winner
|
||||
&& (kind === "present_low_result" || kind === "present_medium_result" || kind === "candidate_saved")) {
|
||||
return {
|
||||
kind: "complete_with_candidate",
|
||||
time: winner.representativeTime,
|
||||
preservesCase: true,
|
||||
appliesCandidateTime: true,
|
||||
};
|
||||
}
|
||||
return kind === "present_low_result" || kind === "candidate_saved"
|
||||
return kind === "present_low_result" || kind === "present_medium_result" || kind === "candidate_saved"
|
||||
? { kind: "edit_birth_time_details", preservesCase: true, appliesCandidateTime: false }
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -2,204 +2,11 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { candidateWorkingTime } from "../src/lib/birth-time-candidate-completion.ts";
|
||||
|
||||
const terminalCase = {
|
||||
id: "5425f9e7-3d45-491d-aab3-24cfd4261d51",
|
||||
user_id: "07e583fc-90b9-4fcb-a9d3-8de654eeac9a",
|
||||
status: "candidate",
|
||||
candidate_result_id: "d9133ba2-afcf-56da-b40b-ace3d7124a7d",
|
||||
candidate_result: {
|
||||
confidence: "medium",
|
||||
winningSegment: { representativeTime: "04:53" },
|
||||
},
|
||||
turn_state: {
|
||||
nextAction: {
|
||||
kind: "present_medium_result",
|
||||
resultId: "d9133ba2-afcf-56da-b40b-ace3d7124a7d",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const lowTerminalCase = {
|
||||
...terminalCase,
|
||||
status: "rectifying",
|
||||
candidate_result: {
|
||||
...terminalCase.candidate_result,
|
||||
confidence: "low",
|
||||
},
|
||||
turn_state: {
|
||||
...terminalCase.turn_state,
|
||||
nextAction: {
|
||||
kind: "present_low_result",
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const completionRequest = {
|
||||
userId: terminalCase.user_id,
|
||||
caseId: terminalCase.id,
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
time: "04:53",
|
||||
} as const;
|
||||
|
||||
test("candidate completion only accepts the persisted terminal representative time", () => {
|
||||
assert.equal(candidateWorkingTime(terminalCase, {
|
||||
...completionRequest,
|
||||
}), "04:53");
|
||||
|
||||
assert.equal(candidateWorkingTime(terminalCase, {
|
||||
...completionRequest,
|
||||
time: "04:54",
|
||||
test("unconfirmed candidate results cannot directly become the active consultation time", () => {
|
||||
assert.equal(candidateWorkingTime({}, {
|
||||
userId: "07e583fc-90b9-4fcb-a9d3-8de654eeac9a",
|
||||
caseId: "5425f9e7-3d45-491d-aab3-24cfd4261d51",
|
||||
resultId: "d9133ba2-afcf-56da-b40b-ace3d7124a7d",
|
||||
time: "04:53",
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("accepts a matching low-confidence result from the rectifying state", () => {
|
||||
assert.equal(candidateWorkingTime(lowTerminalCase, {
|
||||
...completionRequest,
|
||||
}), "04:53");
|
||||
});
|
||||
|
||||
test("accepts a persisted candidate-saved compatibility action", () => {
|
||||
assert.equal(candidateWorkingTime({
|
||||
...terminalCase,
|
||||
turn_state: {
|
||||
nextAction: {
|
||||
kind: "candidate_saved",
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
},
|
||||
},
|
||||
}, completionRequest), "04:53");
|
||||
});
|
||||
|
||||
test("does not accept a medium terminal action from the rectifying state", () => {
|
||||
assert.equal(candidateWorkingTime({
|
||||
...lowTerminalCase,
|
||||
turn_state: {
|
||||
...lowTerminalCase.turn_state,
|
||||
nextAction: {
|
||||
kind: "present_medium_result",
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
...completionRequest,
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("non-terminal cases cannot be adopted for consultation", () => {
|
||||
assert.equal(candidateWorkingTime({
|
||||
...terminalCase,
|
||||
turn_state: { nextAction: { kind: "ask_dynamic_choice" } },
|
||||
}, {
|
||||
...completionRequest,
|
||||
}), null);
|
||||
});
|
||||
|
||||
const rejectedCompletions = [
|
||||
{
|
||||
name: "case owned by another user",
|
||||
stored: { ...terminalCase, user_id: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" },
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "request from another user",
|
||||
stored: terminalCase,
|
||||
request: { ...completionRequest, userId: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" },
|
||||
},
|
||||
{
|
||||
name: "missing case owner",
|
||||
stored: { ...terminalCase, user_id: null },
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "empty case owner",
|
||||
stored: { ...terminalCase, user_id: "" },
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "wrong case ID",
|
||||
stored: terminalCase,
|
||||
request: { ...completionRequest, caseId: "c84052ca-bcea-40a8-a32a-56980bbf7b22" },
|
||||
},
|
||||
{
|
||||
name: "wrong persisted result ID",
|
||||
stored: { ...terminalCase, candidate_result_id: "a3e41512-9fa0-4866-a187-e3b3aa07aee0" },
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "wrong action result ID",
|
||||
stored: {
|
||||
...terminalCase,
|
||||
turn_state: {
|
||||
nextAction: {
|
||||
...terminalCase.turn_state.nextAction,
|
||||
resultId: "a3e41512-9fa0-4866-a187-e3b3aa07aee0",
|
||||
},
|
||||
},
|
||||
},
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "wrong requested result ID",
|
||||
stored: terminalCase,
|
||||
request: { ...completionRequest, resultId: "a3e41512-9fa0-4866-a187-e3b3aa07aee0" },
|
||||
},
|
||||
{
|
||||
name: "missing winning segment",
|
||||
stored: { ...terminalCase, candidate_result: { confidence: "medium" } },
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "missing representative time",
|
||||
stored: {
|
||||
...terminalCase,
|
||||
candidate_result: { confidence: "medium", winningSegment: {} },
|
||||
},
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "low-result action paired with candidate status",
|
||||
stored: {
|
||||
...terminalCase,
|
||||
turn_state: {
|
||||
nextAction: {
|
||||
kind: "present_low_result",
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "medium-result action paired with rectifying status",
|
||||
stored: {
|
||||
...lowTerminalCase,
|
||||
turn_state: {
|
||||
nextAction: {
|
||||
kind: "present_medium_result",
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "candidate-saved action paired with rectifying status",
|
||||
stored: {
|
||||
...lowTerminalCase,
|
||||
turn_state: {
|
||||
nextAction: {
|
||||
kind: "candidate_saved",
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
request: completionRequest,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const scenario of rejectedCompletions) {
|
||||
test(`rejects candidate completion with ${scenario.name}`, () => {
|
||||
assert.equal(candidateWorkingTime(scenario.stored, scenario.request), null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,14 +56,13 @@ test("low without a result and saved medium both return to declared-time editing
|
||||
});
|
||||
});
|
||||
|
||||
test("dynamic medium terminal completes with its candidate working time", () => {
|
||||
test("dynamic medium terminal preserves its candidate range without direct adoption", () => {
|
||||
const medium = dynamicBirthTimePreview("medium");
|
||||
|
||||
assert.deepEqual(guidedTerminalPath(medium), {
|
||||
kind: "complete_with_candidate",
|
||||
time: "05:43",
|
||||
kind: "edit_birth_time_details",
|
||||
preservesCase: true,
|
||||
appliesCandidateTime: true,
|
||||
appliesCandidateTime: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,31 +120,29 @@ test("ready completion is explicit and terminal low has no finish mutation", ()
|
||||
assert.doesNotMatch(candidateSource, /controller\.finish/);
|
||||
});
|
||||
|
||||
test("terminal candidate owns one explicit next step and its completion error", () => {
|
||||
test("unconfirmed terminal candidates preserve the range without offering direct adoption", () => {
|
||||
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
|
||||
const choiceQuestionSource = readFileSync(new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), "utf8");
|
||||
const rectificationSource = readFileSync(new URL("../src/components/birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
const legacyRectificationSource = readFileSync(new URL("../src/components/birth-time-legacy-rectification.tsx", import.meta.url), "utf8");
|
||||
const globalCssSource = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
assert.match(candidateResultSource, /评估已完成,下一步/);
|
||||
assert.match(candidateResultSource, /采用 \$\{path\.time\} 并进入对话/);
|
||||
assert.match(candidateResultSource, /正在采用 \$\{path\.time\}…/);
|
||||
assert.match(candidateResultSource, /birth-time-next-step/);
|
||||
assert.match(candidateResultSource, /尚未达到采用条件/);
|
||||
assert.match(candidateResultSource, /补充资料并重新评估/);
|
||||
assert.doesNotMatch(candidateResultSource, /采用 \$\{path\.time\} 并进入对话/);
|
||||
assert.doesNotMatch(candidateResultSource, /birth-time-next-step/);
|
||||
assert.match(rectificationSource, /error=\{error\}/);
|
||||
assert.match(rectificationSource, /const childOwnsError = action\.kind === "ask_dynamic_choice"\s*\|\| action\.kind === "clarify_unmatched_answer"/);
|
||||
assert.match(rectificationSource, /error && !showsCandidate && !childOwnsError/);
|
||||
assert.equal(choiceQuestionSource.match(/role="alert"/g)?.length, 1);
|
||||
assert.match(legacyRectificationSource, /error=\{error\}/);
|
||||
assert.match(legacyRectificationSource, /error && !showsCandidate/);
|
||||
assert.match(globalCssSource, /\.birth-time-next-step/);
|
||||
});
|
||||
|
||||
test("terminal and entrypoint CJK phrases stay intact at narrow widths", () => {
|
||||
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(candidateResultSource, /作为<span className="phrase-nowrap">当前排盘时间<\/span>并进入对话;<span className="phrase-nowrap">原始填报<\/span>和本次<span className="phrase-nowrap">候选结果<\/span><span className="phrase-nowrap">仍会保留<\/span>。/);
|
||||
assert.match(candidateResultSource, /候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。/);
|
||||
assert.match(pageSource, /当前使用候选时间排盘;<span className="phrase-nowrap">原始填报范围<\/span>仍保留。/);
|
||||
});
|
||||
|
||||
|
||||
@@ -75,9 +75,8 @@ test("low-confidence preview mirrors the persisted dynamic terminal state", () =
|
||||
assert.equal(low.candidateResult.winningSegment?.representativeTime, "05:21");
|
||||
assert.equal(low.nextAction.resultId, low.candidateResult.resultId);
|
||||
assert.deepEqual(guidedTerminalPath(low), {
|
||||
kind: "complete_with_candidate",
|
||||
time: low.candidateResult.winningSegment?.representativeTime,
|
||||
kind: "edit_birth_time_details",
|
||||
preservesCase: true,
|
||||
appliesCandidateTime: true,
|
||||
appliesCandidateTime: false,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user