Additive migration: nullable profiles.gender / chart_profiles.gender with a female/male CHECK, owner-only via existing table RLS, column grants mirroring the neighbouring birth columns (service_role for the account PATCH, no admin_runtime). Account PATCH/GET, people POST/PUT/GET, subject resolution and the consult route carry each person's own value; a people write only touches gender when the key is sent. DB test runs in the gate's DB job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
192 lines
9.7 KiB
TypeScript
192 lines
9.7 KiB
TypeScript
import { isAyanamsaName, resolveAyanamsa } from "./ayanamsa.ts";
|
|
import { parseProfileGender } from "./profile-gender.ts";
|
|
import { MAX_OTHER_SUBJECTS } from "./subject-birth.ts";
|
|
|
|
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
const DATE = /^(?:19|20)\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/;
|
|
const SOURCES = ["hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"] as const;
|
|
const PERIODS = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const;
|
|
const STATUSES = ["reported", "assessing", "rectifying", "candidate"] as const;
|
|
|
|
export type ChartSubjectWrite = {
|
|
readonly columns: Record<string, string | number | null>;
|
|
readonly profile: Record<string, unknown>;
|
|
};
|
|
|
|
function recordOf(value: unknown): Record<string, unknown> | null {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function text(value: unknown, max: number): string {
|
|
return typeof value === "string" ? value.trim().slice(0, max) : "";
|
|
}
|
|
|
|
function clock(value: unknown): string | null {
|
|
const raw = text(value, 8);
|
|
return CLOCK.test(raw) ? raw : null;
|
|
}
|
|
|
|
function date(value: unknown): string | null {
|
|
const raw = text(value, 10);
|
|
if (!DATE.test(raw)) return null;
|
|
const parsed = new Date(`${raw}T00:00:00Z`);
|
|
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== raw) return null;
|
|
return raw;
|
|
}
|
|
|
|
function finite(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim()) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function oneOf<T extends string>(value: unknown, allowed: readonly T[]): T | null {
|
|
const raw = text(value, 40);
|
|
return allowed.find((item) => item === raw) ?? null;
|
|
}
|
|
|
|
function minutes(value: unknown): number | null {
|
|
const parsed = finite(value);
|
|
if (parsed == null || !Number.isInteger(parsed) || parsed < 0 || parsed > 720) return null;
|
|
return parsed;
|
|
}
|
|
|
|
export function parseChartSubjectWrite(value: unknown): { ok: true; write: ChartSubjectWrite } | { ok: false; error: string } {
|
|
const profile = recordOf(value);
|
|
if (!profile) return { ok: false, error: "星盘资料格式不正确" };
|
|
if ("active_birth_time" in profile || "activeBirthTime" in profile
|
|
|| "active_birth_date" in profile || "activeDate" in profile
|
|
|| "active_birth_timezone_offset" in profile || "activeTimezoneOffset" in profile) {
|
|
return { ok: false, error: "不能直接写入排盘采用时间" };
|
|
}
|
|
const statusRaw = text(profile.birthTimeStatus ?? profile.birth_time_status, 40);
|
|
if (statusRaw === "accepted" || statusRaw === "confirmed") {
|
|
return { ok: false, error: "不能把出生时间标成已采用" };
|
|
}
|
|
const name = text(profile.name, 80);
|
|
if (!name) return { ok: false, error: "请填写称呼" };
|
|
const birthDate = date(profile.date ?? profile.birth_date);
|
|
const reported = clock(profile.reportedTime ?? profile.reported_birth_time);
|
|
const source = oneOf(profile.birthTimeSource ?? profile.birth_time_source, SOURCES);
|
|
const period = oneOf(profile.birthTimePeriod ?? profile.birth_time_period, PERIODS);
|
|
const windowStart = clock(profile.declaredWindowStart ?? profile.declared_window_start);
|
|
const windowEnd = clock(profile.declaredWindowEnd ?? profile.declared_window_end);
|
|
const before = minutes(profile.uncertaintyBeforeMinutes ?? profile.uncertainty_before_minutes);
|
|
const after = minutes(profile.uncertaintyAfterMinutes ?? profile.uncertainty_after_minutes);
|
|
const latitude = finite(profile.latitude);
|
|
const longitude = finite(profile.longitude);
|
|
if (latitude != null && (latitude < -90 || latitude > 90)) return { ok: false, error: "出生地坐标不正确" };
|
|
if (longitude != null && (longitude < -180 || longitude > 180)) return { ok: false, error: "出生地坐标不正确" };
|
|
const timezoneOffset = finite(profile.timezoneOffset ?? profile.timezone_offset);
|
|
const timezoneId = text(profile.timezoneId ?? profile.timezone_id, 80);
|
|
const label = text(profile.birthPlaceLabel ?? profile.birth_place_label, 160);
|
|
const status = oneOf(statusRaw, STATUSES) ?? (reported ? "reported" : null);
|
|
const ayanamsa = isAyanamsaName(profile.ayanamsa) ? profile.ayanamsa : resolveAyanamsa(profile);
|
|
// Optional gender: written only when the client sends the key, so a caller
|
|
// that does not know the field never clears it. null / "" clear it.
|
|
const genderSent = Object.prototype.hasOwnProperty.call(profile, "gender");
|
|
const gender = parseProfileGender(profile.gender);
|
|
if (genderSent && profile.gender != null && profile.gender !== "" && !gender) {
|
|
return { ok: false, error: "性别只能选女、男或不填" };
|
|
}
|
|
const columns: Record<string, string | number | null> = {
|
|
name,
|
|
birth_date: birthDate,
|
|
reported_birth_time: reported,
|
|
birth_time_source: source,
|
|
birth_time_period: period,
|
|
declared_window_start: windowStart,
|
|
declared_window_end: windowEnd,
|
|
uncertainty_before_minutes: before,
|
|
uncertainty_after_minutes: after,
|
|
latitude,
|
|
longitude,
|
|
timezone_id: timezoneId || null,
|
|
timezone_offset: timezoneOffset,
|
|
birth_place_label: label || null,
|
|
birth_place_type: text(profile.birthPlaceType ?? profile.birth_place_type, 40) || null,
|
|
birth_place_provider: text(profile.birthPlaceProvider ?? profile.birth_place_provider, 40) || null,
|
|
birth_place_provider_id: text(profile.birthPlaceProviderId ?? profile.birth_place_provider_id, 120) || null,
|
|
ayanamsa,
|
|
birth_time_status: status,
|
|
...(genderSent ? { gender } : {}),
|
|
};
|
|
const mirror = {
|
|
name,
|
|
date: birthDate ?? "",
|
|
time: reported ?? "",
|
|
reportedTime: reported ?? "",
|
|
birthTimeSource: source ?? "",
|
|
birthTimePeriod: period ?? "",
|
|
declaredWindowStart: windowStart ?? "",
|
|
declaredWindowEnd: windowEnd ?? "",
|
|
uncertaintyBeforeMinutes: before,
|
|
uncertaintyAfterMinutes: after,
|
|
birthTimeStatus: status ?? "",
|
|
countryCode: text(profile.countryCode ?? profile.country_code, 8) || "CN",
|
|
provinceCode: text(profile.provinceCode ?? profile.province_code, 20),
|
|
cityCode: text(profile.cityCode ?? profile.city_code, 20),
|
|
districtCode: text(profile.districtCode ?? profile.district_code, 20),
|
|
birthPlaceLabel: label,
|
|
birthPlaceType: columns.birth_place_type ?? "",
|
|
birthPlaceProvider: columns.birth_place_provider ?? "",
|
|
birthPlaceProviderId: columns.birth_place_provider_id ?? "",
|
|
timezoneId,
|
|
timezoneSource: text(profile.timezoneSource ?? profile.timezone_source, 40),
|
|
latitude,
|
|
longitude,
|
|
timezoneOffset,
|
|
ayanamsa,
|
|
};
|
|
return { ok: true, write: { columns, profile: mirror } };
|
|
}
|
|
|
|
export function libraryProfileFromSubjectRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
const stored = recordOf(row.profile) ?? {};
|
|
const reported = typeof row.reported_birth_time === "string" ? row.reported_birth_time.slice(0, 5) : "";
|
|
const active = typeof row.active_birth_time === "string" ? row.active_birth_time.slice(0, 5) : "";
|
|
const status = typeof row.birth_time_status === "string" ? row.birth_time_status : "";
|
|
const chartTime = status === "accepted" || status === "confirmed" ? (active || reported) : reported;
|
|
return {
|
|
name: typeof row.name === "string" ? row.name : "",
|
|
date: typeof row.birth_date === "string" ? row.birth_date.slice(0, 10) : "",
|
|
time: chartTime,
|
|
reportedTime: reported,
|
|
birthTimeSource: typeof row.birth_time_source === "string" ? row.birth_time_source : "",
|
|
birthTimePeriod: typeof row.birth_time_period === "string" ? row.birth_time_period : "",
|
|
declaredWindowStart: typeof row.declared_window_start === "string" ? row.declared_window_start.slice(0, 5) : "",
|
|
declaredWindowEnd: typeof row.declared_window_end === "string" ? row.declared_window_end.slice(0, 5) : "",
|
|
uncertaintyBeforeMinutes: typeof row.uncertainty_before_minutes === "number" ? row.uncertainty_before_minutes : null,
|
|
uncertaintyAfterMinutes: typeof row.uncertainty_after_minutes === "number" ? row.uncertainty_after_minutes : null,
|
|
birthTimeStatus: status,
|
|
countryCode: typeof stored.countryCode === "string" ? stored.countryCode : "CN",
|
|
provinceCode: typeof stored.provinceCode === "string" ? stored.provinceCode : "",
|
|
cityCode: typeof stored.cityCode === "string" ? stored.cityCode : "",
|
|
districtCode: typeof stored.districtCode === "string" ? stored.districtCode : "",
|
|
birthPlaceLabel: typeof row.birth_place_label === "string" ? row.birth_place_label : "",
|
|
birthPlaceType: typeof row.birth_place_type === "string" ? row.birth_place_type : "",
|
|
birthPlaceProvider: typeof row.birth_place_provider === "string" ? row.birth_place_provider : "",
|
|
birthPlaceProviderId: typeof row.birth_place_provider_id === "string" ? row.birth_place_provider_id : "",
|
|
timezoneId: typeof row.timezone_id === "string" ? row.timezone_id : "",
|
|
timezoneSource: typeof stored.timezoneSource === "string" ? stored.timezoneSource : "",
|
|
latitude: typeof row.latitude === "number" ? row.latitude : null,
|
|
longitude: typeof row.longitude === "number" ? row.longitude : null,
|
|
timezoneOffset: typeof row.timezone_offset === "number" ? row.timezone_offset : null,
|
|
ayanamsa: resolveAyanamsa(row),
|
|
...(parseProfileGender(row.gender) ? { gender: parseProfileGender(row.gender) } : {}),
|
|
...(typeof row.active_birth_date === "string" ? { activeDate: row.active_birth_date.slice(0, 10) } : {}),
|
|
...(typeof row.active_birth_timezone_offset === "number"
|
|
? { activeTimezoneOffset: row.active_birth_timezone_offset }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export function subjectLimitReached(otherCount: number): boolean {
|
|
return otherCount >= MAX_OTHER_SUBJECTS;
|
|
}
|