fix(consult): resolve chat subject from server-owned profile binding
This commit is contained in:
@@ -13,6 +13,12 @@ import {
|
||||
} 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"
|
||||
@@ -133,6 +139,11 @@ type PrepareConsultationRouteInput<Reservation> = Readonly<{
|
||||
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<
|
||||
@@ -142,6 +153,12 @@ type PrepareConsultationRouteWithGuard<Reservation, GuardResult> = Omit<
|
||||
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;
|
||||
@@ -149,6 +166,7 @@ export type PreparedConsultationRoute<Reservation, GuardResult = undefined> = Re
|
||||
generalDailyReference: GeneralDailyReference | null;
|
||||
reservation: Reservation;
|
||||
preReserveResult: GuardResult;
|
||||
subject: PreparedConsultationSubject | null;
|
||||
}>;
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
@@ -500,10 +518,31 @@ 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 {
|
||||
profile = await input.loadProfile(input.userId);
|
||||
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 (input.mode === "general_no_birth_time") profile = null;
|
||||
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");
|
||||
}
|
||||
@@ -531,6 +570,7 @@ export async function prepareConsultationRoute<Reservation, GuardResult>(
|
||||
if (
|
||||
consultationMode === "verified_chart"
|
||||
&& serverChart.truth.birthTimeStatus === "accepted"
|
||||
&& subjectRole === "self"
|
||||
&& input.loadCandidateRange
|
||||
) {
|
||||
try {
|
||||
@@ -580,5 +620,6 @@ export async function prepareConsultationRoute<Reservation, GuardResult>(
|
||||
generalDailyReference,
|
||||
reservation,
|
||||
preReserveResult,
|
||||
subject,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* 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<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function pickText(row: Record<string, unknown>, 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<string, unknown>, 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<string, unknown> | 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<string, unknown>): 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<OwnedChartProfile | null>,
|
||||
userId: string,
|
||||
chartProfileId: string,
|
||||
): Promise<OwnedChartProfile | null> {
|
||||
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<OwnedChartProfile | null>;
|
||||
},
|
||||
chartProfileId: string,
|
||||
): Promise<ResolvedConsultationSubject> {
|
||||
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<unknown>;
|
||||
loadOwnedChartProfile: (chartProfileId: string) => Promise<OwnedChartProfile | null>;
|
||||
/** Present so callers can pass the request body. Never read for chart truth. */
|
||||
clientBirth?: unknown;
|
||||
}): Promise<ResolvedConsultationSubject> {
|
||||
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<string, unknown>;
|
||||
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<unknown>;
|
||||
loadOwnedChartProfile: (chartProfileId: string) => Promise<OwnedChartProfile | null>;
|
||||
}): 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user