Files
Jyotisha/frontend/src/lib/account-profile-patch.ts
T

418 lines
16 KiB
TypeScript

import { z } from "zod";
import { isBirthClockTime, parseBirthDate } from "./birth-time-intake-model.ts";
import { AYANAMSA_VALUES } from "./ayanamsa.ts";
import { PROFILE_GENDERS } from "./profile-gender.ts";
const nullableTrimmedString = (maximum: number) => z.string().trim().min(1).max(maximum).nullable();
const nullableBirthDate = z.string().refine((value) => parseBirthDate(value) !== undefined, {
message: "出生日期必须是真实的 1900—2100 年 ISO 日期",
}).nullable();
const nullableBirthClock = z.string().refine(isBirthClockTime, {
message: "出生时间必须是 HH:mm",
}).nullable();
const birthTimeSourceSchema = z.enum([
"hospital_record",
"family_exact",
"approximate",
"period_only",
"unknown",
"legacy_import",
]);
const birthTimePeriodSchema = z.enum([
"early_morning",
"morning",
"afternoon",
"evening",
"late_night",
]);
export const accountProfilePatchSchema = z.object({
name: nullableTrimmedString(80).optional(),
birth_date: nullableBirthDate.optional(),
// Accepted only for backward-compatible parsing. The account route never
// writes this client field into active/confirmed birth-time truth.
birth_time: nullableBirthClock.optional(),
reported_birth_time: nullableBirthClock.optional(),
birth_time_source: birthTimeSourceSchema.nullable().optional(),
birth_time_period: birthTimePeriodSchema.nullable().optional(),
declared_window_start: nullableBirthClock.optional(),
declared_window_end: nullableBirthClock.optional(),
birth_time_clue: nullableTrimmedString(240).optional(),
uncertainty_before_minutes: z.number().int().min(0).max(720).nullable().optional(),
uncertainty_after_minutes: z.number().int().min(0).max(720).nullable().optional(),
country_code: nullableTrimmedString(8).optional(),
province_code: nullableTrimmedString(24).optional(),
city_code: nullableTrimmedString(24).optional(),
district_code: nullableTrimmedString(24).optional(),
latitude: z.number().finite().min(-90).max(90).nullable().optional(),
longitude: z.number().finite().min(-180).max(180).nullable().optional(),
timezone_offset: z.number().finite().min(-12).max(14).nullable().optional(),
birth_place_label: nullableTrimmedString(240).optional(),
birth_place_type: nullableTrimmedString(40).optional(),
birth_place_provider: z.enum(["geoapify", "china_locations", "mapbox", "geonames"]).nullable().optional(),
birth_place_provider_id: nullableTrimmedString(160).optional(),
timezone_id: nullableTrimmedString(80).optional(),
timezone_source: z.literal("iana_historical").nullable().optional(),
ayanamsa: z.enum(AYANAMSA_VALUES).optional(),
// Optional (TASK-consult-gender-optional-20260927); null clears it.
gender: z.enum(PROFILE_GENDERS).nullable().optional(),
}).strict().superRefine((value, context) => {
const source = value.birth_time_source;
const time = value.reported_birth_time;
const before = value.uncertainty_before_minutes;
const after = value.uncertainty_after_minutes;
const addIssue = (path: string, message: string) => context.addIssue({
code: z.ZodIssueCode.custom,
path: [path],
message,
});
const declarationKeys = [
"birth_date",
"reported_birth_time",
"birth_time_source",
"birth_time_period",
"declared_window_start",
"declared_window_end",
"birth_time_clue",
"uncertainty_before_minutes",
"uncertainty_after_minutes",
] as const;
const mutatesDeclaration = declarationKeys.some((key) => value[key] !== undefined);
const coordinateCount = [value.latitude, value.longitude].filter((coordinate) => coordinate != null).length;
if (coordinateCount === 1) {
addIssue("latitude", "出生地点经纬度必须完整提交");
}
const globalLocationFields = [
value.birth_place_label,
value.birth_place_type,
value.birth_place_provider,
value.birth_place_provider_id,
value.timezone_id,
value.timezone_source,
];
const hasGlobalLocation = globalLocationFields.some((field) => field != null);
if (hasGlobalLocation && (coordinateCount !== 2 || !value.timezone_id)) {
addIssue("timezone_id", "全球出生地点必须包含完整坐标与 IANA 时区");
}
if (source === undefined) {
if (mutatesDeclaration) addIssue("birth_time_source", "修改出生资料时必须同时说明时间来源");
return;
}
if (source === null) {
if (value.birth_date !== undefined && value.birth_date !== null) {
addIssue("birth_time_source", "填写出生日期后必须说明时间来源");
}
if (time || value.birth_time || value.birth_time_period || value.declared_window_start
|| value.declared_window_end || value.birth_time_clue
|| before != null || after != null) {
addIssue("birth_time_source", "未选择时间来源时不得提交时间或时间范围");
}
return;
}
if (!value.birth_date) addIssue("birth_date", "出生时间声明必须包含真实出生日期");
if (source !== "legacy_import" && value.birth_time) {
addIssue("birth_time", "只有既有资料迁移可以提交兼容时间字段");
}
const ensureNoPeriod = () => {
if (value.birth_time_period) addIssue("birth_time_period", "具体时间来源不得同时提交时段");
};
const ensureNoDeclaredWindow = () => {
if (value.declared_window_start || value.declared_window_end) {
addIssue("declared_window_start", "具体时间来源不得同时提交开始结束范围");
}
};
const ensureNoUncertainty = () => {
if (before != null || after != null) {
addIssue("uncertainty_before_minutes", "该时间来源不得提交时间范围");
}
};
if (source === "hospital_record") {
if (!time) addIssue("reported_birth_time", "医院记录需要具体时间");
if (before !== 2 || after !== 2) addIssue("uncertainty_before_minutes", "医院记录固定检查前后 2 分钟");
ensureNoPeriod();
ensureNoDeclaredWindow();
} else if (source === "family_exact") {
if (!time) addIssue("reported_birth_time", "家人记忆需要具体时间");
if (![0, 5, 10, 15].includes(before ?? -1) || before !== after) {
addIssue("uncertainty_before_minutes", "准确时间填报必须前后一致");
}
ensureNoPeriod();
ensureNoDeclaredWindow();
} else if (source === "approximate") {
if (!time) addIssue("reported_birth_time", "大概时间需要具体 HH:mm");
if (![15, 30, 60, 120].includes(before ?? -1) || before !== after) {
addIssue("uncertainty_before_minutes", "大概时间必须是前后 15、30、60 或 120 分钟");
}
ensureNoPeriod();
ensureNoDeclaredWindow();
} else if (source === "legacy_import") {
if (!time && !value.birth_time) addIssue("reported_birth_time", "既有资料需要具体时间");
ensureNoPeriod();
ensureNoDeclaredWindow();
ensureNoUncertainty();
} else if (source === "period_only") {
const start = value.declared_window_start;
const end = value.declared_window_end;
const hasWindow = Boolean(start && end && start !== end);
if (!hasWindow && !value.birth_time_period) {
addIssue("declared_window_start", "不确定准确时间时必须选择开始和结束时间");
}
if ((start && !end) || (!start && end) || (start && end && start === end)) {
addIssue("declared_window_end", "开始和结束必须是不同的钟点");
}
if (time) addIssue("reported_birth_time", "只知道时段时不得同时提交具体分钟");
ensureNoUncertainty();
} else if (source === "unknown") {
if (time) addIssue("reported_birth_time", "时间未知时不得提交具体分钟");
if (value.birth_time_period) addIssue("birth_time_period", "时间未知时不得提交确定时段");
if (value.declared_window_start || value.declared_window_end) {
addIssue("declared_window_start", "时间未知时不得提交开始结束范围");
}
ensureNoUncertainty();
}
});
export type AccountProfilePatch = z.infer<typeof accountProfilePatchSchema>;
type AccountBirthTimeState = Readonly<{
birth_date: string | null;
reported_birth_time: string | null;
birth_time_source: string | null;
birth_time_period: string | null;
declared_window_start?: string | null;
declared_window_end?: string | null;
birth_time_clue: string | null;
uncertainty_before_minutes: number | null;
uncertainty_after_minutes: number | null;
active_birth_time: string | null;
active_birth_date?: string | null;
active_birth_timezone_offset?: number | null;
birth_time: string | null;
birth_time_status: string | null;
rectification_case_id: string | null;
country_code?: string | null;
province_code?: string | null;
city_code?: string | null;
district_code?: string | null;
latitude?: number | null;
longitude?: number | null;
timezone_offset?: number | null;
birth_place_label?: string | null;
birth_place_type?: string | null;
birth_place_provider?: string | null;
birth_place_provider_id?: string | null;
timezone_id?: string | null;
timezone_source?: string | null;
}>;
const declarationFields = [
"birth_date",
"reported_birth_time",
"birth_time_source",
"birth_time_period",
"declared_window_start",
"declared_window_end",
"birth_time_clue",
"uncertainty_before_minutes",
"uncertainty_after_minutes",
"country_code",
"province_code",
"city_code",
"district_code",
"latitude",
"longitude",
"timezone_offset",
"birth_place_label",
"birth_place_type",
"birth_place_provider",
"birth_place_provider_id",
"timezone_id",
"timezone_source",
] as const;
const concurrencyFields = [
...declarationFields,
"active_birth_time",
"active_birth_date",
"active_birth_timezone_offset",
"birth_time",
"birth_time_status",
"rectification_case_id",
] as const;
type ConditionalProfileQuery<Query> = Readonly<{
eq: (column: string, value: string | number) => Query;
is: (column: string, value: null) => Query;
}>;
/** Keeps an ordinary profile edit from overwriting a concurrent edit or confirmation. */
export function applyAccountProfileConcurrencyGuards<
Query extends ConditionalProfileQuery<Query>,
>(query: Query, current: AccountBirthTimeState): Query {
let guarded = query;
for (const field of concurrencyFields) {
const value = current[field];
if (value === undefined) continue;
guarded = value === null
? guarded.is(field, null)
: guarded.eq(field, value);
}
return guarded;
}
export type AccountBirthTimeApplicationPatch = Readonly<{
active_birth_time?: string | null;
birth_time_status?: "accepted" | "reported";
rectification_case_id?: null;
/** Only when a confirmed or legacy minute is dropped: the legacy column the chart falls back to. */
birth_time?: null;
/** Only when a confirmed adoption is dropped; `guard_adopted_birth_date` clears them too. */
active_birth_date?: null;
active_birth_timezone_offset?: null;
active_birth_provenance?: null;
}>;
function normalizeApplicableBirthClock(value: string | null | undefined): string | null {
if (!value) return null;
if (isBirthClockTime(value)) return value;
return /^(?:[01]\d|2[0-3]):[0-5]\d:00(?:\.0+)?$/.test(value)
? value.slice(0, 5)
: null;
}
function resolveExactFamilyBirthTime(
current: AccountBirthTimeState | null,
patch: AccountProfilePatch,
): string | null {
const source = patch.birth_time_source !== undefined
? patch.birth_time_source
: current?.birth_time_source;
const before = patch.uncertainty_before_minutes !== undefined
? patch.uncertainty_before_minutes
: current?.uncertainty_before_minutes;
const after = patch.uncertainty_after_minutes !== undefined
? patch.uncertainty_after_minutes
: current?.uncertainty_after_minutes;
const reportedTime = patch.reported_birth_time !== undefined
? patch.reported_birth_time
: current?.reported_birth_time;
if (source !== "family_exact" || before !== 0 || after !== 0) return null;
return normalizeApplicableBirthClock(reportedTime);
}
/**
* Stored and submitted declarations differ only in shape: Postgres returns a
* `time` as `HH:MM:SS`, the form sends `HH:MM`; numbers may arrive as strings;
* an empty string and null both mean "not given". The profile form resubmits
* every birth field on each save, so a raw `!==` would read a name-only save
* as a new declaration and drop a confirmed minute.
*/
function sameDeclarationValue(submitted: unknown, stored: unknown): boolean {
const empty = (value: unknown) => value === null || value === undefined || value === "";
if (empty(submitted) || empty(stored)) return empty(submitted) && empty(stored);
if (typeof submitted === "string" && typeof stored === "string") {
const clock = /^(\d{2}:\d{2})(?::00(?:\.0+)?)?$/;
const a = clock.exec(submitted);
const b = clock.exec(stored);
if (a && b) return a[1] === b[1];
return submitted === stored;
}
const a = Number(submitted);
const b = Number(stored);
if (Number.isFinite(a) && Number.isFinite(b)) return a === b;
return submitted === stored;
}
export function resolveAccountBirthTimeApplicationPatch(
current: AccountBirthTimeState | null,
patch: AccountProfilePatch,
): AccountBirthTimeApplicationPatch {
const exactFamilyBirthTime = resolveExactFamilyBirthTime(current, patch);
if (!current) {
return patch.birth_time_source ? {
active_birth_time: exactFamilyBirthTime,
birth_time_status: exactFamilyBirthTime ? "accepted" : "reported",
rectification_case_id: null,
} : {};
}
const confirmed = current.birth_time_status === "confirmed"
|| (current.birth_time_status === null
&& normalizeApplicableBirthClock(current.birth_time) !== null);
const declarationChanged = declarationFields.some((field) => (
patch[field] !== undefined && !sameDeclarationValue(patch[field], current[field])
));
// Product 2026-09-29 (TASK-mobile-chart-and-confirmed-edit M2, revising
// BUG-264): a confirmed or adopted minute survives every edit except a new
// declaration. Once the user changes the birth date, time or place, the chart
// follows the new declaration exactly as an unconfirmed one would, and the
// old minute is dropped everywhere the chart could still read it.
if (confirmed) {
if (!declarationChanged) return {};
return {
...(exactFamilyBirthTime
? { active_birth_time: exactFamilyBirthTime, birth_time_status: "accepted" as const }
: { active_birth_time: null, birth_time_status: "reported" as const }),
rectification_case_id: null,
...(current.birth_time != null ? { birth_time: null } : {}),
...(current.active_birth_date != null || current.active_birth_timezone_offset != null
? { active_birth_date: null, active_birth_timezone_offset: null, active_birth_provenance: null }
: {}),
};
}
const repairsMissingStatus = current.birth_time_status === null
&& current.active_birth_time === null
&& current.birth_time === null
&& current.rectification_case_id === null
&& Boolean(patch.birth_time_source);
const repairsReportedExactTime = current.birth_time_status === "reported"
&& current.active_birth_time === null
&& current.rectification_case_id === null
&& exactFamilyBirthTime !== null
&& declarationFields.some((field) => patch[field] !== undefined);
if (!declarationChanged && !repairsMissingStatus && !repairsReportedExactTime) return {};
if (exactFamilyBirthTime) return {
active_birth_time: exactFamilyBirthTime,
birth_time_status: "accepted",
rectification_case_id: null,
};
return {
active_birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
};
}
export type AppliedAccountBirthTime = Readonly<{
status: string | null;
activeTime: string | null;
}>;
/**
* The birth-time truth the account owns after a successful write. Status and
* active minute are derived server-side, so a caller that keeps the declaration
* it submitted would consult under a mode the server no longer accepts.
*/
export function resolveAppliedAccountBirthTime(
current: AccountBirthTimeState | null,
applicationPatch: AccountBirthTimeApplicationPatch,
): AppliedAccountBirthTime {
const activeTime = normalizeApplicableBirthClock(
applicationPatch.active_birth_time !== undefined
? applicationPatch.active_birth_time
: current?.active_birth_time ?? current?.birth_time,
);
return Object.freeze({
status: applicationPatch.birth_time_status
?? current?.birth_time_status
?? (activeTime ? "confirmed" : null),
activeTime,
});
}