UI fix:
- Close-only modals showing a redundant second close button now use
hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.
Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
preserving payload shape, minimal vite module type, frontend body/query-key
types, SystemInfo for Settings.
Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.
Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
294 lines
9.3 KiB
TypeScript
294 lines
9.3 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) {
|
|
return error(
|
|
reply,
|
|
result.error,
|
|
(result as { status?: number }).status ?? 400,
|
|
);
|
|
}
|
|
|
|
await logAudit({
|
|
request,
|
|
authData: request.authData,
|
|
action: "update",
|
|
entityType: "project",
|
|
entityId: id,
|
|
description: `Upraven projekt ${result.name}`,
|
|
});
|
|
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");
|
|
},
|
|
);
|
|
}
|