Files
Jyotisha/frontend/src/lib/account-deletion.ts
T

112 lines
4.7 KiB
TypeScript

/**
* Self-service account deletion (2026-09-30). Shared by the settings entry,
* the frozen-account screen, the API route and the purge worker. Pure: no
* server imports, so client components can use it.
*
* Rules (product): request → signed out everywhere, account frozen; within 7
* days signing in again offers 撤销注销; after 7 days personal content is
* deleted and the identity becomes an anonymous tombstone. Orders, the credit
* ledger, usage and billing rows are kept for bookkeeping, pointing at that
* tombstone. Remaining credits are forfeited.
*/
export const ACCOUNT_DELETION_GRACE_DAYS = 7;
/** Typed by the user to confirm, the same way the staging reset asks for a phrase. */
export const ACCOUNT_DELETION_CONFIRM_WORD = "注销";
/** Stable code every paid route returns while a deletion is pending. */
export const ACCOUNT_DELETION_PENDING_CODE = "account_deletion_pending";
/** Mirrors `public.account_deletion_kept_tables()`; everything else the user owns is deleted. */
export const ACCOUNT_DELETION_KEPT_TABLES = [
"account_deletion_requests",
"admin_session_revocations",
"admin_user_roles",
"admin_users",
"birth_time_rectification_billing",
"consultation_requests",
"credit_request_cancellations",
"credit_transactions",
"payment_orders",
"pricing_experiment_events",
"redemption_attempts",
"redemption_codes",
"usage_ledger",
"usage_reservations",
"user_product_redemptions",
"user_subscriptions",
// Complaint handling records; the typed-in contact is cleared at purge.
"user_feedback",
] as const;
/** What the confirmation dialog tells the user will be deleted. */
export const ACCOUNT_DELETION_DELETED_ITEMS = [
"全部对话记录",
"你和星盘档案里其他人的出生资料与星盘",
"个人报告",
"生时校正记录",
"合盘记录",
"账号本身(邮箱、昵称、登录方式)",
] as const;
export type AccountDeletionStatus =
| Readonly<{ status: "none" }>
| Readonly<{ status: "pending"; requestedAt: string | null; scheduledFor: string }>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isoOrNull(value: unknown): string | null {
if (typeof value !== "string" && !(value instanceof Date)) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
/** Reads the status the API returns (and the RPC result); anything unusable is "none". */
export function parseAccountDeletionStatus(value: unknown): AccountDeletionStatus {
if (!isRecord(value) || value.status !== "pending") return { status: "none" };
const scheduledFor = isoOrNull(value.scheduledFor);
if (!scheduledFor) return { status: "none" };
return { status: "pending", requestedAt: isoOrNull(value.requestedAt), scheduledFor };
}
/** 「10 月 7 日」 in Beijing time — the day the deletion becomes permanent. */
export function formatAccountDeletionDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "七天后";
const parts = new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", month: "numeric", day: "numeric" }).formatToParts(date);
const month = parts.find((part) => part.type === "month")?.value;
const day = parts.find((part) => part.type === "day")?.value;
return month && day ? `${month} 月 ${day} 日` : "七天后";
}
export function isAccountDeletionConfirmation(input: unknown): boolean {
return typeof input === "string" && input.trim() === ACCOUNT_DELETION_CONFIRM_WORD;
}
/** Maps a database error from the request RPC to a stable route code. */
export function accountDeletionRequestErrorCode(message: string | undefined): "admin_account" | "not_found" | "unavailable" {
if (message?.includes("account_deletion_admin_account")) return "admin_account";
if (message?.includes("account_deletion_user_not_found")) return "not_found";
return "unavailable";
}
export type AccountDeletionRpcClient = {
rpc(name: string, args: Readonly<Record<string, unknown>>): PromiseLike<{ data: unknown; error: { message?: string } | null }>;
};
/**
* The permanent-deletion date when the account has a pending request, else
* null. A read failure (for example before the migration has run) reads as
* "not pending": this guard must never lock people out by accident.
*/
export async function readPendingAccountDeletionWith(client: AccountDeletionRpcClient, userId: string): Promise<string | null> {
try {
const { data, error } = await client.rpc("account_deletion_scheduled_for", { p_user_id: userId });
if (error || data === null || data === undefined) return null;
return isoOrNull(data);
} catch {
return null;
}
}