192 lines
6.9 KiB
TypeScript
192 lines
6.9 KiB
TypeScript
import { chinaLocations } from "../data/china-locations.ts";
|
|
import type { AccountRectificationCaseState } from "./birth-time-consultation-consent.ts";
|
|
import {
|
|
declaredBirthInputSchema,
|
|
type DeclaredBirthInput,
|
|
} from "./conversational-rectification/persistence-contracts.ts";
|
|
|
|
type RecordValue = Record<string, unknown>;
|
|
|
|
const unfinishedStatuses = new Set<AccountRectificationCaseState["status"]>([
|
|
"starting",
|
|
"active",
|
|
"paused",
|
|
"confirming",
|
|
]);
|
|
|
|
function record(value: unknown): RecordValue | null {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
? value as RecordValue
|
|
: null;
|
|
}
|
|
|
|
function text(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function clock(value: unknown): string | null {
|
|
const normalized = text(value)?.slice(0, 5) ?? null;
|
|
return normalized && /^([01]\d|2[0-3]):[0-5]\d$/.test(normalized)
|
|
? normalized
|
|
: null;
|
|
}
|
|
|
|
function finiteNumber(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function integer(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
|
}
|
|
|
|
function currentDeclaration(value: unknown): DeclaredBirthInput | null {
|
|
const profile = record(value);
|
|
if (!profile) return null;
|
|
const birthDate = text(profile.birth_date);
|
|
const source = text(profile.birth_time_source);
|
|
const cityCode = text(profile.city_code);
|
|
const latitude = finiteNumber(profile.latitude);
|
|
const longitude = finiteNumber(profile.longitude);
|
|
const timezoneOffset = finiteNumber(profile.timezone_offset);
|
|
if (!birthDate || !source || !cityCode || latitude === null || longitude === null
|
|
|| timezoneOffset === null) return null;
|
|
|
|
const birthplace = {
|
|
...(text(profile.country_code) ? { countryCode: text(profile.country_code) } : {}),
|
|
...(text(profile.province_code) ? { provinceCode: text(profile.province_code) } : {}),
|
|
cityCode,
|
|
...(text(profile.district_code) ? { districtCode: text(profile.district_code) } : {}),
|
|
latitude,
|
|
longitude,
|
|
timezoneOffset,
|
|
};
|
|
const common = {
|
|
birthDate,
|
|
birthTimeClue: text(profile.birth_time_clue),
|
|
birthplace,
|
|
};
|
|
const reportedTime = clock(profile.reported_birth_time);
|
|
const reportedPeriod = text(profile.birth_time_period);
|
|
const uncertaintyBeforeMinutes = integer(profile.uncertainty_before_minutes);
|
|
const uncertaintyAfterMinutes = integer(profile.uncertainty_after_minutes);
|
|
let input: unknown;
|
|
switch (source) {
|
|
case "hospital_record":
|
|
case "family_exact":
|
|
case "approximate":
|
|
input = {
|
|
...common,
|
|
source,
|
|
reportedTime,
|
|
uncertaintyBeforeMinutes,
|
|
uncertaintyAfterMinutes,
|
|
};
|
|
break;
|
|
case "period_only":
|
|
input = { ...common, source, reportedPeriod };
|
|
break;
|
|
case "unknown":
|
|
input = { ...common, source };
|
|
break;
|
|
case "legacy_import":
|
|
input = {
|
|
...common,
|
|
source,
|
|
...(reportedTime ? { reportedTime } : {}),
|
|
...(reportedPeriod ? { reportedPeriod } : {}),
|
|
...(uncertaintyBeforeMinutes === null ? {} : { uncertaintyBeforeMinutes }),
|
|
...(uncertaintyAfterMinutes === null ? {} : { uncertaintyAfterMinutes }),
|
|
};
|
|
break;
|
|
default:
|
|
return null;
|
|
}
|
|
const parsed = declaredBirthInputSchema.safeParse(input);
|
|
return parsed.success ? parsed.data : null;
|
|
}
|
|
|
|
function canonicalPlaceLabel(input: DeclaredBirthInput): string | null {
|
|
const place = input.birthplace;
|
|
const country = chinaLocations.country;
|
|
if (place.countryCode !== country.code || !place.provinceCode || !place.cityCode) return null;
|
|
const province = country.provinces.find((candidate) => candidate.code === place.provinceCode);
|
|
const city = province?.cities.find((candidate) => candidate.code === place.cityCode);
|
|
const district = place.districtCode
|
|
? city?.districts.find((candidate) => candidate.code === place.districtCode)
|
|
: undefined;
|
|
if (!province || !city || (place.districtCode && !district)) return null;
|
|
return [country.name, province.name, city.name, district?.name]
|
|
.filter((label, index, labels) => Boolean(label) && labels.indexOf(label) === index)
|
|
.join(" · ");
|
|
}
|
|
|
|
function withoutOptionalPlaceLabel(input: DeclaredBirthInput): unknown {
|
|
const birthplace: Record<string, unknown> = { ...input.birthplace };
|
|
delete birthplace.city;
|
|
return { ...input, birthplace };
|
|
}
|
|
|
|
function sameJson(left: unknown, right: unknown): boolean {
|
|
if (Object.is(left, right)) return true;
|
|
if (Array.isArray(left) || Array.isArray(right)) {
|
|
return Array.isArray(left) && Array.isArray(right)
|
|
&& left.length === right.length
|
|
&& left.every((value, index) => sameJson(value, right[index]));
|
|
}
|
|
const leftRecord = record(left);
|
|
const rightRecord = record(right);
|
|
if (!leftRecord || !rightRecord) return false;
|
|
const leftKeys = Object.keys(leftRecord).sort();
|
|
const rightKeys = Object.keys(rightRecord).sort();
|
|
return leftKeys.length === rightKeys.length
|
|
&& leftKeys.every((key, index) => key === rightKeys[index]
|
|
&& sameJson(leftRecord[key], rightRecord[key]));
|
|
}
|
|
|
|
function declarationMatches(current: DeclaredBirthInput, stored: DeclaredBirthInput) {
|
|
if (!sameJson(withoutOptionalPlaceLabel(current), withoutOptionalPlaceLabel(stored))) {
|
|
return false;
|
|
}
|
|
if (!stored.birthplace.city) return true;
|
|
return stored.birthplace.city === canonicalPlaceLabel(current);
|
|
}
|
|
|
|
function project(row: RecordValue): AccountRectificationCaseState | null {
|
|
const status = typeof row.status === "string"
|
|
&& unfinishedStatuses.has(row.status as AccountRectificationCaseState["status"])
|
|
? row.status as AccountRectificationCaseState["status"]
|
|
: null;
|
|
if (typeof row.id !== "string"
|
|
|| row.journey_protocol !== "conversational-evidence-v3"
|
|
|| !status
|
|
|| typeof row.turn_version !== "number"
|
|
|| !Number.isSafeInteger(row.turn_version)
|
|
|| row.turn_version < 0) return null;
|
|
return Object.freeze({
|
|
caseId: row.id,
|
|
journeyProtocol: "conversational-evidence-v3" as const,
|
|
status,
|
|
turnVersion: row.turn_version,
|
|
isRevision: typeof row.revision_of_case_id === "string",
|
|
preservesActiveTime: typeof row.baseline_active_time === "string",
|
|
});
|
|
}
|
|
|
|
/** Selects the latest loaded unfinished case that belongs to this declaration. */
|
|
export function resolveAccountRectificationCase(
|
|
profile: unknown,
|
|
rows: readonly unknown[],
|
|
): AccountRectificationCaseState | null {
|
|
const current = currentDeclaration(profile);
|
|
if (!current) return null;
|
|
for (const value of rows) {
|
|
const row = record(value);
|
|
if (!row) continue;
|
|
const projected = project(row);
|
|
if (!projected) continue;
|
|
const declared = declaredBirthInputSchema.safeParse(row.declared_birth_input);
|
|
if (declared.success && declarationMatches(current, declared.data)) return projected;
|
|
}
|
|
return null;
|
|
}
|