/** * 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 type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js'; import { requireOwnerOrAdmin, requireRole, type Authenticate } from '../../../shared/auth.js'; import { GetCustomer, ListCustomers, 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 { CustomerSummary, 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 preferencesSchema = z .object({ orderUpdates: z.boolean().optional(), newsletter: z.boolean().optional(), promos: z.boolean().optional(), }) .strict(); const profilePatchSchema = z .object({ displayName: z.string().min(1).max(200).optional(), phone: z.string().min(1).max(50).optional(), preferences: preferencesSchema.optional(), }) .refine( (value) => value.displayName !== undefined || value.phone !== undefined || value.preferences !== undefined, { message: 'At least one of displayName, phone or preferences 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 { const profiles = new PgProfileRepository(deps.pool); const addresses = new PgAddressRepository(deps.pool); const getCustomer = new GetCustomer(profiles); const updateProfile = new UpdateProfile(profiles); const listCustomers = new ListCustomers(profiles); const listAddresses = new ListAddresses(addresses); const createAddress = new CreateAddress(addresses); const updateAddress = new UpdateAddress(addresses); const deleteAddress = new DeleteAddress(addresses); const listUsersSchema: FastifySchema = { tags: ['Users'], summary: 'List users (admin)', querystring: { type: 'object', properties: { q: { type: 'string' }, limit: { type: 'integer', default: 20 }, offset: { type: 'integer', default: 0 }, }, }, response: { 401: errorSchema, 403: errorSchema }, }; app.get('/users', { schema: listUsersSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const query = request.query as Record; const q = query.q || undefined; const offset = Math.max(0, parseInt(query.offset ?? '0', 10) || 0); const limit = Math.min(100, Math.max(1, parseInt(query.limit ?? '20', 10) || 20)); const result = await listCustomers.execute({ q, offset, limit }); return reply.send({ items: result.items.map(serializeCustomer), total: result.total, }); }); const getUserSchema: FastifySchema = { tags: ['Users'], summary: 'Get user', params: { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'uuid' } }, }, response: { 401: errorSchema, 403: errorSchema, 404: errorSchema }, }; app.get('/users/:id', { schema: getUserSchema }, async (request, reply) => { const user = await deps.authenticate(request); const { id } = parseJson(uuidParamSchema, request.params); requireOwnerOrAdmin(user, id); const customer = await getCustomer.execute(id); if (!customer) { throw new AppError(404, 'NOT_FOUND', 'Customer not found'); } const profile = await profiles.findByUserId(id); return reply.send({ ...serializeCustomer(customer), preferences: profile?.preferences ?? undefined }); }); const patchUserSchema: FastifySchema = { tags: ['Users'], summary: 'Update user profile', params: { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'uuid' } }, }, body: { type: 'object' }, response: { 401: errorSchema, 403: errorSchema, 404: errorSchema }, }; app.patch('/users/:id', { schema: patchUserSchema }, 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)); }); const addressesSchema: FastifySchema = { tags: ['Users'], summary: 'List user addresses', params: { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'uuid' } }, }, response: { 401: errorSchema, 403: errorSchema }, }; app.get('/users/:id/addresses', { schema: addressesSchema }, 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) }); }); const createAddressSchema: FastifySchema = { tags: ['Users'], summary: 'Create address', params: { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'uuid' } }, }, body: { type: 'object' }, response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema }, }; app.post('/users/:id/addresses', { schema: createAddressSchema }, 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)); }); const updateAddressSchema: FastifySchema = { tags: ['Users'], summary: 'Update address', params: { type: 'object', required: ['id', 'addressId'], properties: { id: { type: 'string', format: 'uuid' }, addressId: { type: 'string', format: 'uuid' }, }, }, body: { type: 'object' }, response: { 401: errorSchema, 403: errorSchema, 404: errorSchema }, }; app.patch( '/users/:id/addresses/:addressId', { schema: updateAddressSchema }, 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)); }, ); const deleteAddressSchema: FastifySchema = { tags: ['Users'], summary: 'Delete address', params: { type: 'object', required: ['id', 'addressId'], properties: { id: { type: 'string', format: 'uuid' }, addressId: { type: 'string', format: 'uuid' }, }, }, response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema, 404: errorSchema }, }; app.delete( '/users/:id/addresses/:addressId', { schema: deleteAddressSchema }, 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, preferences: profile.preferences, createdAt: profile.createdAt.toISOString(), updatedAt: profile.updatedAt.toISOString(), }; } function serializeCustomer(c: CustomerSummary) { return { id: c.userId, email: c.email, role: c.role, displayName: c.displayName, phone: c.phone, createdAt: c.createdAt.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(), }; }