117 lines
3.3 KiB
TypeScript
117 lines
3.3 KiB
TypeScript
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) {
|
|
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) {
|
|
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);
|
|
}
|
|
}
|