From de9a687779f9979308ed8c369de17a147b349cb8 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 14:16:54 +0800 Subject: [PATCH] fix: advance imported rectification profile pointer --- .../app/api/birth-time-conversation/route.ts | 1 - .../legacy-import.ts | 3 - ...onversational_legacy_import_projection.sql | 13 +- .../conversational-legacy-import.test.ts | 15 ++ ...conversational-rectification-route.test.ts | 44 ++++ ...t_conversational_rectification_contract.py | 5 +- ...sational_rectification_postgres_runtime.py | 190 +++++++++++++++++- 7 files changed, 264 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index a71cdac1..751a5f33 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -219,7 +219,6 @@ export async function loadProductionConversationalRectificationProfile( const protocol = prior ? text(prior.journey_protocol) : null; const status = prior ? text(prior.status) : null; const unfinishedLegacyStatuses = new Set([ - "reported", "assessing", "rectifying", "candidate", diff --git a/frontend/src/lib/conversational-rectification/legacy-import.ts b/frontend/src/lib/conversational-rectification/legacy-import.ts index 69c2a147..afba8be1 100644 --- a/frontend/src/lib/conversational-rectification/legacy-import.ts +++ b/frontend/src/lib/conversational-rectification/legacy-import.ts @@ -20,9 +20,6 @@ const unfinishedLegacyStatuses = new Set([ "rectifying", "candidate", "confirming", - "starting", - "active", - "paused", ]); export type LegacyConversationalImportSource = Readonly<{ diff --git a/frontend/supabase/migrations/20260721010000_conversational_legacy_import_projection.sql b/frontend/supabase/migrations/20260721010000_conversational_legacy_import_projection.sql index 23e9bd3c..9fe91f7e 100644 --- a/frontend/supabase/migrations/20260721010000_conversational_legacy_import_projection.sql +++ b/frontend/supabase/migrations/20260721010000_conversational_legacy_import_projection.sql @@ -210,7 +210,7 @@ begin for update; if not found or v_legacy.journey_protocol not in ('legacy-guided-v1', 'dynamic-choice-v2') - or v_legacy.status in ('confirmed', 'completed', 'abandoned') then + or v_legacy.status not in ('assessing', 'rectifying', 'candidate', 'confirming') then raise exception 'conversational_case_not_found' using errcode = 'P0001'; end if; if v_legacy.turn_version is distinct from p_expected_version then @@ -243,6 +243,9 @@ begin if not found then raise exception 'conversational_case_not_found' using errcode = 'P0001'; end if; + if v_profile.rectification_case_id is distinct from p_legacy_case_id then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; v_profile.credits := public.recover_conversational_rectification_orphan_reservations( p_user_id, null::uuid ); @@ -375,6 +378,14 @@ begin p_legacy_case_id, v_profile.active_birth_time, nullif(p_pending_consultation_question, ''), pg_catalog.now() ); + update public.profiles + set rectification_case_id = p_case_id, + updated_at = pg_catalog.now() + where id = p_user_id + and rectification_case_id = p_legacy_case_id; + if not found then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; insert into public.birth_time_rectification_turns ( case_id, turn_version, narrative, candidate, technical_receipt, evidence_request, evidence_recap, actions, output_validation_receipt diff --git a/frontend/tests/conversational-legacy-import.test.ts b/frontend/tests/conversational-legacy-import.test.ts index 3f771d08..aefd0a90 100644 --- a/frontend/tests/conversational-legacy-import.test.ts +++ b/frontend/tests/conversational-legacy-import.test.ts @@ -140,6 +140,10 @@ test("v1 and v2 projection preserves only declared facts, latest range, and scor test("projection rejects terminal, foreign-owner, and unsupported protocol sources", () => { for (const candidate of [ + { ...source("legacy-guided-v1"), status: "reported" }, + { ...source("dynamic-choice-v2"), status: "starting" }, + { ...source("dynamic-choice-v2"), status: "active" }, + { ...source("dynamic-choice-v2"), status: "paused" }, { ...source("legacy-guided-v1"), status: "completed" }, { ...source("dynamic-choice-v2"), status: "abandoned" }, { ...source("dynamic-choice-v2"), status: "confirmed" }, @@ -155,6 +159,17 @@ test("projection rejects terminal, foreign-owner, and unsupported protocol sourc } }); +test("projection accepts exactly the four legal unfinished legacy case statuses", () => { + for (const status of ["assessing", "rectifying", "candidate", "confirming"]) { + const projected = projectLegacyCaseForConversationalImport({ + source: { ...source("dynamic-choice-v2"), status }, + asOfDate: "2026-07-21", + expectedUserId: userId, + }); + assert.equal(projected.legacyCaseId, legacyCaseId); + } +}); + function importedRow(input: { readonly firstTurn: ConversationalRectificationTurnInput; readonly privateCandidate: NonNullable; diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts index 10e2444a..c3c3ddb2 100644 --- a/frontend/tests/conversational-rectification-route.test.ts +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -331,8 +331,14 @@ test("production profile conversion links terminal v3 revisions and owner-bound [{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "completed" }, priorId, null], [{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "abandoned" }, priorId, null], [{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "active" }, null, null], + [{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "assessing" }, null, priorId], [{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "rectifying" }, null, priorId], [{ id: priorId, journey_protocol: "legacy-guided-v1", status: "candidate" }, null, priorId], + [{ id: priorId, journey_protocol: "legacy-guided-v1", status: "confirming" }, null, priorId], + [{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "reported" }, null, null], + [{ id: priorId, journey_protocol: "legacy-guided-v1", status: "starting" }, null, null], + [{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "active" }, null, null], + [{ id: priorId, journey_protocol: "legacy-guided-v1", status: "paused" }, null, null], [{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "confirmed" }, null, null], [{ id: priorId, journey_protocol: "legacy-guided-v1", status: "abandoned" }, null, null], [{ id: priorId, journey_protocol: "dynamic-choice-v2", status: null }, null, null], @@ -405,6 +411,44 @@ test("legacy import declaration uses the immutable old case time while preservin }); }); +test("an abandoned imported v3 profile pointer becomes the paid revision base", async () => { + const importedCaseId = "00000000-0000-4000-8000-000000000120"; + const importedFromCaseId = "00000000-0000-4000-8000-000000000121"; + const loaded = await loadProductionConversationalRectificationProfile({ + async loadProfile() { + return { + birth_date: "1990-01-01", + reported_birth_time: "05:20:00", + active_birth_time: "04:58:00", + birth_time_source: "legacy_import", + birth_time_period: null, + birth_time_clue: "现存账户线索", + uncertainty_before_minutes: 0, + uncertainty_after_minutes: 0, + country_code: "TW", + province_code: "TPE", + city_code: "TPE-CITY", + district_code: "DAAN", + latitude: 25.0268, + longitude: 121.5434, + timezone_offset: 8, + rectification_case_id: importedCaseId, + }; + }, + async loadRectificationCase() { + return { + id: importedCaseId, + journey_protocol: "conversational-evidence-v3", + status: "abandoned", + imported_from_case_id: importedFromCaseId, + }; + }, + }, userId); + + assert.equal(loaded.revisionOfCaseId, importedCaseId); + assert.equal(loaded.legacyCaseId, null); +}); + test("production unknown-time adapter covers the declared full day with bounded deduplicated scans", async () => { const scanCalls: Array<{ birthTime: string; uncertaintyMinutes: number }> = []; const minute = (value: string) => { diff --git a/tests/test_conversational_rectification_contract.py b/tests/test_conversational_rectification_contract.py index 24c80e56..f86135c8 100644 --- a/tests/test_conversational_rectification_contract.py +++ b/tests/test_conversational_rectification_contract.py @@ -235,7 +235,10 @@ def test_forward_legacy_import_projects_only_trusted_facts_into_v3() -> None: assert "set credits =" not in body assert "for update" in body assert "pg_advisory_xact_lock" in body - assert "v_legacy.status in ('confirmed', 'completed', 'abandoned')" in body + assert "v_legacy.status not in ('assessing', 'rectifying', 'candidate', 'confirming')" in body + assert "v_profile.rectification_case_id is distinct from p_legacy_case_id" in body + assert "rectification_case_id = p_case_id" in body + assert "rectification_case_id = p_legacy_case_id" in body assert "v_legacy.turn_version is distinct from p_expected_version" in body assert "current_date" in body diff --git a/tests/test_conversational_rectification_postgres_runtime.py b/tests/test_conversational_rectification_postgres_runtime.py index cca433e1..555b504a 100644 --- a/tests/test_conversational_rectification_postgres_runtime.py +++ b/tests/test_conversational_rectification_postgres_runtime.py @@ -286,6 +286,7 @@ def _create_case( action_id: str, declared_birth_input: dict[str, object], private_candidate: dict[str, object] | None = None, + revision_of_case_id: str | None = None, ) -> str: return database.sql( f""" @@ -294,7 +295,7 @@ def _create_case( '{action_id}'::uuid, 0, '{action_id}'::uuid, - null, + {"null" if revision_of_case_id is None else f"'{revision_of_case_id}'::uuid"}, null, {_jsonb(declared_birth_input)}, {_jsonb(_valid_turn(action_id))}, @@ -361,6 +362,9 @@ def _create_legacy_case(database: PgDatabase, user_id: str, legacy_case_id: str) '{legacy_case_id}'::uuid, '{user_id}'::uuid, 'rectifying', '1990-01-01', '05:20', 'legacy_import', 0, 0, 'legacy-guided-v1' ); + update public.profiles + set rectification_case_id = '{legacy_case_id}'::uuid + where id = '{user_id}'::uuid; """ ) @@ -2069,3 +2073,187 @@ def test_concurrent_legacy_import_projects_events_once_and_keeps_old_row_read_on assert pg14_database.rejects( f"update public.birth_time_rectification_cases set answers = '{{}}'::jsonb where id = '{legacy_case_id}'::uuid" ) + + +def test_legacy_import_pointer_cas_drift_rolls_back_every_import_artifact( + pg14_database: PgDatabase, +) -> None: + user_id = "00000000-0000-4000-8000-000000002721" + legacy_case_id = "00000000-0000-4000-8000-000000002722" + action_id = "00000000-0000-4000-8000-000000002723" + _create_user(pg14_database, user_id, credits=10) + _create_legacy_case(pg14_database, user_id, legacy_case_id) + pg14_database.sql( + f"update public.profiles set rectification_case_id = null where id = '{user_id}'::uuid" + ) + declared = { + "birthDate": "1990-01-01", + "reportedTime": "05:20", + "source": "legacy_import", + "birthTimeClue": None, + "uncertaintyBeforeMinutes": 0, + "uncertaintyAfterMinutes": 0, + "birthplace": { + "countryCode": "TW", + "provinceCode": "TPE", + "cityCode": "TPE-CITY", + "districtCode": "DAAN", + "latitude": 25.0268, + "longitude": 121.5434, + "timezoneOffset": 8, + }, + } + statement = f""" + select public.import_legacy_conversational_rectification_case( + '{user_id}'::uuid, '{action_id}'::uuid, '{legacy_case_id}'::uuid, + 0, '{action_id}'::uuid, 3, null, + {_jsonb(declared)}, '[]'::jsonb, + {_jsonb(_valid_turn(action_id))}, + {_jsonb({'modelId': 'synthetic-model', 'schemaValidated': True})}, + {_jsonb(_valid_private_candidate())} + )::text; + """ + rejected = subprocess.run( + pg14_database.command("-A", "-t", "-q", "-c", statement), + check=False, + capture_output=True, + text=True, + ) + assert rejected.returncode != 0 + assert "conversational_action_conflict" in rejected.stderr + assert json.loads(pg14_database.sql( + f""" + select pg_catalog.jsonb_build_object( + 'caseCount', (select pg_catalog.count(*) from public.birth_time_rectification_cases where id = '{action_id}'::uuid), + 'evidenceCount', (select pg_catalog.count(*) from public.birth_time_rectification_event_evidence where case_id = '{action_id}'::uuid), + 'billingCount', (select pg_catalog.count(*) from public.birth_time_rectification_billing where case_id = '{action_id}'::uuid), + 'receiptCount', (select pg_catalog.count(*) from public.birth_time_rectification_action_receipts where case_id = '{action_id}'::uuid), + 'profilePointer', (select rectification_case_id from public.profiles where id = '{user_id}'::uuid) + )::text; + """ + )) == { + "caseCount": 0, + "evidenceCount": 0, + "billingCount": 0, + "receiptCount": 0, + "profilePointer": None, + } + + +def test_import_abandon_then_paid_revision_keeps_active_time_and_charges_normally( + pg14_database: PgDatabase, +) -> None: + user_id = "00000000-0000-4000-8000-000000002731" + legacy_case_id = "00000000-0000-4000-8000-000000002732" + import_action = "00000000-0000-4000-8000-000000002733" + abandon_action = "00000000-0000-4000-8000-000000002734" + paid_action = "00000000-0000-4000-8000-000000002735" + _create_user(pg14_database, user_id, credits=10) + _create_legacy_case(pg14_database, user_id, legacy_case_id) + declared = { + "birthDate": "1990-01-01", + "reportedTime": "05:20", + "source": "legacy_import", + "birthTimeClue": None, + "uncertaintyBeforeMinutes": 0, + "uncertaintyAfterMinutes": 0, + "birthplace": { + "countryCode": "TW", + "provinceCode": "TPE", + "cityCode": "TPE-CITY", + "districtCode": "DAAN", + "latitude": 25.0268, + "longitude": 121.5434, + "timezoneOffset": 8, + }, + } + import_statement = f""" + select public.import_legacy_conversational_rectification_case( + '{user_id}'::uuid, '{import_action}'::uuid, '{legacy_case_id}'::uuid, + 0, '{import_action}'::uuid, 3, null, + {_jsonb(declared)}, '[]'::jsonb, + {_jsonb(_valid_turn(import_action))}, + {_jsonb({'modelId': 'synthetic-model', 'schemaValidated': True})}, + {_jsonb(_valid_private_candidate())} + )::text; + """ + imported = json.loads(pg14_database.sql(import_statement)) + assert imported["billing_state"] == "migration_waived" + assert json.loads(pg14_database.sql(import_statement)) == imported + assert pg14_database.sql( + f"select rectification_case_id::text from public.profiles where id = '{user_id}'::uuid" + ) == import_action + + abandoned_turn = { + **_valid_turn(import_action), + "status": "abandoned", + "turnVersion": 1, + "actions": [], + } + abandoned = json.loads(pg14_database.sql( + f""" + select public.abandon_conversational_rectification_case( + '{user_id}'::uuid, '{import_action}'::uuid, 0, + '{abandon_action}'::uuid, {_jsonb(abandoned_turn)}, + {_jsonb({'modelId': 'deterministic-transition-v1', 'schemaValidated': True})}, + null + )::text; + """ + )) + assert abandoned["status"] == "abandoned" + pointed = json.loads(pg14_database.sql( + f""" + select pg_catalog.jsonb_build_object( + 'pointer', profile.rectification_case_id, + 'protocol', pointed.journey_protocol, + 'status', pointed.status, + 'activeTime', pg_catalog.to_char(profile.active_birth_time, 'HH24:MI') + )::text + from public.profiles profile + join public.birth_time_rectification_cases pointed + on pointed.id = profile.rectification_case_id + where profile.id = '{user_id}'::uuid; + """ + )) + assert pointed == { + "pointer": import_action, + "protocol": "conversational-evidence-v3", + "status": "abandoned", + "activeTime": "04:58", + } + + assert _reserve(pg14_database, user_id, paid_action, price=3)["credits"] == 7 + _create_case( + pg14_database, + user_id, + paid_action, + _valid_declared_birth_input(), + revision_of_case_id=import_action, + ) + completed = _complete(pg14_database, user_id, paid_action) + assert completed["billing_state"] == "charged" + assert json.loads(pg14_database.sql( + f""" + select pg_catalog.jsonb_build_object( + 'credits', profile.credits, + 'activeTime', pg_catalog.to_char(profile.active_birth_time, 'HH24:MI'), + 'profilePointer', profile.rectification_case_id, + 'billingState', billing.state, + 'revisionOf', paid.revision_of_case_id, + 'importedFrom', paid.imported_from_case_id, + 'baseline', pg_catalog.to_char(paid.baseline_active_time, 'HH24:MI') + )::text + from public.profiles profile + join public.birth_time_rectification_cases paid on paid.id = '{paid_action}'::uuid + join public.birth_time_rectification_billing billing on billing.case_id = paid.id + where profile.id = '{user_id}'::uuid; + """ + )) == { + "credits": 7, + "activeTime": "04:58", + "profilePointer": import_action, + "billingState": "charged", + "revisionOf": import_action, + "importedFrom": None, + "baseline": "04:58", + }