import { FastifyInstance } from "fastify"; import { z } from "zod"; import { requirePermission } from "../../middleware/auth"; import { logAudit } from "../../services/audit"; import { success, error, parseId, paginated } from "../../utils/response"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parseBody } from "../../schemas/common"; import { UpdateProjectSchema, CreateProjectSchema, CreateProjectNoteSchema, UpdateProjectNoteSchema, } from "../../schemas/projects.schema"; import { listProjects, getProject, createProject, getNextProjectNumber, updateProject, deleteProject, createProjectNote, deleteProjectNote, updateProjectNote, } from "../../services/projects.service"; // Body for DELETE /:id — optional delete_files flag. The body may be absent on // a DELETE request, so default to an empty object and coerce truthy flags. const DeleteProjectSchema = z.object({ delete_files: z .union([z.boolean(), z.string(), z.number()]) .optional() .transform((v) => v === true || v === 1 || v === "1" || v === "true"), }); export default async function projectsRoutes( fastify: FastifyInstance, ): Promise { fastify.get( "/", { preHandler: requirePermission("projects.view") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip, sort, order, search } = parsePagination(query); const parsedCustomerId = query.customer_id ? Number(query.customer_id) : undefined; const customerId = parsedCustomerId !== undefined && Number.isFinite(parsedCustomerId) ? parsedCustomerId : undefined; const result = await listProjects({ page, limit, skip, sort, order, search, status: query.status ? String(query.status) : undefined, customer_id: customerId, }); return paginated( reply, result.data, buildPaginationMeta(result.total, page, limit), ); }, ); // GET /api/admin/projects/next-number — preview the next shared number fastify.get( "/next-number", { preHandler: requirePermission("projects.create") }, async (_request, reply) => { const number = await getNextProjectNumber(); return success(reply, { number, next_number: number }); }, ); // POST /api/admin/projects — create a standalone (manual) project fastify.post( "/", { preHandler: requirePermission("projects.create") }, async (request, reply) => { const parsed = parseBody(CreateProjectSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const result = await createProject(parsed.data); if ("error" in result) return error( reply, result.error, (result as { status?: number }).status ?? 400, ); await logAudit({ request, authData: request.authData, action: "create", entityType: "project", entityId: result.id, description: `Vytvořen projekt ${result.project_number}`, }); return success(reply, { id: result.id }, 201, "Projekt byl vytvořen"); }, ); fastify.get<{ Params: { id: string } }>( "/:id", { preHandler: requirePermission("projects.view") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const project = await getProject(id); if (!project) return error(reply, "Projekt nenalezen", 404); return success(reply, project); }, ); fastify.put<{ Params: { id: string } }>( "/:id", { preHandler: requirePermission("projects.edit") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const parsed = parseBody(UpdateProjectSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const result = await updateProject(id, parsed.data); if (!result) return error(reply, "Projekt nenalezen", 404); if ("error" in result) { if (result.error === "invalid_transition" && "currentStatus" in result) return error( reply, `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`, 400, ); return error( reply, result.error, (result as { status?: number }).status ?? 400, ); } const statusChanged = result.old_status !== result.status; await logAudit({ request, authData: request.authData, action: "update", entityType: "project", entityId: id, description: `Upraven projekt ${result.name}`, oldValues: statusChanged ? { status: result.old_status } : undefined, newValues: statusChanged ? { status: result.status } : undefined, }); // The project→order cascade updated the linked order too — give the // order its own audit trail entry. if (result.synced_order) { const so = result.synced_order; await logAudit({ request, authData: request.authData, action: "update", entityType: "order", entityId: so.id, description: `Objednávka ${so.order_number ?? `#${so.id}`} automaticky ${ so.to === "dokoncena" ? "dokončena" : "stornována" } (synchronizace s projektem)`, oldValues: { status: so.from }, newValues: { status: so.to }, }); } return success(reply, { id }, 200, "Projekt byl uložen"); }, ); // POST /api/admin/projects/:id/notes fastify.post<{ Params: { id: string } }>( "/:id/notes", { preHandler: requirePermission("projects.edit") }, async (request, reply) => { const projectId = parseId(request.params.id, reply); if (projectId === null) return; const parsed = parseBody(CreateProjectNoteSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const authData = request.authData!; const note = await createProjectNote(projectId, { userId: authData.userId, firstName: authData.firstName, lastName: authData.lastName, content: parsed.data.content ?? undefined, }); if (note && "error" in note) { return error( reply, note.error, (note as { status?: number }).status ?? 400, ); } await logAudit({ request, authData, action: "create", entityType: "project", entityId: projectId, description: `Přidána poznámka projektu`, }); return success(reply, { note }, 201, "Poznámka byla přidána"); }, ); // PUT /api/admin/projects/:id/notes/:noteId — author-only edit; the edit // is visibly stamped (edited_at) so a changed note can't pose as original. fastify.put<{ Params: { id: string; noteId: string } }>( "/:id/notes/:noteId", { preHandler: requirePermission("projects.edit") }, async (request, reply) => { const noteId = parseId(request.params.noteId, reply); if (noteId === null) return; const projectId = parseId(request.params.id, reply); if (projectId === null) return; const parsed = parseBody(UpdateProjectNoteSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const authData = request.authData!; const result = await updateProjectNote( projectId, noteId, authData.userId, parsed.data.content, ); if (!result) return error(reply, "Poznámka nenalezena", 404); if (result.error !== undefined) { return error(reply, result.error, result.status ?? 403); } await logAudit({ request, authData, action: "update", entityType: "project", entityId: projectId, description: `Upravena poznámka projektu`, oldValues: { content: result.previousContent }, newValues: { content: result.note.content }, }); return success(reply, { note: result.note }, 200, "Poznámka upravena"); }, ); // DELETE /api/admin/projects/:id/notes/:noteId — admin only (user // decision: authors edit their notes, only an admin removes anyone's). fastify.delete<{ Params: { id: string; noteId: string } }>( "/:id/notes/:noteId", { preHandler: requirePermission("projects.edit") }, async (request, reply) => { const noteId = parseId(request.params.noteId, reply); if (noteId === null) return; const projectId = parseId(request.params.id, reply); if (projectId === null) return; if (request.authData!.roleName !== "admin") { return error(reply, "Poznámky může mazat pouze administrátor", 403); } const note = await deleteProjectNote(projectId, noteId); if (!note) return error(reply, "Poznámka nenalezena", 404); await logAudit({ request, authData: request.authData, action: "delete", entityType: "project", entityId: projectId, description: `Smazána poznámka projektu`, }); return success(reply, null, 200, "Poznámka smazána"); }, ); fastify.delete<{ Params: { id: string } }>( "/:id", { preHandler: requirePermission("projects.delete") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const parsed = parseBody(DeleteProjectSchema, request.body ?? {}); if ("error" in parsed) return error(reply, parsed.error, 400); const deleteFiles = parsed.data.delete_files; const result = await deleteProject(id, deleteFiles); if ("error" in result) { if (result.error === "not_found") return error(reply, "Projekt nenalezen", 404); if (result.error === "has_order") return error( reply, "Nelze smazat projekt propojený s objednávkou. Nejdříve smažte objednávku.", 400, ); return error(reply, "Neznámá chyba", 500); } await logAudit({ request, authData: request.authData, action: "delete", entityType: "project", entityId: id, description: `Smazán projekt ${result.name}`, }); return success(reply, null, 200, "Projekt smazán"); }, ); }