152 lines
4.6 KiB
TypeScript
152 lines
4.6 KiB
TypeScript
/**
|
|
* 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<User> {
|
|
try {
|
|
const result = await this.pool.query<UserRow>(
|
|
`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<UserRow>(
|
|
`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<User | undefined> {
|
|
const result = await this.pool.query<UserRow>(
|
|
`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<UserRow>(
|
|
`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<User> {
|
|
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<UserRow>(
|
|
`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<void> {
|
|
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;
|
|
}
|