diff --git a/savebook/app/api/notes/[id]/route.js b/savebook/app/api/notes/[id]/route.js index 4748eb3..595308a 100644 --- a/savebook/app/api/notes/[id]/route.js +++ b/savebook/app/api/notes/[id]/route.js @@ -37,7 +37,11 @@ export async function GET(request, { params }) { ); } - const note = await Notes.findOne({ _id: id, user: decoded.userId }); + const note = await Notes.findOne({ + _id: id, + user: decoded.userId, + isDeleted: false, // Exclude soft-deleted notes + }); if (!note) { return NextResponse.json( @@ -108,12 +112,20 @@ export async function DELETE(request, { params }) { ); } - // Delete the note - await Notes.findByIdAndDelete(id); + // Soft delete: mark as deleted instead of removing permanently + // User has 30 days to recover from trash before permanent deletion via TTL + await Notes.findByIdAndUpdate( + id, + { + isDeleted: true, + deletedAt: new Date(), + }, + { new: true } + ); return NextResponse.json({ success: true, - message: "Note deleted successfully" + message: "Note deleted. Recover from trash within 30 days." }); } catch (error) { console.error(error); diff --git a/savebook/app/api/notes/route.js b/savebook/app/api/notes/route.js index aa29807..3e19f47 100644 --- a/savebook/app/api/notes/route.js +++ b/savebook/app/api/notes/route.js @@ -35,6 +35,7 @@ export async function GET(request) { const notes = await Notes.find({ user: new mongoose.Types.ObjectId(decoded.userId), + isDeleted: false, // Exclude soft-deleted notes from active notes }).lean(); // 👇 Attach isBookmarked to each note diff --git a/savebook/app/api/notes/trash/[id]/route.js b/savebook/app/api/notes/trash/[id]/route.js new file mode 100644 index 0000000..e4c1638 --- /dev/null +++ b/savebook/app/api/notes/trash/[id]/route.js @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import dbConnect from "@/lib/db/mongodb"; +import Notes from "@/lib/models/Notes"; +import mongoose from "mongoose"; +import { verifyJwtToken } from "@/lib/utils/jwtAuth"; + +export async function DELETE(request, { params }) { + await dbConnect(); + + try { + const { id: noteId } = await params; + const token = request.cookies.get("authToken"); + + if (!token) { + return NextResponse.json( + { error: "Unauthorized: No token provided" }, + { status: 401 } + ); + } + + const decoded = await verifyJwtToken(token.value); + + if (!decoded || !decoded.success) { + return NextResponse.json( + { error: "Unauthorized: Invalid token" }, + { status: 401 } + ); + } + + if (!mongoose.Types.ObjectId.isValid(noteId)) { + return NextResponse.json( + { error: "Invalid note ID" }, + { status: 400 } + ); + } + + const note = await Notes.findOne({ + _id: noteId, + user: decoded.userId, + isDeleted: true, + }); + + if (!note) { + return NextResponse.json( + { error: "Note not found in trash" }, + { status: 404 } + ); + } + + await Notes.findByIdAndDelete(noteId); + + return NextResponse.json({ + success: true, + message: "Note permanently deleted", + }); + } catch (error) { + console.error(error); + return NextResponse.json( + { error: "Server error" }, + { status: 500 } + ); + } +} diff --git a/savebook/app/api/notes/trash/restore/[id]/route.js b/savebook/app/api/notes/trash/restore/[id]/route.js new file mode 100644 index 0000000..22635d8 --- /dev/null +++ b/savebook/app/api/notes/trash/restore/[id]/route.js @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import dbConnect from "@/lib/db/mongodb"; +import Notes from "@/lib/models/Notes"; +import mongoose from "mongoose"; +import { verifyJwtToken } from "@/lib/utils/jwtAuth"; + +export async function PUT(request, { params }) { + await dbConnect(); + + try { + const { id: noteId } = await params; + const token = request.cookies.get("authToken"); + + if (!token) { + return NextResponse.json( + { error: "Unauthorized: No token provided" }, + { status: 401 } + ); + } + + const decoded = await verifyJwtToken(token.value); + + if (!decoded || !decoded.success) { + return NextResponse.json( + { error: "Unauthorized: Invalid token" }, + { status: 401 } + ); + } + + if (!mongoose.Types.ObjectId.isValid(noteId)) { + return NextResponse.json( + { error: "Invalid note ID" }, + { status: 400 } + ); + } + + const note = await Notes.findOne({ + _id: noteId, + user: decoded.userId, + isDeleted: true, + }); + + if (!note) { + return NextResponse.json( + { error: "Note not found in trash or recovery period expired" }, + { status: 404 } + ); + } + + note.isDeleted = false; + note.deletedAt = null; + await note.save(); + + return NextResponse.json({ + success: true, + data: note, + message: "Note restored successfully", + }); + } catch (error) { + console.error(error); + return NextResponse.json( + { error: "Server error" }, + { status: 500 } + ); + } +} diff --git a/savebook/app/api/notes/trash/route.js b/savebook/app/api/notes/trash/route.js new file mode 100644 index 0000000..d5f98d6 --- /dev/null +++ b/savebook/app/api/notes/trash/route.js @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import dbConnect from "@/lib/db/mongodb"; +import Notes from "@/lib/models/Notes"; +import mongoose from "mongoose"; +import { verifyJwtToken } from "@/lib/utils/jwtAuth"; + +/** + * GET /api/notes/trash + * Retrieve all soft-deleted notes for the current user + * Notes are recoverable for 30 days after deletion + */ +export async function GET(request) { + await dbConnect(); + + try { + const token = request.cookies.get("authToken"); + + if (!token) { + return NextResponse.json( + { error: "Unauthorized: No token provided" }, + { status: 401 } + ); + } + + const decoded = await verifyJwtToken(token.value); + + if (!decoded || !decoded.success) { + return NextResponse.json( + { error: "Unauthorized: Invalid token" }, + { status: 401 } + ); + } + + // Get deleted notes within 30-day recovery window + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + const trash = await Notes.find({ + user: new mongoose.Types.ObjectId(decoded.userId), + isDeleted: true, + deletedAt: { $gte: thirtyDaysAgo }, // Only show notes deleted within last 30 days + }) + .sort({ deletedAt: -1 }) + .lean(); + + return NextResponse.json({ + success: true, + data: trash, + message: `${trash.length} deleted notes available for recovery (expires in 30 days)`, + }); + } catch (error) { + console.error(error); + return NextResponse.json( + { error: "Server error" }, + { status: 500 } + ); + } +} + diff --git a/savebook/components/notes/Notes.js b/savebook/components/notes/Notes.js index 5593efe..1b3bf11 100644 --- a/savebook/components/notes/Notes.js +++ b/savebook/components/notes/Notes.js @@ -8,6 +8,7 @@ import Addnote from './AddNote'; import NoteItem from './NoteItem'; import { useAuth } from '@/context/auth/authContext'; import RichTextEditor from './RichTextEditor'; +import Trash from './Trash'; // Separate navigation handler component to use router with Suspense const NavigationHandler = ({ isAuthenticated, loading }) => { @@ -560,6 +561,9 @@ export default function Notes() {

+ {activeTab === 'trash' ? ( + + ) : (
{/* Search and Filter Section */} @@ -705,6 +709,16 @@ export default function Notes() { Whiteboards {totalWhiteboards} +
@@ -721,6 +735,7 @@ export default function Notes() {
+ )} diff --git a/savebook/components/notes/Trash.js b/savebook/components/notes/Trash.js new file mode 100644 index 0000000..260795d --- /dev/null +++ b/savebook/components/notes/Trash.js @@ -0,0 +1,277 @@ +"use client" +import React, { useContext, useEffect, useState } from 'react' +import toast from 'react-hot-toast'; +import NoteItem from './NoteItem'; +import { useAuth } from '@/context/auth/authContext'; +import noteContext from '@/context/noteContext'; + +export default function Trash() { + const { isAuthenticated, loading } = useAuth(); + const context = useContext(noteContext); + const { decryptNote } = context || {}; + const [trashedNotes, setTrashedNotes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedTag, setSelectedTag] = useState('all'); + + // Fetch trashed notes + const getTrashedNotes = async () => { + try { + setIsLoading(true); + const response = await fetch('/api/notes/trash'); + + if (!response.ok) { + throw new Error('Failed to fetch trash'); + } + + const data = await response.json(); + const notesData = data.data || []; + + // Decrypt notes if decryptNote function is available + const decryptedNotes = decryptNote + ? await Promise.all(notesData.map(note => decryptNote(note))) + : notesData; + + setTrashedNotes(decryptedNotes); + + if (decryptedNotes.length > 0) { + toast.success(`Loaded ${decryptedNotes.length} deleted notes`); + } + } catch (error) { + console.error('Error fetching trash:', error); + toast.error('Failed to load trash'); + setTrashedNotes([]); + } finally { + setIsLoading(false); + } + }; + + // Load trash on mount + useEffect(() => { + if (isAuthenticated && !loading && decryptNote) { + getTrashedNotes(); + } + }, [isAuthenticated, loading, decryptNote]); + + // Restore note from trash + const restoreNote = async (noteId) => { + try { + const response = await fetch(`/api/notes/trash/restore/${noteId}`, { + method: 'PUT' + }); + + if (!response.ok) { + throw new Error('Failed to restore note'); + } + + const data = await response.json(); + + // Remove from trash display + setTrashedNotes(trashedNotes.filter(note => note._id !== noteId)); + toast.success('Note restored successfully'); + } catch (error) { + console.error('Error restoring note:', error); + toast.error('Failed to restore note'); + } + }; + + // Permanently delete note from trash + const permanentlyDeleteNote = async (noteId) => { + try { + // Confirm deletion + const userConfirmed = window.confirm( + 'This will permanently delete the note. This action cannot be undone. Are you sure?' + ); + + if (!userConfirmed) { + return; + } + + const response = await fetch(`/api/notes/trash/${noteId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error('Failed to permanently delete note'); + } + + // Remove from trash display + setTrashedNotes(trashedNotes.filter(note => note._id !== noteId)); + toast.success('Note permanently deleted'); + } catch (error) { + console.error('Error permanently deleting note:', error); + toast.error('Failed to permanently delete note'); + } + }; + + const tagOptions = [ + { id: 1, value: "General", color: "bg-blue-500" }, + { id: 2, value: "Basic", color: "bg-gray-500" }, + { id: 3, value: "Finance", color: "bg-green-500" }, + { id: 4, value: "Grocery", color: "bg-orange-500" }, + { id: 5, value: "Office", color: "bg-purple-500" }, + { id: 6, value: "Personal", color: "bg-pink-500" }, + { id: 7, value: "Work", color: "bg-indigo-500" }, + { id: 8, value: "Ideas", color: "bg-teal-500" } + ]; + + // Filter trashed notes by search and tag + const filteredNotes = trashedNotes.filter(note => { + const matchesSearch = note.title?.toLowerCase().includes(searchTerm.toLowerCase()) || + note.description?.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesTag = selectedTag === 'all' || note.tag === selectedTag; + return matchesSearch && matchesTag; + }); + + if (!isAuthenticated && !loading) { + return null; + } + + return ( +
+
+ {/* Header */} +
+

+ 🗑️ Trash +

+

+ Recover deleted notes within 30 days. After 30 days, notes are permanently deleted. +

+
+ + {/* Navigation Tabs */} +
+ + +
+ + {/* Search and Filter */} +
+ setSearchTerm(e.target.value)} + className="flex-1 px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
+ + {/* Stats */} +
+
+

Total in Trash

+

+ {trashedNotes.length} +

+
+
+

Matching Search

+

+ {filteredNotes.length} +

+
+
+

Recovery Window

+

30 days

+
+
+ + {/* Trash Items */} +
+ {isLoading ? ( +
+
+
+ ) : filteredNotes.length === 0 ? ( +
+

+ {trashedNotes.length === 0 ? "No deleted notes" : "No notes match your search"} +

+

+ {trashedNotes.length === 0 ? "Your trash is empty. Deleted notes will appear here." : "Try adjusting your search filters."} +

+
+ ) : ( + filteredNotes.map(note => ( +
+
+ {/* Note Header */} +
+
+

+ {note.title} +

+

+ {note.description} +

+
+ {note.tag && ( + t.value === note.tag)?.color || 'bg-gray-500' + }`}> + {note.tag} + + )} +
+ + {/* Deletion Info */} +
+

+ Deleted {new Date(note.deletedAt).toLocaleDateString()} at {new Date(note.deletedAt).toLocaleTimeString()} +

+
+ + {/* Action Buttons */} +
+ + +
+
+
+ )) + )} +
+
+
+ ); +} diff --git a/savebook/context/NoteState.js b/savebook/context/NoteState.js index 31232c4..a0e3c46 100644 --- a/savebook/context/NoteState.js +++ b/savebook/context/NoteState.js @@ -175,7 +175,7 @@ const NoteState = (props) => { }, [notes, getMasterKey]) return ( - + {props.children} ) diff --git a/savebook/lib/models/Notes.js b/savebook/lib/models/Notes.js index 08223fd..5c99ae9 100644 --- a/savebook/lib/models/Notes.js +++ b/savebook/lib/models/Notes.js @@ -60,7 +60,29 @@ const NotesSchema = new Schema({ type: String, default: null, }, + + // Soft delete implementation for recovery window + isDeleted: { + type: Boolean, + default: false, + index: true, // Index for efficient queries excluding soft-deleted notes + }, + + deletedAt: { + type: Date, + default: null, + }, }); +// Add TTL index: automatically delete soft-deleted notes after 30 days +// This removes records permanently after 30-day recovery period +NotesSchema.index( + { deletedAt: 1 }, + { + expireAfterSeconds: 2592000, // 30 days in seconds + partialFilterExpression: { isDeleted: true } + } +); + export default mongoose.models.Notes || mongoose.model('Notes', NotesSchema);