Files
Jyotisha/frontend/src/lib/consultation-route-service.ts
T
Jesse_Chen 0d59d51814
Independent Staging Quality Gate / validate (push) Successful in 14m4s
Independent Staging Quality Gate / publish (push) Successful in 9m22s
fix: allow public daily guidance without birth minute
2026-08-15 23:55:57 +08:00

346 lines
13 KiB
TypeScript

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<Reservation> = Readonly<{
userId: string;
mode: ConsultationBirthTimeMode;
loadProfile: (userId: string) => Promise<unknown>;
resolveTimezoneOffset?: (profile: unknown, selectedTime?: string) => Promise<unknown>;
beforeReserve?: (context: ConsultationPreReserveContext) => unknown | Promise<unknown>;
reserve: () => Promise<Reservation>;
}>;
type PrepareConsultationRouteWithGuard<Reservation, GuardResult> = Omit<
PrepareConsultationRouteInput<Reservation>,
"beforeReserve"
> & Readonly<{
beforeReserve: (context: ConsultationPreReserveContext) => GuardResult | Promise<GuardResult>;
}>;
export type PreparedConsultationRoute<Reservation, GuardResult = undefined> = Readonly<{
consultationMode: ConsultationBirthTimeMode;
serverChart: ServerChartConsultation | null;
generalDailyReference: GeneralDailyReference | null;
reservation: Reservation;
preReserveResult: GuardResult;
}>;
type RecordValue = Record<string, unknown>;
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<ConsultationBirthTimeMode, "general_no_birth_time"> | 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<ConsultationBirthTimeMode, "general_no_birth_time">,
): 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<Reservation, GuardResult>(
input: PrepareConsultationRouteWithGuard<Reservation, GuardResult>,
): Promise<PreparedConsultationRoute<Reservation, Awaited<GuardResult>>>;
export function prepareConsultationRoute<Reservation>(
input: PrepareConsultationRouteInput<Reservation>,
): Promise<PreparedConsultationRoute<Reservation>>;
export async function prepareConsultationRoute<Reservation, GuardResult>(
input: PrepareConsultationRouteInput<Reservation> | PrepareConsultationRouteWithGuard<Reservation, GuardResult>,
): Promise<PreparedConsultationRoute<Reservation, Awaited<GuardResult> | 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<GuardResult>
: undefined;
const reservation = await input.reserve();
return Object.freeze({ consultationMode, serverChart, generalDailyReference, reservation, preReserveResult });
}