import type { EkklesiaUserId } from "@ekklesia/contracts"; import { eq, sql } from "drizzle-orm"; import { ekklesiaCoreUser } from "../identity/schema.js"; import type { AccountDrizzleDatabase } from "./database.js"; import { type AccountPreferences, type AccountPreferencesUpdate, parseAccountPreferencesUpdate, resolveAccountPreferences, } from "./preferences.js"; import { ekklesiaCoreUserPreference } from "./schema.js"; export type AccountStoreErrorCode = | "ACCOUNT_NOT_FOUND" | "ACCOUNT_PREFERENCES_INVALID" | "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED"; const accountStoreMessages: Readonly> = Object.freeze({ ACCOUNT_NOT_FOUND: "The account was not found.", ACCOUNT_PREFERENCES_INVALID: "The preferences are not valid.", ACCOUNT_READ_FAILED: "The account could not be read.", ACCOUNT_WRITE_FAILED: "The preferences could not be saved.", }); /** Never carries the underlying database error, SQL text, host, or stack — matching * `DashboardStoreError` exactly. */ export class AccountStoreError extends Error { readonly code: AccountStoreErrorCode; constructor(code: AccountStoreErrorCode) { super(accountStoreMessages[code]); this.name = "AccountStoreError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } /** * What a signed-in person is shown about their own account: the name and email they signed up * with, and their preferences. Deliberately nothing else — not the image URL, not verification * state, not timestamps — so the one place the browser learns about an account carries exactly * what the account area displays (ADR 0062). */ export interface AccountSummary { readonly name: string; readonly email: string; readonly preferences: AccountPreferences; } /** * A person's own account, and only their own: every method takes the one user id whose account it * reads or writes, and the HTTP surface supplies it from the authenticated session, never from the * request. There is no method that lists accounts or reads someone else's by any other key. */ export interface AccountStore { /** The person's summary, or `undefined` when no such person exists. A person who never chose a * preference gets the defaults. */ readAccount(userId: EkklesiaUserId): Promise; /** Changes the named preferences and keeps the rest, returning everything now in effect. */ updatePreferences( userId: EkklesiaUserId, update: AccountPreferencesUpdate, ): Promise; } function rethrowAsStoreError(error: unknown, code: AccountStoreErrorCode): never { if (error instanceof AccountStoreError) { throw error; } throw new AccountStoreError(code); } /** * Core's persistence for a person's own account summary and preferences. * * Validation lives here as well as at the HTTP boundary, so the invariants hold for any caller: an * update is re-read through the same strict parser, and a preference row can only be written for a * person who exists. */ export function createAccountStore(db: AccountDrizzleDatabase): AccountStore { return Object.freeze({ async readAccount(userId: EkklesiaUserId): Promise { try { const rows = await db .select({ appearance: ekklesiaCoreUserPreference.appearance, email: ekklesiaCoreUser.email, motion: ekklesiaCoreUserPreference.motion, name: ekklesiaCoreUser.name, }) .from(ekklesiaCoreUser) .leftJoin( ekklesiaCoreUserPreference, eq(ekklesiaCoreUserPreference.userId, ekklesiaCoreUser.id), ) .where(eq(ekklesiaCoreUser.id, userId)) .limit(1); const row = rows[0]; if (!row) { return undefined; } return Object.freeze({ email: row.email, name: row.name, preferences: resolveAccountPreferences(row), }); } catch (error) { rethrowAsStoreError(error, "ACCOUNT_READ_FAILED"); } }, async updatePreferences( userId: EkklesiaUserId, update: AccountPreferencesUpdate, ): Promise { const parsed = parseAccountPreferencesUpdate(update); if (parsed === undefined) { throw new AccountStoreError("ACCOUNT_PREFERENCES_INVALID"); } try { return await db.transaction(async (tx) => { // Checked inside the transaction rather than left to the foreign key, so a person who // does not exist is a clear "not found" instead of a constraint failure. const people = await tx .select({ id: ekklesiaCoreUser.id }) .from(ekklesiaCoreUser) .where(eq(ekklesiaCoreUser.id, userId)) .limit(1); if (people.length === 0) { throw new AccountStoreError("ACCOUNT_NOT_FOUND"); } const rows = await tx .insert(ekklesiaCoreUserPreference) .values({ userId, ...parsed }) .onConflictDoUpdate({ set: { ...parsed, updatedAt: sql`now()` }, target: ekklesiaCoreUserPreference.userId, }) .returning({ appearance: ekklesiaCoreUserPreference.appearance, motion: ekklesiaCoreUserPreference.motion, }); const row = rows[0]; if (!row) { throw new AccountStoreError("ACCOUNT_WRITE_FAILED"); } return resolveAccountPreferences(row); }); } catch (error) { rethrowAsStoreError(error, "ACCOUNT_WRITE_FAILED"); } }, }); }