234 lines
8.3 KiB
TypeScript
234 lines
8.3 KiB
TypeScript
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
import { z } from "zod";
|
|
import {
|
|
parseRectificationQuestionnaire,
|
|
parseRectificationScoring,
|
|
} from "./birth-time-journey-adapters.ts";
|
|
import {
|
|
candidateResultSchema,
|
|
journeySnapshotSchema,
|
|
lifeEventSchema,
|
|
} from "./birth-time-journey.ts";
|
|
import type { StoredRectificationCase } from "./birth-time-journey-service.ts";
|
|
import { evidenceDomains } from "./birth-time-question-planner.ts";
|
|
import {
|
|
dynamicJourneyTurnStateSchema,
|
|
evidenceDraftSchema,
|
|
journeyTurnStateSchema,
|
|
} from "./birth-time-journey-turn.ts";
|
|
import type { EvidenceDraft, JourneyTurnState } from "./birth-time-journey-turn.ts";
|
|
import {
|
|
dynamicTurnMatchesPrivateQuestion,
|
|
parseDynamicPrivateRow,
|
|
} from "./birth-time-journey-dynamic-state.ts";
|
|
import { BirthTimeJourneyStoreError } from "./birth-time-journey-store-errors.ts";
|
|
import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts";
|
|
|
|
type JourneyLoadResult = { readonly data: unknown; readonly error: unknown };
|
|
type JourneyLoadQuery = {
|
|
eq(column: string, value: string): JourneyLoadQuery;
|
|
maybeSingle(): PromiseLike<JourneyLoadResult>;
|
|
};
|
|
export type JourneyLoadClient = {
|
|
from(table: string): { select(columns: string): JourneyLoadQuery };
|
|
};
|
|
|
|
export function createJourneyLoadClient(supabase: SupabaseClient): JourneyLoadClient {
|
|
return {
|
|
from(table) {
|
|
return {
|
|
select(columns) {
|
|
const query = supabase.from(table).select(columns);
|
|
const loadQuery: JourneyLoadQuery = {
|
|
eq(column, value) {
|
|
query.eq(column, value);
|
|
return loadQuery;
|
|
},
|
|
async maybeSingle() {
|
|
const { data, error } = await query.maybeSingle();
|
|
return { data, error };
|
|
},
|
|
};
|
|
return loadQuery;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
const storedCaseSchema = z.object({
|
|
id: z.string().uuid(),
|
|
user_id: z.string().uuid(),
|
|
journey_protocol: z.enum(["legacy-guided-v1", "dynamic-choice-v2"]).default("legacy-guided-v1"),
|
|
journey_snapshot: journeySnapshotSchema,
|
|
questionnaire: z.record(z.unknown()),
|
|
answers: z.record(z.enum(["A", "B", "C", "D"])),
|
|
scoring_result: z.record(z.unknown()),
|
|
reported_date: z.string(),
|
|
life_events: z.array(lifeEventSchema).default([]),
|
|
candidate_result: z.record(z.unknown()).default({}),
|
|
turn_version: z.number().int().nonnegative().default(0),
|
|
turn_state: z.unknown().default({}),
|
|
evidence_draft: z.unknown().nullable().default(null),
|
|
processed_action_ids: z.array(z.string().uuid()).default([]),
|
|
adaptive_round: z.number().int().min(0).max(3).default(0),
|
|
asked_domains: z.array(z.enum(evidenceDomains)).default([]),
|
|
});
|
|
const eventLocationSchema = z.object({
|
|
latitude: z.number(),
|
|
longitude: z.number(),
|
|
timezone_offset: z.number(),
|
|
});
|
|
|
|
const requiredDynamicPublicFields = [
|
|
"journey_protocol",
|
|
"life_events",
|
|
"candidate_result",
|
|
"turn_version",
|
|
"turn_state",
|
|
"evidence_draft",
|
|
"processed_action_ids",
|
|
"adaptive_round",
|
|
"asked_domains",
|
|
] as const;
|
|
|
|
function hasRequiredDynamicPublicFields(value: unknown): boolean {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
return requiredDynamicPublicFields.every((field) => Object.prototype.hasOwnProperty.call(value, field));
|
|
}
|
|
|
|
function exactEmptyObject(value: unknown): boolean {
|
|
return typeof value === "object"
|
|
&& value !== null
|
|
&& !Array.isArray(value)
|
|
&& Object.keys(value).length === 0;
|
|
}
|
|
|
|
function parseLegacyTurn(value: unknown): JourneyTurnState | null {
|
|
if (exactEmptyObject(value)) return null;
|
|
const parsed = journeyTurnStateSchema.safeParse(value);
|
|
if (parsed.success) return parsed.data;
|
|
throw new BirthTimeJourneyStoreError("load_case");
|
|
}
|
|
|
|
function parseEvidenceDraft(value: unknown): EvidenceDraft | null {
|
|
if (value === null) return null;
|
|
const parsed = evidenceDraftSchema.safeParse(value);
|
|
if (parsed.success) return parsed.data;
|
|
throw new BirthTimeJourneyStoreError("load_case");
|
|
}
|
|
|
|
export async function loadStoredRectificationCase(
|
|
client: JourneyLoadClient,
|
|
userId: string,
|
|
caseId: string,
|
|
): Promise<StoredRectificationCase | null> {
|
|
const { data, error } = await client
|
|
.from("birth_time_rectification_cases")
|
|
.select("id,user_id,journey_protocol,journey_snapshot,questionnaire,answers,scoring_result,reported_date,life_events,candidate_result,turn_version,turn_state,evidence_draft,processed_action_ids,adaptive_round,asked_domains")
|
|
.eq("id", caseId)
|
|
.eq("user_id", userId)
|
|
.maybeSingle();
|
|
if (error) throw new BirthTimeJourneyStoreError("load_case");
|
|
if (!data) return null;
|
|
if (
|
|
typeof data === "object"
|
|
&& data !== null
|
|
&& "journey_protocol" in data
|
|
&& data.journey_protocol === "dynamic-choice-v2"
|
|
&& !hasRequiredDynamicPublicFields(data)
|
|
) throw new BirthTimeJourneyStoreError("load_case");
|
|
const parsed = storedCaseSchema.parse(data);
|
|
const { data: profile, error: profileError } = await client
|
|
.from("profiles")
|
|
.select("birth_date,reported_birth_time,birth_time_period,latitude,longitude,timezone_id,timezone_offset")
|
|
.eq("id", userId)
|
|
.maybeSingle();
|
|
if (profileError || !profile) throw new BirthTimeJourneyStoreError("load_case");
|
|
let resolvedProfile: unknown;
|
|
try {
|
|
resolvedProfile = await resolveMissingBirthTimezoneOffset(profile);
|
|
} catch {
|
|
throw new BirthTimeJourneyStoreError("load_case");
|
|
}
|
|
const location = eventLocationSchema.parse(resolvedProfile);
|
|
const scoring = Object.keys(parsed.scoring_result).length > 0
|
|
? parseRectificationScoring(parsed.scoring_result)
|
|
: undefined;
|
|
const questionnaire = Object.keys(parsed.questionnaire).length > 0
|
|
? parseRectificationQuestionnaire(parsed.questionnaire)
|
|
: null;
|
|
const candidateResult = Object.keys(parsed.candidate_result).length > 0
|
|
? candidateResultSchema.parse(parsed.candidate_result)
|
|
: null;
|
|
const turnState = parsed.journey_protocol === "legacy-guided-v1"
|
|
? parseLegacyTurn(parsed.turn_state)
|
|
: null;
|
|
const dynamicTurn = parsed.journey_protocol === "dynamic-choice-v2"
|
|
? dynamicJourneyTurnStateSchema.safeParse(parsed.turn_state)
|
|
: null;
|
|
if (dynamicTurn && !dynamicTurn.success) throw new BirthTimeJourneyStoreError("load_case");
|
|
const dynamicTurnState = dynamicTurn?.data ?? null;
|
|
if (
|
|
dynamicTurnState !== null
|
|
&& dynamicTurnState.turnVersion !== parsed.turn_version
|
|
) throw new BirthTimeJourneyStoreError("load_case");
|
|
const evidenceDraft = parseEvidenceDraft(parsed.evidence_draft);
|
|
let dynamicPrivate = null;
|
|
if (parsed.journey_protocol === "dynamic-choice-v2") {
|
|
const { data: privateRow, error: privateError } = await client
|
|
.from("birth_time_rectification_dynamic_state")
|
|
.select("case_id,user_id,candidate_model,current_choice_question,choice_answers,choice_evidence,dynamic_control,agent_context")
|
|
.eq("case_id", caseId)
|
|
.eq("user_id", userId)
|
|
.maybeSingle();
|
|
if (privateError) throw new BirthTimeJourneyStoreError("load_case");
|
|
dynamicPrivate = parseDynamicPrivateRow(privateRow, userId, caseId);
|
|
if (
|
|
dynamicTurnState === null
|
|
|| !dynamicTurnMatchesPrivateQuestion(dynamicTurnState, dynamicPrivate)
|
|
) throw new BirthTimeJourneyStoreError("load_case");
|
|
}
|
|
const common = {
|
|
id: parsed.id,
|
|
userId: parsed.user_id,
|
|
snapshot: parsed.journey_snapshot,
|
|
questionnaire,
|
|
answers: parsed.answers,
|
|
eventContext: {
|
|
birthDate: parsed.reported_date,
|
|
lat: location.latitude,
|
|
lon: location.longitude,
|
|
tz: location.timezone_offset,
|
|
},
|
|
lifeEvents: parsed.life_events,
|
|
candidateResult,
|
|
turnVersion: parsed.turn_version,
|
|
processedActionIds: parsed.processed_action_ids,
|
|
persistedProgress: {
|
|
adaptiveRound: parsed.adaptive_round,
|
|
askedDomains: parsed.asked_domains,
|
|
},
|
|
...(scoring ? { scoring } : {}),
|
|
};
|
|
if (parsed.journey_protocol === "legacy-guided-v1") {
|
|
return {
|
|
...common,
|
|
journeyProtocol: "legacy-guided-v1",
|
|
turnState,
|
|
evidenceDraft,
|
|
} satisfies StoredRectificationCase;
|
|
}
|
|
if (dynamicTurnState === null || dynamicPrivate === null || evidenceDraft !== null) {
|
|
throw new BirthTimeJourneyStoreError("load_case");
|
|
}
|
|
return {
|
|
...common,
|
|
journeyProtocol: "dynamic-choice-v2",
|
|
turnState: null,
|
|
dynamicTurnState,
|
|
evidenceDraft: null,
|
|
...dynamicPrivate,
|
|
} satisfies StoredRectificationCase;
|
|
}
|