import { chinaLocations } from "../data/china-locations.ts"; import { isBirthClockTime, parseBirthDate } from "./birth-time-intake-model.ts"; import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts"; import type { ConsultationBirthTimeMode } from "./consultation-birth-time-mode.ts"; import type { GeneralDailyReference } from "./general-daily-panchanga.ts"; export type ConsultationProfileTruthErrorCode = | "profile_unavailable" | "profile_incomplete" | "profile_inconsistent" | "mode_changed"; export class ConsultationProfileTruthError extends Error { readonly code: ConsultationProfileTruthErrorCode; constructor(code: ConsultationProfileTruthErrorCode) { super(`Consultation profile truth rejected: ${code}`); this.name = "ConsultationProfileTruthError"; this.code = code; } } type ServerChartToolInput = Readonly<{ year: number; month: number; day: number; hour: number; minute: number; city: string; lat: number; lon: number; tz: number; }>; export type ServerChartConsultation = Readonly<{ name: string; toolInput: ServerChartToolInput; truth: Readonly<{ birthDate: string; reportedBirthTime: string | null; activeBirthTime: string | null; selectedTimeKind: "reported" | "active"; birthTimeSource: string; birthTimeStatus: string; placeLabel: string; placeCodes: Readonly<{ countryCode: string | null; provinceCode: string | null; cityCode: string | null; districtCode: string | null; }>; placeId: string | null; placeType: string | null; placeProvider: string | null; timezoneId: string | null; timezoneSource: string | null; latitude: number; longitude: number; timezoneOffset: number; }>; }>; type ConsultationPreReserveContext = Readonly<{ consultationMode: ConsultationBirthTimeMode; serverChart: ServerChartConsultation | null; generalDailyReference: GeneralDailyReference | null; }>; type PrepareConsultationRouteInput = Readonly<{ userId: string; mode: ConsultationBirthTimeMode; loadProfile: (userId: string) => Promise; resolveTimezoneOffset?: (profile: unknown, selectedTime?: string) => Promise; beforeReserve?: (context: ConsultationPreReserveContext) => unknown | Promise; reserve: () => Promise; }>; type PrepareConsultationRouteWithGuard = Omit< PrepareConsultationRouteInput, "beforeReserve" > & Readonly<{ beforeReserve: (context: ConsultationPreReserveContext) => GuardResult | Promise; }>; export type PreparedConsultationRoute = Readonly<{ consultationMode: ConsultationBirthTimeMode; serverChart: ServerChartConsultation | null; generalDailyReference: GeneralDailyReference | null; reservation: Reservation; preReserveResult: GuardResult; }>; type RecordValue = Record; const allowedBirthTimeSources = new Set([ "hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import", ]); const allowedBirthTimeStatuses = new Set([ "reported", "assessing", "rectifying", "candidate", "accepted", "confirmed", ]); const concreteReportedSources = new Set([ "hospital_record", "family_exact", "approximate", ]); function record(value: unknown): RecordValue | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : null; } function requiredText(profile: RecordValue, key: string): string { const value = profile[key]; if (typeof value !== "string" || !value.trim()) { throw new ConsultationProfileTruthError("profile_incomplete"); } return value.trim(); } function nullableClock(profile: RecordValue, key: string): string | null { const value = profile[key]; if (value === null || value === undefined) return null; if (typeof value !== "string") { throw new ConsultationProfileTruthError("profile_inconsistent"); } const clock = value.slice(0, 5); if (!isBirthClockTime(clock)) { throw new ConsultationProfileTruthError("profile_inconsistent"); } return clock; } function requiredFiniteNumber(profile: RecordValue, key: string, minimum: number, maximum: number) { const value = profile[key]; if (typeof value !== "number" || !Number.isFinite(value)) { throw new ConsultationProfileTruthError("profile_incomplete"); } if (value < minimum || value > maximum) { throw new ConsultationProfileTruthError("profile_inconsistent"); } return value; } function optionalText(profile: RecordValue, key: string): string | null { const value = profile[key]; return typeof value === "string" && value.trim() ? value.trim() : null; } function generalDailyReferenceFromProfile(value: unknown): GeneralDailyReference | null { const profile = record(value); if (!profile) return null; const latitude = profile.latitude; const longitude = profile.longitude; const timezoneOffset = profile.timezone_offset; if (typeof latitude !== "number" || !Number.isFinite(latitude) || latitude < -90 || latitude > 90 || typeof longitude !== "number" || !Number.isFinite(longitude) || longitude < -180 || longitude > 180 || typeof timezoneOffset !== "number" || !Number.isFinite(timezoneOffset) || timezoneOffset < -12 || timezoneOffset > 14) { return null; } return Object.freeze({ latitude, longitude, timezoneOffset, placeLabel: optionalText(profile, "birth_place_label") ?? "已保存地点", }); } function persistedChartMode(value: unknown): Exclude | null { const profile = record(value); if (!profile) return null; const status = optionalText(profile, "birth_time_status"); const source = optionalText(profile, "birth_time_source"); const activeTime = optionalText(profile, "active_birth_time")?.slice(0, 5) ?? ""; const reportedTime = optionalText(profile, "reported_birth_time")?.slice(0, 5) ?? ""; if ((status === "accepted" || status === "confirmed") && isBirthClockTime(activeTime)) return "verified_chart"; if (status && allowedBirthTimeStatuses.has(status) && status !== "accepted" && status !== "confirmed" && source && concreteReportedSources.has(source) && isBirthClockTime(reportedTime)) return "unverified_birth_time"; return null; } function legacyChinaPlaceLabel(profile: RecordValue): string | null { const countryCode = optionalText(profile, "country_code"); const provinceCode = optionalText(profile, "province_code"); const cityCode = optionalText(profile, "city_code"); const districtCode = optionalText(profile, "district_code"); const country = chinaLocations.country; if (countryCode !== country.code || !provinceCode || !cityCode) return null; const province = country.provinces.find((candidate) => candidate.code === provinceCode); const city = province?.cities.find((candidate) => candidate.code === cityCode); const district = districtCode ? city?.districts.find((candidate) => candidate.code === districtCode) : undefined; if (!province || !city || (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 serverChartFromProfile( value: unknown, mode: Exclude, ): ServerChartConsultation { const profile = record(value); if (!profile) throw new ConsultationProfileTruthError("profile_incomplete"); const name = requiredText(profile, "name"); if (name.length > 80) throw new ConsultationProfileTruthError("profile_inconsistent"); const birthDate = requiredText(profile, "birth_date"); if (!parseBirthDate(birthDate)) { throw new ConsultationProfileTruthError("profile_inconsistent"); } const [year, month, day] = birthDate.split("-").map(Number); const reportedBirthTime = nullableClock(profile, "reported_birth_time"); const activeBirthTime = nullableClock(profile, "active_birth_time"); const birthTimeSource = requiredText(profile, "birth_time_source"); const birthTimeStatus = requiredText(profile, "birth_time_status"); if (!allowedBirthTimeSources.has(birthTimeSource) || !allowedBirthTimeStatuses.has(birthTimeStatus)) { throw new ConsultationProfileTruthError("profile_inconsistent"); } const countryCode = optionalText(profile, "country_code"); const provinceCode = optionalText(profile, "province_code"); const cityCode = optionalText(profile, "city_code"); const districtCode = optionalText(profile, "district_code"); const placeId = optionalText(profile, "birth_place_provider_id"); const placeType = optionalText(profile, "birth_place_type"); const placeProvider = optionalText(profile, "birth_place_provider"); const timezoneId = optionalText(profile, "timezone_id"); const timezoneSource = optionalText(profile, "timezone_source"); const latitude = requiredFiniteNumber(profile, "latitude", -90, 90); const longitude = requiredFiniteNumber(profile, "longitude", -180, 180); const timezoneOffset = requiredFiniteNumber(profile, "timezone_offset", -12, 14); const placeLabel = optionalText(profile, "birth_place_label") ?? legacyChinaPlaceLabel(profile) ?? placeId; if (!placeLabel) throw new ConsultationProfileTruthError("profile_incomplete"); let selectedTime: string; let selectedTimeKind: "reported" | "active"; if (mode === "verified_chart") { if (birthTimeStatus !== "accepted" && birthTimeStatus !== "confirmed") { throw new ConsultationProfileTruthError("mode_changed"); } if (!activeBirthTime) throw new ConsultationProfileTruthError("profile_incomplete"); selectedTime = activeBirthTime; selectedTimeKind = "active"; } else { if (birthTimeStatus === "accepted" || birthTimeStatus === "confirmed" || !concreteReportedSources.has(birthTimeSource)) { throw new ConsultationProfileTruthError("mode_changed"); } if (!reportedBirthTime) throw new ConsultationProfileTruthError("profile_incomplete"); selectedTime = reportedBirthTime; selectedTimeKind = "reported"; } const [hour, minute] = selectedTime.split(":").map(Number); return Object.freeze({ name, toolInput: Object.freeze({ year, month, day, hour, minute, city: placeLabel, lat: latitude, lon: longitude, tz: timezoneOffset, }), truth: Object.freeze({ birthDate, reportedBirthTime, activeBirthTime, selectedTimeKind, birthTimeSource, birthTimeStatus, placeLabel, placeCodes: Object.freeze({ countryCode, provinceCode, cityCode, districtCode, }), placeId, placeType, placeProvider, timezoneId, timezoneSource, latitude, longitude, timezoneOffset, }), }); } /** * The route's pre-billing service boundary. Chart modes must load and resolve * account truth successfully before the reservation callback can run. */ export function prepareConsultationRoute( input: PrepareConsultationRouteWithGuard, ): Promise>>; export function prepareConsultationRoute( input: PrepareConsultationRouteInput, ): Promise>; export async function prepareConsultationRoute( input: PrepareConsultationRouteInput | PrepareConsultationRouteWithGuard, ): Promise | undefined>> { let profile: unknown; try { profile = await input.loadProfile(input.userId); } catch (error) { if (input.mode === "general_no_birth_time") profile = null; else if (error instanceof ConsultationProfileTruthError) throw error; else throw new ConsultationProfileTruthError("profile_unavailable"); } const consultationMode = input.mode === "general_no_birth_time" ? persistedChartMode(profile) ?? input.mode : input.mode; let serverChart: ServerChartConsultation | null = null; const generalDailyReference = consultationMode === "general_no_birth_time" ? generalDailyReferenceFromProfile(profile) : null; if (consultationMode !== "general_no_birth_time") { const profileValue = record(profile); const selectedTime = consultationMode === "verified_chart" ? nullableClock(profileValue ?? {}, "active_birth_time") : nullableClock(profileValue ?? {}, "reported_birth_time"); try { profile = await (input.resolveTimezoneOffset ?? ((value, time) => ( resolveMissingBirthTimezoneOffset(value, { preferredTime: time }) )))(profile, selectedTime ?? undefined); } catch { throw new ConsultationProfileTruthError("profile_unavailable"); } serverChart = serverChartFromProfile(profile, consultationMode); } const preReserveResult = input.beforeReserve ? await input.beforeReserve({ consultationMode, serverChart, generalDailyReference }) as Awaited : undefined; const reservation = await input.reserve(); return Object.freeze({ consultationMode, serverChart, generalDailyReference, reservation, preReserveResult }); }