/**
* 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
{
const result = await this.pool.query(
`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 {
const result = await this.pool.query(
`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 {
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(
`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 {
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 {
const result = await this.pool.query(
`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,
};
}