feat(admin): deleting an account stops deleting other people's work

`tasks.created_by` was ON DELETE CASCADE, so retiring a colleague removed every
task they had ever written — including in projects somebody else owns. The
interface said so in plain words before you pressed the button, which is a
warning rather than a fix. It was the last remaining way to lose other people's
work by accident.

SET NULL instead: the task stays and loses its author, the same shape
`assignee_id` and `completed_by` already have.

SQLite cannot alter a foreign key action, so the table is rebuilt — and with
foreign keys on, the drop in the middle of a rebuild is a cascading delete that
takes every row referencing `tasks` with it. `PRAGMA foreign_keys` is a no-op
inside a transaction, so migrations now run on a connection of their own, and a
migration can opt out of enforcement with `-- verdande:rebuild-tables`.
`PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a
dangling reference fails the migration rather than writing it.

The test fills a database at 0007, upgrades it, and counts. Without the marker it
loses a task, a label link and a comment — which is exactly what it is for.

The admin list now says two numbers instead of one: what disappears, and what
stays behind unattributed. They are different sentences and the second is a
reassurance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 09:03:59 +02:00
co-authored by Claude Opus 5
parent a29534c811
commit ebdc8eacf2
8 changed files with 541 additions and 79 deletions
+8 -5
View File
@@ -30,11 +30,13 @@ type adminUserJSON struct {
Self bool `json:"self"`
CreatedAt string `json:"created_at"`
LastSeenAt string `json:"last_seen_at,omitempty"`
// ProjectCount and TaskCount are what a delete would destroy. Sent with the
// list rather than fetched when the button is pressed, so the confirmation can
// name real numbers without a round trip in the middle of a decision.
ProjectCount int `json:"project_count"`
TaskCount int `json:"task_count"`
// ProjectCount and TaskCount are what a delete would destroy, and
// AuthoredElsewhere is what it would leave behind without an author. Sent with
// the list rather than fetched when the button is pressed, so the confirmation
// can name real numbers without a round trip in the middle of a decision.
ProjectCount int `json:"project_count"`
TaskCount int `json:"task_count"`
AuthoredElsewhere int `json:"authored_elsewhere"`
}
type pendingInviteJSON struct {
@@ -70,6 +72,7 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
IsAdmin: u.IsAdmin, Self: u.ID == me.ID,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
ProjectCount: u.ProjectCount, TaskCount: u.TaskCount,
AuthoredElsewhere: u.AuthoredElsewhere,
}
// Zero means never signed in, which is not a time and must not be sent as
// one — 1970 in an interface reads as a bug rather than as "never".
+46 -10
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"net/http"
"net/url"
"testing"
)
@@ -125,8 +126,8 @@ func TestASecondAdministratorCanBeDemoted(t *testing.T) {
}
}
// Deleting an account takes more than its own projects, and the list says so
// before anybody presses the button.
// Deleting an account takes its own projects and leaves everything else behind.
// The list says both numbers before anybody presses the button.
func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
@@ -143,9 +144,10 @@ func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
"content": "deres egen", "project_id": theirs["id"].(string),
})
// And a task they wrote in a project somebody else owns. tasks.created_by
// cascades, so this one goes too — and a count that missed it would understate
// the damage in exactly the case that matters.
// And a task they wrote in a project somebody else owns. This one survives —
// `created_by` is ON DELETE SET NULL — and it is counted separately, because
// "this much disappears" and "this much stays unattributed" are two different
// things to tell somebody who is about to press delete.
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
sharedID := mine["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
@@ -168,18 +170,52 @@ func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
if found["project_count"] != float64(1) {
t.Errorf("project_count = %v, want 1 — the Inbox must not be counted", found["project_count"])
}
if found["task_count"] != float64(2) {
t.Errorf("task_count = %v, want 2 — the task in the shared project cascades too",
if found["task_count"] != float64(1) {
t.Errorf("task_count = %v, want 1 — only the task in their own project goes",
found["task_count"])
}
if found["authored_elsewhere"] != float64(1) {
t.Errorf("authored_elsewhere = %v, want 1 — the task in the shared project stays",
found["authored_elsewhere"])
}
// And the delete really does take it.
// And the delete leaves it standing, without an author.
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: status %d", resp.StatusCode)
}
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+sharedID, nil)
if got, _ := tasks["tasks"].([]any); len(got) != 0 {
t.Errorf("a task written by the deleted account survived in a shared project: %v", got)
got, _ := tasks["tasks"].([]any)
if len(got) != 1 {
t.Fatalf("a task written by the deleted account should survive in a shared project, got %v", got)
}
surviving := got[0].(map[string]any)
if surviving["content"] != "skrevet i et delt projekt" {
t.Errorf("the surviving task is not the one that was written: %v", surviving["content"])
}
if surviving["created_by"] != "" {
t.Errorf("created_by = %q, want empty — the author is gone, the work is not",
surviving["created_by"])
}
}
// The tasks table was rebuilt to change created_by's delete action, and a rebuild
// is where an index quietly stops matching its table: tasks_fts is external-content
// and keyed on tasks.rowid, which a rebuilt table hands out afresh. Search is the
// only place that would notice.
func TestSearchStillFindsTasksAfterTheTableRebuild(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Have"})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "beskær det grønne hegn", "project_id": project["id"].(string),
})
for _, q := range []string{"grønne", "gronne", "hegn"} {
_, found := ts.do(t, "GET", "/api/v1/search?q="+url.QueryEscape(q), nil)
if got, _ := found["tasks"].([]any); len(got) != 1 {
t.Errorf("search for %q found %d tasks, want 1", q, len(got))
}
}
}