From 8a6e1bc39ea5b8b7e9858ca7fb436525800bd249 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 14:59:51 +0200 Subject: [PATCH] feat(projects): rename, delete, and a trash you can actually reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project could be created and shared and never renamed or deleted. Both have worked through the API since the beginning; there was simply nothing to click. The heading is now the field — a heading that happens to be editable rather than a form that looks like one — and saves on blur, with Escape putting the old name back. Owner only, and the Inbox keeps its plain heading because it cannot be either renamed away or deleted. Deleting needed more than a button. It is soft, with thirty days to change your mind, and restoring has been possible the whole time — from an id, which is exactly what you no longer have once the project has gone from the screen. A recovery window nobody can reach is not one. So GET /trash/projects lists what you have deleted, with how many tasks went with it and how long is left, and Settings → Data has the button. Owned, not shared: a member of a project somebody else deleted has no business bringing it back, and there is a test that says so. Co-Authored-By: Claude Opus 5 --- docs/api.md | 2 + docs/openapi.yaml | 36 ++++++ internal/httpapi/projects_handlers.go | 33 +++++ internal/httpapi/projects_test.go | 83 +++++++++++++ internal/httpapi/server.go | 7 +- internal/store/projects.go | 49 ++++++++ web/e2e/smoke.spec.js | 39 ++++++ web/src/lib/api.js | 1 + .../routes/indstillinger/data/+page.svelte | 62 ++++++++++ web/src/routes/projekt/[id]/+page.svelte | 116 +++++++++++++++++- 10 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 internal/httpapi/projects_test.go 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} +
    + {#each trashed as project (project.id)} +
  • +
    + {project.name} + + {project.task_count} opgave{project.task_count === 1 ? '' : 'r'} · + {daysLeft(project.purge_after)} dage tilbage + +
    + +
  • + {/each} +
+ {/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 @@