feat: add edited_at column to notes and implement note update functionality with audit logging

This commit is contained in:
2025-11-18 09:43:39 +01:00
parent acb7117c7d
commit fae7615818
4 changed files with 222 additions and 83 deletions

View File

@@ -0,0 +1,27 @@
import db from "./src/lib/db.js";
export default function migrateAddEditedAtToNotes() {
try {
// Check if edited_at column already exists
const columns = db.prepare("PRAGMA table_info(notes)").all();
const hasEditedAt = columns.some(col => col.name === 'edited_at');
if (!hasEditedAt) {
// Add the edited_at column
db.exec(`
ALTER TABLE notes ADD COLUMN edited_at TEXT;
`);
console.log("Migration completed: Added edited_at column to notes table");
} else {
console.log("Migration skipped: edited_at column already exists");
}
} catch (error) {
console.error("Migration failed:", error);
throw error;
}
}
// Run the migration if this file is executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
migrateAddEditedAtToNotes();
}

View File

@@ -68,5 +68,70 @@ async function deleteNoteHandler(req, { params }) {
} }
} }
async function updateNoteHandler(req, { params }) {
const { id } = await params;
const noteId = id;
const { note: noteText } = await req.json();
if (!noteText || !noteId) {
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
}
try {
// Get original note for audit log and permission check
const originalNote = db
.prepare("SELECT * FROM notes WHERE note_id = ?")
.get(noteId);
if (!originalNote) {
return NextResponse.json({ error: "Note not found" }, { status: 404 });
}
// Check if user has permission to update this note
// Users can update their own notes, or admins can update any note
const userRole = req.user?.role;
const userId = req.user?.id;
if (userRole !== 'admin' && originalNote.created_by !== userId) {
return NextResponse.json({ error: "Unauthorized to update this note" }, { status: 403 });
}
// Update the note
db.prepare(
`
UPDATE notes SET note = ?, edited_at = datetime('now', 'localtime') WHERE note_id = ?
`
).run(noteText, noteId);
// Log note update
await logApiActionSafe(
req,
AUDIT_ACTIONS.NOTE_UPDATE,
RESOURCE_TYPES.NOTE,
noteId,
req.auth,
{
originalNote: {
note_length: originalNote?.note?.length || 0,
project_id: originalNote?.project_id,
task_id: originalNote?.task_id,
},
updatedNote: {
note_length: noteText.length,
},
}
);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating note:", error);
return NextResponse.json(
{ error: "Failed to update note", details: error.message },
{ status: 500 }
);
}
}
// Protected route - require user authentication // Protected route - require user authentication
export const DELETE = withUserAuth(deleteNoteHandler); export const DELETE = withUserAuth(deleteNoteHandler);
export const PUT = withUserAuth(updateNoteHandler);

View File

@@ -136,50 +136,7 @@ async function deleteNoteHandler(req, { params }) {
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
async function updateNoteHandler(req, { params }) {
const { id } = await params;
const noteId = id;
const { note } = await req.json();
if (!note || !noteId) {
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
}
// Get original note for audit log
const originalNote = db
.prepare("SELECT * FROM notes WHERE note_id = ?")
.get(noteId);
db.prepare(
`
UPDATE notes SET note = ? WHERE note_id = ?
`
).run(note, noteId);
// Log note update
await logApiActionSafe(
req,
AUDIT_ACTIONS.NOTE_UPDATE,
RESOURCE_TYPES.NOTE,
noteId,
req.auth, // Use req.auth instead of req.session
{
originalNote: {
note_length: originalNote?.note?.length || 0,
project_id: originalNote?.project_id,
task_id: originalNote?.task_id,
},
updatedNote: {
note_length: note.length,
},
}
);
return NextResponse.json({ success: true });
}
// Protected routes - require authentication // Protected routes - require authentication
export const GET = withReadAuth(getNotesHandler); export const GET = withReadAuth(getNotesHandler);
export const POST = withUserAuth(createNoteHandler); export const POST = withUserAuth(createNoteHandler);
export const DELETE = withUserAuth(deleteNoteHandler); export const DELETE = withUserAuth(deleteNoteHandler);
export const PUT = withUserAuth(updateNoteHandler);

View File

@@ -28,6 +28,8 @@ export default function ProjectViewPage() {
const [notes, setNotes] = useState([]); const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploadedFiles, setUploadedFiles] = useState([]); const [uploadedFiles, setUploadedFiles] = useState([]);
const [editingNoteId, setEditingNoteId] = useState(null);
const [editText, setEditText] = useState('');
// Helper function to parse note text with links // Helper function to parse note text with links
const parseNoteText = (text) => { const parseNoteText = (text) => {
@@ -69,14 +71,14 @@ export default function ProjectViewPage() {
setNotes(prevNotes => [newNote, ...prevNotes]); setNotes(prevNotes => [newNote, ...prevNotes]);
}; };
// Helper function to check if user can delete a note // Helper function to check if user can modify a note (edit or delete)
const canDeleteNote = (note) => { const canModifyNote = (note) => {
if (!session?.user) return false; if (!session?.user) return false;
// Admins can delete any note // Admins can modify any note
if (session.user.role === 'admin') return true; if (session.user.role === 'admin') return true;
// Users can delete their own notes // Users can modify their own notes
return note.created_by === session.user.id; return note.created_by === session.user.id;
}; };
@@ -113,6 +115,34 @@ export default function ProjectViewPage() {
); );
}; };
// Helper function to save edited note
const handleSaveNote = async (noteId) => {
try {
const res = await fetch(`/api/notes/${noteId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ note: editText }),
});
if (res.ok) {
// Update the note in local state
setNotes(prevNotes =>
prevNotes.map(note =>
note.note_id === noteId ? { ...note, note: editText, edited_at: new Date().toISOString() } : note
)
);
setEditingNoteId(null);
setEditText('');
} else {
alert('Błąd podczas aktualizacji notatki');
}
} catch (error) {
console.error('Error updating note:', error);
alert('Błąd podczas aktualizacji notatki');
}
};
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!params.id) return; if (!params.id) return;
@@ -839,49 +869,109 @@ export default function ProjectViewPage() {
{n.created_by_name} {n.created_by_name}
</span> </span>
)} )}
{n.edited_at && (
<span className="text-xs text-gray-400 italic">
edytowane
</span>
)}
</div> </div>
{canDeleteNote(n) && ( {canModifyNote(n) && (
<button <div className="flex gap-1">
onClick={async () => { <button
if (confirm('Czy na pewno chcesz usunąć tę notatkę?')) { onClick={() => {
try { setEditingNoteId(n.note_id);
const res = await fetch(`/api/notes/${n.note_id}`, { setEditText(n.note);
method: 'DELETE', }}
}); className="opacity-0 group-hover:opacity-100 transition-opacity p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded"
if (res.ok) { title="Edytuj notatkę"
// Remove the note from local state instead of full page reload >
setNotes(prevNotes => prevNotes.filter(note => note.note_id !== n.note_id)); <svg
} else { className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onClick={async () => {
if (confirm('Czy na pewno chcesz usunąć tę notatkę?')) {
try {
const res = await fetch(`/api/notes/${n.note_id}`, {
method: 'DELETE',
});
if (res.ok) {
// Remove the note from local state instead of full page reload
setNotes(prevNotes => prevNotes.filter(note => note.note_id !== n.note_id));
} else {
alert('Błąd podczas usuwania notatki');
}
} catch (error) {
console.error('Error deleting note:', error);
alert('Błąd podczas usuwania notatki'); alert('Błąd podczas usuwania notatki');
} }
} catch (error) {
console.error('Error deleting note:', error);
alert('Błąd podczas usuwania notatki');
} }
} }}
}} className="opacity-0 group-hover:opacity-100 transition-opacity p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded"
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded" title="Usuń notatkę"
title="Usuń notatkę"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path <svg
strokeLinecap="round" className="w-4 h-4"
strokeLinejoin="round" fill="none"
strokeWidth={2} stroke="currentColor"
d="M6 18L18 6M6 6l12 12" viewBox="0 0 24 24"
/> >
</svg> <path
</button> strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
)} )}
</div> </div>
<div className="text-gray-900 leading-relaxed"> {editingNoteId === n.note_id ? (
{parseNoteText(n.note)} <div className="space-y-2">
</div> <textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={3}
placeholder="Wpisz notatkę..."
/>
<div className="flex gap-2">
<Button
onClick={() => handleSaveNote(n.note_id)}
variant="primary"
size="sm"
>
Zapisz
</Button>
<Button
onClick={() => {
setEditingNoteId(null);
setEditText('');
}}
variant="outline"
size="sm"
>
Anuluj
</Button>
</div>
</div>
) : (
<div className="text-gray-900 leading-relaxed">
{parseNoteText(n.note)}
</div>
)}
</div> </div>
))} ))}
</div> </div>