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
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user