feat(projects): rename, delete, and a trash you can actually reach
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 <noreply@anthropic.com>
This commit is contained in:
committed by
Kristian Wind
co-authored by
Claude Opus 5
parent
0dbea0ea4b
commit
8a6e1bc39e
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user