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:
@@ -3,7 +3,6 @@
|
||||
* calls use cases, maps domain errors to the shared error envelope.
|
||||
*/
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { z } from 'zod';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
@@ -47,8 +46,6 @@ export async function registerIdentityRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: IdentityRoutesDeps,
|
||||
): Promise<void> {
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const cookieSecure = deps.cookieSecure ?? true;
|
||||
const hasher = deps.hasher ?? new Argon2PasswordHasher();
|
||||
const users = new PgUserRepository(deps.pool);
|
||||
@@ -70,7 +67,9 @@ export async function registerIdentityRoutes(
|
||||
const input = parseJson(credentialsSchema, request.body);
|
||||
try {
|
||||
const user = await registerUser.execute(input);
|
||||
return reply.code(201).send({ id: user.id, email: user.email, createdAt: user.createdAt });
|
||||
return reply
|
||||
.code(201)
|
||||
.send({ id: user.id, email: user.email, role: user.role, createdAt: user.createdAt });
|
||||
} catch (error) {
|
||||
if (error instanceof EmailAlreadyRegisteredError) {
|
||||
throw new AppError(409, 'EMAIL_ALREADY_REGISTERED', 'Email already registered');
|
||||
@@ -84,7 +83,9 @@ export async function registerIdentityRoutes(
|
||||
try {
|
||||
const result = await login.execute(input);
|
||||
setSessionCookie(reply, result.token, cookieSecure);
|
||||
return reply.code(200).send({ id: result.user.id, email: result.user.email });
|
||||
return reply
|
||||
.code(200)
|
||||
.send({ id: result.user.id, email: result.user.email, role: result.user.role });
|
||||
} catch (error) {
|
||||
if (error instanceof RateLimitedError) {
|
||||
void reply.header('Retry-After', String(Math.ceil(error.retryAfterMs / 1000)));
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
* Identity domain. Pure types and rules: no framework, no infrastructure.
|
||||
*/
|
||||
|
||||
import type { Role } from '../../../shared/auth.js';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ export {
|
||||
SESSION_COOKIE_NAME,
|
||||
type IdentityRoutesDeps,
|
||||
} from './api/identity.routes.js';
|
||||
export { createSessionAuthenticator } from './infrastructure/session-authenticator.js';
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -24,14 +26,14 @@ export class PgUserRepository implements UserRepository {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`INSERT INTO identity_users (email, password_hash)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, email, created_at`,
|
||||
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, createdAt: row.created_at };
|
||||
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();
|
||||
@@ -42,7 +44,7 @@ export class PgUserRepository implements UserRepository {
|
||||
|
||||
async findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined> {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`SELECT id, email, password_hash, created_at
|
||||
`SELECT id, email, password_hash, role, created_at
|
||||
FROM identity_users
|
||||
WHERE email = $1`,
|
||||
[email],
|
||||
@@ -54,6 +56,7 @@ export class PgUserRepository implements UserRepository {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
createdAt: row.created_at,
|
||||
passwordHash: row.password_hash,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Resolves the session cookie into the current user.
|
||||
* Server-side truth: validity (expiry + revocation) and role come from the DB,
|
||||
* never from the client. Exported through the module index so the composition
|
||||
* root can inject it into other modules without cross-module imports.
|
||||
*/
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import type { Authenticate, CurrentUser } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { hashSessionToken } from './session-token.js';
|
||||
import { SESSION_COOKIE_NAME } from '../api/identity.routes.js';
|
||||
|
||||
interface ResolvedRow {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const RESOLVE_SQL = `
|
||||
SELECT u.id, u.email, u.role
|
||||
FROM identity_sessions s
|
||||
JOIN identity_users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1
|
||||
AND s.revoked_at IS NULL
|
||||
AND s.expires_at > now()
|
||||
`;
|
||||
|
||||
export function createSessionAuthenticator(pool: pg.Pool): Authenticate {
|
||||
return async (request: FastifyRequest): Promise<CurrentUser> => {
|
||||
const token = request.cookies[SESSION_COOKIE_NAME];
|
||||
if (!token) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', 'Authentication required');
|
||||
}
|
||||
|
||||
const result = await pool.query<ResolvedRow>(RESOLVE_SQL, [hashSessionToken(token)]);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', 'Authentication required');
|
||||
}
|
||||
|
||||
return { id: row.id, email: row.email, role: row.role as CurrentUser['role'] };
|
||||
};
|
||||
}
|
||||
163
project/src/modules/users/api/users.routes.ts
Normal file
163
project/src/modules/users/api/users.routes.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Users API adapters. Authorization (role + ownership) runs BEFORE existence
|
||||
* checks, so a non-owner always gets 403 regardless of resource existence.
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type pg from 'pg';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { requireOwnerOrAdmin, requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { GetProfile, ListProfiles, UpdateProfile } from '../application/profile-use-cases.js';
|
||||
import {
|
||||
CreateAddress,
|
||||
DeleteAddress,
|
||||
ListAddresses,
|
||||
UpdateAddress,
|
||||
} from '../application/address-use-cases.js';
|
||||
import { PgProfileRepository } from '../infrastructure/pg-profile-repository.js';
|
||||
import { PgAddressRepository } from '../infrastructure/pg-address-repository.js';
|
||||
import type { Address } from '../domain/address.js';
|
||||
import type { Profile } from '../domain/profile.js';
|
||||
|
||||
export interface UsersRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
/** Injected by the composition root (identity owns session resolution). */
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
const uuidParamSchema = z.object({ id: z.uuid() });
|
||||
const addressIdParamSchema = z.object({ id: z.uuid(), addressId: z.uuid() });
|
||||
|
||||
const profilePatchSchema = z
|
||||
.object({
|
||||
displayName: z.string().min(1).max(200).optional(),
|
||||
phone: z.string().min(1).max(50).optional(),
|
||||
})
|
||||
.refine((value) => value.displayName !== undefined || value.phone !== undefined, {
|
||||
message: 'At least one of displayName or phone is required',
|
||||
});
|
||||
|
||||
const newAddressSchema = z.object({
|
||||
label: z.string().max(100).optional().nullable(),
|
||||
recipientName: z.string().min(1).max(200),
|
||||
street: z.string().min(1).max(300),
|
||||
city: z.string().min(1).max(100),
|
||||
postalCode: z.string().min(1).max(20),
|
||||
country: z.string().min(1).max(100),
|
||||
isDefault: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const addressPatchSchema = newAddressSchema
|
||||
.partial()
|
||||
.refine((value) => Object.values(value).some((field) => field !== undefined), {
|
||||
message: 'At least one address field is required',
|
||||
});
|
||||
|
||||
export async function registerUsersRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: UsersRoutesDeps,
|
||||
): Promise<void> {
|
||||
const profiles = new PgProfileRepository(deps.pool);
|
||||
const addresses = new PgAddressRepository(deps.pool);
|
||||
const getProfile = new GetProfile(profiles);
|
||||
const updateProfile = new UpdateProfile(profiles);
|
||||
const listProfiles = new ListProfiles(profiles);
|
||||
const listAddresses = new ListAddresses(addresses);
|
||||
const createAddress = new CreateAddress(addresses);
|
||||
const updateAddress = new UpdateAddress(addresses);
|
||||
const deleteAddress = new DeleteAddress(addresses);
|
||||
|
||||
app.get('/users', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const items = await listProfiles.execute();
|
||||
return reply.send({ items: items.map(serializeProfile) });
|
||||
});
|
||||
|
||||
app.get('/users/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(uuidParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const profile = await getProfile.execute(id);
|
||||
if (!profile) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Profile not found');
|
||||
}
|
||||
return reply.send(serializeProfile(profile));
|
||||
});
|
||||
|
||||
app.patch('/users/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(uuidParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const patch = parseJson(profilePatchSchema, request.body);
|
||||
const profile = await updateProfile.execute(id, patch);
|
||||
return reply.send(serializeProfile(profile));
|
||||
});
|
||||
|
||||
app.get('/users/:id/addresses', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(uuidParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const items = await listAddresses.execute(id);
|
||||
return reply.send({ items: items.map(serializeAddress) });
|
||||
});
|
||||
|
||||
app.post('/users/:id/addresses', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(uuidParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const input = parseJson(newAddressSchema, request.body);
|
||||
const address = await createAddress.execute(id, input);
|
||||
return reply.code(201).send(serializeAddress(address));
|
||||
});
|
||||
|
||||
app.patch('/users/:id/addresses/:addressId', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id, addressId } = parseJson(addressIdParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const patch = parseJson(addressPatchSchema, request.body);
|
||||
const address = await updateAddress.execute(id, addressId, patch);
|
||||
if (!address) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Address not found');
|
||||
}
|
||||
return reply.send(serializeAddress(address));
|
||||
});
|
||||
|
||||
app.delete('/users/:id/addresses/:addressId', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id, addressId } = parseJson(addressIdParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const deleted = await deleteAddress.execute(id, addressId);
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Address not found');
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
function serializeProfile(profile: Profile) {
|
||||
return {
|
||||
userId: profile.userId,
|
||||
displayName: profile.displayName,
|
||||
phone: profile.phone,
|
||||
createdAt: profile.createdAt.toISOString(),
|
||||
updatedAt: profile.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAddress(address: Address) {
|
||||
return {
|
||||
id: address.id,
|
||||
userId: address.userId,
|
||||
label: address.label,
|
||||
recipientName: address.recipientName,
|
||||
street: address.street,
|
||||
city: address.city,
|
||||
postalCode: address.postalCode,
|
||||
country: address.country,
|
||||
isDefault: address.isDefault,
|
||||
createdAt: address.createdAt.toISOString(),
|
||||
updatedAt: address.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
37
project/src/modules/users/application/address-use-cases.ts
Normal file
37
project/src/modules/users/application/address-use-cases.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Address use cases. Every operation is scoped to the owner's userId.
|
||||
*/
|
||||
import type { AddressRepository } from '../domain/ports.js';
|
||||
import type { Address, AddressPatch, NewAddress } from '../domain/address.js';
|
||||
|
||||
export class ListAddresses {
|
||||
constructor(private readonly addresses: AddressRepository) {}
|
||||
async execute(userId: string): Promise<Address[]> {
|
||||
return this.addresses.listByUserId(userId);
|
||||
}
|
||||
}
|
||||
|
||||
export class CreateAddress {
|
||||
constructor(private readonly addresses: AddressRepository) {}
|
||||
async execute(userId: string, input: NewAddress): Promise<Address> {
|
||||
return this.addresses.create(userId, input);
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateAddress {
|
||||
constructor(private readonly addresses: AddressRepository) {}
|
||||
async execute(
|
||||
userId: string,
|
||||
addressId: string,
|
||||
patch: AddressPatch,
|
||||
): Promise<Address | undefined> {
|
||||
return this.addresses.update(userId, addressId, patch);
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteAddress {
|
||||
constructor(private readonly addresses: AddressRepository) {}
|
||||
async execute(userId: string, addressId: string): Promise<boolean> {
|
||||
return this.addresses.delete(userId, addressId);
|
||||
}
|
||||
}
|
||||
27
project/src/modules/users/application/profile-use-cases.ts
Normal file
27
project/src/modules/users/application/profile-use-cases.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Profile use cases. Thin orchestration over ports; ownership/role checks
|
||||
* happen in the API layer before these run.
|
||||
*/
|
||||
import type { ProfileRepository } from '../domain/ports.js';
|
||||
import type { Profile, ProfilePatch } from '../domain/profile.js';
|
||||
|
||||
export class GetProfile {
|
||||
constructor(private readonly profiles: ProfileRepository) {}
|
||||
async execute(userId: string): Promise<Profile | undefined> {
|
||||
return this.profiles.findByUserId(userId);
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateProfile {
|
||||
constructor(private readonly profiles: ProfileRepository) {}
|
||||
async execute(userId: string, patch: ProfilePatch): Promise<Profile> {
|
||||
return this.profiles.upsert(userId, patch);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListProfiles {
|
||||
constructor(private readonly profiles: ProfileRepository) {}
|
||||
async execute(): Promise<Profile[]> {
|
||||
return this.profiles.list();
|
||||
}
|
||||
}
|
||||
30
project/src/modules/users/domain/address.ts
Normal file
30
project/src/modules/users/domain/address.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Address domain model.
|
||||
*/
|
||||
|
||||
export interface Address {
|
||||
id: string;
|
||||
userId: string;
|
||||
label: string | null;
|
||||
recipientName: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
isDefault: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface NewAddress {
|
||||
label?: string | null;
|
||||
recipientName: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
/** Fields an address update may set. Undefined = leave unchanged. */
|
||||
export type AddressPatch = Partial<NewAddress>;
|
||||
22
project/src/modules/users/domain/ports.ts
Normal file
22
project/src/modules/users/domain/ports.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Ports (driven interfaces). Domain owns them; infrastructure implements them.
|
||||
* All operations are scoped by userId so ownership is enforced in the query.
|
||||
*/
|
||||
import type { Profile, ProfilePatch } from './profile.js';
|
||||
import type { Address, AddressPatch, NewAddress } from './address.js';
|
||||
|
||||
export interface ProfileRepository {
|
||||
findByUserId(userId: string): Promise<Profile | undefined>;
|
||||
/** Idempotent upsert; only provided fields change. */
|
||||
upsert(userId: string, patch: ProfilePatch): Promise<Profile>;
|
||||
list(): Promise<Profile[]>;
|
||||
}
|
||||
|
||||
export interface AddressRepository {
|
||||
listByUserId(userId: string): Promise<Address[]>;
|
||||
create(userId: string, input: NewAddress): Promise<Address>;
|
||||
/** Returns undefined when the address does not belong to userId. */
|
||||
update(userId: string, addressId: string, patch: AddressPatch): Promise<Address | undefined>;
|
||||
/** Returns false when the address does not belong to userId. */
|
||||
delete(userId: string, addressId: string): Promise<boolean>;
|
||||
}
|
||||
17
project/src/modules/users/domain/profile.ts
Normal file
17
project/src/modules/users/domain/profile.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Users domain. Pure types: no framework, no infrastructure.
|
||||
*/
|
||||
|
||||
export interface Profile {
|
||||
userId: string;
|
||||
displayName: string | null;
|
||||
phone: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/** Fields a profile update may set. Undefined = leave unchanged. */
|
||||
export interface ProfilePatch {
|
||||
displayName?: string | null;
|
||||
phone?: string | null;
|
||||
}
|
||||
6
project/src/modules/users/index.ts
Normal file
6
project/src/modules/users/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Public API of the users module. Everything the module exposes to the outside
|
||||
* world goes through this file. Auth arrives by injection (shared contract),
|
||||
* never by importing identity.
|
||||
*/
|
||||
export { registerUsersRoutes, type UsersRoutesDeps } from './api/users.routes.js';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* PostgreSQL ProfileRepository. Parameterized queries only; update columns are
|
||||
* whitelisted, never built from user input.
|
||||
*/
|
||||
import type pg from 'pg';
|
||||
import type { ProfileRepository } from '../domain/ports.js';
|
||||
import type { Profile, ProfilePatch } from '../domain/profile.js';
|
||||
|
||||
interface ProfileRow {
|
||||
user_id: string;
|
||||
display_name: string | null;
|
||||
phone: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export class PgProfileRepository implements ProfileRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<Profile | undefined> {
|
||||
const result = await this.pool.query<ProfileRow>(
|
||||
`SELECT user_id, display_name, phone, created_at, updated_at
|
||||
FROM users_profiles WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toProfile(row) : undefined;
|
||||
}
|
||||
|
||||
async upsert(userId: string, patch: ProfilePatch): Promise<Profile> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO users_profiles (user_id) VALUES ($1)
|
||||
ON CONFLICT (user_id) DO NOTHING`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
const setClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (patch.displayName !== undefined) {
|
||||
values.push(patch.displayName);
|
||||
setClauses.push(`display_name = $${values.length}`);
|
||||
}
|
||||
if (patch.phone !== undefined) {
|
||||
values.push(patch.phone);
|
||||
setClauses.push(`phone = $${values.length}`);
|
||||
}
|
||||
if (setClauses.length > 0) {
|
||||
values.push(userId);
|
||||
await this.pool.query(
|
||||
`UPDATE users_profiles SET ${setClauses.join(', ')}, updated_at = now()
|
||||
WHERE user_id = $${values.length}`,
|
||||
values,
|
||||
);
|
||||
}
|
||||
|
||||
const profile = await this.findByUserId(userId);
|
||||
if (!profile) {
|
||||
throw new Error('users_profiles upsert did not return a row');
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async list(): Promise<Profile[]> {
|
||||
const result = await this.pool.query<ProfileRow>(
|
||||
`SELECT user_id, display_name, phone, created_at, updated_at
|
||||
FROM users_profiles ORDER BY created_at`,
|
||||
);
|
||||
return result.rows.map(toProfile);
|
||||
}
|
||||
}
|
||||
|
||||
function toProfile(row: ProfileRow): Profile {
|
||||
return {
|
||||
userId: row.user_id,
|
||||
displayName: row.display_name,
|
||||
phone: row.phone,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user