From 64bb7f9c37d019ae128685a265265f1e5e58e74f Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 12 Jun 2026 16:26:38 +0200 Subject: [PATCH] feat(projects): editable notes with edit stamp + NoteCard bubble component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 ' - 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 --- .../migration.sql | 3 + prisma/schema.prisma | 1 + src/__tests__/project-notes.test.ts | 227 ++++++++++++++++ src/admin/components/NoteCard.tsx | 249 ++++++++++++++++++ src/admin/lib/queries/projects.ts | 2 + src/admin/pages/ProjectDetail.tsx | 125 +++------ src/routes/admin/projects.ts | 47 +++- src/schemas/projects.schema.ts | 8 + src/services/ai-tools.ts | 1 + src/services/projects.service.ts | 34 +++ 10 files changed, 611 insertions(+), 86 deletions(-) create mode 100644 prisma/migrations/20260612160000_project_notes_edited_at/migration.sql create mode 100644 src/__tests__/project-notes.test.ts create mode 100644 src/admin/components/NoteCard.tsx diff --git a/prisma/migrations/20260612160000_project_notes_edited_at/migration.sql b/prisma/migrations/20260612160000_project_notes_edited_at/migration.sql new file mode 100644 index 0000000..2b16428 --- /dev/null +++ b/prisma/migrations/20260612160000_project_notes_edited_at/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE `project_notes` ADD COLUMN `edited_at` DATETIME(0) NULL; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6f5642e..1142cc9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -457,6 +457,7 @@ model project_notes { user_name String? @db.VarChar(100) content String? @db.Text created_at DateTime? @default(now()) @db.DateTime(0) + edited_at DateTime? @db.DateTime(0) projects projects @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "project_notes_ibfk_1") @@index([project_id], map: "project_id") diff --git a/src/__tests__/project-notes.test.ts b/src/__tests__/project-notes.test.ts new file mode 100644 index 0000000..3d8cda0 --- /dev/null +++ b/src/__tests__/project-notes.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import Fastify from "fastify"; +import cookie from "@fastify/cookie"; +import rateLimit from "@fastify/rate-limit"; +import jwt from "jsonwebtoken"; +import prisma from "../config/database"; +import { config } from "../config/env"; +import { securityHeaders } from "../middleware/security"; +import projectsRoutes from "../routes/admin/projects"; + +// --------------------------------------------------------------------------- +// Project notes: author-only editing with a visible edited_at stamp, and +// admin-only deletion (a projects.edit holder may add + edit their own notes +// but never delete — user decision 2026-06-12). +// --------------------------------------------------------------------------- + +const N = "projnotes_test_"; + +let app: Awaited>; +let adminToken: string; +let userAId: number; +let userAToken: string; +let userBToken: string; +let roleId: number; +let projectId: number; +let noteId: number; + +async function buildApp() { + const a = Fastify({ logger: false }); + await a.register(cookie); + await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" }); + a.addHook("onRequest", securityHeaders); + await a.register(projectsRoutes, { prefix: "/api/admin/projects" }); + return a; +} + +function generateToken(user: { + id: number; + username: string; + roleName: string | null; +}): string { + return jwt.sign( + { sub: user.id, username: user.username, role: user.roleName }, + config.jwt.secret, + { expiresIn: "15m" }, + ); +} + +function inject( + method: "POST" | "PUT" | "DELETE", + path: string, + token: string, + body?: unknown, +) { + return app.inject({ + method, + url: path, + headers: { + Authorization: `Bearer ${token}`, + // Content-Type only with a body — Fastify 400s an empty JSON body. + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + ...(body !== undefined ? { payload: body as object } : {}), + }); +} + +beforeAll(async () => { + app = await buildApp(); + + // Defensive pre-clean of a previously failed run. + await prisma.project_notes.deleteMany({ + where: { content: { contains: N } }, + }); + await prisma.projects.deleteMany({ where: { name: { startsWith: N } } }); + await prisma.users.deleteMany({ where: { username: { startsWith: N } } }); + await prisma.roles.deleteMany({ where: { name: `${N}role` } }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + include: { roles: true }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = generateToken({ + id: admin.id, + username: admin.username, + roleName: admin.roles?.name ?? null, + }); + + // Non-admin role with projects.view + projects.edit. + const role = await prisma.roles.create({ + data: { name: `${N}role`, display_name: "ProjNotes test role" }, + }); + roleId = role.id; + const perms = await prisma.permissions.findMany({ + where: { name: { in: ["projects.view", "projects.edit"] } }, + select: { id: true }, + }); + if (perms.length !== 2) { + throw new Error("Test setup: projects.view/projects.edit missing"); + } + await prisma.role_permissions.createMany({ + data: perms.map((p) => ({ role_id: roleId, permission_id: p.id })), + }); + + const mkUser = async (suffix: string) => + prisma.users.create({ + data: { + username: `${N}${suffix}`, + email: `${N}${suffix}@test.local`, + password_hash: "x", + first_name: "Projnotes", + last_name: suffix.toUpperCase(), + is_active: true, + role_id: roleId, + }, + }); + const userA = await mkUser("a"); + const userB = await mkUser("b"); + userAId = userA.id; + userAToken = generateToken({ + id: userA.id, + username: userA.username, + roleName: `${N}role`, + }); + userBToken = generateToken({ + id: userB.id, + username: userB.username, + roleName: `${N}role`, + }); + + const project = await prisma.projects.create({ + data: { name: `${N}project`, status: "aktivni" }, + }); + projectId = project.id; + + // Note authored by user A (through the route, like the UI does). + const res = await inject( + "POST", + `/api/admin/projects/${projectId}/notes`, + userAToken, + { content: `${N}original` }, + ); + expect(res.statusCode).toBe(201); + noteId = res.json().data.note.id; +}); + +afterAll(async () => { + await prisma.project_notes.deleteMany({ where: { project_id: projectId } }); + await prisma.projects.deleteMany({ where: { id: projectId } }); + await prisma.users.deleteMany({ where: { username: { startsWith: N } } }); + await prisma.roles.deleteMany({ where: { id: roleId } }); + await app.close(); +}); + +describe("project notes — author-only edit with edited_at stamp", () => { + it("the author edits their own note and the edit is stamped", async () => { + const res = await inject( + "PUT", + `/api/admin/projects/${projectId}/notes/${noteId}`, + userAToken, + { content: `${N}edited` }, + ); + expect(res.statusCode).toBe(200); + const row = await prisma.project_notes.findUnique({ + where: { id: noteId }, + }); + expect(row?.content).toBe(`${N}edited`); + expect(row?.user_id).toBe(userAId); + expect(row?.edited_at).not.toBeNull(); + }); + + it("another user cannot edit someone else's note", async () => { + const res = await inject( + "PUT", + `/api/admin/projects/${projectId}/notes/${noteId}`, + userBToken, + { content: `${N}hijack` }, + ); + expect(res.statusCode).toBe(403); + const row = await prisma.project_notes.findUnique({ + where: { id: noteId }, + }); + expect(row?.content).toBe(`${N}edited`); // unchanged + }); + + it("rejects empty content and unknown note ids", async () => { + const empty = await inject( + "PUT", + `/api/admin/projects/${projectId}/notes/${noteId}`, + userAToken, + { content: " " }, + ); + expect(empty.statusCode).toBe(400); + + const missing = await inject( + "PUT", + `/api/admin/projects/${projectId}/notes/999999999`, + userAToken, + { content: "x" }, + ); + expect(missing.statusCode).toBe(404); + }); +}); + +describe("project notes — admin-only deletion", () => { + it("a projects.edit holder cannot delete (even their own note)", async () => { + const res = await inject( + "DELETE", + `/api/admin/projects/${projectId}/notes/${noteId}`, + userAToken, + ); + expect(res.statusCode).toBe(403); + }); + + it("an admin deletes anybody's note", async () => { + const res = await inject( + "DELETE", + `/api/admin/projects/${projectId}/notes/${noteId}`, + adminToken, + ); + expect(res.statusCode).toBe(200); + const row = await prisma.project_notes.findUnique({ + where: { id: noteId }, + }); + expect(row).toBeNull(); + }); +}); diff --git a/src/admin/components/NoteCard.tsx b/src/admin/components/NoteCard.tsx new file mode 100644 index 0000000..fcd90dd --- /dev/null +++ b/src/admin/components/NoteCard.tsx @@ -0,0 +1,249 @@ +import { useState } from "react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import TextField from "@mui/material/TextField"; +import Button from "@mui/material/Button"; + +/** + * Chat-bubble style note row (author avatar + bubble), shared by the + * project notes list. Mobile-first: the header wraps (name / timestamp), + * the action icons live in the bubble's top-right corner so they never + * squeeze the content column, and editing happens inline in the bubble. + */ + +const initialsOf = (name: string): string => + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() ?? "") + .join(""); + +// Same edit/delete glyphs as the data tables (AttendanceShiftTable & co.). +const EditIcon = ( + + + + +); + +const DeleteIcon = ( + + + + +); + +function formatNoteDate(dateStr: string | null | undefined): string { + if (!dateStr) return ""; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return ""; + const hours = String(d.getHours()).padStart(2, "0"); + const mins = String(d.getMinutes()).padStart(2, "0"); + return `${d.getDate()}. ${d.getMonth() + 1}. ${d.getFullYear()} ${hours}:${mins}`; +} + +export interface NoteCardProps { + authorName: string; + createdAt: string; + editedAt?: string | null; + content: string; + canEdit?: boolean; + canDelete?: boolean; + deleting?: boolean; + /** Reject (throw) to keep the editor open — the caller shows the alert. */ + onSave?: (content: string) => Promise; + onDelete?: () => void; +} + +export default function NoteCard({ + authorName, + createdAt, + editedAt, + content, + canEdit = false, + canDelete = false, + deleting = false, + onSave, + onDelete, +}: NoteCardProps) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + const [saving, setSaving] = useState(false); + + const startEdit = () => { + setDraft(content); + setEditing(true); + }; + + const save = async () => { + if (!onSave || !draft.trim() || saving) return; + setSaving(true); + try { + await onSave(draft.trim()); + setEditing(false); + } catch { + // Caller already alerted; keep the editor open with the draft. + } finally { + setSaving(false); + } + }; + + return ( + + ({ + width: 32, + height: 32, + borderRadius: "50%", + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "0.75rem", + fontWeight: 700, + bgcolor: `rgba(${t.vars!.palette.primary.mainChannel} / 0.12)`, + color: "primary.main", + })} + > + {initialsOf(authorName)} + + + + + + + {authorName} + + + {formatNoteDate(createdAt)} + {editedAt && ( + + {" "} + · upraveno {formatNoteDate(editedAt)} + + )} + + + {!editing && (canEdit || canDelete) && ( + + {canEdit && ( + + {EditIcon} + + )} + {canDelete && ( + + {DeleteIcon} + + )} + + )} + + + {editing ? ( + + setDraft(e.target.value)} + multiline + minRows={2} + maxRows={16} + fullWidth + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter" && e.ctrlKey && draft.trim()) save(); + if (e.key === "Escape") setEditing(false); + }} + /> + + + + + + ) : ( + + {content} + + )} + + + ); +} diff --git a/src/admin/lib/queries/projects.ts b/src/admin/lib/queries/projects.ts index b549155..b9d7e97 100644 --- a/src/admin/lib/queries/projects.ts +++ b/src/admin/lib/queries/projects.ts @@ -5,8 +5,10 @@ import apiFetch from "../../utils/api"; export interface ProjectNote { id: number; content: string; + user_id: number | null; user_name: string; created_at: string; + edited_at: string | null; } export interface ProjectData { diff --git a/src/admin/pages/ProjectDetail.tsx b/src/admin/pages/ProjectDetail.tsx index b04ab4c..4efd98e 100644 --- a/src/admin/pages/ProjectDetail.tsx +++ b/src/admin/pages/ProjectDetail.tsx @@ -1,12 +1,12 @@ import { useState, useEffect, useRef } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useApiMutation } from "../lib/queries/mutations"; +import apiFetch from "../utils/api"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; import MenuItem from "@mui/material/MenuItem"; import { @@ -17,6 +17,7 @@ import { import { userListOptions, type User as ApiUser } from "../lib/queries/users"; import Forbidden from "../components/Forbidden"; import ProjectFileManager from "../components/ProjectFileManager"; +import NoteCard from "../components/NoteCard"; import { Button, Card, @@ -49,17 +50,6 @@ const STATUS_COLORS: Record< zruseny: "default", }; -function formatNoteDate(dateStr: string) { - if (!dateStr) return ""; - const d = new Date(dateStr); - const day = d.getDate(); - const month = d.getMonth() + 1; - const year = d.getFullYear(); - const hours = String(d.getHours()).padStart(2, "0"); - const mins = String(d.getMinutes()).padStart(2, "0"); - return `${day}. ${month}. ${year} ${hours}:${mins}`; -} - interface User { id: number; name: string; @@ -86,28 +76,12 @@ const BackIcon = ( ); -const TrashIcon = ( - - - - - -); - export default function ProjectDetail() { const { id } = useParams(); const alert = useAlert(); - const { hasPermission, isAdmin } = useAuth(); + const { hasPermission, isAdmin, user } = useAuth(); const navigate = useNavigate(); + const qc = useQueryClient(); const [saving, setSaving] = useState(false); const [form, setForm] = useState({ @@ -278,6 +252,30 @@ export default function ProjectDetail() { } }; + // Throws on failure so NoteCard keeps its editor open with the draft. + const saveNoteEdit = async (noteId: number, content: string) => { + // Raw apiFetch: the body must carry ONLY content (strict schema), the + // note id lives in the URL — useApiMutation sends its whole input. + let res: Response; + try { + res = await apiFetch(`${API_BASE}/projects/${id}/notes/${noteId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + } catch { + alert.error("Chyba připojení"); + throw new Error("network"); + } + const body = await res.json().catch(() => null); + if (!res.ok) { + alert.error(body?.error || "Uložení selhalo"); + throw new Error(body?.error || "Uložení selhalo"); + } + qc.invalidateQueries({ queryKey: ["projects"] }); + alert.success("Poznámka upravena"); + }; + if (isPending) { return ; } @@ -493,61 +491,18 @@ export default function ProjectDetail() { {(notes.length > 0 || project.notes) && ( {notes.map((note) => ( - - - - - - {note.user_name} - - - {formatNoteDate(note.created_at)} - - - - {note.content} - - - {isAdmin && ( - handleDeleteNote(note.id)} - title="Smazat poznámku" - aria-label="Smazat poznámku" - disabled={deletingNoteId === note.id} - sx={{ flexShrink: 0 }} - > - {TrashIcon} - - )} - - + authorName={note.user_name} + createdAt={note.created_at} + editedAt={note.edited_at} + content={note.content || ""} + canEdit={user?.id === note.user_id} + canDelete={isAdmin} + deleting={deletingNoteId === note.id} + onSave={(content) => saveNoteEdit(note.id, content)} + onDelete={() => handleDeleteNote(note.id)} + /> ))} )} diff --git a/src/routes/admin/projects.ts b/src/routes/admin/projects.ts index 54baf45..72e71f1 100644 --- a/src/routes/admin/projects.ts +++ b/src/routes/admin/projects.ts @@ -9,6 +9,7 @@ import { UpdateProjectSchema, CreateProjectSchema, CreateProjectNoteSchema, + UpdateProjectNoteSchema, } from "../../schemas/projects.schema"; import { listProjects, @@ -19,6 +20,7 @@ import { deleteProject, createProjectNote, deleteProjectNote, + updateProjectNote, } from "../../services/projects.service"; // Body for DELETE /:id — optional delete_files flag. The body may be absent on @@ -174,7 +176,47 @@ export default async function projectsRoutes( }, ); - // DELETE /api/admin/projects/:id/notes/:noteId + // 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") }, @@ -183,6 +225,9 @@ export default async function projectsRoutes( 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); diff --git a/src/schemas/projects.schema.ts b/src/schemas/projects.schema.ts index 1593152..30716c9 100644 --- a/src/schemas/projects.schema.ts +++ b/src/schemas/projects.schema.ts @@ -27,6 +27,14 @@ export const CreateProjectNoteSchema = z.object({ content: z.string().nullish(), }); +export const UpdateProjectNoteSchema = z.strictObject({ + content: z + .string() + .trim() + .min(1, "Poznámka nesmí být prázdná") + .max(5000, "Poznámka může mít maximálně 5000 znaků"), +}); + export type CreateProjectInput = z.infer; export type UpdateProjectInput = z.infer; export type CreateProjectNoteInput = z.infer; diff --git a/src/services/ai-tools.ts b/src/services/ai-tools.ts index e8d3bad..d8b1658 100644 --- a/src/services/ai-tools.ts +++ b/src/services/ai-tools.ts @@ -1320,6 +1320,7 @@ const TOOLS: ToolSpec[] = [ recent_notes: p.project_notes.slice(0, 5).map((n) => ({ author: n.user_name, date: n.created_at, + ...(n.edited_at ? { edited_at: n.edited_at } : {}), text: stripHtml(n.content).slice(0, 300), })), }; diff --git a/src/services/projects.service.ts b/src/services/projects.service.ts index 9783970..1611f43 100644 --- a/src/services/projects.service.ts +++ b/src/services/projects.service.ts @@ -283,3 +283,37 @@ export async function deleteProjectNote(projectId: number, noteId: number) { await prisma.project_notes.delete({ where: { id: noteId } }); return note; } + +/** + * Author-only note edit: a note belongs to whoever wrote it (even admins + * edit only their own — editing someone else's words would falsify + * authorship; admins delete instead). Stamps edited_at for the UI marker. + */ +export async function updateProjectNote( + projectId: number, + noteId: number, + userId: number, + content: string, +): Promise< + | null + | { error: string; status: number; note?: never } + | { + error?: never; + status?: never; + note: Awaited>; + previousContent: string | null; + } +> { + const note = await prisma.project_notes.findFirst({ + where: { id: noteId, project_id: projectId }, + }); + if (!note) return null; + if (note.user_id !== userId) { + return { error: "Upravit lze pouze vlastní poznámku", status: 403 }; + } + const updated = await prisma.project_notes.update({ + where: { id: noteId }, + data: { content, edited_at: new Date() }, + }); + return { note: updated, previousContent: note.content }; +}