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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown>): 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<string, unknown>) {
|
||||
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*;/);
|
||||
});
|
||||
Reference in New Issue
Block a user