feat(projects): editable notes with edit stamp + NoteCard bubble component

- 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>
This commit is contained in:
BOHA
2026-06-12 16:26:38 +02:00
parent 6f3f1c40e7
commit 64bb7f9c37
10 changed files with 611 additions and 86 deletions

View File

@@ -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<ReturnType<typeof prisma.project_notes.update>>;
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 };
}