- projects.service: VALID_TRANSITIONS (aktivni->dokonceny/zruseny, terminal->aktivni reopen), tolerant of legacy statuses; getProject returns valid_transitions; updateProject validates transitions (invalid_transition token -> Czech 400 in the route). - NEW project->order cascade, transactional with the project update: finishing/ cancelling a project completes/cancels its linked received order — only while the order is still open (never resurrects storno, never cancels completed); reopen never cascades. Direct tx write, no service recursion; cascaded order change gets its own audit row. - orders.service: reopen edges (dokoncena/stornovana -> v_realizaci); syncProjectStatus is reopen-aware (reopen skips project sync) and returns the cascaded projects for per-project audit rows in the route. - orders.schema: fix phantom 'zrusena' enum token -> canonical 'stornovana' (every storno PUT through the route 400ed with a raw English Zod message; latent until now because no UI sent it) + route-level regression test. - NEW project-order-status-sync.test.ts: 15 tests — both cascade directions, guards, reopen-no-cascade, legacy tolerance, forward-sync regression. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
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<void> {
|
|
fastify.get(
|
|
"/",
|
|
{ preHandler: requirePermission("projects.view") },
|
|
async (request, reply) => {
|
|
const query = request.query as Record<string, unknown>;
|
|
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");
|
|
},
|
|
);
|
|
}
|