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:
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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user