/** * PostgreSQL UserRepository. Parameterized queries only. * Unique email is enforced by the DB (citext UNIQUE) — race-safe. */ import type pg from 'pg'; import type { UserRepository } from '../domain/ports.js'; import type { NewUser, User } from '../domain/user.js'; import type { Role } from '../../../shared/auth.js'; import { EmailAlreadyRegisteredError } from '../domain/errors.js'; interface UserRow { id: string; email: string; password_hash: string; role: Role; created_at: Date; } const UNIQUE_VIOLATION = '23505'; export class PgUserRepository implements UserRepository { constructor(private readonly pool: pg.Pool) {} async create(user: NewUser): Promise { try { const result = await this.pool.query( `INSERT INTO identity_users (email, password_hash) VALUES ($1, $2) RETURNING id, email, role, created_at`, [user.email, user.passwordHash], ); const row = result.rows[0]; if (!row) { throw new Error('identity_users INSERT returned no row'); } return { id: row.id, email: row.email, role: row.role, createdAt: row.created_at }; } catch (error) { if (isPgError(error) && error.code === UNIQUE_VIOLATION) { throw new EmailAlreadyRegisteredError(); } throw error; } } async findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined> { const result = await this.pool.query( `SELECT id, email, password_hash, role, created_at FROM identity_users WHERE email = $1`, [email], ); const row = result.rows[0]; if (!row) { return undefined; } return { id: row.id, email: row.email, role: row.role, createdAt: row.created_at, passwordHash: row.password_hash, }; } async findById(id: string): Promise { const result = await this.pool.query( `SELECT id, email, role, created_at FROM identity_users WHERE id = $1`, [id], ); const row = result.rows[0]; if (!row) return undefined; return { id: row.id, email: row.email, role: row.role, createdAt: row.created_at }; } async listUsers(params?: { limit?: number; offset?: number; role?: string; q?: string; }): Promise<{ items: User[]; total: number }> { const limit = params?.limit ?? 20; const offset = params?.offset ?? 0; const conditions: string[] = []; const values: unknown[] = []; let i = 1; if (params?.role) { conditions.push(`role = $${i++}`); values.push(params.role); } if (params?.q) { conditions.push(`(email ILIKE $${i++} OR role ILIKE $${i++})`); values.push(`%${params.q}%`); values.push(`%${params.q}%`); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const countResult = await this.pool.query<{ count: string }>( `SELECT COUNT(*) FROM identity_users ${where}`, values, ); const total = parseInt(countResult.rows[0]?.count ?? '0', 10); const rows = await this.pool.query( `SELECT id, email, role, created_at FROM identity_users ${where} ORDER BY created_at DESC LIMIT $${i++} OFFSET $${i}`, [...values, limit, offset], ); return { items: rows.rows.map((r) => ({ id: r.id, email: r.email, role: r.role, createdAt: r.created_at, })), total, }; } async updateUser(id: string, patch: { role?: string; passwordHash?: string }): Promise { const sets: string[] = []; const values: unknown[] = []; let i = 1; if (patch.role) { sets.push(`role = $${i++}`); values.push(patch.role); } if (patch.passwordHash) { sets.push(`password_hash = $${i++}`); values.push(patch.passwordHash); } if (!sets.length) { const existing = await this.findById(id); if (!existing) throw new Error('User not found'); return existing; } values.push(id); const result = await this.pool.query( `UPDATE identity_users SET ${sets.join(', ')} WHERE id = $${i} RETURNING id, email, role, created_at`, values, ); const row = result.rows[0]; if (!row) throw new Error('User not found'); return { id: row.id, email: row.email, role: row.role, createdAt: row.created_at }; } async deleteUser(id: string): Promise { await this.pool.query(`DELETE FROM identity_users WHERE id = $1`, [id]); } } function isPgError(error: unknown): error is { code: string } { return typeof error === 'object' && error !== null && 'code' in error; }