feat: add notes archival option before deleting (#20)
Frontend CD / build (push) Successful in 4m59s
Backend CD / build (push) Successful in 22m37s

# What

- Add archiving notes option
- Notes should be archived before deleting.

# Why

- Makes the flow more clear and follow the same pattern as other app.Reviewed-on: #20
This commit was merged in pull request #20.
This commit is contained in:
2026-07-28 13:52:19 +00:00
parent 7b45e2ccbd
commit 4993b1e806
20 changed files with 406 additions and 59 deletions
+20
View File
@@ -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.
+24 -23
View File
@@ -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
+2 -1
View File
@@ -155,7 +155,8 @@ describe('NoteAdd Component', () => {
tags: [],
lastUpdate: '',
shared: false,
shareToken: null
shareToken: null,
archived: false
}
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, newNote);
});
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+1
View File
@@ -7,6 +7,7 @@ type NoteResponse = {
lastUpdate: string;
shared: boolean;
shareToken: string | null;
archived: boolean;
};
export type { NoteResponse };
+197 -24
View File
@@ -52,6 +52,7 @@ function Home(): React.ReactNode {
const [tasks, setTasks] = useState<TaskResponse[]>([]);
const [completedTasks, setCompletedTasks] = useState<TaskResponse[]>([]);
const [notes, setNotes] = useState<NoteResponse[]>([]);
const [archivedNotes, setArchivedNotes] = useState<NoteResponse[]>([]);
const [savedNotes, setSavedNotes] = useState<NoteResponse[]>([]);
const [savedTasks, setSavedTasks] = useState<TaskResponse[]>([]);
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(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<void> => {
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<void> => {
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 {
}}
/>
<Dropdown onSelect={eventKey => eventKey && handleOptionChange(eventKey)}>
<Dropdown
onSelect={eventKey => eventKey && handleOptionChange(eventKey)}
>
<Dropdown.Toggle
variant="success"
id="filter-dropdown"
@@ -501,7 +578,10 @@ function Home(): React.ReactNode {
</Badge>
</Dropdown.Toggle>
<Dropdown.Menu className="shadow-lg border-0" style={{ minWidth: '200px' }}>
<Dropdown.Menu
className="shadow-lg border-0"
style={{ minWidth: '200px' }}
>
<Dropdown.Header className="text-muted small">
<i className="bi bi-funnel me-2"></i>
Filter Options
@@ -568,7 +648,10 @@ function Home(): React.ReactNode {
<Row className="mt-3">
{tasks.map((task: TaskResponse) => (
<Col xs={12} key={task.id.toString()}>
<Card key={task.id.toString()} className={`task-card ${task.highPriority ? 'high-importance' : ''}`}>
<Card
key={task.id.toString()}
className={`task-card ${task.highPriority ? 'high-importance' : ''}`}
>
<Card.Body>
<Row>
<Col xs={10}>
@@ -585,7 +668,10 @@ function Home(): React.ReactNode {
</Col>
<Col xs={2} className="text-end">
<Dropdown>
<Dropdown.Toggle variant="success" data-testid={`task-dropdown-menu-${task.id}`}>
<Dropdown.Toggle
variant="success"
data-testid={`task-dropdown-menu-${task.id}`}
>
<ThreeDotsVertical />
</Dropdown.Toggle>
<Dropdown.Menu>
@@ -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')}
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={() => 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 {
<span className="home-item-icon">
<JournalText />
</span>
<NoteTitle
title={note.title}
noteUrl={note.url}
/>
<NoteTitle title={note.title} noteUrl={note.url} />
</Card.Title>
</Col>
<Col xs={2} className="text-end">
<Dropdown>
<Dropdown.Toggle variant="success" data-testid={`note-dropdown-menu-${note.id}`}>
<Dropdown.Toggle
variant="success"
data-testid={`note-dropdown-menu-${note.id}`}
>
<ThreeDotsVertical />
</Dropdown.Toggle>
<Dropdown.Menu>
@@ -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')}
</Dropdown.Item>
{note.shared && note.shareToken && (
<Dropdown.Item
@@ -686,10 +777,10 @@ function Home(): React.ReactNode {
)}
<Dropdown.Item
as="button"
onClick={() => 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')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
@@ -727,7 +818,9 @@ function Home(): React.ReactNode {
</Col>
{completedTasks.map((task: TaskResponse) => (
<Col xs={12} key={`completed-${task.id.toString()}`}>
<Card className={`task-card task-completed ${task.highPriority ? 'high-importance' : ''}`}>
<Card
className={`task-card task-completed ${task.highPriority ? 'high-importance' : ''}`}
>
<Card.Body>
<Row>
<Col xs={10}>
@@ -744,7 +837,10 @@ function Home(): React.ReactNode {
</Col>
<Col xs={2} className="text-end">
<Dropdown>
<Dropdown.Toggle variant="success" data-testid={`completed-task-dropdown-menu-${task.id}`}>
<Dropdown.Toggle
variant="success"
data-testid={`completed-task-dropdown-menu-${task.id}`}
>
<ThreeDotsVertical />
</Dropdown.Toggle>
<Dropdown.Menu>
@@ -757,7 +853,8 @@ function Home(): React.ReactNode {
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={() => 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 {
</Row>
)}
{archivedNotes.length > 0 && (
<Row className="mt-4">
<Col xs={12}>
<h5 className="text-muted">{t('home_archived_notes_title')}</h5>
</Col>
{archivedNotes.map((note: NoteResponse) => (
<Col xs={12} key={`archived-${note.id.toString()}`}>
<Card className="task-card task-completed mb-3">
<Card.Body>
<Row>
<Col xs={10}>
<Card.Title>
<span className="home-item-icon">
<JournalText />
</span>
<NoteTitle title={note.title} noteUrl={note.url} />
</Card.Title>
</Col>
<Col xs={2} className="text-end">
<Dropdown>
<Dropdown.Toggle
variant="success"
data-testid={`archived-note-dropdown-menu-${note.id}`}
>
<ThreeDotsVertical />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
as="button"
onClick={() => restoreNote(note.id)}
data-testid={`archived-note-dropdown-restore-item-${note.id}`}
>
{t('note_action_restore')}
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={() =>
confirmDelete({ type: 'note', id: note.id })}
data-testid={`archived-note-dropdown-delete-item-${note.id}`}
>
{t('note_action_delete_permanently')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Col>
</Row>
<span className="text-muted span-line-break font-size-14">
{getFirstRows(note.description)}
</span>
</Card.Body>
<Card.Footer className="task-card-footer">
<TaskTag
tags={note.tags}
lastUpdate={note.lastUpdate}
taskOrNote="note"
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
setModalTitle(note.title);
setModalContent(note.description);
setShowMarkdownView(true);
localStorage.setItem(
OPEN_NOTE_ID_KEY,
note.id.toString()
);
}}
/>
</Card.Footer>
</Card>
</Col>
))}
</Row>
)}
<ModalMarkdown
show={showMarkdownView}
onHide={handleCloseModal}
@@ -799,13 +971,13 @@ function Home(): React.ReactNode {
{t('delete_modal_title')}
</Modal.Title>
</Modal.Header>
<Modal.Body>
{t('delete_modal_body')}
</Modal.Body>
<Modal.Body>{t('delete_modal_body')}</Modal.Body>
<Modal.Footer className="d-flex flex-wrap gap-2 justify-content-end">
<Button
variant="outline-secondary"
onClick={() => setShowDeleteModal(false)}
onClick={() => {
setShowDeleteModal(false);
}}
className="task-note-btn"
>
{t('delete_modal_cancel')}
@@ -814,6 +986,7 @@ function Home(): React.ReactNode {
variant="danger"
onClick={handleConfirmDelete}
className="task-note-btn"
data-testid="confirm-delete-button"
>
{t('delete_modal_confirm')}
</Button>
+2 -1
View File
@@ -230,7 +230,8 @@ function NoteAdd(): React.ReactNode {
tags: finalTags,
lastUpdate: '',
shared: false,
shareToken: null
shareToken: null,
archived: false
};
const saved = action === 'add'
@@ -115,4 +115,28 @@ public class NoteController {
public ResponseEntity<NoteResponse> 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<NoteResponse> 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<NoteResponse> restoreNote(@PathVariable Long id) {
return ResponseEntity.ok(noteService.restoreNote(id));
}
}
@@ -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
+ '}';
}
}
@@ -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");
}
}
@@ -14,7 +14,8 @@ public record NoteResponse(
String lastUpdate,
List<String> 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());
}
}
@@ -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<NoteEntity> 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<NoteEntity> 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<NoteEntity> 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);
@@ -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);
@@ -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);
@@ -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);
@@ -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()))
@@ -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));