import { and, asc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm"; import { pluginSettings, users } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { countValue, rowsAffected, supportsReturning, } from "./mutation-result.js"; import { insertReturning, updateReturning } from "./returning.js"; /** isExternal: the user has at least one external sign-in identity. */ export type UserRecord = typeof users.$inferSelect & { isExternal: boolean }; export type NewUserRecord = typeof users.$inferInsert; export type UserUpdate = Partial>; export type NewFirstLocalUserRecord = Omit; // Qualified by hand: drizzle prints a column in a subquery without its table, // which would bind users.id to the identity row's own id. const userColumns = { ...getTableColumns(users), isExternal: sql`EXISTS (SELECT 1 FROM ${sql.identifier("user_external_identities")} WHERE ${sql.identifier("user_external_identities")}.${sql.identifier("user_id")} = ${sql.identifier("users")}.${sql.identifier("id")})`.mapWith( (value: unknown) => value === true || Number(value) === 1, ), }; // The unique-violation codes of better-sqlite3, node-postgres and mysql2. const UNIQUE_VIOLATION_CODES = new Set([ "SQLITE_CONSTRAINT_UNIQUE", "23505", "ER_DUP_ENTRY", ]); /** * Whether creating a user failed because the username is taken. The id is * generated, so the username index is the only one a new row can collide on. * drizzle wraps driver errors, so the code may sit on a cause. */ export function isUsernameTaken(error: unknown): boolean { for (let e = error; e instanceof Error; e = e.cause) { if (UNIQUE_VIOLATION_CODES.has((e as { code?: string }).code ?? "")) { return true; } } return false; } export class UserRepository { constructor( private readonly context: DatabaseContext, private readonly onWrite?: () => void | Promise, ) {} async listAll(): Promise { return this.context.drizzle.select(userColumns).from(users); } /** * One page of users, optionally filtered by username. * * The admin panel used to render every account at once, which is fine at * home and not fine on a directory-backed install with thousands of them. * Sorted by username so paging is stable between requests. */ async listPage(input: { search?: string; limit: number; offset: number; }): Promise<{ users: UserRecord[]; total: number }> { const term = input.search?.trim(); const where = term ? like(sql`lower(${users.username})`, `%${term.toLowerCase()}%`) : undefined; const [rows, totalResult] = await Promise.all([ this.context.drizzle .select(userColumns) .from(users) .where(where) // Case-insensitive: a plain sort puts every capitalised name ahead of // every lowercase one, which reads as unordered in the admin list. .orderBy(asc(sql`lower(${users.username})`)) .limit(input.limit) .offset(input.offset), this.context.drizzle .select({ count: sql`COUNT(*)` }) .from(users) .where(where), ]); return { users: rows, total: countValue(totalResult[0]?.count) }; } async findById(id: string): Promise { const rows = await this.context.drizzle .select(userColumns) .from(users) .where(eq(users.id, id)) .limit(1); return rows[0] ?? null; } async findByUsername(username: string): Promise { const rows = await this.context.drizzle .select(userColumns) .from(users) .where(eq(users.username, username)) .limit(1); return rows[0] ?? null; } async listByIds(ids: string[]): Promise { const uniqueIds = Array.from(new Set(ids.filter(Boolean))); if (uniqueIds.length === 0) { return []; } return this.context.drizzle .select(userColumns) .from(users) .where(inArray(users.id, uniqueIds)); } async create(user: NewUserRecord): Promise { const rows = await insertReturning(this.context, users, user); await this.afterWrite(); return ( (await this.findById(rows[0].id)) ?? { ...rows[0], isExternal: false } ); } async createFirstLocalUser( user: NewFirstLocalUserRecord, ): Promise<{ user: UserRecord; isFirstUser: boolean }> { const result = await this.createCheckingIfFirst((isFirstUser) => ({ ...user, isAdmin: isFirstUser, })); await this.afterWrite(); return { ...result, user: { ...result.user, isExternal: false } }; } async createFirstSsoUser( user: NewUserRecord, ): Promise<{ user: UserRecord; isFirstUser: boolean }> { const result = await this.createCheckingIfFirst((isFirstUser) => ({ ...user, isAdmin: isFirstUser || Boolean(user.isAdmin), })); await this.afterWrite(); return { ...result, user: { ...result.user, isExternal: true } }; } /** * Creates a user, making them an admin if the table was empty. * * The check and the insert have to be one transaction: two people signing up * at once would otherwise both see an empty table and both become admin. * * The two branches are not a style choice. better-sqlite3 is synchronous and * rejects an async transaction callback outright, "Transaction function * cannot return a promise", so a single body cannot serve both. It fails * loudly rather than silently skipping the write, which is the one mercy here. */ private async createCheckingIfFirst( build: (isFirstUser: boolean) => NewUserRecord, ): Promise<{ user: typeof users.$inferSelect; isFirstUser: boolean }> { if (this.context.dialect === "sqlite") { /* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect is checked directly above, and better-sqlite3 rejects an async transaction callback, so this cannot use the shared helpers. */ return this.context.drizzle.transaction((tx) => { const isFirstUser = tx.select({ id: users.id }).from(users).all().length === 0; const rows = tx .insert(users) .values(build(isFirstUser)) .returning() .all(); return { user: rows[0], isFirstUser }; }); /* eslint-enable no-restricted-syntax */ } return this.context.drizzle.transaction(async (tx) => { const existing = await tx.select({ id: users.id }).from(users); const isFirstUser = existing.length === 0; const values = build(isFirstUser); if (supportsReturning(this.context.dialect)) { // eslint-disable-next-line no-restricted-syntax -- guarded by the check on this line const rows = await tx.insert(users).values(values).returning(); return { user: rows[0], isFirstUser }; } // users is keyed by a text id the caller supplies, so there is something // to read back by even without RETURNING. await tx.insert(users).values(values); const [user] = await tx .select() .from(users) .where(eq(users.id, values.id)); return { user, isFirstUser }; }); } async update(id: string, update: UserUpdate): Promise { const rows = await updateReturning( this.context, users, update, eq(users.id, id), ); await this.afterWrite(); return rows[0] ? this.findById(rows[0].id) : null; } async delete(id: string): Promise { // plugin_settings.scope_id is polymorphic, so it carries no foreign key to // users and the engine will not cascade it. Removed here rather than in a // route so every caller that deletes a user is covered. await this.context.drizzle .delete(pluginSettings) .where( and( inArray(pluginSettings.scope, ["user", "secret"]), eq(pluginSettings.scopeId, id), ), ); const result = await this.context.drizzle .delete(users) .where(eq(users.id, id)); await this.afterWrite(); return rowsAffected(result) > 0; } async countAdmins(): Promise { const rows = await this.context.drizzle .select({ id: users.id }) .from(users) .where(eq(users.isAdmin, true)); return rows.length; } async countAll(): Promise { const rows = await this.context.drizzle .select({ id: users.id }) .from(users); return rows.length; } private async afterWrite(): Promise { await this.onWrite?.(); } }