- Auth: TOTP params from config, JWT error logging, audit log failure logging, replaced_by_hash validation on token rotation - Invoices: remove dead VAT code, consistent PDF permissions, WebP magic-byte detection, deduped exchange-rate fetches - Orders/Offers: multipart limit from config, use paginated() helper, payment method from DB in PDF - Projects: verify project exists before creating note - Attendance: action_type enum validation, consistent local-time shift_date construction, holiday attendance in work fund, trips.view permission on last-km query - Users: paginated() helper usage, remove duplicate dashboard keys, parallel currency conversion, single hashToken implementation - Frontend: memoized customInput, reliable print onload, modal prop standardization (isOpen), ConfirmModal type icons, id===0 key fallback, Login useCallback, CompanySettings ConfirmModal, Attendance timeout cleanup, Dashboard memoization, beforeunload dirty-state warnings on Invoice/Offer/Order detail - Schema: invoice_alert_log timestamp, config/env comment on Date.prototype.toJSON override - Utils: exchange-rate inflight dedup Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
209 lines
6.5 KiB
TypeScript
209 lines
6.5 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
|
import { requirePermission } from "../../middleware/auth";
|
|
import { logAudit } from "../../services/audit";
|
|
import { success, error, parseId } from "../../utils/response";
|
|
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
|
import { parseBody } from "../../schemas/common";
|
|
import {
|
|
CreateProjectSchema,
|
|
UpdateProjectSchema,
|
|
CreateProjectNoteSchema,
|
|
} from "../../schemas/projects.schema";
|
|
import {
|
|
listProjects,
|
|
getProject,
|
|
createProject,
|
|
updateProject,
|
|
deleteProject,
|
|
createProjectNote,
|
|
deleteProjectNote,
|
|
getNextProjectNumber,
|
|
} from "../../services/projects.service";
|
|
|
|
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 result = await listProjects({
|
|
page,
|
|
limit,
|
|
skip,
|
|
sort,
|
|
order,
|
|
search,
|
|
status: query.status ? String(query.status) : undefined,
|
|
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
|
});
|
|
|
|
return reply.send({
|
|
success: true,
|
|
data: result.data,
|
|
pagination: buildPaginationMeta(result.total, page, limit),
|
|
});
|
|
},
|
|
);
|
|
|
|
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.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 project = await createProject(parsed.data);
|
|
if ("error" in project) {
|
|
return error(reply, project.error, (project as any).status ?? 400);
|
|
}
|
|
|
|
await logAudit({
|
|
request,
|
|
authData: request.authData,
|
|
action: "create",
|
|
entityType: "project",
|
|
entityId: project.id,
|
|
description: `Vytvořen projekt ${project.name}`,
|
|
});
|
|
return success(reply, { id: project.id }, 201, "Projekt byl vytvořen");
|
|
},
|
|
);
|
|
|
|
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 any).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 any).status ?? 400);
|
|
}
|
|
|
|
return success(reply, { note }, 201, "Poznámka byla přidána");
|
|
},
|
|
);
|
|
|
|
// GET /api/admin/projects/next-number — shared sequence with orders (matches PHP)
|
|
fastify.get(
|
|
"/next-number",
|
|
{ preHandler: requirePermission("projects.create") },
|
|
async (_request, reply) => {
|
|
const nextNumber = await getNextProjectNumber();
|
|
return success(reply, { next_number: nextNumber });
|
|
},
|
|
);
|
|
|
|
// DELETE /api/admin/projects/:id/notes/:noteId
|
|
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;
|
|
|
|
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 body = request.body as Record<string, unknown>;
|
|
const deleteFiles = !!body?.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");
|
|
},
|
|
);
|
|
}
|