313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
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 { MediaStorage, MediaValidationError } from '../content/media-storage.js';
|
|
import {
|
|
CleaningTaskError,
|
|
cleaningTaskStatuses,
|
|
type CleaningActor,
|
|
type CleaningTaskRepository
|
|
} from '../cleaning/cleaning-task-repository.js';
|
|
|
|
const listSchema = z.object({
|
|
page: z.coerce.number().int().min(1).default(1),
|
|
pageSize: z.coerce.number().int().min(1).max(50).default(20),
|
|
status: z.enum(cleaningTaskStatuses).optional()
|
|
});
|
|
const paramsSchema = z.object({ taskId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
|
const submitSchema = z.object({
|
|
photoUrls: z.array(z.string().url()).max(9).default([]),
|
|
note: z.string().trim().max(512).optional()
|
|
});
|
|
const assignSchema = z.object({
|
|
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
|
|
note: z.string().trim().max(512).optional()
|
|
}).strict();
|
|
const completeSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
|
|
const rejectSchema = z.object({ reason: z.string().trim().min(1).max(512) }).strict();
|
|
|
|
export interface CleaningRouteOptions {
|
|
repository: Pick<CleaningTaskRepository,
|
|
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
|
|
| 'assign' | 'complete' | 'reject' | 'settlementCandidates'
|
|
| 'assertCanUploadPhoto' | 'stats'>;
|
|
mediaStorage?: MediaStorage;
|
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
|
jwtSecret: string;
|
|
}
|
|
|
|
export async function registerCleaningRoutes(
|
|
app: FastifyInstance,
|
|
options: CleaningRouteOptions
|
|
): Promise<void> {
|
|
if (options.mediaStorage && !app.hasContentTypeParser('application/octet-stream')) {
|
|
app.addContentTypeParser(
|
|
'application/octet-stream',
|
|
{ parseAs: 'buffer', bodyLimit: 8 * 1024 * 1024 },
|
|
(_request, body, done) => done(null, body)
|
|
);
|
|
}
|
|
|
|
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
|
|
const actor = await requireActor(request, reply, options, 'read');
|
|
if (!actor) return;
|
|
const query = listSchema.safeParse(request.query);
|
|
if (!query.success) return invalid(reply, request.traceId);
|
|
return handle(reply, request.traceId, async () => ({
|
|
code: 0,
|
|
data: await options.repository.listHall({ ...actor, ...query.data }),
|
|
traceId: request.traceId
|
|
}));
|
|
});
|
|
|
|
app.get('/app-api/cleaning/tasks/mine', async (request, reply) => {
|
|
const actor = await requireActor(request, reply, options, 'read');
|
|
if (!actor) return;
|
|
const query = listSchema.safeParse(request.query);
|
|
if (!query.success) return invalid(reply, request.traceId);
|
|
return handle(reply, request.traceId, async () => ({
|
|
code: 0,
|
|
data: await options.repository.listMine({ ...actor, ...query.data }),
|
|
traceId: request.traceId
|
|
}));
|
|
});
|
|
|
|
app.get('/app-api/cleaning/stats', async (request, reply) => {
|
|
const actor = await requireActor(request, reply, options, 'read');
|
|
if (!actor) return;
|
|
return handle(reply, request.traceId, async () => ({
|
|
code: 0,
|
|
data: await options.repository.stats(actor),
|
|
traceId: request.traceId
|
|
}));
|
|
});
|
|
|
|
app.get('/admin-api/cleaning/tasks', async (request, reply) => {
|
|
const actor = await requireActor(request, reply, options, 'read');
|
|
if (!actor) return;
|
|
const query = listSchema.safeParse(request.query);
|
|
if (!query.success) return invalid(reply, request.traceId);
|
|
return handle(reply, request.traceId, async () => ({
|
|
code: 0,
|
|
data: await options.repository.listManage({ ...actor, ...query.data }),
|
|
traceId: request.traceId
|
|
}));
|
|
});
|
|
|
|
app.get('/admin-api/cleaning/settlement-candidates', async (request, reply) => {
|
|
const actor = await requireActor(request, reply, options, 'read');
|
|
if (!actor) return;
|
|
const query = listSchema.omit({ status: true }).safeParse(request.query);
|
|
if (!query.success) return invalid(reply, request.traceId);
|
|
return handle(reply, request.traceId, async () => ({
|
|
code: 0,
|
|
data: await options.repository.settlementCandidates({ ...actor, ...query.data }),
|
|
traceId: request.traceId
|
|
}));
|
|
});
|
|
|
|
app.post('/app-api/cleaning/tasks/:taskId/claim', async (request, reply) => {
|
|
const actor = await requireActor(request, reply, options, 'write');
|
|
if (!actor) return;
|
|
const params = paramsSchema.safeParse(request.params);
|
|
if (!params.success) return invalid(reply, request.traceId);
|
|
return handle(reply, request.traceId, async () => ({
|
|
code: 0,
|
|
data: await options.repository.claim({ ...actor, taskId: params.data.taskId }),
|
|
traceId: request.traceId
|
|
}));
|
|
});
|
|
|
|
app.post('/admin-api/cleaning/tasks/:taskId/assign', async (request, reply) => {
|
|
const actor = await requireActor(request, reply, options, 'write');
|
|
if (!actor) return;
|
|
const params = paramsSchema.safeParse(request.params);
|
|
const body = assignSchema.safeParse(request.body ?? {});
|
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
|
return handle(reply, request.traceId, async () => ({
|
|
code: 0,
|
|
data: await options.repository.assign({
|
|
...actor,
|
|
taskId: params.data.taskId,
|
|
cleanerUserId: body.data.cleanerUserId,
|
|
note: body.data.note
|
|
}),
|
|
traceId: request.traceId
|
|
}));
|
|
});
|
|
|
|
app.post('/app-api/cleaning/tasks/:taskId/start', async (request, reply) => {
|
|
const actor = await requireActor(request, reply, options, 'write');
|
|
if (!actor) return;
|
|
const params = paramsSchema.safeParse(request.params);
|
|
if (!params.success) return invalid(reply, request.traceId);
|
|
return handle(reply, request.traceId, async () => ({
|
|
code: 0,
|
|
data: await options.repository.start({ ...actor, taskId: params.data.taskId }),
|
|
traceId: request.traceId
|
|
}));
|
|
});
|
|
|
|
app.post('/app-api/cleaning/tasks/:taskId/rework', async (request, reply) => {
|
|
const actor = await requireActor(request, reply, options, 'write');
|
|
if (!actor) return;
|
|
const params = paramsSchema.safeParse(request.params);
|
|
if (!params.success) return invalid(reply, request.traceId);
|
|
return handle(reply, request.traceId, async () => ({
|
|
code: 0,
|
|
data: await options.repository.rework({ ...actor, taskId: params.data.taskId }),
|
|
traceId: request.traceId
|
|
}));
|
|
});
|
|
|
|
app.post('/app-api/cleaning/tasks/:taskId/photos', async (request, reply) => {
|
|
if (!options.mediaStorage) return reply.status(501).send({
|
|
code: 'CLEANING_PHOTO_UPLOAD_UNAVAILABLE',
|
|
message: 'Cleaning photo upload is not configured.',
|
|
traceId: request.traceId
|
|
});
|
|
const actor = await requireActor(request, reply, options, 'write');
|
|
if (!actor) return;
|
|
const params = paramsSchema.safeParse(request.params);
|
|
const originalName = singleHeader(request.headers['x-file-name']);
|
|
if (!params.success || !originalName || !Buffer.isBuffer(request.body)) {
|
|
return invalid(reply, request.traceId);
|
|
}
|
|
return handle(reply, request.traceId, async () => {
|
|
await options.repository.assertCanUploadPhoto({ ...actor, taskId: params.data.taskId });
|
|
const image = await options.mediaStorage!.storeImage({
|
|
tenantId: actor.tenantId,
|
|
originalName,
|
|
contentType: singleHeader(request.headers['x-image-content-type']) ?? '',
|
|
body: request.body as Buffer
|
|
});
|
|
return reply.status(201).send({ code: 0, data: image, traceId: request.traceId });
|
|
});
|
|
});
|
|
|
|
app.post('/app-api/cleaning/tasks/:taskId/submit', async (request, reply) => {
|
|
const actor = await requireActor(request, reply, options, 'write');
|
|
if (!actor) return;
|
|
const params = paramsSchema.safeParse(request.params);
|
|
const body = submitSchema.safeParse(request.body ?? {});
|
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
|
return handle(reply, request.traceId, async () => ({
|
|
code: 0,
|
|
data: await options.repository.submit({
|
|
...actor,
|
|
taskId: params.data.taskId,
|
|
photoUrls: body.data.photoUrls,
|
|
note: body.data.note
|
|
}),
|
|
traceId: request.traceId
|
|
}));
|
|
});
|
|
|
|
app.post('/admin-api/cleaning/tasks/:taskId/complete', async (request, reply) => {
|
|
const actor = await requireActor(request, reply, options, 'write');
|
|
if (!actor) return;
|
|
const params = paramsSchema.safeParse(request.params);
|
|
const body = completeSchema.safeParse(request.body ?? {});
|
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
|
return handle(reply, request.traceId, async () => ({
|
|
code: 0,
|
|
data: await options.repository.complete({
|
|
...actor,
|
|
taskId: params.data.taskId,
|
|
note: body.data.note
|
|
}),
|
|
traceId: request.traceId
|
|
}));
|
|
});
|
|
|
|
app.post('/admin-api/cleaning/tasks/:taskId/reject', async (request, reply) => {
|
|
const actor = await requireActor(request, reply, options, 'write');
|
|
if (!actor) return;
|
|
const params = paramsSchema.safeParse(request.params);
|
|
const body = rejectSchema.safeParse(request.body ?? {});
|
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
|
return handle(reply, request.traceId, async () => ({
|
|
code: 0,
|
|
data: await options.repository.reject({
|
|
...actor,
|
|
taskId: params.data.taskId,
|
|
reason: body.data.reason
|
|
}),
|
|
traceId: request.traceId
|
|
}));
|
|
});
|
|
}
|
|
|
|
async function requireActor(
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
options: CleaningRouteOptions,
|
|
mode: 'read' | 'write'
|
|
): Promise<CleaningActor | null> {
|
|
const auth = await authenticateAccessToken(
|
|
request.headers.authorization,
|
|
options.authRepository,
|
|
options.jwtSecret
|
|
);
|
|
if (!auth) {
|
|
reply.status(401).send({
|
|
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
|
|
});
|
|
return null;
|
|
}
|
|
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
|
|
const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write';
|
|
if (!access.capabilities.includes(permission)
|
|
&& !access.capabilities.includes('tenant.manage')
|
|
&& !access.roles.includes('PLATFORM_ADMIN')) {
|
|
reply.status(403).send({
|
|
code: 'CLEANING_TASK_FORBIDDEN',
|
|
message: 'Cleaning task permission is required.',
|
|
traceId: request.traceId
|
|
});
|
|
return null;
|
|
}
|
|
return {
|
|
tenantId: auth.session.tenantId,
|
|
userId: auth.session.user.id,
|
|
access,
|
|
traceId: request.traceId
|
|
};
|
|
}
|
|
|
|
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
|
try {
|
|
return await work();
|
|
} catch (error) {
|
|
if (error instanceof MediaValidationError) {
|
|
return reply.status(400).send({
|
|
code: error.code,
|
|
message: 'The cleaning task request cannot be completed.',
|
|
traceId
|
|
});
|
|
}
|
|
if (!(error instanceof CleaningTaskError)) throw error;
|
|
const statusCode = error.code === 'CLEANING_TASK_FORBIDDEN' ? 403 : 409;
|
|
return reply.status(statusCode).send({
|
|
code: error.code,
|
|
message: 'The cleaning task request cannot be completed.',
|
|
traceId
|
|
});
|
|
}
|
|
}
|
|
|
|
function singleHeader(value: string | string[] | undefined) {
|
|
return Array.isArray(value) ? value[0] : value;
|
|
}
|
|
|
|
function invalid(reply: FastifyReply, traceId: string) {
|
|
return reply.status(400).send({
|
|
code: 'INVALID_CLEANING_TASK_REQUEST',
|
|
message: 'The cleaning task request is invalid.',
|
|
traceId
|
|
});
|
|
}
|