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
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 });
}
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
export const GET = withReadAuth(getNotesHandler);
export const POST = withUserAuth(createNoteHandler);
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 [loading, setLoading] = useState(true);
const [uploadedFiles, setUploadedFiles] = useState([]);
const [editingNoteId, setEditingNoteId] = useState(null);
const [editText, setEditText] = useState('');
// Helper function to parse note text with links
const parseNoteText = (text) => {
@@ -69,14 +71,14 @@ export default function ProjectViewPage() {
setNotes(prevNotes => [newNote, ...prevNotes]);
};
// Helper function to check if user can delete a note
const canDeleteNote = (note) => {
// Helper function to check if user can modify a note (edit or delete)
const canModifyNote = (note) => {
if (!session?.user) return false;
// Admins can delete any note
// Admins can modify any note
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;
};
@@ -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(() => {
const fetchData = async () => {
if (!params.id) return;
@@ -839,8 +869,36 @@ export default function ProjectViewPage() {
{n.created_by_name}
</span>
)}
{n.edited_at && (
<span className="text-xs text-gray-400 italic">
edytowane
</span>
)}
</div>
{canDeleteNote(n) && (
{canModifyNote(n) && (
<div className="flex gap-1">
<button
onClick={() => {
setEditingNoteId(n.note_id);
setEditText(n.note);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded"
title="Edytuj notatkę"
>
<svg
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ę?')) {
@@ -877,11 +935,43 @@ export default function ProjectViewPage() {
/>
</svg>
</button>
</div>
)}
</div>
{editingNoteId === n.note_id ? (
<div className="space-y-2">
<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>