287 lines
9.7 KiB
TypeScript
287 lines
9.7 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<string, string | undefined>;
|
|
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(),
|
|
};
|
|
}
|