- author-only note editing (PUT /projects/:id/notes/:noteId, strict schema, audit with old/new content); edits stamped via new edited_at column (migration 20260612160000) and shown as '· upraveno <datum>' - note deletion tightened to admin-only server-side (was any projects.edit holder via direct API) - new NoteCard component: chat-bubble note row (initials avatar, wrapping header, inline editor, actions in the bubble corner so they don't squeeze content on mobile), table-matching edit/delete icons - Odin project detail carries the edited_at stamp; route-level tests cover author-edit, foreign-edit 403, validation, admin-only delete Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
282 lines
9.1 KiB
TypeScript
282 lines
9.1 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 any).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 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);
|
|
}
|
|
|
|
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");
|
|
},
|
|
);
|
|
}
|