import crypto from "node:crypto"; import { and, asc, eq } from "drizzle-orm"; import type { AppDatabase } from "../database-context.js"; import { users } from "../schema/users.js"; import { LastActiveAdminStoreError, UsernameAlreadyExistsStoreError, type CreateUserAccount, type UserStore, } from "../../domain/ports/user.store.js"; import type { UserRole } from "../../shared/constants/user-roles.js"; function isUniqueConstraintError(error: unknown): boolean { return ( error instanceof Error && "code" in error && (error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE" ); } export class UserRepository implements UserStore { constructor(private readonly database: AppDatabase) {} async list() { const db = this.database; return db.select().from(users).orderBy(asc(users.name)); } async count() { const db = this.database; const rows = await db.select({ id: users.id }).from(users).limit(1); return rows.length; } async findById(userId: string): Promise { const db = this.database; const [row] = await db.select().from(users).where(eq(users.id, userId)).limit(1); return row ?? null; } async findByUsername(username: string): Promise { const db = this.database; const [row] = await db .select() .from(users) .where(eq(users.username, username.trim().toLowerCase())) .limit(1); return row ?? null; } async create(input: CreateUserAccount) { const db = this.database; const id = crypto.randomUUID(); try { await db.insert(users).values({ id, username: input.username.trim().toLowerCase(), name: input.name, role: input.role, passwordHash: input.passwordHash, active: true, mustChangePassword: true, }); } catch (error) { if (isUniqueConstraintError(error)) throw new UsernameAlreadyExistsStoreError(); throw error; } return this.findById(id); } async updateRoleAndActive( userId: string, patch: { role?: UserRole; active?: boolean } ) { return this.database.transaction((tx) => { const [target] = tx.select().from(users).where(eq(users.id, userId)).limit(1).all(); if (!target) return null; const removesActiveAdmin = target.role === "admin" && target.active && (patch.role === "user" || patch.active === false); if (removesActiveAdmin) { const activeAdmins = tx .select({ id: users.id }) .from(users) .where(and(eq(users.role, "admin"), eq(users.active, true))) .limit(2) .all(); if (activeAdmins.length <= 1) throw new LastActiveAdminStoreError(); } tx.update(users) .set({ ...(patch.role !== undefined ? { role: patch.role } : {}), ...(patch.active !== undefined ? { active: patch.active } : {}), updatedAt: new Date(), }) .where(eq(users.id, userId)) .run(); return tx.select().from(users).where(eq(users.id, userId)).limit(1).all()[0] ?? null; }); } async setPassword( userId: string, passwordHash: string, mustChangePassword: boolean ) { const db = this.database; await db .update(users) .set({ passwordHash, mustChangePassword, updatedAt: new Date() }) .where(eq(users.id, userId)); return this.findById(userId); } }