Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
112 lines
4.7 KiB
TypeScript
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;
|
|
}
|
|
}
|