565 lines
23 KiB
TypeScript
565 lines
23 KiB
TypeScript
import {
|
|
parseServerReportDocument,
|
|
computeEvidenceHash,
|
|
} from "./personal-report-contract.server-core.ts";
|
|
import { REPORT_DOCUMENT_SCHEMA_VERSION } from "./personal-report-contract.ts";
|
|
import type { ReportDocument } from "./personal-report-contract.ts";
|
|
|
|
/**
|
|
* Server-only persistence layer for personal reports (pure core).
|
|
*
|
|
* Routing: production callers pass the result of createServerSupabaseClient()
|
|
* (which already resolves self-hosted PostgreSQL vs Supabase). This module
|
|
* never inspects query-builder or pg specifics: it depends on the narrow
|
|
* PersonalReportDataClient port below, so unit tests can inject an in-memory
|
|
* fake and neither Supabase QueryBuilder nor node-postgres shapes leak into
|
|
* the API layer. The production entry personal-report-service.ts adds
|
|
* `import "server-only"` and re-exports this module.
|
|
*
|
|
* Ownership: every operation is scoped by userId; completeReady additionally
|
|
* verifies the validated document's reportId against the row id and the row's
|
|
* stored hash columns against the document (hashes are recomputed here, never
|
|
* trusted as model self-report).
|
|
*
|
|
* Idempotency: unique (user_id, request_id) is the primary lock. Replaying a
|
|
* known requestId requires the same requestFingerprint; a different
|
|
* fingerprint under the same requestId returns request_conflict instead of
|
|
* silently overwriting. Failed records are never implicitly resurrected by
|
|
* this service; callers reuse a failed record only via a new requestId.
|
|
*
|
|
* Privacy: this module never logs, indexes or returns birth details, report
|
|
* bodies, prompts, model text or exception stacks. failures carry a stable
|
|
* failure_code enum only.
|
|
*/
|
|
|
|
export const PERSONAL_REPORT_FAILURE_CODES = [
|
|
"profile_incomplete",
|
|
"birth_time_not_usable",
|
|
"report_generation_in_progress",
|
|
"report_rate_limited",
|
|
"calculation_unavailable",
|
|
"model_unavailable",
|
|
"report_schema_invalid",
|
|
"report_guard_rejected",
|
|
"report_not_found",
|
|
] as const;
|
|
export type PersonalReportFailureCode = (typeof PERSONAL_REPORT_FAILURE_CODES)[number];
|
|
|
|
export const PERSONAL_REPORT_STATUSES = ["generating", "ready", "failed"] as const;
|
|
export type PersonalReportStatus = (typeof PERSONAL_REPORT_STATUSES)[number];
|
|
|
|
export const PERSONAL_REPORT_TYPES = ["personal_full", "personal_thematic"] as const;
|
|
export const PERSONAL_REPORT_PRESENTATION_MODES = ["default", "research"] as const;
|
|
export const PERSONAL_REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const;
|
|
|
|
export type PersonalReportServiceErrorCode =
|
|
| "invalid_request"
|
|
| "not_found"
|
|
| "invalid_state"
|
|
| "generation_in_progress"
|
|
| "request_conflict"
|
|
| "invalid_document"
|
|
| "invalid_failure_code"
|
|
| "storage_failed";
|
|
|
|
export class PersonalReportServiceError extends Error {
|
|
readonly code: PersonalReportServiceErrorCode;
|
|
|
|
constructor(code: PersonalReportServiceErrorCode, message?: string) {
|
|
super(message ?? `Personal report service error: ${code}`);
|
|
this.name = "PersonalReportServiceError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
export type PersonalReportRecord = Readonly<{
|
|
id: string;
|
|
userId: string;
|
|
sessionId: string | null;
|
|
chartProfileId: string | null;
|
|
requestId: string;
|
|
requestFingerprint: string;
|
|
reportType: (typeof PERSONAL_REPORT_TYPES)[number];
|
|
status: PersonalReportStatus;
|
|
schemaVersion: string;
|
|
presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number];
|
|
depth: (typeof PERSONAL_REPORT_DEPTHS)[number];
|
|
requestedThemes: readonly string[];
|
|
reportDocument: ReportDocument | null;
|
|
calculationHash: string | null;
|
|
evidenceHash: string | null;
|
|
skillName: string | null;
|
|
skillVersion: string | null;
|
|
skillSourceCommit: string | null;
|
|
skillSnapshotSha256: string;
|
|
failureCode: PersonalReportFailureCode | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
completedAt: string | null;
|
|
}>;
|
|
|
|
export type CreateGeneratingInput = Readonly<{
|
|
userId: string;
|
|
requestId: string;
|
|
requestFingerprint: string;
|
|
reportType: (typeof PERSONAL_REPORT_TYPES)[number];
|
|
presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number];
|
|
depth: (typeof PERSONAL_REPORT_DEPTHS)[number];
|
|
requestedThemes?: readonly string[];
|
|
sessionId?: string | null;
|
|
chartProfileId?: string | null;
|
|
skillName: string;
|
|
skillVersion: string;
|
|
skillSourceCommit?: string | null;
|
|
skillSnapshotSha256: string;
|
|
}>;
|
|
|
|
export type CreateGeneratingResult =
|
|
| Readonly<{ kind: "created"; record: PersonalReportRecord }>
|
|
| Readonly<{ kind: "replayed"; record: PersonalReportRecord }>
|
|
| Readonly<{ kind: "request_conflict"; record: PersonalReportRecord }>
|
|
| Readonly<{ kind: "generation_in_progress"; record: PersonalReportRecord }>;
|
|
|
|
export type PersonalReportQueryResult = Readonly<{
|
|
data: unknown;
|
|
error: Readonly<{ message: string; code?: string }> | null;
|
|
count?: number | null;
|
|
}>;
|
|
|
|
/** Narrow structural port implemented by the in-memory fake and the adapter. */
|
|
export interface PersonalReportQueryBuilder extends PromiseLike<PersonalReportQueryResult> {
|
|
select(columns: string): PersonalReportQueryBuilder;
|
|
insert(row: Readonly<Record<string, unknown>>): PersonalReportQueryBuilder;
|
|
update(values: Readonly<Record<string, unknown>>): PersonalReportQueryBuilder;
|
|
delete(options?: Readonly<{ count?: string }>): PersonalReportQueryBuilder;
|
|
eq(column: string, value: unknown): PersonalReportQueryBuilder;
|
|
order(column: string, options?: Readonly<{ ascending?: boolean }>): PersonalReportQueryBuilder;
|
|
limit(value: number): PersonalReportQueryBuilder;
|
|
maybeSingle(): PromiseLike<PersonalReportQueryResult>;
|
|
single(): PromiseLike<PersonalReportQueryResult>;
|
|
}
|
|
|
|
export interface PersonalReportDataClient {
|
|
from(table: "personal_reports"): PersonalReportQueryBuilder;
|
|
}
|
|
|
|
export type PersonalReportServiceDeps = Readonly<{
|
|
now?: () => Date;
|
|
}>;
|
|
|
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
const sha256Pattern = /^[0-9a-f]{64}$/;
|
|
const sha1Pattern = /^[0-9a-f]{40}$/;
|
|
const skillNamePattern = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/;
|
|
const skillVersionPattern =
|
|
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
|
|
const RECORD_COLUMNS = [
|
|
"id",
|
|
"user_id",
|
|
"session_id",
|
|
"chart_profile_id",
|
|
"request_id",
|
|
"request_fingerprint",
|
|
"report_type",
|
|
"status",
|
|
"schema_version",
|
|
"presentation_mode",
|
|
"depth",
|
|
"requested_themes",
|
|
"report_document",
|
|
"calculation_hash",
|
|
"evidence_hash",
|
|
"skill_name",
|
|
"skill_version",
|
|
"skill_source_commit",
|
|
"skill_snapshot_sha256",
|
|
"failure_code",
|
|
"created_at",
|
|
"updated_at",
|
|
"completed_at",
|
|
].join(",");
|
|
|
|
type DbRow = Readonly<Record<string, unknown>>;
|
|
|
|
function requireUuid(value: unknown, field: string): string {
|
|
if (typeof value !== "string" || !uuidPattern.test(value)) {
|
|
throw new PersonalReportServiceError("invalid_request", `${field} must be a uuid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function optionalUuid(value: unknown, field: string): string | null {
|
|
if (value === null || value === undefined) return null;
|
|
return requireUuid(value, field);
|
|
}
|
|
|
|
function requireSha256(value: unknown, field: string): string {
|
|
if (typeof value !== "string" || !sha256Pattern.test(value)) {
|
|
throw new PersonalReportServiceError("invalid_request", `${field} must be a 64-char sha256 hex`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requireSkillName(value: unknown): string {
|
|
if (typeof value !== "string" || !skillNamePattern.test(value)) {
|
|
throw new PersonalReportServiceError("invalid_request", "skillName must be a registry package name");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requireSkillVersion(value: unknown): string {
|
|
if (typeof value !== "string" || !skillVersionPattern.test(value)) {
|
|
throw new PersonalReportServiceError("invalid_request", "skillVersion must be a semantic version");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function optionalCommit(value: unknown, field: string): string | null {
|
|
if (value === null || value === undefined) return null;
|
|
if (typeof value !== "string" || !sha1Pattern.test(value)) {
|
|
throw new PersonalReportServiceError("invalid_request", `${field} must be a 40-char commit sha`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function stringOrNull(value: unknown): string | null {
|
|
return typeof value === "string" ? value : null;
|
|
}
|
|
|
|
function recordFromRow(row: DbRow | null | undefined): PersonalReportRecord | null {
|
|
if (!row) return null;
|
|
const requestedThemes = Array.isArray(row.requested_themes)
|
|
? row.requested_themes.filter((theme): theme is string => typeof theme === "string")
|
|
: [];
|
|
const reportDocument = row.report_document === null || row.report_document === undefined
|
|
? null
|
|
: row.report_document as ReportDocument;
|
|
return {
|
|
id: stringOrNull(row.id) ?? "",
|
|
userId: stringOrNull(row.user_id) ?? "",
|
|
sessionId: stringOrNull(row.session_id),
|
|
chartProfileId: stringOrNull(row.chart_profile_id),
|
|
requestId: stringOrNull(row.request_id) ?? "",
|
|
requestFingerprint: stringOrNull(row.request_fingerprint) ?? "",
|
|
reportType: row.report_type as PersonalReportRecord["reportType"],
|
|
status: row.status as PersonalReportStatus,
|
|
schemaVersion: stringOrNull(row.schema_version) ?? "",
|
|
presentationMode: row.presentation_mode as PersonalReportRecord["presentationMode"],
|
|
depth: row.depth as PersonalReportRecord["depth"],
|
|
requestedThemes,
|
|
reportDocument,
|
|
calculationHash: stringOrNull(row.calculation_hash),
|
|
evidenceHash: stringOrNull(row.evidence_hash),
|
|
skillName: stringOrNull(row.skill_name),
|
|
skillVersion: stringOrNull(row.skill_version),
|
|
skillSourceCommit: stringOrNull(row.skill_source_commit),
|
|
skillSnapshotSha256: stringOrNull(row.skill_snapshot_sha256) ?? "",
|
|
failureCode: stringOrNull(row.failure_code) as PersonalReportFailureCode | null,
|
|
createdAt: stringOrNull(row.created_at) ?? "",
|
|
updatedAt: stringOrNull(row.updated_at) ?? "",
|
|
completedAt: stringOrNull(row.completed_at),
|
|
};
|
|
}
|
|
|
|
function normalizedResult(result: PersonalReportQueryResult): PersonalReportQueryResult {
|
|
return {
|
|
data: result.data,
|
|
error: result.error ? { message: result.error.message, code: result.error.code } : null,
|
|
...(typeof result.count === "number" ? { count: result.count } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Wraps any routed client (real Supabase or the local PostgreSQL compatibility
|
|
* client) into the narrow port. Every chain step is re-wrapped so callers only
|
|
* ever see PersonalReportQueryBuilder shapes.
|
|
*/
|
|
export function createPersonalReportDataClient(supabase: {
|
|
from(table: string): unknown;
|
|
}): PersonalReportDataClient {
|
|
type AnyBuilder = {
|
|
select(columns: string): unknown;
|
|
insert(row: unknown): unknown;
|
|
update(values: unknown): unknown;
|
|
delete(options?: unknown): unknown;
|
|
eq(column: string, value: unknown): unknown;
|
|
order(column: string, options?: unknown): unknown;
|
|
limit(value: number): unknown;
|
|
maybeSingle(): PromiseLike<PersonalReportQueryResult>;
|
|
single(): PromiseLike<PersonalReportQueryResult>;
|
|
then: PromiseLike<PersonalReportQueryResult>["then"];
|
|
};
|
|
|
|
const wrap = (builder: AnyBuilder): PersonalReportQueryBuilder => ({
|
|
select: (columns) => wrap(builder.select(columns) as AnyBuilder),
|
|
insert: (row) => wrap(builder.insert(row) as AnyBuilder),
|
|
update: (values) => wrap(builder.update(values) as AnyBuilder),
|
|
delete: (options) => wrap(builder.delete(options) as AnyBuilder),
|
|
eq: (column, value) => wrap(builder.eq(column, value) as AnyBuilder),
|
|
order: (column, options) => wrap(builder.order(column, options) as AnyBuilder),
|
|
limit: (value) => wrap(builder.limit(value) as AnyBuilder),
|
|
maybeSingle: () => builder.maybeSingle().then(normalizedResult),
|
|
single: () => builder.single().then(normalizedResult),
|
|
then: (onfulfilled, onrejected) =>
|
|
Promise.resolve(builder.then(normalizedResult)).then(onfulfilled, onrejected),
|
|
});
|
|
|
|
return {
|
|
from: (table) => wrap(supabase.from(table) as unknown as AnyBuilder),
|
|
};
|
|
}
|
|
|
|
export function createPersonalReportService(
|
|
client: PersonalReportDataClient,
|
|
deps: PersonalReportServiceDeps = {},
|
|
): PersonalReportService {
|
|
const now = deps.now ?? (() => new Date());
|
|
const records = () => client.from("personal_reports");
|
|
|
|
async function loadRow(
|
|
userId: string,
|
|
reportId: string,
|
|
): Promise<DbRow | null> {
|
|
const { data, error } = await records()
|
|
.select(RECORD_COLUMNS)
|
|
.eq("id", reportId)
|
|
.eq("user_id", userId)
|
|
.maybeSingle();
|
|
if (error) throw new PersonalReportServiceError("storage_failed", error.message);
|
|
return data as DbRow | null;
|
|
}
|
|
|
|
async function loadByRequest(
|
|
userId: string,
|
|
requestId: string,
|
|
): Promise<DbRow | null> {
|
|
const { data, error } = await records()
|
|
.select(RECORD_COLUMNS)
|
|
.eq("user_id", userId)
|
|
.eq("request_id", requestId)
|
|
.maybeSingle();
|
|
if (error) throw new PersonalReportServiceError("storage_failed", error.message);
|
|
return data as DbRow | null;
|
|
}
|
|
|
|
async function anyGeneratingFor(userId: string): Promise<DbRow | null> {
|
|
const { data, error } = await records()
|
|
.select(RECORD_COLUMNS)
|
|
.eq("user_id", userId)
|
|
.eq("status", "generating")
|
|
.limit(1)
|
|
.maybeSingle();
|
|
if (error) throw new PersonalReportServiceError("storage_failed", error.message);
|
|
return data as DbRow | null;
|
|
}
|
|
|
|
return {
|
|
async createGenerating(input: CreateGeneratingInput): Promise<CreateGeneratingResult> {
|
|
const userId = requireUuid(input.userId, "userId");
|
|
const requestId = requireUuid(input.requestId, "requestId");
|
|
const requestFingerprint = requireSha256(input.requestFingerprint, "requestFingerprint");
|
|
if (!PERSONAL_REPORT_TYPES.includes(input.reportType)) {
|
|
throw new PersonalReportServiceError("invalid_request", "unsupported reportType");
|
|
}
|
|
if (!PERSONAL_REPORT_PRESENTATION_MODES.includes(input.presentationMode)) {
|
|
throw new PersonalReportServiceError("invalid_request", "unsupported presentationMode");
|
|
}
|
|
if (!PERSONAL_REPORT_DEPTHS.includes(input.depth)) {
|
|
throw new PersonalReportServiceError("invalid_request", "unsupported depth");
|
|
}
|
|
optionalUuid(input.sessionId, "sessionId");
|
|
optionalUuid(input.chartProfileId, "chartProfileId");
|
|
requireSkillName(input.skillName);
|
|
requireSkillVersion(input.skillVersion);
|
|
requireSha256(input.skillSnapshotSha256, "skillSnapshotSha256");
|
|
optionalCommit(input.skillSourceCommit, "skillSourceCommit");
|
|
const themes = input.requestedThemes ?? [];
|
|
if (!Array.isArray(themes) || themes.length > 12 || themes.some((theme) => typeof theme !== "string" || theme.length > 64)) {
|
|
throw new PersonalReportServiceError("invalid_request", "invalid requestedThemes");
|
|
}
|
|
|
|
const row: Record<string, unknown> = {
|
|
user_id: userId,
|
|
request_id: requestId,
|
|
request_fingerprint: requestFingerprint,
|
|
report_type: input.reportType,
|
|
status: "generating",
|
|
schema_version: REPORT_DOCUMENT_SCHEMA_VERSION,
|
|
presentation_mode: input.presentationMode,
|
|
depth: input.depth,
|
|
requested_themes: themes,
|
|
skill_name: input.skillName,
|
|
skill_version: input.skillVersion,
|
|
skill_snapshot_sha256: input.skillSnapshotSha256,
|
|
session_id: input.sessionId ?? null,
|
|
chart_profile_id: input.chartProfileId ?? null,
|
|
skill_source_commit: input.skillSourceCommit ?? null,
|
|
created_at: now().toISOString(),
|
|
updated_at: now().toISOString(),
|
|
};
|
|
|
|
const { data, error } = await records()
|
|
.insert(row)
|
|
.select(RECORD_COLUMNS)
|
|
.single();
|
|
if (!error && data) return { kind: "created", record: recordFromRow(data as DbRow)! };
|
|
|
|
// The (user_id, request_id) unique constraint already holds this
|
|
// request, or the per-user in-flight index rejected a second
|
|
// generation. Re-read state instead of trusting backend error codes.
|
|
const existing = await loadByRequest(userId, requestId);
|
|
if (existing) {
|
|
if (existing.request_fingerprint === requestFingerprint) {
|
|
return { kind: "replayed", record: recordFromRow(existing)! };
|
|
}
|
|
return { kind: "request_conflict", record: recordFromRow(existing)! };
|
|
}
|
|
const inFlight = await anyGeneratingFor(userId);
|
|
if (inFlight) return { kind: "generation_in_progress", record: recordFromRow(inFlight)! };
|
|
throw new PersonalReportServiceError("storage_failed", error?.message ?? "insert failed");
|
|
},
|
|
|
|
async getByUserAndRequestId(userId: string, requestId: string): Promise<PersonalReportRecord | null> {
|
|
requireUuid(userId, "userId");
|
|
requireUuid(requestId, "requestId");
|
|
const row = await loadByRequest(userId, requestId);
|
|
return recordFromRow(row);
|
|
},
|
|
|
|
async getOwnedById(userId: string, reportId: string): Promise<PersonalReportRecord | null> {
|
|
requireUuid(userId, "userId");
|
|
requireUuid(reportId, "reportId");
|
|
const row = await loadRow(userId, reportId);
|
|
return recordFromRow(row);
|
|
},
|
|
|
|
async completeReady(
|
|
userId: string,
|
|
reportId: string,
|
|
document: unknown,
|
|
): Promise<PersonalReportRecord> {
|
|
requireUuid(userId, "userId");
|
|
requireUuid(reportId, "reportId");
|
|
let parsed: ReportDocument;
|
|
try {
|
|
parsed = parseServerReportDocument(document);
|
|
} catch (error) {
|
|
throw new PersonalReportServiceError(
|
|
"invalid_document",
|
|
error instanceof Error ? error.message : "report document failed contract validation",
|
|
);
|
|
}
|
|
if (parsed.reportId !== reportId) {
|
|
throw new PersonalReportServiceError("invalid_document", "document reportId does not match record id");
|
|
}
|
|
const row = await loadRow(userId, reportId);
|
|
if (!row) throw new PersonalReportServiceError("not_found");
|
|
if (row.status !== "generating") throw new PersonalReportServiceError("invalid_state", `status is ${String(row.status)}`);
|
|
|
|
// The generating row is the immutable server-side identity lock. The
|
|
// document may only become ready when all four Skill provenance fields
|
|
// exactly match it (including nullable sourceCommit). Optional name/version
|
|
// remain a read-compatibility allowance for historical ready documents,
|
|
// never an allowance for newly completed rows.
|
|
if ((parsed.provenance.skillName ?? null) !== (stringOrNull(row.skill_name) ?? null)
|
|
|| (parsed.provenance.skillVersion ?? null) !== (stringOrNull(row.skill_version) ?? null)
|
|
|| parsed.provenance.skillSnapshotSha256 !== stringOrNull(row.skill_snapshot_sha256)
|
|
|| parsed.provenance.skillSourceCommit !== (stringOrNull(row.skill_source_commit) ?? null)) {
|
|
throw new PersonalReportServiceError("invalid_document", "skill identity does not match stored row");
|
|
}
|
|
|
|
// Hashes are recomputed here and cross-checked against the stored row;
|
|
// provenance.evidenceHash was already verified by parseServerReportDocument.
|
|
const evidenceHash = computeEvidenceHash(parsed.evidenceAppendix);
|
|
if (row.evidence_hash !== null && row.evidence_hash !== evidenceHash) {
|
|
throw new PersonalReportServiceError("invalid_document", "evidence hash does not match stored row");
|
|
}
|
|
if (row.calculation_hash !== null && row.calculation_hash !== parsed.provenance.calculationHash) {
|
|
throw new PersonalReportServiceError("invalid_document", "calculation hash does not match stored row");
|
|
}
|
|
|
|
const completedAt = now().toISOString();
|
|
const { data, error } = await records()
|
|
.update({
|
|
status: "ready",
|
|
schema_version: parsed.schemaVersion,
|
|
report_document: parsed,
|
|
evidence_hash: evidenceHash,
|
|
calculation_hash: parsed.provenance.calculationHash,
|
|
completed_at: completedAt,
|
|
updated_at: completedAt,
|
|
})
|
|
.eq("id", reportId)
|
|
.eq("user_id", userId)
|
|
.eq("status", "generating")
|
|
.select(RECORD_COLUMNS)
|
|
.single();
|
|
if (!error && data) return recordFromRow(data as DbRow)!;
|
|
const current = await loadRow(userId, reportId);
|
|
if (!current) throw new PersonalReportServiceError("not_found");
|
|
throw new PersonalReportServiceError("invalid_state", `status is ${String(current.status)}`);
|
|
},
|
|
|
|
async markFailed(
|
|
userId: string,
|
|
reportId: string,
|
|
failureCode: string,
|
|
): Promise<PersonalReportRecord> {
|
|
requireUuid(userId, "userId");
|
|
requireUuid(reportId, "reportId");
|
|
if (!PERSONAL_REPORT_FAILURE_CODES.includes(failureCode as PersonalReportFailureCode)) {
|
|
throw new PersonalReportServiceError("invalid_failure_code", `unsupported failure code ${failureCode}`);
|
|
}
|
|
const row = await loadRow(userId, reportId);
|
|
if (!row) throw new PersonalReportServiceError("not_found");
|
|
if (row.status !== "generating") throw new PersonalReportServiceError("invalid_state", `status is ${String(row.status)}`);
|
|
|
|
const updatedAt = now().toISOString();
|
|
const { data, error } = await records()
|
|
.update({
|
|
status: "failed",
|
|
failure_code: failureCode,
|
|
updated_at: updatedAt,
|
|
})
|
|
.eq("id", reportId)
|
|
.eq("user_id", userId)
|
|
.eq("status", "generating")
|
|
.select(RECORD_COLUMNS)
|
|
.single();
|
|
if (!error && data) return recordFromRow(data as DbRow)!;
|
|
const current = await loadRow(userId, reportId);
|
|
if (!current) throw new PersonalReportServiceError("not_found");
|
|
throw new PersonalReportServiceError("invalid_state", `status is ${String(current.status)}`);
|
|
},
|
|
|
|
async deleteOwned(userId: string, reportId: string): Promise<boolean> {
|
|
requireUuid(userId, "userId");
|
|
requireUuid(reportId, "reportId");
|
|
const { error, count } = await records()
|
|
.delete({ count: "exact" })
|
|
.eq("id", reportId)
|
|
.eq("user_id", userId);
|
|
if (error) throw new PersonalReportServiceError("storage_failed", error.message);
|
|
return (count ?? 0) > 0;
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface PersonalReportService {
|
|
createGenerating(input: CreateGeneratingInput): Promise<CreateGeneratingResult>;
|
|
getByUserAndRequestId(userId: string, requestId: string): Promise<PersonalReportRecord | null>;
|
|
getOwnedById(userId: string, reportId: string): Promise<PersonalReportRecord | null>;
|
|
completeReady(userId: string, reportId: string, document: unknown): Promise<PersonalReportRecord>;
|
|
markFailed(userId: string, reportId: string, failureCode: string): Promise<PersonalReportRecord>;
|
|
deleteOwned(userId: string, reportId: string): Promise<boolean>;
|
|
}
|
|
|
|
/** Production wiring: routes through the caller's resolved backend client. */
|
|
export function createSupabasePersonalReportService(
|
|
supabase: { from(table: string): unknown },
|
|
deps?: PersonalReportServiceDeps,
|
|
): PersonalReportService {
|
|
return createPersonalReportService(createPersonalReportDataClient(supabase), deps);
|
|
}
|