- 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
132 lines
3.7 KiB
TypeScript
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,
|
|
};
|
|
}
|