leistungsbilanz-ts/src/db/repositories/user.repository.ts
Julian Appel 7222398628 Typecheck tests against current types
tsx runs tests without type checking and tsconfig.json covers only src,
so 71 type errors had accumulated unnoticed. tsconfig.tests.json now
checks every test and helper, and npm run typecheck includes it.

Fixtures gain the fields the DTOs have grown since they were written
(manualQuantity, circuit apparent power/cos phi, downstream board loads,
section totals, building, cover and board info fields). Repository
lookups that return "row ?? null" now declare the null in their type,
which their callers already handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:17:45 +01:00

117 lines
3.4 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): Promise<typeof users.$inferSelect | null> {
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<typeof users.$inferSelect | null> {
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);
}
}