Files
mpm/apps/platform-backend/src/users/user.repository.ts

133 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { PasswordHasher } from './password-hasher';
import type { AuthUser, RoleName, UserRecord } from './user.types';
interface UserRow {
id: string;
username: string;
email: string;
password_hash: string;
display_name: string;
role_name: RoleName;
is_active: boolean;
failed_login_attempts: number;
locked_until: Date | null;
last_login_at: Date | null;
created_at: Date;
updated_at: Date;
}
const USER_COLUMNS = `u.id, u.username, u.email, u.password_hash, u.display_name, r.name AS role_name,
u.is_active, u.failed_login_attempts, u.locked_until,
u.last_login_at, u.created_at, u.updated_at`;
/** Wandelt einen Datenbank-Datensatz in die öffentliche Benutzer-Repräsentation um. */
function toAuthUser(record: UserRecord): AuthUser {
return {
id: record.id,
username: record.username,
email: record.email,
displayName: record.displayName,
role: record.role,
};
}
/**
* Benutzer-Repository (Infrastructure): Alle Datenbankzugriffe für Benutzer.
* Enthält keine Business-Logik – nur Datenzugriff.
*/
@Injectable()
export class UserRepository {
constructor(
private readonly database: DatabaseService,
private readonly passwordHasher: PasswordHasher,
) {}
async findByUsername(username: string): Promise<UserRecord | null> {
const result = await this.database.query<UserRow>(
`SELECT ${USER_COLUMNS}
FROM users u JOIN roles r ON r.id = u.role_id
WHERE u.username = $1`,
[username],
);
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
}
async findById(id: string): Promise<UserRecord | null> {
const result = await this.database.query<UserRow>(
`SELECT ${USER_COLUMNS}
FROM users u JOIN roles r ON r.id = u.role_id
WHERE u.id = $1`,
[id],
);
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
}
async updateLoginSuccess(userId: string): Promise<void> {
await this.database.query(
`UPDATE users
SET last_login_at = now(),
failed_login_attempts = 0,
locked_until = NULL,
updated_at = now()
WHERE id = $1`,
[userId],
);
}
async updateLoginFailure(
userId: string,
attempts: number,
shouldLock: boolean,
lockoutMinutes: number,
): Promise<void> {
await this.database.query(
`UPDATE users
SET failed_login_attempts = $2,
locked_until = CASE WHEN $3::boolean
THEN now() + make_interval(mins => $4::int)
ELSE locked_until END,
updated_at = now()
WHERE id = $1`,
[userId, attempts, shouldLock, lockoutMinutes],
);
}
async create(input: {
username: string;
email: string;
password: string;
displayName: string;
role: RoleName;
}): Promise<AuthUser> {
const passwordHash = await this.passwordHasher.hash(input.password);
const result = await this.database.query<UserRow>(
`INSERT INTO users (username, email, password_hash, display_name, role_id)
VALUES ($1, $2, $3, $4, (SELECT id FROM roles WHERE name = $5))
RETURNING id, username, email, display_name,
(SELECT name FROM roles WHERE id = role_id) AS role_name,
true AS is_active, 0 AS failed_login_attempts, NULL::timestamptz AS locked_until,
NULL::timestamptz AS last_login_at, now() AS created_at, now() AS updated_at`,
[input.username, input.email, passwordHash, input.displayName, input.role],
);
return toAuthUser(this.mapRow(result.rows[0]));
}
private mapRow(row: UserRow): UserRecord {
return {
id: row.id,
username: row.username,
email: row.email,
passwordHash: row.password_hash,
displayName: row.display_name,
role: row.role_name,
isActive: row.is_active,
failedLoginAttempts: row.failed_login_attempts,
lockedUntil: row.locked_until,
lastLoginAt: row.last_login_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}