import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import type { AuthRepository } from '../auth/auth-repository.js'; import { authenticateAccessToken } from '../auth/authenticate.js'; import type { AccessProfile } from '../auth/rbac-repository.js'; import { UserManagementError, type AssignableRole, type ManagementActor, type UserManagementRepository } from '../auth/user-management-repository.js'; const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) }); const rolesSchema = z.array(z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN'])).max(4); const storesSchema = z.array(z.string().regex(/^[1-9]\d{0,19}$/)).max(100); const createSchema = z.object({ nickname: z.string().trim().min(1).max(128), phone: z.string().trim().regex(/^\+?[0-9]{6,20}$/), note: z.string().trim().max(1000).default(''), roles: rolesSchema.default(['STAFF']), storeIds: storesSchema.default([]) }); const updateSchema = z.object({ nickname: z.string().trim().min(1).max(128).optional(), phone: z.string().trim().regex(/^\+?[0-9]{6,20}$/).optional(), note: z.string().trim().max(1000).optional(), status: z.enum(['ACTIVE', 'DISABLED']).optional(), roles: rolesSchema.optional(), storeIds: storesSchema.optional() }).refine((value) => Object.keys(value).length > 0); const listSchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(20), status: z.enum(['ACTIVE', 'DISABLED']).optional(), role: z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN']).optional(), search: z.string().trim().max(128).optional() }); export interface UserManagementRouteOptions { repository: Pick; authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; jwtSecret: string; } export async function registerUserManagementRoutes( app: FastifyInstance, options: UserManagementRouteOptions ): Promise { app.get('/admin-api/users', async (request, reply) => { const actor = await requireManager(request, reply, options); if (!actor) return; const query = listSchema.safeParse(request.query); if (!query.success) return invalid(reply, request.traceId); const data = await options.repository.listUsers({ actor, ...query.data }); return { code: 0, data, traceId: request.traceId }; }); app.post('/admin-api/staff', async (request, reply) => { const actor = await requireManager(request, reply, options); if (!actor) return; const body = createSchema.safeParse(request.body); if (!body.success) return invalid(reply, request.traceId); return handleMutation(reply, request.traceId, async () => { const data = await options.repository.createStaff(actor, { ...body.data, roles: body.data.roles as AssignableRole[] }); return reply.status(201).send({ code: 0, data, traceId: request.traceId }); }); }); app.patch('/admin-api/users/:id', async (request, reply) => { const actor = await requireManager(request, reply, options); if (!actor) return; const params = idSchema.safeParse(request.params); const body = updateSchema.safeParse(request.body); if (!params.success || !body.success) return invalid(reply, request.traceId); return handleMutation(reply, request.traceId, async () => { const data = await options.repository.updateUser(actor, params.data.id, { ...body.data, roles: body.data.roles as AssignableRole[] | undefined }); return { code: 0, data, traceId: request.traceId }; }); }); app.post('/admin-api/users/:id/reset-sessions', async (request, reply) => { const actor = await requireManager(request, reply, options); if (!actor) return; const params = idSchema.safeParse(request.params); if (!params.success) return invalid(reply, request.traceId); return handleMutation(reply, request.traceId, async () => ({ code: 0, data: await options.repository.resetSessions(actor, params.data.id), traceId: request.traceId })); }); } async function requireManager( request: FastifyRequest, reply: FastifyReply, options: UserManagementRouteOptions ): Promise { const auth = await authenticateAccessToken( request.headers.authorization, options.authRepository, options.jwtSecret ); if (!auth) { reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'The access token or session is invalid.', traceId: request.traceId }); return null; } const access = await options.accessControl.getAccessProfile( auth.session.tenantId, auth.session.user.id ); if (!access.capabilities.some((code) => code === 'staff.manage' || code === 'tenant.manage') && !access.roles.includes('PLATFORM_ADMIN')) { reply.status(403).send({ code: 'STAFF_MANAGEMENT_FORBIDDEN', message: 'Staff management permission is required.', traceId: request.traceId }); return null; } return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access, traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' }; } async function handleMutation(reply: FastifyReply, traceId: string, work: () => Promise) { try { return await work(); } catch (error) { if (!(error instanceof UserManagementError)) throw error; const forbidden = error.code.endsWith('_FORBIDDEN') || error.code === 'USER_NOT_MANAGEABLE'; return reply.status(forbidden ? 403 : 404).send({ code: error.code, message: 'The requested user, role or store assignment is not allowed.', traceId }); } } function invalid(reply: FastifyReply, traceId: string) { return reply.status(400).send({ code: 'INVALID_USER_MANAGEMENT_REQUEST', message: 'The user management request is invalid.', traceId }); }