diff --git a/CHANGELOG.md b/CHANGELOG.md index 07c1286..8a9d4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## 2026-07-28 + +### Added +- Option to archive notes. + +### Changed +- Notes should be archived before deleting. + +```bash +# Docker images +docker pull rmcampos/tasknote-app:app-v2026.07.28.000 +docker pull rmcampos/tasknote-api:api-v2026.07.28.000 +``` + ## 2026-07-23 ### Added @@ -21,6 +35,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Delete my account buttons layout to match the system design. - Loaded tasks now has a light yellow styling. +```bash +# Docker images +docker pull rmcampos/tasknote-app:app-v2026.07.23.190 +docker pull rmcampos/tasknote-api:api-v2026.07.23.189 +``` + ### Fixed - Dropped the untagged tag from loading in the add notes and tasks form. diff --git a/client/src/__test__/views/Home.test.tsx b/client/src/__test__/views/Home.test.tsx index 35a5257..8f24210 100644 --- a/client/src/__test__/views/Home.test.tsx +++ b/client/src/__test__/views/Home.test.tsx @@ -22,8 +22,11 @@ vi.mock('react-i18next', () => ({ vi.mock('../../api-service/api', () => ({ default: { getJSON: vi.fn(), + postJSON: vi.fn(), patchJSON: vi.fn(), - deleteNoContent: vi.fn() + putJSON: vi.fn(), + deleteNoContent: vi.fn(), + getJSONNoAuth: vi.fn() } })); @@ -131,7 +134,8 @@ const mockNotes: NoteResponse[] = [ lastUpdate: '2023-10-10', url: 'http://example.com', shared: false, - shareToken: null + shareToken: null, + archived: false }, { id: 2, @@ -141,7 +145,8 @@ const mockNotes: NoteResponse[] = [ lastUpdate: '2023-10-09', url: null, shared: false, - shareToken: null + shareToken: null, + archived: false } ]; @@ -192,6 +197,7 @@ describe('Home Component', () => { return Promise.resolve([]); }); (api.deleteNoContent as any).mockResolvedValue(undefined); + (api.putJSON as any).mockResolvedValue(undefined); // Mock window.innerWidth for the cleanText function Object.defineProperty(window, 'innerWidth', { @@ -366,7 +372,9 @@ describe('Home Component', () => { expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('tasks')); }); - test('deletes note', async () => { + test('archives note', async () => { + (api.putJSON as any).mockResolvedValue(undefined); + await act(async () => { renderHome(); }); @@ -379,33 +387,26 @@ describe('Home Component', () => { const noteDropdownToggles = screen.getAllByTestId('three-dots-icon'); // Note dropdowns start after task dropdowns const firstNoteDropdown = noteDropdownToggles[mockTasks.length]; - + // Click the dropdown toggle await act(async () => { fireEvent.click(firstNoteDropdown); }); - // Find and click the "Delete" option by testId - const deleteButtons = screen.getAllByRole('button'); - const deleteButton = deleteButtons.find( - button => button.textContent === 'task_table_action_delete' - ); + // Find and click the "Archive" option by testId + const archiveButton = screen.getByTestId('note-dropdown-archive-item-1'); await act(async () => { - fireEvent.click(deleteButton!); + fireEvent.click(archiveButton); }); - // Modal should appear; confirm deletion - const confirmButton = screen.getByText('delete_modal_confirm'); - await act(async () => { - fireEvent.click(confirmButton); + // Should call archive API immediately + await waitFor(() => { + expect(api.putJSON).toHaveBeenCalledWith(expect.stringContaining('/notes/1/archive'), {}); }); - // Should call deleteNoContent API - expect(api.deleteNoContent).toHaveBeenCalledWith(expect.stringContaining('/1')); - // Should reload notes - expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('notes')) + expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('notes')); }); test('opens markdown modal when clicking on note tag', async () => { @@ -492,7 +493,7 @@ describe('Home Component', () => { }); }); - test('keeps filter selection after deleting a note', async () => { + test('keeps filter selection after archiving a note', async () => { await act(async () => { renderHome(); }); @@ -524,11 +525,11 @@ describe('Home Component', () => { }); const deleteButtons = screen.getAllByRole('button'); - const deleteButton = deleteButtons.find( - button => button.textContent === 'task_table_action_delete' + const archiveButton = deleteButtons.find( + button => button.textContent === 'note_action_archive' ); await act(async () => { - fireEvent.click(deleteButton!); + fireEvent.click(archiveButton!); }); // After reload, filter should still be applied - tasks should remain hidden diff --git a/client/src/__test__/views/NoteAdd.test.tsx b/client/src/__test__/views/NoteAdd.test.tsx index 4992689..34d464c 100644 --- a/client/src/__test__/views/NoteAdd.test.tsx +++ b/client/src/__test__/views/NoteAdd.test.tsx @@ -155,7 +155,8 @@ describe('NoteAdd Component', () => { tags: [], lastUpdate: '', shared: false, - shareToken: null + shareToken: null, + archived: false } expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, newNote); }); diff --git a/client/src/constants/english.ts b/client/src/constants/english.ts index 605aac7..cfada5d 100644 --- a/client/src/constants/english.ts +++ b/client/src/constants/english.ts @@ -77,6 +77,7 @@ const enTranslations = { home_card_task_empty: 'No pending tasks', home_card_task_done: 'done tasks!', home_completed_tasks_title: 'Completed tasks', + home_archived_notes_title: 'Archived notes', home_card_task_done_empty: 'No done tasks!', home_card_task_btn: 'Go to Tasks', home_card_note_title: 'Notes Summary', @@ -122,6 +123,9 @@ const enTranslations = { note_action_share: 'Share', note_action_unshare: 'Unshare', note_action_copy_link: 'Copy link', + note_action_archive: 'Archive', + note_action_restore: 'Restore', + note_action_delete_permanently: 'Delete permanently', about_page_title_one: 'About the', about_page_title_two: 'TaskNote App', diff --git a/client/src/constants/portuguese.ts b/client/src/constants/portuguese.ts index 158888a..3ba85d6 100644 --- a/client/src/constants/portuguese.ts +++ b/client/src/constants/portuguese.ts @@ -77,6 +77,7 @@ const ptBrTranslations = { home_card_task_empty: 'Nenhuma tarefa pendente', home_card_task_done: 'tarefa(s) concluída(s)', home_completed_tasks_title: 'Tarefas concluídas', + home_archived_notes_title: 'Notas arquivadas', home_card_task_done_empty: 'Nenhuma tarefa condluída', home_card_task_btn: 'Ir para Tarefas', home_card_note_title: 'Resumo de Notas', @@ -122,6 +123,9 @@ const ptBrTranslations = { note_action_share: 'Compartilhar', note_action_unshare: 'Parar de compartilhar', note_action_copy_link: 'Copiar link', + note_action_archive: 'Arquivar', + note_action_restore: 'Restaurar', + note_action_delete_permanently: 'Excluir permanentemente', about_page_title_one: 'Sobre o', about_page_title_two: 'App TaskNote', diff --git a/client/src/constants/russian.ts b/client/src/constants/russian.ts index a2786f7..e16a1ee 100644 --- a/client/src/constants/russian.ts +++ b/client/src/constants/russian.ts @@ -77,6 +77,7 @@ const ruTranslations = { home_card_task_empty: 'Нет незавершённых задач', home_card_task_done: 'выполненные задачи!', home_completed_tasks_title: 'Выполненные задачи', + home_archived_notes_title: 'Архивированные заметки', home_card_task_done_empty: 'Нет выполненных задач!', home_card_task_btn: 'Перейти к задачам', home_card_note_title: 'Обзор заметок', @@ -122,6 +123,9 @@ const ruTranslations = { note_action_share: 'Поделиться', note_action_unshare: 'Закрыть доступ', note_action_copy_link: 'Копировать ссылку', + note_action_archive: 'Архивировать', + note_action_restore: 'Восстановить', + note_action_delete_permanently: 'Удалить навсегда', about_page_title_one: 'около', about_page_title_two: 'TaskNote App', diff --git a/client/src/constants/spanish.ts b/client/src/constants/spanish.ts index 2c83918..a359cde 100644 --- a/client/src/constants/spanish.ts +++ b/client/src/constants/spanish.ts @@ -77,6 +77,7 @@ const esTranslations = { home_card_task_empty: 'No tienes tareas pendientes', home_card_task_done: 'tarea(s) completada(s)', home_completed_tasks_title: 'Tareas completadas', + home_archived_notes_title: 'Notas archivadas', home_card_task_done_empty: 'No tareas completadas', home_card_task_btn: 'Ir a Tareas', home_card_note_title: 'Resumen de Notas', @@ -122,6 +123,9 @@ const esTranslations = { note_action_share: 'Compartir', note_action_unshare: 'Dejar de compartir', note_action_copy_link: 'Copiar enlace', + note_action_archive: 'Archivar', + note_action_restore: 'Restaurar', + note_action_delete_permanently: 'Eliminar permanentemente', about_page_title_one: 'Acerca de', about_page_title_two: 'TaskNote App', diff --git a/client/src/types/NoteResponse.ts b/client/src/types/NoteResponse.ts index 2af8113..fcfcf66 100644 --- a/client/src/types/NoteResponse.ts +++ b/client/src/types/NoteResponse.ts @@ -7,6 +7,7 @@ type NoteResponse = { lastUpdate: string; shared: boolean; shareToken: string | null; + archived: boolean; }; export type { NoteResponse }; diff --git a/client/src/views/Home/index.tsx b/client/src/views/Home/index.tsx index 3580939..2a8cfe8 100644 --- a/client/src/views/Home/index.tsx +++ b/client/src/views/Home/index.tsx @@ -52,6 +52,7 @@ function Home(): React.ReactNode { const [tasks, setTasks] = useState([]); const [completedTasks, setCompletedTasks] = useState([]); const [notes, setNotes] = useState([]); + const [archivedNotes, setArchivedNotes] = useState([]); const [savedNotes, setSavedNotes] = useState([]); const [savedTasks, setSavedTasks] = useState([]); const [showDeleteModal, setShowDeleteModal] = useState(false); @@ -126,6 +127,27 @@ function Home(): React.ReactNode { } }; + /** + * Filter notes into active and archived sets. + * + * @param {NoteResponse[]} allNotes The full list of notes to partition. + * @returns {{ active: NoteResponse[]; archived: NoteResponse[] }} Active and archived notes. + */ + const partitionNotes = (allNotes: NoteResponse[]): { active: NoteResponse[]; archived: NoteResponse[] } => { + return allNotes.reduce<{ active: NoteResponse[]; archived: NoteResponse[] }>( + (acc, note) => { + if (note.archived) { + acc.archived.push(note); + } + else { + acc.active.push(note); + } + return acc; + }, + { active: [], archived: [] } + ); + }; + /** * Opens the delete confirmation modal for a task or note. * @@ -155,6 +177,40 @@ function Home(): React.ReactNode { setDeleteTarget(null); }; + /** + * Archive a note, moving it to the archived notes section. + * + * @param {number} noteId The note ID to be archived. + */ + const archiveNote = async (noteId: number): Promise => { + try { + await api.putJSON(`${ApiConfig.notesUrl}/${noteId}/archive`, {}); + await loadAllNotes(); + } + catch (e) { + handleError(e); + } + }; + + /** + * Restore an archived note back to active notes. + * + * @param {number} noteId The note ID to be restored. + */ + const restoreNote = async (noteId: number): Promise => { + try { + await api.putJSON(`${ApiConfig.notesUrl}/${noteId}/restore`, {}); + await loadAllNotes(); + } + catch (e) { + handleError(e); + } + }; + + const handleArchiveNote = (noteId: number): void => { + void archiveNote(noteId); + }; + /** * Share or unshare a note. * @@ -194,9 +250,11 @@ function Home(): React.ReactNode { const applyFilter = (text: string, radioFilter: string | undefined, allTasks: TaskResponse[], allNotes: NoteResponse[]): void => { const activeTasks = allTasks.filter((task: TaskResponse) => !task.completed); const doneTasks = allTasks.filter((task: TaskResponse) => task.completed); + const { active: activeNotes, archived: archivedNoteList } = partitionNotes(allNotes); if (!text && (!radioFilter || radioFilter === 'everything')) { - setNotes([...allNotes]); + setNotes([...activeNotes]); + setArchivedNotes([...archivedNoteList]); setTasks([...activeTasks]); setCompletedTasks([...doneTasks]); return; @@ -206,9 +264,10 @@ function Home(): React.ReactNode { if (radioFilter && radioFilter === 'onlyTasks') { setNotes([]); + setArchivedNotes([]); } else { - let filteredNotes = allNotes.filter((note: NoteResponse) => { + let filteredNotes = activeNotes.filter((note: NoteResponse) => { const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase()); const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase()); const anyUrlMatch = note.url?.includes(text.toLowerCase()); @@ -223,7 +282,23 @@ function Home(): React.ReactNode { filteredNotes = filteredNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter)); } + let filteredArchivedNotes = archivedNoteList.filter((note: NoteResponse) => { + const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase()); + const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase()); + const anyUrlMatch = note.url?.includes(text.toLowerCase()); + const anyTagMatch = note.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase())); + return anyTitleMatch || anyContentMatch || anyUrlMatch || anyTagMatch; + }); + + if (tagToFilter === 'untagged') { + filteredArchivedNotes = filteredArchivedNotes.filter((note: NoteResponse) => !note.tags || note.tags.length === 0); + } + else if (tagToFilter) { + filteredArchivedNotes = filteredArchivedNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter)); + } + setNotes([...filteredNotes]); + setArchivedNotes([...filteredArchivedNotes]); } if (radioFilter && radioFilter === 'onlyNotes') { @@ -478,7 +553,9 @@ function Home(): React.ReactNode { }} /> - eventKey && handleOptionChange(eventKey)}> + eventKey && handleOptionChange(eventKey)} + > - + Filter Options @@ -568,7 +648,10 @@ function Home(): React.ReactNode { {tasks.map((task: TaskResponse) => ( - + @@ -585,7 +668,10 @@ function Home(): React.ReactNode { - + @@ -601,11 +687,14 @@ function Home(): React.ReactNode { onClick={() => toggleTaskCompleted(task)} data-testid={`task-dropdown-done-item-${task.id}`} > - {task.completed ? t('task_table_action_undone') : t('task_table_action_done')} + {task.completed + ? t('task_table_action_undone') + : t('task_table_action_done')} confirmDelete({ type: 'task', id: task.id })} + onClick={() => + confirmDelete({ type: 'task', id: task.id })} data-testid={`task-dropdown-delete-item-${task.id}`} > {t('task_table_action_delete')} @@ -646,15 +735,15 @@ function Home(): React.ReactNode { - + - + @@ -673,7 +762,9 @@ function Home(): React.ReactNode { onClick={() => toggleShareNote(note)} data-testid={`note-dropdown-share-item-${note.id}`} > - {note.shared ? t('note_action_unshare') : t('note_action_share')} + {note.shared + ? t('note_action_unshare') + : t('note_action_share')} {note.shared && note.shareToken && ( confirmDelete({ type: 'note', id: note.id })} - data-testid={`note-dropdown-delete-item-${note.id}`} + onClick={() => handleArchiveNote(note.id)} + data-testid={`note-dropdown-archive-item-${note.id}`} > - {t('task_table_action_delete')} + {t('note_action_archive')} @@ -727,7 +818,9 @@ function Home(): React.ReactNode { {completedTasks.map((task: TaskResponse) => ( - + @@ -744,7 +837,10 @@ function Home(): React.ReactNode { - + @@ -757,7 +853,8 @@ function Home(): React.ReactNode { confirmDelete({ type: 'task', id: task.id })} + onClick={() => + confirmDelete({ type: 'task', id: task.id })} data-testid={`completed-task-dropdown-delete-item-${task.id}`} > {t('task_table_action_delete')} @@ -780,6 +877,81 @@ function Home(): React.ReactNode { )} + {archivedNotes.length > 0 && ( + + +
{t('home_archived_notes_title')}
+ + {archivedNotes.map((note: NoteResponse) => ( + + + + + + + + + + + + + + + + + + + restoreNote(note.id)} + data-testid={`archived-note-dropdown-restore-item-${note.id}`} + > + {t('note_action_restore')} + + + confirmDelete({ type: 'note', id: note.id })} + data-testid={`archived-note-dropdown-delete-item-${note.id}`} + > + {t('note_action_delete_permanently')} + + + + + + + + {getFirstRows(note.description)} + + + + ) => { + e.preventDefault(); + e.stopPropagation(); + setModalTitle(note.title); + setModalContent(note.description); + setShowMarkdownView(true); + localStorage.setItem( + OPEN_NOTE_ID_KEY, + note.id.toString() + ); + }} + /> + + + + ))} +
+ )} + - - {t('delete_modal_body')} - + {t('delete_modal_body')} diff --git a/client/src/views/NoteAdd/index.tsx b/client/src/views/NoteAdd/index.tsx index a9704da..4253009 100644 --- a/client/src/views/NoteAdd/index.tsx +++ b/client/src/views/NoteAdd/index.tsx @@ -230,7 +230,8 @@ function NoteAdd(): React.ReactNode { tags: finalTags, lastUpdate: '', shared: false, - shareToken: null + shareToken: null, + archived: false }; const saved = action === 'add' diff --git a/server/src/main/java/br/com/tasknoteapp/server/controller/NoteController.java b/server/src/main/java/br/com/tasknoteapp/server/controller/NoteController.java index d861d3a..527d373 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/controller/NoteController.java +++ b/server/src/main/java/br/com/tasknoteapp/server/controller/NoteController.java @@ -115,4 +115,28 @@ public class NoteController { public ResponseEntity unshareNote(@PathVariable Long id) { return ResponseEntity.ok(noteService.unshareNote(id)); } + + /** + * Archive a note, disabling edits and revoking public sharing. + * + * @param id Note identification. + * @return NoteResponse with the archived note. + * @throws NoteNotFoundException when note not found. + */ + @PutMapping("/{id}/archive") + public ResponseEntity archiveNote(@PathVariable Long id) { + return ResponseEntity.ok(noteService.archiveNote(id)); + } + + /** + * Restore an archived note back to active state. + * + * @param id Note identification. + * @return NoteResponse with the restored note. + * @throws NoteNotFoundException when note not found. + */ + @PutMapping("/{id}/restore") + public ResponseEntity restoreNote(@PathVariable Long id) { + return ResponseEntity.ok(noteService.restoreNote(id)); + } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java index f435efe..44a366b 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java @@ -49,6 +49,9 @@ public class NoteEntity { @Column(name = "share_token", length = 36) private String shareToken; + @Column(name = "archived", nullable = false) + private boolean archived = false; + public Long getId() { return id; } @@ -113,6 +116,14 @@ public class NoteEntity { this.shareToken = shareToken; } + public boolean isArchived() { + return archived; + } + + public void setArchived(boolean archived) { + this.archived = archived; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -145,6 +156,8 @@ public class NoteEntity { + tags + ", lastUpdate=" + lastUpdate + + ", archived=" + + archived + '}'; } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/exception/NoteArchivedException.java b/server/src/main/java/br/com/tasknoteapp/server/exception/NoteArchivedException.java new file mode 100644 index 0000000..4985af8 --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/exception/NoteArchivedException.java @@ -0,0 +1,9 @@ +package br.com.tasknoteapp.server.exception; + +/** This class represents a conflict when an note is archived and cannot be modified. */ +public class NoteArchivedException extends BaseBadRequestException { + + public NoteArchivedException() { + super("note", "Note is archived"); + } +} diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/NoteResponse.java b/server/src/main/java/br/com/tasknoteapp/server/response/NoteResponse.java index 85b9dd5..0902765 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/response/NoteResponse.java +++ b/server/src/main/java/br/com/tasknoteapp/server/response/NoteResponse.java @@ -14,7 +14,8 @@ public record NoteResponse( String lastUpdate, List tags, boolean shared, - String shareToken) { + String shareToken, + boolean archived) { /** * Creates a NoteResponse given a NoteEntity and its Urals. @@ -34,6 +35,7 @@ public record NoteResponse( timeAgoFmt, entity.getTags().stream().map(TagEntity::getName).toList(), entity.isShared(), - entity.getShareToken()); + entity.getShareToken(), + entity.isArchived()); } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java b/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java index f25a23a..ba4416e 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java @@ -4,6 +4,7 @@ import br.com.tasknoteapp.server.entity.NoteEntity; import br.com.tasknoteapp.server.entity.NoteUrlEntity; import br.com.tasknoteapp.server.entity.TagEntity; import br.com.tasknoteapp.server.entity.UserEntity; +import br.com.tasknoteapp.server.exception.NoteArchivedException; import br.com.tasknoteapp.server.exception.NoteNotFoundException; import br.com.tasknoteapp.server.repository.NoteRepository; import br.com.tasknoteapp.server.repository.NoteUrlRepository; @@ -160,6 +161,10 @@ public class NoteService { } NoteEntity noteEntity = note.get(); + if (noteEntity.isArchived()) { + throw new NoteArchivedException(); + } + if (!Objects.isNull(patch.title()) && !patch.title().isBlank()) { noteEntity.setTitle(patch.title().trim()); } @@ -208,6 +213,9 @@ public class NoteService { } NoteEntity noteEntity = note.get(); + if (!noteEntity.isArchived()) { + throw new NoteArchivedException(); + } noteUrlRepository.deleteByNote_id(noteId); logger.info("URL deleted from note ID {}", noteId); @@ -255,6 +263,10 @@ public class NoteService { NoteEntity noteEntity = noteOpt.get(); + if (noteEntity.isArchived()) { + throw new NoteArchivedException(); + } + if (!noteEntity.isShared()) { noteEntity.setShared(true); noteEntity.setShareToken(UUID.randomUUID().toString()); @@ -282,6 +294,11 @@ public class NoteService { } NoteEntity noteEntity = noteOpt.get(); + + if (noteEntity.isArchived()) { + throw new NoteArchivedException(); + } + noteEntity.setShared(false); noteEntity.setShareToken(null); noteRepository.save(noteEntity); @@ -301,7 +318,7 @@ public class NoteService { logger.info("Fetching shared note with token {}", shareToken); Optional noteOpt = noteRepository.findByShareToken(shareToken); - if (noteOpt.isEmpty() || !noteOpt.get().isShared()) { + if (noteOpt.isEmpty() || !noteOpt.get().isShared() || noteOpt.get().isArchived()) { throw new NoteNotFoundException(); } @@ -352,6 +369,66 @@ public class NoteService { return notes.stream().map(n -> NoteResponse.fromEntity(n, noteUrls.get(n.getId()))).toList(); } + /** + * Archive a note, disabling edits and revoking public sharing. + * + * @param noteId The note id from the database. + * @return {@link NoteResponse} containing the archived note. + */ + @Transactional + public NoteResponse archiveNote(Long noteId) { + UserEntity user = getCurrentUser(); + logger.info("Archiving note ID {} for user ID {}", noteId, user.getId()); + + Optional noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId()); + if (noteOpt.isEmpty()) { + throw new NoteNotFoundException(); + } + + NoteEntity noteEntity = noteOpt.get(); + if (noteEntity.isArchived()) { + throw new NoteArchivedException(); + } + + noteEntity.setArchived(true); + noteEntity.setShared(false); + noteEntity.setShareToken(null); + noteEntity.setLastUpdate(LocalDateTime.now()); + noteRepository.save(noteEntity); + logger.info("Note ID {} archived", noteId); + + return NoteResponse.fromEntity(noteEntity, getNoteUrl(noteEntity.getId())); + } + + /** + * Restore an archived note back to active state. + * + * @param noteId The note id from the database. + * @return {@link NoteResponse} containing the restored note. + */ + @Transactional + public NoteResponse restoreNote(Long noteId) { + UserEntity user = getCurrentUser(); + logger.info("Restoring note ID {} for user ID {}", noteId, user.getId()); + + Optional noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId()); + if (noteOpt.isEmpty()) { + throw new NoteNotFoundException(); + } + + NoteEntity noteEntity = noteOpt.get(); + if (!noteEntity.isArchived()) { + throw new NoteArchivedException(); + } + + noteEntity.setArchived(false); + noteEntity.setLastUpdate(LocalDateTime.now()); + noteRepository.save(noteEntity); + logger.info("Note ID {} restored", noteId); + + return NoteResponse.fromEntity(noteEntity, getNoteUrl(noteEntity.getId())); + } + private NoteUrlEntity saveUrl(NoteEntity noteEntity, String url) { NoteUrlEntity noteUrl = new NoteUrlEntity(); noteUrl.setUrl(url); diff --git a/server/src/main/resources/db/migration/V202607291800__add_note_archived.sql b/server/src/main/resources/db/migration/V202607291800__add_note_archived.sql new file mode 100644 index 0000000..f10f6a6 --- /dev/null +++ b/server/src/main/resources/db/migration/V202607291800__add_note_archived.sql @@ -0,0 +1,4 @@ +ALTER TABLE tasknote.notes + ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE INDEX IF NOT EXISTS idx_notes_archived ON tasknote.notes (archived); diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java index 02c5e25..d5c7a54 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java @@ -44,7 +44,7 @@ class NoteControllerTest { void getAllNotes_notesFound_shouldSucceed() throws Exception { NoteUrlResponse noteUrl = new NoteUrlResponse(111L, "https://test.com"); NoteResponse note = - new NoteResponse(111L, "title", "description", "https://test.com", null, List.of("tag"), false, null); + new NoteResponse(111L, "title", "description", "https://test.com", null, List.of("tag"), false, null, false); when(noteService.getAllNotes()).thenReturn(List.of(note)); @@ -109,7 +109,8 @@ class NoteControllerTest { null, List.of("tag"), false, - null); + null, + false); when(noteService.patchNote(noteId, patchRequest)).thenReturn(response); @@ -201,7 +202,7 @@ class NoteControllerTest { NoteRequest request = new NoteRequest("Title", "Description", null, List.of("tag")); NoteResponse entity = new NoteResponse(1L, request.title(), request.description(), - null, null, List.of("tag"), false, null); + null, null, List.of("tag"), false, null, false); when(noteService.createNote(request)).thenReturn(entity); @@ -331,7 +332,8 @@ class NoteControllerTest { final Long noteId = 1L; final String token = "test-token-uuid"; NoteResponse response = - new NoteResponse(noteId, "title", "description", null, null, List.of("tag"), true, token); + new NoteResponse( + noteId, "title", "description", null, null, List.of("tag"), true, token, false); when(noteService.shareNote(noteId)).thenReturn(response); @@ -366,7 +368,8 @@ class NoteControllerTest { void unshareNote_happyPath_shouldSucceed() throws Exception { final Long noteId = 1L; NoteResponse response = - new NoteResponse(noteId, "title", "description", null, null, List.of("tag"), false, null); + new NoteResponse( + noteId, "title", "description", null, null, List.of("tag"), false, null, false); when(noteService.unshareNote(noteId)).thenReturn(response); diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/PublicNoteControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/PublicNoteControllerTest.java index 273c203..6f81ad4 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/PublicNoteControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/PublicNoteControllerTest.java @@ -32,7 +32,8 @@ class PublicNoteControllerTest { void getSharedNote_happyPath_shouldSucceed() throws Exception { final String token = "test-share-token"; NoteResponse response = - new NoteResponse(1L, "title", "description", null, null, List.of("tag"), true, token); + new NoteResponse( + 1L, "title", "description", null, null, List.of("tag"), true, token, false); when(noteService.getSharedNote(token)).thenReturn(response); diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/NoteServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/NoteServiceTest.java index e40793d..3694392 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/NoteServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/NoteServiceTest.java @@ -150,6 +150,7 @@ class NoteServiceTest { @Test void deleteNote() { + note.setArchived(true); when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail())); when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user)); when(noteRepository.findByIdAndUser_id(note.getId(), user.getId())) diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/UserSessionServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/UserSessionServiceTest.java index e9a1336..ef66a7c 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/UserSessionServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/UserSessionServiceTest.java @@ -44,7 +44,7 @@ class UserSessionServiceTest { TaskResponse task = new TaskResponse(1L, false, "Task 1", true, null, null, null, null, List.of()); NoteResponse note = - new NoteResponse(1L, "Note 1", "Description", null, null, null, false, null); + new NoteResponse(1L, "Note 1", "Description", null, null, null, false, null, false); when(authService.getCurrentUser()).thenReturn(Optional.of(user)); when(taskService.getAllTasks()).thenReturn(List.of(task));