diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 461d64f8..6c144987 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -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; diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 8f89c1c2..49f10cea 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -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"); diff --git a/frontend/src/lib/account-profile-patch.ts b/frontend/src/lib/account-profile-patch.ts index a4d7a1a9..50ce0fce 100644 --- a/frontend/src/lib/account-profile-patch.ts +++ b/frontend/src/lib/account-profile-patch.ts @@ -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; diff --git a/frontend/src/lib/chart-subject-write.ts b/frontend/src/lib/chart-subject-write.ts index d638d024..d94c7c70 100644 --- a/frontend/src/lib/chart-subject-write.ts +++ b/frontend/src/lib/chart-subject-write.ts @@ -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 = { 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): 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 } diff --git a/frontend/src/lib/consultation-route-service.ts b/frontend/src/lib/consultation-route-service.ts index 3d14ac2e..a4d7e42e 100644 --- a/frontend/src/lib/consultation-route-service.ts +++ b/frontend/src/lib/consultation-route-service.ts @@ -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, diff --git a/frontend/src/lib/consultation-subject-resolver.ts b/frontend/src/lib/consultation-subject-resolver.ts index 46e56455..769155e9 100644 --- a/frontend/src/lib/consultation-subject-resolver.ts +++ b/frontend/src/lib/consultation-subject-resolver.ts @@ -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 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 } : {}), }; } diff --git a/frontend/src/lib/home-types.ts b/frontend/src/lib/home-types.ts index aa3be4ea..d31d2ff3 100644 --- a/frontend/src/lib/home-types.ts +++ b/frontend/src/lib/home-types.ts @@ -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"; diff --git a/frontend/src/lib/profile-gender.ts b/frontend/src/lib/profile-gender.ts new file mode 100644 index 00000000..f9d58b3b --- /dev/null +++ b/frontend/src/lib/profile-gender.ts @@ -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> = { + 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; +} diff --git a/frontend/src/lib/subject-birth.ts b/frontend/src/lib/subject-birth.ts index f07ce145..c3073c30 100644 --- a/frontend/src/lib/subject-birth.ts +++ b/frontend/src/lib/subject-birth.ts @@ -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 { diff --git a/frontend/supabase/migrations/20260927010000_profile_gender.sql b/frontend/supabase/migrations/20260927010000_profile_gender.sql new file mode 100644 index 00000000..da49e470 --- /dev/null +++ b/frontend/supabase/migrations/20260927010000_profile_gender.sql @@ -0,0 +1,48 @@ +-- Optional gender for the household profile and every saved person +-- (TASK-consult-gender-optional-20260927). Consultation reads it to pick the +-- spouse significator on the marriage evidence card; empty means "not filled". +-- +-- Additive only (AGENTS §7.6): two nullable columns with no default and no +-- backfill, a CHECK that only constrains the new column, and column grants +-- that mirror the neighbouring birth columns. The code deployed before this +-- migration never selects or writes `gender`, so the migrate-then-deploy +-- window is safe; rolling the application back leaves the column unused. +-- +-- Privacy: same level as birth data. RLS stays table-level (owner only); +-- admin_runtime gets no grant, like birth_date. +begin; + +alter table public.profiles + add column if not exists gender text; + +alter table public.profiles + drop constraint if exists profiles_gender_check; + +alter table public.profiles + add constraint profiles_gender_check + check (gender is null or gender in ('female', 'male')); + +-- authenticated already has table-level SELECT on profiles (owner-only RLS). +grant update (gender) on table public.profiles to authenticated; +-- The account PATCH writes through service_role (BUG-600: a column without an +-- explicit service_role grant fails with "permission denied"). +grant select (gender) on table public.profiles to service_role; +grant insert (gender) on table public.profiles to service_role; +grant update (gender) on table public.profiles to service_role; + +alter table public.chart_profiles + add column if not exists gender text; + +alter table public.chart_profiles + drop constraint if exists chart_profiles_gender_check; + +alter table public.chart_profiles + add constraint chart_profiles_gender_check + check (gender is null or gender in ('female', 'male')); + +-- authenticated already has table-level SELECT on chart_profiles (owner-only RLS); +-- insert / update are column grants, so the new column is listed explicitly. +grant insert (gender) on table public.chart_profiles to authenticated; +grant update (gender) on table public.chart_profiles to authenticated; + +commit; diff --git a/frontend/tests/database-profile-gender.test.ts b/frontend/tests/database-profile-gender.test.ts new file mode 100644 index 00000000..3577280f --- /dev/null +++ b/frontend/tests/database-profile-gender.test.ts @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts"; + +// TASK-consult-gender-optional-20260927 T1. Fictional people only; no real +// birth data or gender. Runs in the gate's DB job (needs Docker). +const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const migrationSql = readFileSync( + new URL("../supabase/migrations/20260927010000_profile_gender.sql", import.meta.url), + "utf8", +); +const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0; +const APP = ["app_runtime", "app-runtime-test-password"] as const; +const SERVICE = ["service_runtime", "service-runtime-test-password"] as const; +const ADMIN = ["admin_runtime", "admin-runtime-test-password"] as const; + +function applyMigrations(fixture: PostgresFixture): void { + const result = spawnSync(process.execPath, [runner], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") }, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); +} + +function lastLine(value: string): string { + const lines = value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return lines[lines.length - 1] ?? ""; +} + +function asUser(fixture: PostgresFixture, userId: string, sql: string): string { + return lastLine(fixture.psqlAs(...APP, `set role authenticated; select set_config('request.jwt.claim.sub', '${userId}', true); ${sql}`)); +} + +function createUser(fixture: PostgresFixture, label: string): string { + const id = randomUUID(); + fixture.psql(`insert into identity.users (id, name, email, email_verified, email_verified_at) + values ('${id}', 'Fictional ${label}', '${id}@example.invalid', true, now());`); + return id; +} + +/** The column set the deployed (pre-gender) people route inserts. */ +function legacySubjectInsert(id: string, owner: string, name: string): string { + return `insert into public.chart_profiles (id, user_id, role, profile, updated_at, name, birth_date, + reported_birth_time, birth_time_source, latitude, longitude, timezone_id, timezone_offset, + birth_place_label, ayanamsa, birth_time_status) + values ('${id}', '${owner}', 'other', '{"name":"${name}"}'::jsonb, now(), '${name}', '1990-01-01', + '08:00', 'family_exact', 22.3, 114.1, 'Asia/Shanghai', 8, '虚构港', 'raman', 'reported');`; +} + +test("gender migration is additive: nullable, checked, owner-only, and legacy writes still work", { + skip: docker ? false : "docker unavailable", +}, () => { + const fixture = startPostgresFixture(); + try { + assert.doesNotMatch(migrationSql, /drop column|alter column|rename|set not null|update public\./i); + assert.doesNotMatch(migrationSql, /grant[^;]*gender[^;]*admin_runtime/i); + applyMigrations(fixture); + + for (const table of ["profiles", "chart_profiles"]) { + assert.equal( + fixture.psql(`select is_nullable || '|' || coalesce(column_default, 'none') || '|' || data_type + from information_schema.columns + where table_schema = 'public' and table_name = '${table}' and column_name = 'gender'`), + "YES|none|text", + `${table}.gender must be a nullable text column without a default`, + ); + // Same privacy level as birth_date: no admin_runtime read, no anon read. + assert.equal(fixture.psql(`select has_column_privilege('admin_runtime', 'public.${table}', 'gender', 'select')`), "f"); + assert.equal(fixture.psql(`select has_column_privilege('anon', 'public.${table}', 'gender', 'select')`), "f"); + assert.equal(fixture.psql(`select has_column_privilege('authenticated', 'public.${table}', 'gender', 'select')`), "t"); + assert.equal(fixture.psql(`select has_column_privilege('authenticated', 'public.${table}', 'gender', 'update')`), "t"); + } + assert.equal(fixture.psql("select has_column_privilege('service_role', 'public.profiles', 'gender', 'update')"), "t"); + assert.equal(fixture.psql("select has_column_privilege('service_role', 'public.profiles', 'gender', 'select')"), "t"); + + const owner = createUser(fixture, "gender owner"); + const stranger = createUser(fixture, "gender stranger"); + // Existing rows read as "not filled". + assert.equal(fixture.psql(`select gender is null from public.profiles where id = '${owner}'`), "t"); + + // The deployed (pre-gender) people insert still succeeds and leaves it empty. + const legacyId = randomUUID(); + fixture.psqlAs(...APP, `set role authenticated; select set_config('request.jwt.claim.sub', '${owner}', true); + ${legacySubjectInsert(legacyId, owner, "虚构甲")}`); + assert.equal(fixture.psql(`select gender is null from public.chart_profiles where id = '${legacyId}'`), "t"); + + // CHECK: only female / male / null. + assert.throws(() => fixture.psql(`update public.profiles set gender = 'other' where id = '${owner}'`), /profiles_gender_check/); + assert.throws(() => fixture.psql(`update public.chart_profiles set gender = '女' where id = '${legacyId}'`), /chart_profiles_gender_check/); + + // Owner reads and writes their own; a stranger sees nothing and changes nothing. + asUser(fixture, owner, `update public.profiles set gender = 'female' where id = '${owner}';`); + assert.equal(asUser(fixture, owner, `select gender from public.profiles where id = '${owner}';`), "female"); + assert.equal(asUser(fixture, stranger, `select count(*) from public.profiles where id = '${owner}';`), "0"); + asUser(fixture, stranger, `update public.profiles set gender = 'male' where id = '${owner}';`); + assert.equal(fixture.psql(`select gender from public.profiles where id = '${owner}'`), "female"); + + asUser(fixture, owner, `update public.chart_profiles set gender = 'male' where id = '${legacyId}';`); + assert.equal(asUser(fixture, owner, `select gender from public.chart_profiles where id = '${legacyId}';`), "male"); + assert.equal(asUser(fixture, stranger, `select count(*) from public.chart_profiles where id = '${legacyId}';`), "0"); + asUser(fixture, stranger, `update public.chart_profiles set gender = 'female' where id = '${legacyId}';`); + assert.equal(fixture.psql(`select gender from public.chart_profiles where id = '${legacyId}'`), "male"); + + // Clearing back to "not filled" is allowed. + asUser(fixture, owner, `update public.chart_profiles set gender = null where id = '${legacyId}';`); + assert.equal(fixture.psql(`select gender is null from public.chart_profiles where id = '${legacyId}'`), "t"); + + // The account PATCH path writes through service_role. + fixture.psqlAs(...SERVICE, `set role service_role; update public.profiles set gender = 'male' where id = '${owner}';`); + assert.equal(fixture.psql(`select gender from public.profiles where id = '${owner}'`), "male"); + + assert.throws( + () => fixture.psqlAs(...ADMIN, `select gender from public.profiles where id = '${owner}';`), + /permission denied/, + ); + } finally { + fixture.stop(); + } +}); + +test("people and account routes read and write gender on real PostgreSQL without cross-person leaks", { + skip: docker ? false : "docker unavailable", +}, () => { + const fixture = startPostgresFixture(); + try { + applyMigrations(fixture); + const owner = createUser(fixture, "route owner"); + const stranger = createUser(fixture, "route stranger"); + const script = ` + import { mock } from 'node:test'; + import { createLocalPostgresDataClient, closeLocalPostgresDataPools } from './src/lib/db/local-postgres-client-core.ts'; + const appUrl = ${JSON.stringify(fixture.connectionUrl(...APP))}; + const serviceUrl = ${JSON.stringify(fixture.connectionUrl(...SERVICE))}; + const owner = ${JSON.stringify(owner)}; + const stranger = ${JSON.stringify(stranger)}; + let current = owner; + const clientFor = (id) => { + const local = createLocalPostgresDataClient(appUrl, { id }); + return { + from: local.from.bind(local), + rpc: local.rpc.bind(local), + auth: { getUser: async () => ({ data: { user: { id, email: id + '@example.invalid' } }, error: null }) }, + }; + }; + mock.module('server-only', { namedExports: {} }); + mock.module('@/lib/supabase/server', { namedExports: { createServerSupabaseClient: async () => clientFor(current) } }); + mock.module('@/lib/supabase/admin', { namedExports: { + createAdminSupabaseClient: () => createLocalPostgresDataClient(serviceUrl, null, 'service_role'), + isAdminUser: async () => false, + isAdminEmail: () => false, + } }); + const people = await import('./src/app/api/chart-profiles/route.ts'); + const person = await import('./src/app/api/chart-profiles/[id]/route.ts'); + const account = await import('./src/app/api/account/route.ts'); + const json = (method, body) => new Request('https://example.invalid/api', { + method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), + }); + const profile = (extra) => ({ + name: '虚构乙', date: '1992-06-15', reportedTime: '07:40', birthTimeSource: 'family_exact', + birthPlaceLabel: '虚构港', latitude: 22.3, longitude: 114.1, timezoneOffset: 8, timezoneId: 'Asia/Shanghai', + ayanamsa: 'lahiri', ...extra, + }); + const out = {}; + const created = await people.POST(json('POST', { role: 'other', profile: profile({ gender: 'female' }) })); + const createdBody = await created.json(); + out.createdStatus = created.status; + out.createdGender = createdBody.profile?.profile?.gender ?? null; + const id = createdBody.profile?.id; + const params = { params: Promise.resolve({ id }) }; + const kept = await person.PUT(json('PUT', { profile: profile({ name: '虚构乙改' }) }), params); + out.keptGender = (await kept.json()).profile?.profile?.gender ?? null; + const cleared = await person.PUT(json('PUT', { profile: profile({ gender: null }) }), params); + out.clearedHasGender = 'gender' in ((await cleared.json()).profile?.profile ?? {}); + const male = await person.PUT(json('PUT', { profile: profile({ gender: 'male' }) }), params); + out.maleGender = (await male.json()).profile?.profile?.gender ?? null; + const invalid = await person.PUT(json('PUT', { profile: profile({ gender: 'x' }) }), params); + out.invalidStatus = invalid.status; + const listed = await (await people.GET()).json(); + out.listedGender = listed.profiles?.[0]?.profile?.gender ?? null; + const patched = await account.PATCH(json('PATCH', { gender: 'female' })); + out.patchStatus = patched.status; + const badPatch = await account.PATCH(json('PATCH', { gender: 'other' })); + out.badPatchStatus = badPatch.status; + current = stranger; + const foreign = await person.GET(new Request('https://example.invalid/api'), params); + out.foreignStatus = foreign.status; + const foreignList = await (await people.GET()).json(); + out.foreignCount = foreignList.profiles?.length ?? -1; + out.id = id; + console.log(JSON.stringify(out)); + await closeLocalPostgresDataPools(); + `; + const result = spawnSync( + process.execPath, + ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script], + { encoding: "utf8", env: { ...process.env, AUTH_PROVIDER: "self-hosted" } }, + ); + assert.equal(result.status, 0, result.stderr); + const out = JSON.parse(result.stdout.trim().split("\n").at(-1) || "{}"); + assert.equal(out.createdStatus, 200); + assert.equal(out.createdGender, "female"); + assert.equal(out.keptGender, "female", "a PUT without the gender key keeps the stored value"); + assert.equal(out.clearedHasGender, false, "gender null clears it (not filled)"); + assert.equal(out.maleGender, "male"); + assert.equal(out.invalidStatus, 400); + assert.equal(out.listedGender, "male"); + assert.equal(out.patchStatus, 200); + assert.equal(out.badPatchStatus, 400); + assert.equal(out.foreignStatus, 404); + assert.equal(out.foreignCount, 0); + assert.equal(fixture.psql(`select gender from public.profiles where id = '${owner}'`), "female"); + assert.equal(fixture.psql(`select gender from public.chart_profiles where id = '${out.id}'`), "male"); + assert.equal(fixture.psql(`select gender is null from public.profiles where id = '${stranger}'`), "t"); + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/profile-gender-20260927.test.ts b/frontend/tests/profile-gender-20260927.test.ts new file mode 100644 index 00000000..ebc953a4 --- /dev/null +++ b/frontend/tests/profile-gender-20260927.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { accountProfilePatchSchema } from "../src/lib/account-profile-patch.ts"; +import { libraryProfileFromSubjectRow, parseChartSubjectWrite } from "../src/lib/chart-subject-write.ts"; +import { prepareConsultationRoute } from "../src/lib/consultation-route-service.ts"; +import { chartProfileToConsultationRow, type OwnedChartProfile } from "../src/lib/consultation-subject-resolver.ts"; +import { readProfile } from "../src/lib/home-profile.ts"; +import { + PROFILE_GENDER_HINT, + parseProfileGender, + profileGenderLabel, +} from "../src/lib/profile-gender.ts"; +import { CHART_SUBJECT_API_SELECT, CHART_SUBJECT_SELECT, resolveSubjectBirth } from "../src/lib/subject-birth.ts"; + +// TASK-consult-gender-optional-20260927 T1 (data layer). Fictional people only. + +const selfRow = Object.freeze({ + name: "虚构户主", + birth_date: "1990-01-02", + reported_birth_time: "08:15:00", + active_birth_time: null, + birth_time_source: "family_exact", + birth_time_status: "reported", + birth_place_label: "虚构城", + latitude: 31.2, + longitude: 121.5, + timezone_offset: 8, + timezone_id: "Asia/Shanghai", + ayanamsa: "raman", + gender: "female", +}); + +function typedOther(extra: Record = {}) { + return { + name: "虚构乙", + birth_date: "1988-03-04", + reported_birth_time: "09:20:00", + birth_time_source: "family_exact", + birth_time_status: "reported", + birth_place_label: "虚构港", + latitude: 22.3, + longitude: 114.2, + timezone_offset: 8, + timezone_id: "Asia/Shanghai", + ayanamsa: "lahiri", + ...extra, + }; +} + +function owned(profile: Record): OwnedChartProfile { + return { id: "00000000-0000-4000-8000-000000000002", userId: "user-1", role: "other", profile }; +} + +async function prepare(binding: { chartProfileId: string | null; chartProfileRole: "self" | "other" | null }, other: Record) { + return prepareConsultationRoute({ + userId: "user-1", + mode: "unverified_birth_time", + subject: { + binding: { ...binding, chartProfileName: null }, + loadOwnedChartProfile: async () => owned(other), + }, + loadProfile: async () => selfRow, + resolveTimezoneOffset: async (value) => value, + reserve: async () => "reserved", + }); +} + +test("gender values: female / male / null only, labelled 女 / 男 / 未填", () => { + assert.equal(parseProfileGender("female"), "female"); + assert.equal(parseProfileGender("male"), "male"); + for (const value of [null, undefined, "", "女", "other", 1]) assert.equal(parseProfileGender(value), null); + assert.equal(profileGenderLabel("female"), "女"); + assert.equal(profileGenderLabel("male"), "男"); + assert.equal(profileGenderLabel(null), "未填"); + assert.equal(PROFILE_GENDER_HINT, "用于婚恋解读里判断夫星 / 妻星,不填也能用。"); +}); + +test("account PATCH accepts gender alone, clears it with null, and rejects anything else", () => { + assert.equal(accountProfilePatchSchema.safeParse({ gender: "female" }).success, true); + assert.equal(accountProfilePatchSchema.safeParse({ gender: "male" }).success, true); + assert.equal(accountProfilePatchSchema.safeParse({ gender: null }).success, true); + assert.equal(accountProfilePatchSchema.safeParse({ gender: "other" }).success, false); + assert.equal(accountProfilePatchSchema.safeParse({ gender: "女" }).success, false); + const route = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); + assert.match(route, /payload\.gender !== undefined \? \{ gender: payload\.gender \}/); + assert.match(route, /rectification_case_id,gender"\)/, "GET returns the stored gender"); +}); + +test("people writes carry gender only when sent; reads expose it only when stored", () => { + const base = { name: "虚构乙", date: "1988-03-04", reportedTime: "09:20", birthTimeSource: "family_exact" }; + const absent = parseChartSubjectWrite(base); + assert.ok(absent.ok); + assert.equal("gender" in absent.write.columns, false, "a client that does not send gender never clears it"); + const female = parseChartSubjectWrite({ ...base, gender: "female" }); + assert.ok(female.ok); + assert.equal(female.write.columns.gender, "female"); + assert.equal("gender" in female.write.profile, false, "gender stays out of the rollback jsonb mirror"); + for (const cleared of [null, ""]) { + const result = parseChartSubjectWrite({ ...base, gender: cleared }); + assert.ok(result.ok); + assert.equal(result.write.columns.gender, null); + } + const invalid = parseChartSubjectWrite({ ...base, gender: "other" }); + assert.equal(invalid.ok, false); + + assert.equal(libraryProfileFromSubjectRow({ gender: "male" }).gender, "male"); + assert.equal("gender" in libraryProfileFromSubjectRow({ gender: null }), false); + assert.equal(readProfile({ gender: "female" }).gender, "female"); + assert.equal("gender" in readProfile({ gender: "x" }), false); + assert.match(CHART_SUBJECT_SELECT, /,gender(,|$)/); + assert.match(CHART_SUBJECT_API_SELECT, /,gender,/); +}); + +test("subject resolution carries each person's own gender and never the owner's", async () => { + assert.equal(chartProfileToConsultationRow({ ...typedOther(), gender: "male" })?.gender, "male"); + assert.equal(chartProfileToConsultationRow(typedOther())?.gender, null); + + const other = await resolveSubjectBirth({ + userId: "user-1", + subjectId: "00000000-0000-4000-8000-000000000002", + loadSelfRow: async () => selfRow, + loadOtherRow: async () => ({ + id: "00000000-0000-4000-8000-000000000002", + user_id: "user-1", + role: "other", + ...typedOther(), + }), + }); + assert.equal(other.row.gender, null, "an empty other person does not inherit the owner's gender"); + + const self = await prepare({ chartProfileId: "self", chartProfileRole: "self" }, typedOther({ gender: "male" })); + assert.equal(self.serverChart?.gender, "female"); + const male = await prepare( + { chartProfileId: "00000000-0000-4000-8000-000000000002", chartProfileRole: "other" }, + typedOther({ gender: "male" }), + ); + assert.equal(male.serverChart?.gender, "male"); + const unset = await prepare( + { chartProfileId: "00000000-0000-4000-8000-000000000002", chartProfileRole: "other" }, + typedOther(), + ); + assert.equal(unset.serverChart?.gender, null, "the owner is female; the other person stays not filled"); + assert.equal("gender" in (unset.serverChart?.toolInput ?? {}), false, "gender is never sent to the engine"); +}); + +test("the migration is additive and backward compatible with the deployed code", () => { + const sql = readFileSync(new URL("../supabase/migrations/20260927010000_profile_gender.sql", import.meta.url), "utf8") + .replace(/--.*$/gm, ""); + assert.match(sql, /alter table public\.profiles\s+add column if not exists gender text;/); + assert.match(sql, /alter table public\.chart_profiles\s+add column if not exists gender text;/); + assert.match(sql, /check \(gender is null or gender in \('female', 'male'\)\)/); + assert.match(sql, /grant update \(gender\) on table public\.profiles to service_role;/); + assert.match(sql, /grant update \(gender\) on table public\.chart_profiles to authenticated;/); + assert.doesNotMatch(sql, /not null|default|drop column|alter column|rename|update public\./i); + assert.doesNotMatch(sql, /admin_runtime\s*;/); +});