Files
mercadodevida/project/src/modules/users/infrastructure/pg-address-repository.ts
rikrdo 546971280f feat(F-006): users profile, addresses and RBAC
- users module: profile + address CRUD behind use cases (users_profiles,
  users_addresses)
- roles customer/admin on identity_users; role resolved from DB per request
- shared auth contract (Authenticate, requireRole, requireOwnerOrAdmin)
  injected from composition root; users never imports identity
- authorization runs before existence checks; address SQL scoped by user_id
- @fastify/cookie registered once at app root (cross-module)
- migrations 003_identity_roles + 004_users (reversible)
- no new npm dependencies; tests: unit 52, integration 22

Gates: reviewer/security/qa APPROVED; verify.sh green
2026-08-15 09:28:15 +02:00

132 lines
3.7 KiB
TypeScript

/**
* PostgreSQL AddressRepository. All operations are scoped by user_id, so a
* caller can never read or mutate another user's address even with a valid id.
*/
import type pg from 'pg';
import type { AddressRepository } from '../domain/ports.js';
import type { Address, AddressPatch, NewAddress } from '../domain/address.js';
interface AddressRow {
id: string;
user_id: string;
label: string | null;
recipient_name: string;
street: string;
city: string;
postal_code: string;
country: string;
is_default: boolean;
created_at: Date;
updated_at: Date;
}
/** Whitelist of updatable columns -> input key. Prevents SQL building from input. */
const UPDATABLE: ReadonlyArray<[keyof AddressPatch, string]> = [
['label', 'label'],
['recipientName', 'recipient_name'],
['street', 'street'],
['city', 'city'],
['postalCode', 'postal_code'],
['country', 'country'],
['isDefault', 'is_default'],
];
export class PgAddressRepository implements AddressRepository {
constructor(private readonly pool: pg.Pool) {}
async listByUserId(userId: string): Promise<Address[]> {
const result = await this.pool.query<AddressRow>(
`SELECT * FROM users_addresses WHERE user_id = $1
ORDER BY is_default DESC, created_at`,
[userId],
);
return result.rows.map(toAddress);
}
async create(userId: string, input: NewAddress): Promise<Address> {
const result = await this.pool.query<AddressRow>(
`INSERT INTO users_addresses
(user_id, label, recipient_name, street, city, postal_code, country, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
userId,
input.label ?? null,
input.recipientName,
input.street,
input.city,
input.postalCode,
input.country,
input.isDefault ?? false,
],
);
const row = result.rows[0];
if (!row) {
throw new Error('users_addresses INSERT returned no row');
}
return toAddress(row);
}
async update(
userId: string,
addressId: string,
patch: AddressPatch,
): Promise<Address | undefined> {
const setClauses: string[] = [];
const values: unknown[] = [];
for (const [key, column] of UPDATABLE) {
const value = patch[key];
if (value !== undefined) {
values.push(value);
setClauses.push(`${column} = $${values.length}`);
}
}
if (setClauses.length === 0) {
return this.findByIdScoped(userId, addressId);
}
values.push(userId, addressId);
const result = await this.pool.query<AddressRow>(
`UPDATE users_addresses SET ${setClauses.join(', ')}, updated_at = now()
WHERE user_id = $${values.length - 1} AND id = $${values.length}
RETURNING *`,
values,
);
const row = result.rows[0];
return row ? toAddress(row) : undefined;
}
async delete(userId: string, addressId: string): Promise<boolean> {
const result = await this.pool.query(
`DELETE FROM users_addresses WHERE user_id = $1 AND id = $2`,
[userId, addressId],
);
return (result.rowCount ?? 0) > 0;
}
private async findByIdScoped(userId: string, addressId: string): Promise<Address | undefined> {
const result = await this.pool.query<AddressRow>(
`SELECT * FROM users_addresses WHERE user_id = $1 AND id = $2`,
[userId, addressId],
);
const row = result.rows[0];
return row ? toAddress(row) : undefined;
}
}
function toAddress(row: AddressRow): Address {
return {
id: row.id,
userId: row.user_id,
label: row.label,
recipientName: row.recipient_name,
street: row.street,
city: row.city,
postalCode: row.postal_code,
country: row.country,
isDefault: row.is_default,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}