Files
Jyotisha/frontend/src/lib/consultation-route-service.ts
T

626 lines
23 KiB
TypeScript

import type { ReportCandidateClockRange } from "./report-candidate-range";
import { chinaLocations } from "../data/china-locations.ts";
import { isBirthClockTime, parseBirthDate } from "./birth-time-intake-model.ts";
import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts";
import { effectiveBirthDate } from "./effective-birth-date.ts";
import {
declaredClockRangeFromProfile,
declaredRangeWrapsMidnight,
} from "./declared-birth-window.ts";
import {
isNatalMinuteConsultationMode,
type ConsultationBirthTimeMode,
} from "./consultation-birth-time-mode.ts";
import type { GeneralDailyReference } from "./general-daily-panchanga.ts";
import { resolveAyanamsa, type AyanamsaName } from "./ayanamsa.ts";
import {
ConsultationSubjectError,
resolveConsultationSubject,
type ConsultationSubjectBinding,
type OwnedChartProfile,
} from "./consultation-subject-resolver.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;
}
}
export type DeclaredBirthAccuracy = Readonly<{
declaredAccuracy: "rectified" | "minute" | "15min";
timeSource: string;
}>;
type ServerChartToolInput = Readonly<{
year: number;
month: number;
day: number;
hour: number;
minute: number;
city: string;
lat: number;
lon: number;
tz: number;
ayanamsa: AyanamsaName;
declared_accuracy: DeclaredBirthAccuracy["declaredAccuracy"];
time_source: string;
birth_time_accuracy?: "provisional";
candidate_range?: Readonly<{ start_time: string; end_time: string }>;
}>;
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;
}>;
}>;
export type DeclaredBirthWindowConsultation = Readonly<{
name: string;
toolInput: Readonly<{
year: number;
month: number;
day: number;
city: string;
lat: number;
lon: number;
tz: number;
ayanamsa: AyanamsaName;
rangeStart: string;
rangeEnd: string;
}>;
truth: Readonly<{
birthDate: string;
birthTimeSource: string;
birthTimePeriod: string | null;
birthTimeStatus: string;
wrapsMidnight: boolean;
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;
declaredWindow: DeclaredBirthWindowConsultation | null;
generalDailyReference: GeneralDailyReference | null;
}>;
type PrepareConsultationRouteInput<Reservation> = Readonly<{
userId: string;
mode: ConsultationBirthTimeMode;
loadProfile: (userId: string) => Promise<unknown>;
loadCandidateRange?: (userId: string) => Promise<ReportCandidateClockRange | null>;
resolveTimezoneOffset?: (profile: unknown, selectedTime?: string) => Promise<unknown>;
beforeReserve?: (context: ConsultationPreReserveContext) => unknown | Promise<unknown>;
reserve: () => Promise<Reservation>;
subject?: Readonly<{
binding: ConsultationSubjectBinding;
loadOwnedChartProfile: (chartProfileId: string) => Promise<OwnedChartProfile | null>;
clientBirth?: unknown;
}>;
}>;
type PrepareConsultationRouteWithGuard<Reservation, GuardResult> = Omit<
PrepareConsultationRouteInput<Reservation>,
"beforeReserve"
> & Readonly<{
beforeReserve: (context: ConsultationPreReserveContext) => GuardResult | Promise<GuardResult>;
}>;
export type PreparedConsultationSubject = Readonly<{
role: "self" | "other";
name: string;
chartProfileId: string | null;
}>;
export type PreparedConsultationRoute<Reservation, GuardResult = undefined> = Readonly<{
consultationMode: ConsultationBirthTimeMode;
serverChart: ServerChartConsultation | null;
declaredWindow: DeclaredBirthWindowConsultation | null;
generalDailyReference: GeneralDailyReference | null;
reservation: Reservation;
preReserveResult: GuardResult;
subject: PreparedConsultationSubject | null;
}>;
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",
]);
/**
* Map persisted profile truth onto the Python rectification-gate vocabulary.
* `rectified` is reserved for an accepted or confirmed active birth time.
*/
export function declaredBirthAccuracyFromProfile(input: {
birthTimeStatus: string;
birthTimeSource: string;
hasActiveBirthTime: boolean;
}): DeclaredBirthAccuracy {
if (
(input.birthTimeStatus === "accepted" || input.birthTimeStatus === "confirmed")
&& input.hasActiveBirthTime
) {
return { declaredAccuracy: "rectified", timeSource: "rectified" };
}
if (input.birthTimeSource === "hospital_record") {
return { declaredAccuracy: "minute", timeSource: "hospital" };
}
if (input.birthTimeSource === "family_exact") {
return { declaredAccuracy: "minute", timeSource: "family_clear" };
}
return { declaredAccuracy: "15min", timeSource: "family_vague" };
}
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 persistedConsultationMode(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";
if (status && allowedBirthTimeStatuses.has(status) && status !== "accepted" && status !== "confirmed"
&& source && (source === "period_only" || source === "unknown" || source === "legacy_import")
&& !isBirthClockTime(reportedTime)) return "declared_birth_window";
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: Extract<ConsultationBirthTimeMode, "verified_chart" | "unverified_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 = effectiveBirthDate(profile, mode === "verified_chart") ?? 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);
const accuracy = declaredBirthAccuracyFromProfile({
birthTimeStatus,
birthTimeSource,
hasActiveBirthTime: Boolean(activeBirthTime),
});
return Object.freeze({
name,
toolInput: Object.freeze({
year,
month,
day,
hour,
minute,
city: placeLabel,
lat: latitude,
lon: longitude,
tz: timezoneOffset,
ayanamsa: resolveAyanamsa(profile),
declared_accuracy: accuracy.declaredAccuracy,
time_source: accuracy.timeSource,
}),
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,
}),
});
}
function declaredWindowFromProfile(value: unknown): DeclaredBirthWindowConsultation {
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 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");
}
if (birthTimeStatus === "accepted" || birthTimeStatus === "confirmed") {
throw new ConsultationProfileTruthError("mode_changed");
}
if (concreteReportedSources.has(birthTimeSource) && nullableClock(profile, "reported_birth_time")) {
throw new ConsultationProfileTruthError("mode_changed");
}
const birthTimePeriod = optionalText(profile, "birth_time_period");
const range = declaredClockRangeFromProfile(profile);
if (!range) throw new ConsultationProfileTruthError("profile_incomplete");
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");
return Object.freeze({
name,
toolInput: Object.freeze({
year,
month,
day,
city: placeLabel,
lat: latitude,
lon: longitude,
tz: timezoneOffset,
ayanamsa: resolveAyanamsa(profile),
rangeStart: range.startTime,
rangeEnd: range.endTime,
}),
truth: Object.freeze({
birthDate,
birthTimeSource,
birthTimePeriod,
birthTimeStatus,
wrapsMidnight: declaredRangeWrapsMidnight(range),
placeLabel,
placeCodes: Object.freeze({
countryCode,
provinceCode,
cityCode,
districtCode,
}),
placeId,
placeType,
placeProvider,
timezoneId,
timezoneSource,
latitude,
longitude,
timezoneOffset,
}),
});
}
function reconcileConsultationMode(
requested: ConsultationBirthTimeMode,
profile: unknown,
): ConsultationBirthTimeMode {
const persisted = persistedConsultationMode(profile);
if (requested === "general_no_birth_time") return persisted ?? requested;
if (requested === "declared_birth_window") {
if (persisted === "verified_chart" || persisted === "unverified_birth_time") return persisted;
return "declared_birth_window";
}
return requested;
}
/**
* 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;
let subjectRole: "self" | "other" = "self";
let subject: PreparedConsultationSubject | null = null;
try {
if (input.subject) {
const resolved = await resolveConsultationSubject({
userId: input.userId,
binding: input.subject.binding,
loadSelfProfile: input.loadProfile,
loadOwnedChartProfile: input.subject.loadOwnedChartProfile,
clientBirth: input.subject.clientBirth,
});
subjectRole = resolved.role;
subject = {
role: resolved.role,
name: resolved.name,
chartProfileId: resolved.chartProfileId,
};
if (resolved.profile == null) throw new ConsultationProfileTruthError("profile_unavailable");
profile = resolved.profile;
} else {
profile = await input.loadProfile(input.userId);
}
} catch (error) {
if (error instanceof ConsultationSubjectError) throw error;
if (input.mode === "general_no_birth_time" && subjectRole !== "other") profile = null;
else if (error instanceof ConsultationProfileTruthError) throw error;
else throw new ConsultationProfileTruthError("profile_unavailable");
}
const consultationMode = reconcileConsultationMode(input.mode, profile);
let serverChart: ServerChartConsultation | null = null;
let declaredWindow: DeclaredBirthWindowConsultation | null = null;
const generalDailyReference = consultationMode === "general_no_birth_time"
|| consultationMode === "declared_birth_window"
? generalDailyReferenceFromProfile(profile)
: null;
if (isNatalMinuteConsultationMode(consultationMode)) {
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, useActiveDate: consultationMode === "verified_chart" })
)))(profile, selectedTime ?? undefined);
} catch {
throw new ConsultationProfileTruthError("profile_unavailable");
}
serverChart = serverChartFromProfile(profile, consultationMode);
if (
consultationMode === "verified_chart"
&& serverChart.truth.birthTimeStatus === "accepted"
&& subjectRole === "self"
&& input.loadCandidateRange
) {
try {
const range = await input.loadCandidateRange(input.userId);
if (range) {
serverChart = Object.freeze({
...serverChart,
toolInput: Object.freeze({
...serverChart.toolInput,
birth_time_accuracy: "provisional" as const,
candidate_range: Object.freeze({
start_time: range.startTime,
end_time: range.endTime,
...(range.candidate_intervals ? { candidate_intervals: range.candidate_intervals } : {}),
}),
}),
});
}
} catch {
// Fail closed: accepted charts still consult the representative minute.
}
}
} else if (consultationMode === "declared_birth_window") {
const range = declaredClockRangeFromProfile(record(profile) ?? {});
try {
profile = await (input.resolveTimezoneOffset ?? ((value, time) => (
resolveMissingBirthTimezoneOffset(value, { preferredTime: time })
)))(profile, range?.startTime);
} catch {
throw new ConsultationProfileTruthError("profile_unavailable");
}
declaredWindow = declaredWindowFromProfile(profile);
}
const preReserveResult = input.beforeReserve
? await input.beforeReserve({
consultationMode,
serverChart,
declaredWindow,
generalDailyReference,
}) as Awaited<GuardResult>
: undefined;
const reservation = await input.reserve();
return Object.freeze({
consultationMode,
serverChart,
declaredWindow,
generalDailyReference,
reservation,
preReserveResult,
subject,
});
}