diff --git a/docs/api.md b/docs/api.md index e5ed3f8..0fa398e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -100,6 +100,8 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \ | `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` | | `DELETE` | `/projects/{id}/members/{userID}` | | | `GET` | `/projects/{id}/activity` | | +| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. | +| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. | ### Views, filters and labels diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 0bd9b8f..076dab6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -785,6 +785,42 @@ paths: "204": { description: Deleted. } "404": { $ref: "#/components/responses/NotFound" } + /api/v1/trash/projects: + get: + tags: [projects] + summary: Projects you have deleted and can still bring back. + description: | + Only your own. Restoring has been possible since the trash existed, but + the endpoint takes an id — and the id is what you no longer have once the + project has gone from the interface. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + projects: + type: array + items: + allOf: + - $ref: "#/components/schemas/Project" + - type: object + properties: + deleted_at: { type: string, format: date-time } + purge_after: + type: string + format: date-time + description: | + When it stops being recoverable. Sent rather than + left for the client to work out, because the + retention window is the server's and a second + copy of it will one day disagree. + task_count: + type: integer + description: Tasks that went with it, and would come back with it. + /api/v1/trash/projects/{projectID}/restore: parameters: - { name: projectID, in: path, required: true, schema: { type: string } } diff --git a/internal/httpapi/projects_handlers.go b/internal/httpapi/projects_handlers.go index 695723d..91aee6d 100644 --- a/internal/httpapi/projects_handlers.go +++ b/internal/httpapi/projects_handlers.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "strings" + "time" "github.com/go-chi/chi/v5" @@ -151,6 +152,38 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +type trashedProjectJSON struct { + projectJSON + DeletedAt string `json:"deleted_at"` + // PurgeAfter is when it stops being recoverable. Sent rather than left for + // the frontend to work out, because the retention window is the server's and + // a copy of it in the interface is a copy that will one day disagree. + PurgeAfter string `json:"purge_after"` + TaskCount int `json:"task_count"` +} + +// handleListTrashedProjects is what makes restoring reachable. The restore +// endpoint has always existed; without this, using it meant knowing the id of +// something that had already vanished from the interface. +func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) { + trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID) + if err != nil { + s.internal(w, "list trashed projects", err) + return + } + + out := make([]trashedProjectJSON, 0, len(trashed)) + for _, t := range trashed { + out = append(out, trashedProjectJSON{ + projectJSON: toProjectJSON(t.Project), + DeletedAt: t.DeletedAt.Format(time.RFC3339), + PurgeAfter: t.DeletedAt.Add(s.cfg.TrashRetention).Format(time.RFC3339), + TaskCount: t.TaskCount, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"projects": out}) +} + func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") // Permission cannot be checked through ProjectRole here: a trashed project is diff --git a/internal/httpapi/projects_test.go b/internal/httpapi/projects_test.go new file mode 100644 index 0000000..5411354 --- /dev/null +++ b/internal/httpapi/projects_test.go @@ -0,0 +1,83 @@ +package httpapi + +import ( + "net/http" + "testing" +) + +// Restoring has been possible since the trash existed. Finding what to restore +// has not: the endpoint takes an id, and the id is exactly what you no longer +// have once the project has gone from the interface. +func TestTheTrashCanBeListedAndRestored(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ferie"}) + id := created["id"].(string) + ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "book fly", "project_id": id}) + + if resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+id, nil); resp.StatusCode != http.StatusNoContent { + t.Fatalf("delete: status %d", resp.StatusCode) + } + + // Gone from the ordinary listing. + _, live := ts.do(t, "GET", "/api/v1/projects", nil) + for _, raw := range live["projects"].([]any) { + if raw.(map[string]any)["id"] == id { + t.Fatal("a deleted project is still in the project list") + } + } + + // And findable in the trash, with enough to decide whether to bring it back. + _, trash := ts.do(t, "GET", "/api/v1/trash/projects", nil) + list, _ := trash["projects"].([]any) + if len(list) != 1 { + t.Fatalf("want one project in the trash, got %v", list) + } + entry := list[0].(map[string]any) + if entry["name"] != "Ferie" { + t.Errorf("name = %v", entry["name"]) + } + if entry["task_count"] != float64(1) { + t.Errorf("task_count = %v, want 1 — the count is what says whether it matters", + entry["task_count"]) + } + if entry["purge_after"] == nil || entry["deleted_at"] == nil { + t.Errorf("no deadline was reported: %v", entry) + } + + if resp, _ := ts.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil); resp.StatusCode != http.StatusNoContent { + t.Fatalf("restore: status %d", resp.StatusCode) + } + + // Back, with its task. + _, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+id, nil) + if got, _ := tasks["tasks"].([]any); len(got) != 1 { + t.Errorf("the task did not come back with the project: %v", got) + } + _, trash = ts.do(t, "GET", "/api/v1/trash/projects", nil) + if list, _ := trash["projects"].([]any); len(list) != 0 { + t.Errorf("still in the trash after restoring: %v", list) + } +} + +// Somebody else's deleted project is not in your trash, and you cannot restore it. +func TestTheTrashIsPerOwner(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"}) + id := created["id"].(string) + ts.do(t, "DELETE", "/api/v1/projects/"+id, nil) + + other := ts.newUser(t, "anden@example.dk", "Anden") + + _, trash := other.do(t, "GET", "/api/v1/trash/projects", nil) + if list, _ := trash["projects"].([]any); len(list) != 0 { + t.Errorf("somebody else's deleted project is visible: %v", list) + } + resp, _ := other.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("status %d, want 404", resp.StatusCode) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 0fbbd80..c55580b 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -285,9 +285,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server }) }) - // Restoring is outside the block above: a trashed project is invisible - // to the permission check by design, so the handler resolves ownership - // itself. + // The trash is outside the block above: a deleted project is invisible + // to the permission check by design, so these handlers resolve + // ownership themselves. + r.Get("/trash/projects", s.handleListTrashedProjects) r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject) r.Route("/sections/{sectionID}", func(r chi.Router) { diff --git a/internal/store/projects.go b/internal/store/projects.go index c8e5741..8e5c534 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -234,6 +234,55 @@ func (db *DB) DeleteProject(ctx context.Context, projectID string) error { }) } +// TrashedProject is a deleted project, with when it went and when it goes for good. +type TrashedProject struct { + Project + DeletedAt time.Time + TaskCount int +} + +// ListTrashedProjects returns what the caller owns and has deleted. +// +// Restoring has been possible since the trash existed, but only if you already +// knew the project's id — which is exactly what you no longer have once it has +// gone from the interface. A recovery window nobody can reach is not one. +// +// Owned, not shared: a member of a project somebody else deleted has no business +// bringing it back. +func (db *DB) ListTrashedProjects(ctx context.Context, userID string) ([]TrashedProject, error) { + rows, err := db.QueryContext(ctx, ` + SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox, + p.archived, p.sort_order, p.created_at, p.updated_at, p.deleted_at, + (SELECT count(*) FROM tasks t + WHERE t.project_id = p.id AND t.deleted_at = p.deleted_at) AS task_count + FROM projects p + WHERE p.owner_id = ? AND p.deleted_at IS NOT NULL + ORDER BY p.deleted_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []TrashedProject{} + for rows.Next() { + var t TrashedProject + var created, updated, deleted int64 + var icon sql.NullString + if err := rows.Scan(&t.ID, &t.Name, &t.Color, &icon, &t.ViewMode, &t.OwnerID, + &t.IsInbox, &t.Archived, &t.SortOrder, &created, &updated, &deleted, + &t.TaskCount); err != nil { + return nil, err + } + t.Icon = icon.String + t.CreatedAt = time.Unix(created, 0).UTC() + t.UpdatedAt = time.Unix(updated, 0).UTC() + t.DeletedAt = time.Unix(deleted, 0).UTC() + t.Role = RoleOwner + out = append(out, t) + } + return out, rows.Err() +} + // RestoreProject brings a project back, along with the tasks that were deleted with // it. Tasks deleted before it are left in the trash: they were deleted on purpose // and separately, and restoring a project should not undo that too. diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 115eb3e..9855b8e 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -180,6 +180,45 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({ expect(await worker.text()).toContain("addEventListener('push'"); }); +/** + * A project can be renamed, deleted and brought back. + * + * The round trip matters more than the three steps: deleting used to be a + * one-way door in the interface — the API could restore, but only from an id + * that had already disappeared from the screen. + */ +test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); + await sidebar.getByLabel('Projektnavn').fill('Ferie'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + + const title = page.getByLabel('Projektets navn'); + await expect(title).toHaveValue('Ferie'); + + await title.fill('Sommerferie'); + await title.press('Enter'); + await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible(); + + page.once('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: 'Slet projektet' }).click(); + + await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden(); + + // And it is reachable again, which is the half that did not exist. + await page.goto('/indstillinger/data'); + const trash = page.locator('section.panel').filter({ hasText: 'Papirkurv' }); + await expect(trash.getByText('Sommerferie')).toBeVisible(); + + await trash.getByRole('button', { name: 'Hent tilbage' }).click(); + await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible(); + + expect(trouble).toEqual([]); +}); + /** * The MCP connector address must not fall through to the app shell. * diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 2a9b62c..74749b8 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -176,6 +176,7 @@ export const api = { createProject: (data) => post('/projects', data), updateProject: (id, data) => patch(`/projects/${id}`, data), deleteProject: (id) => del(`/projects/${id}`), + listTrashedProjects: () => get('/trash/projects'), restoreProject: (id) => post(`/trash/projects/${id}/restore`), listSections: (projectId) => get(`/projects/${projectId}/sections`), diff --git a/web/src/routes/indstillinger/data/+page.svelte b/web/src/routes/indstillinger/data/+page.svelte index 1f9e271..06e1975 100644 --- a/web/src/routes/indstillinger/data/+page.svelte +++ b/web/src/routes/indstillinger/data/+page.svelte @@ -101,6 +101,36 @@ } let exportProject = $state(''); + + // --- the trash ------------------------------------------------------------- + + let trashed = $state([]); + let loadingTrash = $state(true); + + $effect(() => { + api + .listTrashedProjects() + .then((r) => (trashed = r.projects)) + .catch((e) => app.toast(humanMessage(e))) + .finally(() => (loadingTrash = false)); + }); + + async function restore(project) { + const previous = trashed; + trashed = trashed.filter((p) => p.id !== project.id); + try { + await api.restoreProject(project.id); + await app.refreshProjects(); + } catch (e) { + trashed = previous; + app.toast(humanMessage(e)); + } + } + + /** Whole days left, rounded down — "0 dage tilbage" is the truthful last day. */ + function daysLeft(iso) { + return Math.max(0, Math.floor((new Date(iso) - Date.now()) / 86400000)); + }
@@ -146,6 +176,38 @@ {/if}
+
+
+

Papirkurv

+

+ Slettede projekter, med deres opgaver. De bliver hentet tilbage præcis som + de var — bortset fra opgaver, du havde slettet hver for sig først; dem var + der en grund til. +

+
+ + {#if loadingTrash} +

…

+ {:else if trashed.length === 0} +

Papirkurven er tom.

+ {:else} + + {/if} +
+

Eksport

diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 6daaaf2..64c7606 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -1,6 +1,7 @@