/** * Ordinary-chat subject truth. * * `self` is the current user's `profiles` row. `other` is a `chart_profiles` * row owned by that user. Session name/role snapshots and client birth fields * are not chart truth. A failed other lookup never falls back to self. */ export const consultationSubjectErrorCodes = [ "subject_not_found", "subject_role_mismatch", "subject_incomplete", "subject_unavailable", "subject_locked", ] as const; export type ConsultationSubjectErrorCode = (typeof consultationSubjectErrorCodes)[number]; export class ConsultationSubjectError extends Error { readonly code: ConsultationSubjectErrorCode; constructor(code: ConsultationSubjectErrorCode) { super(`Consultation subject rejected: ${code}`); this.name = "ConsultationSubjectError"; this.code = code; } } export type ConsultationSubjectBinding = Readonly<{ chartProfileId: string | null; chartProfileRole: "self" | "other" | null; chartProfileName: string | null; }>; export type OwnedChartProfile = Readonly<{ id: string; userId: string; role: string; profile: unknown; }>; export type ResolvedConsultationSubject = Readonly<{ role: "self" | "other"; chartProfileId: string | null; name: string; profile: unknown; }>; export type ConsultationSubjectFailureBody = Readonly<{ error: string; message: string; code: "subject_incomplete" | "subject_unavailable" | "subject_locked"; }>; const bindingKeys = ["chart_profile_id", "chart_profile_name", "chart_profile_role"] as const; const emptyBinding: ConsultationSubjectBinding = { chartProfileId: null, chartProfileRole: null, chartProfileName: null, }; function textOrNull(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed ? trimmed : null; } function recordOf(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } function pickText(row: Record, keys: readonly string[]): string | null { for (const key of keys) { const value = textOrNull(row[key]); if (value) return value; } return null; } function pickNumber(row: Record, keys: readonly string[]): number | null { for (const key of keys) { const value = row[key]; if (typeof value === "number" && Number.isFinite(value)) return value; } return null; } function profileName(value: unknown): string { const row = recordOf(value); const name = row ? pickText(row, ["name"]) : null; return name ? name.slice(0, 80) : ""; } /** * Library rows are camelCase `Profile` objects. Consultation truth is the * snake_case account shape. Client request keys (`year`, `lat`, `city`, …) * are not read, so they cannot fill gaps. */ export function chartProfileToConsultationRow(value: unknown): Record | null { const row = recordOf(value); if (!row) return null; const status = pickText(row, ["birth_time_status", "birthTimeStatus"]); const verified = status === "accepted" || status === "confirmed"; const activeTime = verified ? pickText(row, ["active_birth_time", "time"]) : pickText(row, ["active_birth_time"]); return { name: pickText(row, ["name"]), birth_date: pickText(row, ["birth_date", "date"]), active_birth_date: pickText(row, ["active_birth_date", "activeDate"]), reported_birth_time: pickText(row, ["reported_birth_time", "reportedTime"]), active_birth_time: activeTime, birth_time_source: pickText(row, ["birth_time_source", "birthTimeSource"]), birth_time_period: pickText(row, ["birth_time_period", "birthTimePeriod"]), declared_window_start: pickText(row, ["declared_window_start", "declaredWindowStart"]), declared_window_end: pickText(row, ["declared_window_end", "declaredWindowEnd"]), birth_time_status: status, country_code: pickText(row, ["country_code", "countryCode"]), province_code: pickText(row, ["province_code", "provinceCode"]), city_code: pickText(row, ["city_code", "cityCode"]), district_code: pickText(row, ["district_code", "districtCode"]), latitude: pickNumber(row, ["latitude"]), longitude: pickNumber(row, ["longitude"]), timezone_offset: pickNumber(row, ["timezone_offset", "timezoneOffset"]), active_birth_timezone_offset: pickNumber(row, ["active_birth_timezone_offset", "activeTimezoneOffset"]), birth_place_label: pickText(row, ["birth_place_label", "birthPlaceLabel"]), birth_place_type: pickText(row, ["birth_place_type", "birthPlaceType"]), birth_place_provider: pickText(row, ["birth_place_provider", "birthPlaceProvider"]), birth_place_provider_id: pickText(row, ["birth_place_provider_id", "birthPlaceProviderId"]), timezone_id: pickText(row, ["timezone_id", "timezoneId"]), timezone_source: pickText(row, ["timezone_source", "timezoneSource"]), ayanamsa: pickText(row, ["ayanamsa"]), }; } function otherProfileComplete(row: Record): boolean { const name = typeof row.name === "string" && row.name.trim(); const birthDate = typeof row.birth_date === "string" && row.birth_date.trim(); const source = typeof row.birth_time_source === "string" && row.birth_time_source.trim(); const status = typeof row.birth_time_status === "string" && row.birth_time_status.trim(); const latitude = typeof row.latitude === "number" && Number.isFinite(row.latitude); const longitude = typeof row.longitude === "number" && Number.isFinite(row.longitude); const label = typeof row.birth_place_label === "string" && row.birth_place_label.trim(); const timezoneOffset = typeof row.timezone_offset === "number" && Number.isFinite(row.timezone_offset); const timezoneId = typeof row.timezone_id === "string" && row.timezone_id.trim(); return Boolean(name && birthDate && source && status && latitude && longitude && label && (timezoneOffset || timezoneId)); } export function consultationSubjectBindingFromSession(row: { chart_profile_id?: unknown; chart_profile_name?: unknown; chart_profile_role?: unknown; }): ConsultationSubjectBinding { const role = row.chart_profile_role === "self" || row.chart_profile_role === "other" ? row.chart_profile_role : null; return { chartProfileId: textOrNull(row.chart_profile_id), chartProfileRole: role, chartProfileName: textOrNull(row.chart_profile_name), }; } export function sessionHasMessages(messages: unknown): boolean { if (messages == null) return false; if (Array.isArray(messages)) return messages.length > 0; return true; } export function sessionBindingRequested(values: object): boolean { return bindingKeys.some((key) => Object.prototype.hasOwnProperty.call(values, key)); } export function sameSessionBinding( left: ConsultationSubjectBinding, right: ConsultationSubjectBinding, ): boolean { return textOrNull(left.chartProfileId) === textOrNull(right.chartProfileId) && (left.chartProfileRole ?? null) === (right.chartProfileRole ?? null) && textOrNull(left.chartProfileName) === textOrNull(right.chartProfileName); } async function readOwnedChart( load: (chartProfileId: string) => Promise, userId: string, chartProfileId: string, ): Promise { try { const row = await load(chartProfileId); if (row === null) return null; if (!row || typeof row.id !== "string" || typeof row.userId !== "string" || typeof row.role !== "string") { throw new ConsultationSubjectError("subject_unavailable"); } if (row.userId !== userId) throw new ConsultationSubjectError("subject_not_found"); return row; } catch (error) { if (error instanceof ConsultationSubjectError) throw error; throw new ConsultationSubjectError("subject_unavailable"); } } async function resolveOther( input: { userId: string; loadOwnedChartProfile: (chartProfileId: string) => Promise; }, chartProfileId: string, ): Promise { const row = await readOwnedChart(input.loadOwnedChartProfile, input.userId, chartProfileId); if (!row) throw new ConsultationSubjectError("subject_not_found"); if (row.role !== "other") throw new ConsultationSubjectError("subject_role_mismatch"); const profile = chartProfileToConsultationRow(row.profile); if (!profile || !otherProfileComplete(profile)) { throw new ConsultationSubjectError("subject_incomplete"); } return Object.freeze({ role: "other", chartProfileId: row.id, name: profileName(profile), profile: Object.freeze({ ...profile }), }); } export async function resolveConsultationSubject(input: { userId: string; binding: ConsultationSubjectBinding; loadSelfProfile: (userId: string) => Promise; loadOwnedChartProfile: (chartProfileId: string) => Promise; /** Present so callers can pass the request body. Never read for chart truth. */ clientBirth?: unknown; }): Promise { const id = textOrNull(input.binding.chartProfileId); const role = input.binding.chartProfileRole ?? null; const explicitOtherId = Boolean(id && id !== "self"); if (role === "other") { if (!explicitOtherId) throw new ConsultationSubjectError("subject_role_mismatch"); return resolveOther(input, id as string); } if (explicitOtherId && role !== "self") { return resolveOther(input, id as string); } if (role === "self" && explicitOtherId) { const row = await readOwnedChart(input.loadOwnedChartProfile, input.userId, id as string); if (!row) throw new ConsultationSubjectError("subject_not_found"); if (row.role !== "self") throw new ConsultationSubjectError("subject_role_mismatch"); } const selfRow = await input.loadSelfProfile(input.userId); return Object.freeze({ role: "self", chartProfileId: "self", name: profileName(selfRow), profile: selfRow, }); } function mergedBinding( requested: object, existing: ConsultationSubjectBinding | null, ): ConsultationSubjectBinding { const record = requested as Record; const base = existing ?? emptyBinding; const role = record.chart_profile_role; return { chartProfileId: Object.prototype.hasOwnProperty.call(record, "chart_profile_id") ? textOrNull(record.chart_profile_id) : base.chartProfileId, chartProfileRole: Object.prototype.hasOwnProperty.call(record, "chart_profile_role") ? role === "self" || role === "other" ? role : null : base.chartProfileRole, chartProfileName: Object.prototype.hasOwnProperty.call(record, "chart_profile_name") ? textOrNull(record.chart_profile_name) : base.chartProfileName, }; } export async function authoritativeSessionBinding(input: { userId: string; requested: object; existingMessages: unknown; existingBinding: ConsultationSubjectBinding | null; loadSelfProfile: (userId: string) => Promise; loadOwnedChartProfile: (chartProfileId: string) => Promise; }): Promise<{ chart_profile_id: string | null; chart_profile_name: string | null; chart_profile_role: "self" | "other" | null; } | null> { if (!sessionBindingRequested(input.requested)) return null; const requestedBinding = mergedBinding(input.requested, input.existingBinding); const existing = input.existingBinding ?? emptyBinding; if (sessionHasMessages(input.existingMessages)) { if (!sameSessionBinding(requestedBinding, existing)) { throw new ConsultationSubjectError("subject_locked"); } return { chart_profile_id: existing.chartProfileId, chart_profile_name: existing.chartProfileName, chart_profile_role: existing.chartProfileRole, }; } const resolved = await resolveConsultationSubject({ userId: input.userId, binding: requestedBinding, loadSelfProfile: input.loadSelfProfile, loadOwnedChartProfile: input.loadOwnedChartProfile, clientBirth: { name: requestedBinding.chartProfileName, }, }); return { chart_profile_id: resolved.role === "self" ? "self" : resolved.chartProfileId, chart_profile_name: resolved.name || null, chart_profile_role: resolved.role, }; } export function ownedChartProfileFromResult(result: { data: unknown; error: unknown; }): OwnedChartProfile | null { if (result.error) throw new ConsultationSubjectError("subject_unavailable"); const row = recordOf(result.data); if (!row) return null; if (typeof row.id !== "string" || typeof row.user_id !== "string" || typeof row.role !== "string") { throw new ConsultationSubjectError("subject_unavailable"); } return { id: row.id, userId: row.user_id, role: row.role, profile: row.profile }; } export function selfNameFromResult(result: { data: unknown; error: unknown }): unknown { if (result.error) throw new ConsultationSubjectError("subject_unavailable"); return result.data; } export function consultationSubjectFailureResponse(error: ConsultationSubjectError): { status: number; body: ConsultationSubjectFailureBody; } { if (error.code === "subject_locked") { return { status: 409, body: { error: "不能更换这段对话的人物", message: "这段对话已经开始。要换一个人,请新建对话。", code: "subject_locked", }, }; } if (error.code === "subject_incomplete") { return { status: 409, body: { error: "这份人物资料还不完整", message: "请补全出生日期、时间和地点后再发送,本次不会扣点。", code: "subject_incomplete", }, }; } if (error.code === "subject_unavailable") { return { status: 503, body: { error: "暂时无法核对人物资料", message: "请稍后重试,本次不会扣点。", code: "subject_unavailable", }, }; } return { status: 409, body: { error: "无法使用这份人物资料", message: "这份资料不存在、已删除,或与当前会话不一致。请新建对话后再试,本次不会扣点。", code: "subject_unavailable", }, }; }