feat(profile): optional gender on the owner profile and every saved person (T1)
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dd85d9965d
commit
371fe68b51
@@ -50,7 +50,7 @@ export async function GET() {
|
||||
const admin = createAdminSupabaseClient();
|
||||
let { data: profile, error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("credits,active_birth_time,birth_time_status,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,ayanamsa,name,birth_time,rectification_case_id")
|
||||
.select("credits,active_birth_time,birth_time_status,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,ayanamsa,name,birth_time,rectification_case_id,gender")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
@@ -66,6 +66,7 @@ export async function GET() {
|
||||
declared_window_start: null,
|
||||
declared_window_end: null,
|
||||
ayanamsa: undefined,
|
||||
gender: undefined,
|
||||
};
|
||||
profileError = withoutDeclaredWindow.error;
|
||||
} else if (withoutDeclaredWindow.error && isMissingProfileColumn(withoutDeclaredWindow.error)) {
|
||||
@@ -85,6 +86,7 @@ export async function GET() {
|
||||
timezone_id: undefined,
|
||||
timezone_source: undefined,
|
||||
ayanamsa: undefined,
|
||||
gender: undefined,
|
||||
} : null;
|
||||
profileError = fallback.error;
|
||||
} else {
|
||||
@@ -286,6 +288,7 @@ export async function PATCH(request: Request) {
|
||||
...(payload.timezone_id !== undefined ? { timezone_id: payload.timezone_id } : {}),
|
||||
...(payload.timezone_source !== undefined ? { timezone_source: payload.timezone_source } : {}),
|
||||
...(payload.ayanamsa !== undefined ? { ayanamsa: payload.ayanamsa } : {}),
|
||||
...(payload.gender !== undefined ? { gender: payload.gender } : {}),
|
||||
};
|
||||
const withoutCoordinates = baseProfile;
|
||||
const invalidatesUnconfirmedApplication = Object.keys(applicationPatch).length > 0;
|
||||
|
||||
@@ -431,7 +431,7 @@ export async function POST(request: Request) {
|
||||
async loadProfile(profileUserId) {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("name,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,ayanamsa")
|
||||
.select("name,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,ayanamsa,gender")
|
||||
.eq("id", profileUserId)
|
||||
.single();
|
||||
if (error || !data) throw new ConsultationProfileTruthError("profile_unavailable");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { isBirthClockTime, parseBirthDate } from "./birth-time-intake-model.ts";
|
||||
import { AYANAMSA_VALUES } from "./ayanamsa.ts";
|
||||
import { PROFILE_GENDERS } from "./profile-gender.ts";
|
||||
|
||||
const nullableTrimmedString = (maximum: number) => z.string().trim().min(1).max(maximum).nullable();
|
||||
const nullableBirthDate = z.string().refine((value) => parseBirthDate(value) !== undefined, {
|
||||
@@ -54,6 +55,8 @@ export const accountProfilePatchSchema = z.object({
|
||||
timezone_id: nullableTrimmedString(80).optional(),
|
||||
timezone_source: z.literal("iana_historical").nullable().optional(),
|
||||
ayanamsa: z.enum(AYANAMSA_VALUES).optional(),
|
||||
// Optional (TASK-consult-gender-optional-20260927); null clears it.
|
||||
gender: z.enum(PROFILE_GENDERS).nullable().optional(),
|
||||
}).strict().superRefine((value, context) => {
|
||||
const source = value.birth_time_source;
|
||||
const time = value.reported_birth_time;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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$/;
|
||||
@@ -86,6 +87,13 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart
|
||||
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,
|
||||
@@ -106,6 +114,7 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart
|
||||
birth_place_provider_id: text(profile.birthPlaceProviderId ?? profile.birth_place_provider_id, 120) || null,
|
||||
ayanamsa,
|
||||
birth_time_status: status,
|
||||
...(genderSent ? { gender } : {}),
|
||||
};
|
||||
const mirror = {
|
||||
name,
|
||||
@@ -169,6 +178,7 @@ export function libraryProfileFromSubjectRow(row: Record<string, unknown>): Reco
|
||||
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 }
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "./consultation-birth-time-mode.ts";
|
||||
import type { GeneralDailyReference } from "./general-daily-panchanga.ts";
|
||||
import { resolveAyanamsa, type AyanamsaName } from "./ayanamsa.ts";
|
||||
import { parseProfileGender, type ProfileGender } from "./profile-gender.ts";
|
||||
import {
|
||||
ConsultationSubjectError,
|
||||
resolveConsultationSubject,
|
||||
@@ -84,6 +85,12 @@ export type ServerChartConsultation = Readonly<{
|
||||
longitude: number;
|
||||
timezoneOffset: number;
|
||||
}>;
|
||||
/**
|
||||
* The bound subject's optional gender (self: profiles, other: that person's
|
||||
* chart_profiles row). Not birth truth and never sent to the engine; only the
|
||||
* marriage evidence card and checklist read it.
|
||||
*/
|
||||
gender?: ProfileGender | null;
|
||||
}>;
|
||||
|
||||
export type DeclaredBirthWindowConsultation = Readonly<{
|
||||
@@ -368,6 +375,7 @@ function serverChartFromProfile(
|
||||
|
||||
return Object.freeze({
|
||||
name,
|
||||
gender: parseProfileGender(profile.gender),
|
||||
toolInput: Object.freeze({
|
||||
year,
|
||||
month,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* falls back to self.
|
||||
*/
|
||||
|
||||
import { parseProfileGender } from "./profile-gender.ts";
|
||||
import {
|
||||
ConsultationSubjectError,
|
||||
resolveSubjectBirth,
|
||||
@@ -124,6 +125,7 @@ export function chartProfileToConsultationRow(value: unknown): Record<string, un
|
||||
timezone_id: pickText(row, ["timezone_id", "timezoneId"]),
|
||||
timezone_source: pickText(row, ["timezone_source", "timezoneSource"]),
|
||||
ayanamsa: pickText(row, ["ayanamsa"]),
|
||||
gender: parseProfileGender(row.gender),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/lib/home-types";
|
||||
import { resolveAyanamsa } from "@/lib/ayanamsa";
|
||||
import { effectiveBirthDate } from "@/lib/effective-birth-date";
|
||||
import { parseProfileGender } from "@/lib/profile-gender";
|
||||
|
||||
export function findProvince(code: string) {
|
||||
return china.provinces.find((province) => province.code === code);
|
||||
@@ -317,6 +318,7 @@ export function readProfile(value: unknown): Profile {
|
||||
longitude,
|
||||
timezoneOffset,
|
||||
ayanamsa: resolveAyanamsa(profile),
|
||||
...(parseProfileGender(profile.gender) ? { gender: parseProfileGender(profile.gender) } : {}),
|
||||
...(chartRelationship ? { chartRelationship } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BeamAvatar } from "@/lib/beam-avatar";
|
||||
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
|
||||
import type { BirthTimeDraft } from "@/lib/birth-time-intake-model";
|
||||
import { DEFAULT_AYANAMSA, type AyanamsaName } from "@/lib/ayanamsa";
|
||||
import type { ProfileGender } from "@/lib/profile-gender";
|
||||
import type { AgentActivityView, ChatMessage } from "@/lib/chat-message-view";
|
||||
import type { ConsultationTimelineRow } from "@/lib/consultation-run-timeline";
|
||||
import type { PublicThinkingSection } from "@/lib/consultation-thinking-plan";
|
||||
@@ -32,6 +33,8 @@ export type Profile = BirthTimeDraft & {
|
||||
activeDate?: string;
|
||||
activeTimezoneOffset?: number;
|
||||
ayanamsa: AyanamsaName;
|
||||
/** Optional; absent or null = not filled (TASK-consult-gender-optional-20260927). */
|
||||
gender?: ProfileGender | null;
|
||||
chartRelationship?: ChartRelationship;
|
||||
};
|
||||
export type ChartRelationship = "self" | "partner" | "family" | "friend" | "client" | "other";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Optional gender on a chart profile (TASK-consult-gender-optional-20260927).
|
||||
*
|
||||
* Stored as `female` / `male`; null (or a missing key) means "not filled".
|
||||
* The value only decides which spouse significator the marriage evidence card
|
||||
* and the condensed checklist name; it never changes the engine calculation.
|
||||
* Privacy: same level as birth data (never in logs, telemetry, feedback
|
||||
* records, BUG history or test fixtures with real data).
|
||||
*/
|
||||
export const PROFILE_GENDERS = ["female", "male"] as const;
|
||||
|
||||
export type ProfileGender = (typeof PROFILE_GENDERS)[number];
|
||||
|
||||
export const PROFILE_GENDER_LABELS: Readonly<Record<ProfileGender, string>> = {
|
||||
female: "女",
|
||||
male: "男",
|
||||
};
|
||||
|
||||
/** The radio's third choice, stored as null. */
|
||||
export const PROFILE_GENDER_UNSET_LABEL = "不填";
|
||||
|
||||
/** Detail pages show this when nothing is stored. */
|
||||
export const PROFILE_GENDER_EMPTY_LABEL = "未填";
|
||||
|
||||
export const PROFILE_GENDER_TITLE = "性别(选填)";
|
||||
|
||||
export const PROFILE_GENDER_HINT = "用于婚恋解读里判断夫星 / 妻星,不填也能用。";
|
||||
|
||||
/** A stored value, or null for anything else (missing, empty, unknown). */
|
||||
export function parseProfileGender(value: unknown): ProfileGender | null {
|
||||
return value === "female" || value === "male" ? value : null;
|
||||
}
|
||||
|
||||
export function profileGenderLabel(value: unknown): string {
|
||||
const gender = parseProfileGender(value);
|
||||
return gender ? PROFILE_GENDER_LABELS[gender] : PROFILE_GENDER_EMPTY_LABEL;
|
||||
}
|
||||
@@ -36,9 +36,9 @@ export class ConsultationSubjectError extends Error {
|
||||
|
||||
export const SELF_SUBJECT_ID = "self";
|
||||
|
||||
export const CHART_SUBJECT_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status";
|
||||
export const CHART_SUBJECT_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender";
|
||||
|
||||
export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,profile,updated_at";
|
||||
export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,profile,updated_at";
|
||||
|
||||
export const MAX_OTHER_SUBJECTS = 4;
|
||||
export const MAX_SAVED_SUBJECTS = 5;
|
||||
@@ -185,6 +185,8 @@ export async function resolveSubjectBirth(input: {
|
||||
birth_place_type: row.birth_place_type ?? null,
|
||||
birth_place_provider: row.birth_place_provider ?? null,
|
||||
birth_place_provider_id: row.birth_place_provider_id ?? null,
|
||||
// Not birth truth: the marriage evidence card reads it (gender-optional task).
|
||||
gender: row.gender ?? null,
|
||||
};
|
||||
let chartable = false;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user