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
+
+