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:
co-authored by
Claude Opus 5
parent
a29534c811
commit
ebdc8eacf2
@@ -30,11 +30,13 @@ type adminUserJSON struct {
|
|||||||
Self bool `json:"self"`
|
Self bool `json:"self"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastSeenAt string `json:"last_seen_at,omitempty"`
|
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||||
// ProjectCount and TaskCount are what a delete would destroy. Sent with the
|
// ProjectCount and TaskCount are what a delete would destroy, and
|
||||||
// list rather than fetched when the button is pressed, so the confirmation can
|
// AuthoredElsewhere is what it would leave behind without an author. Sent with
|
||||||
// name real numbers without a round trip in the middle of a decision.
|
// the list rather than fetched when the button is pressed, so the confirmation
|
||||||
ProjectCount int `json:"project_count"`
|
// can name real numbers without a round trip in the middle of a decision.
|
||||||
TaskCount int `json:"task_count"`
|
ProjectCount int `json:"project_count"`
|
||||||
|
TaskCount int `json:"task_count"`
|
||||||
|
AuthoredElsewhere int `json:"authored_elsewhere"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type pendingInviteJSON struct {
|
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,
|
IsAdmin: u.IsAdmin, Self: u.ID == me.ID,
|
||||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||||
ProjectCount: u.ProjectCount, TaskCount: u.TaskCount,
|
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
|
// 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".
|
// one — 1970 in an interface reads as a bug rather than as "never".
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -125,8 +126,8 @@ func TestASecondAdministratorCanBeDemoted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deleting an account takes more than its own projects, and the list says so
|
// Deleting an account takes its own projects and leaves everything else behind.
|
||||||
// before anybody presses the button.
|
// The list says both numbers before anybody presses the button.
|
||||||
func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
|
func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
@@ -143,9 +144,10 @@ func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
|
|||||||
"content": "deres egen", "project_id": theirs["id"].(string),
|
"content": "deres egen", "project_id": theirs["id"].(string),
|
||||||
})
|
})
|
||||||
|
|
||||||
// And a task they wrote in a project somebody else owns. tasks.created_by
|
// And a task they wrote in a project somebody else owns. This one survives —
|
||||||
// cascades, so this one goes too — and a count that missed it would understate
|
// `created_by` is ON DELETE SET NULL — and it is counted separately, because
|
||||||
// the damage in exactly the case that matters.
|
// "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"})
|
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
|
||||||
sharedID := mine["id"].(string)
|
sharedID := mine["id"].(string)
|
||||||
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
|
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) {
|
if found["project_count"] != float64(1) {
|
||||||
t.Errorf("project_count = %v, want 1 — the Inbox must not be counted", found["project_count"])
|
t.Errorf("project_count = %v, want 1 — the Inbox must not be counted", found["project_count"])
|
||||||
}
|
}
|
||||||
if found["task_count"] != float64(2) {
|
if found["task_count"] != float64(1) {
|
||||||
t.Errorf("task_count = %v, want 2 — the task in the shared project cascades too",
|
t.Errorf("task_count = %v, want 1 — only the task in their own project goes",
|
||||||
found["task_count"])
|
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 {
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
|
||||||
t.Fatalf("delete: status %d", resp.StatusCode)
|
t.Fatalf("delete: status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+sharedID, nil)
|
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+sharedID, nil)
|
||||||
if got, _ := tasks["tasks"].([]any); len(got) != 0 {
|
got, _ := tasks["tasks"].([]any)
|
||||||
t.Errorf("a task written by the deleted account survived in a shared project: %v", got)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
-- `tasks.created_by` stops cascading.
|
||||||
|
--
|
||||||
|
-- It was ON DELETE CASCADE, which made deleting an account delete every task that
|
||||||
|
-- account ever wrote — including in projects somebody else owns. Retiring a
|
||||||
|
-- colleague therefore removed their contributions from shared work rather than
|
||||||
|
-- leaving it behind. It is the last remaining way to lose other people's work by
|
||||||
|
-- accident, and the administrator's page saying so in plain words is a warning, not
|
||||||
|
-- a fix.
|
||||||
|
--
|
||||||
|
-- SET NULL instead: the task stays and loses its author. `created_by` becomes
|
||||||
|
-- nullable to make that expressible, which is the same shape `assignee_id` and
|
||||||
|
-- `completed_by` already have for the same reason.
|
||||||
|
--
|
||||||
|
-- SQLite cannot alter a foreign key action, so the table is rebuilt. The FTS
|
||||||
|
-- triggers come along: they are dropped first so that DROP TABLE and ALTER TABLE
|
||||||
|
-- see a schema they can parse, and recreated below. `tasks_fts` is an
|
||||||
|
-- external-content table keyed on `tasks.rowid`, and a rebuilt table hands out new
|
||||||
|
-- rowids, so the index is rebuilt at the end rather than left pointing at rows that
|
||||||
|
-- have moved.
|
||||||
|
--
|
||||||
|
-- verdande:rebuild-tables
|
||||||
|
|
||||||
|
DROP TRIGGER tasks_fts_insert;
|
||||||
|
DROP TRIGGER tasks_fts_delete;
|
||||||
|
DROP TRIGGER tasks_fts_update;
|
||||||
|
|
||||||
|
CREATE TABLE tasks_rebuilt (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
|
section_id TEXT REFERENCES sections (id) ON DELETE SET NULL,
|
||||||
|
parent_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
priority INTEGER NOT NULL DEFAULT 4,
|
||||||
|
due_date TEXT,
|
||||||
|
due_datetime INTEGER,
|
||||||
|
due_timezone TEXT,
|
||||||
|
duration_min INTEGER,
|
||||||
|
recurrence_rule TEXT,
|
||||||
|
assignee_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
-- Nullable, and SET NULL: an account can go without its work going with it.
|
||||||
|
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
completed_at INTEGER,
|
||||||
|
completed_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
sort_order REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER,
|
||||||
|
fold TEXT GENERATED ALWAYS AS (
|
||||||
|
replace(replace(replace(replace(replace(replace(
|
||||||
|
content || ' ' || description,
|
||||||
|
'ø', 'o'), 'Ø', 'o'),
|
||||||
|
'æ', 'ae'), 'Æ', 'ae'),
|
||||||
|
'å', 'aa'), 'Å', 'aa')
|
||||||
|
) STORED,
|
||||||
|
CHECK (priority BETWEEN 1 AND 4)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tasks_rebuilt
|
||||||
|
(id, project_id, section_id, parent_id, content, description, priority,
|
||||||
|
due_date, due_datetime, due_timezone, duration_min, recurrence_rule,
|
||||||
|
assignee_id, created_by, completed_at, completed_by, sort_order,
|
||||||
|
created_at, updated_at, deleted_at)
|
||||||
|
SELECT
|
||||||
|
id, project_id, section_id, parent_id, content, description, priority,
|
||||||
|
due_date, due_datetime, due_timezone, duration_min, recurrence_rule,
|
||||||
|
assignee_id, created_by, completed_at, completed_by, sort_order,
|
||||||
|
created_at, updated_at, deleted_at
|
||||||
|
FROM tasks;
|
||||||
|
|
||||||
|
DROP TABLE tasks;
|
||||||
|
|
||||||
|
ALTER TABLE tasks_rebuilt RENAME TO tasks;
|
||||||
|
|
||||||
|
CREATE INDEX idx_tasks_project ON tasks (project_id, sort_order)
|
||||||
|
WHERE deleted_at IS NULL AND completed_at IS NULL;
|
||||||
|
CREATE INDEX idx_tasks_due ON tasks (due_date)
|
||||||
|
WHERE deleted_at IS NULL AND completed_at IS NULL AND due_date IS NOT NULL;
|
||||||
|
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id)
|
||||||
|
WHERE deleted_at IS NULL AND completed_at IS NULL AND assignee_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_tasks_parent ON tasks (parent_id) WHERE parent_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TRIGGER tasks_fts_insert AFTER INSERT ON tasks BEGIN
|
||||||
|
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
|
||||||
|
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER tasks_fts_delete AFTER DELETE ON tasks BEGIN
|
||||||
|
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
|
||||||
|
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER tasks_fts_update AFTER UPDATE ON tasks BEGIN
|
||||||
|
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
|
||||||
|
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
|
||||||
|
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
|
||||||
|
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- The rowids the index was built against are gone with the old table.
|
||||||
|
INSERT INTO tasks_fts (tasks_fts) VALUES ('rebuild');
|
||||||
+94
-21
@@ -25,15 +25,18 @@ type DB struct {
|
|||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open prepares the database at path and brings the schema up to date.
|
// dsn is the connection string.
|
||||||
//
|
//
|
||||||
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
||||||
// readers and a WebSocket fan-out, so the pragmas below matter more than usual:
|
// readers and a WebSocket fan-out, so the pragmas below matter more than usual:
|
||||||
// WAL lets readers run while a write is in flight, busy_timeout turns the "database
|
// WAL lets readers run while a write is in flight, busy_timeout turns the "database
|
||||||
// is locked" race into a short wait, and foreign_keys is off by default in SQLite
|
// is locked" race into a short wait, and foreign_keys is off by default in SQLite
|
||||||
// and has to be asked for on every connection.
|
// and has to be asked for on every connection.
|
||||||
func Open(path string) (*DB, error) {
|
//
|
||||||
dsn := "file:" + filepath.ToSlash(path) + "?" + strings.Join([]string{
|
// Its own function so the migration tests can open a database with exactly the
|
||||||
|
// pragmas production runs with.
|
||||||
|
func dsn(path string) string {
|
||||||
|
return "file:" + filepath.ToSlash(path) + "?" + strings.Join([]string{
|
||||||
"_pragma=journal_mode(WAL)",
|
"_pragma=journal_mode(WAL)",
|
||||||
"_pragma=busy_timeout(5000)",
|
"_pragma=busy_timeout(5000)",
|
||||||
"_pragma=foreign_keys(ON)",
|
"_pragma=foreign_keys(ON)",
|
||||||
@@ -56,8 +59,11 @@ func Open(path string) (*DB, error) {
|
|||||||
// spilling them into the data volume.
|
// spilling them into the data volume.
|
||||||
"_pragma=temp_store(MEMORY)",
|
"_pragma=temp_store(MEMORY)",
|
||||||
}, "&")
|
}, "&")
|
||||||
|
}
|
||||||
|
|
||||||
sqlDB, err := sql.Open("sqlite", dsn)
|
// Open prepares the database at path and brings the schema up to date.
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
sqlDB, err := sql.Open("sqlite", dsn(path))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
}
|
}
|
||||||
@@ -91,11 +97,36 @@ func Open(path string) (*DB, error) {
|
|||||||
|
|
||||||
func (db *DB) Path() string { return db.path }
|
func (db *DB) Path() string { return db.path }
|
||||||
|
|
||||||
|
// rebuildMarker opts a migration out of foreign key enforcement for the length of
|
||||||
|
// its transaction.
|
||||||
|
//
|
||||||
|
// SQLite cannot alter a column's foreign key action, so changing one means
|
||||||
|
// rebuilding the table: copy it, drop the original, rename the copy back. With
|
||||||
|
// foreign keys on, the drop is a cascading delete — every row in every table that
|
||||||
|
// references `tasks` would go with it, which is the opposite of what a migration
|
||||||
|
// that exists to *stop* losing rows should do. `PRAGMA foreign_keys` is a no-op
|
||||||
|
// inside a transaction, so it has to be set on the connection before BEGIN; that
|
||||||
|
// is why migrations run on a connection of their own rather than on the pool.
|
||||||
|
//
|
||||||
|
// `PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a
|
||||||
|
// dangling reference fails the migration instead of writing it.
|
||||||
|
const rebuildMarker = "-- verdande:rebuild-tables"
|
||||||
|
|
||||||
// migrate applies every embedded migration that has not run yet, in filename order,
|
// migrate applies every embedded migration that has not run yet, in filename order,
|
||||||
// each inside its own transaction. A migration that fails leaves the schema at the
|
// each inside its own transaction. A migration that fails leaves the schema at the
|
||||||
// last version that succeeded rather than half-applied.
|
// last version that succeeded rather than half-applied.
|
||||||
|
//
|
||||||
|
// Everything here runs on one connection: a migration marked with rebuildMarker
|
||||||
|
// needs a pragma to hold across its transaction, and a pragma only applies to the
|
||||||
|
// connection it was issued on.
|
||||||
func (db *DB) migrate(ctx context.Context) error {
|
func (db *DB) migrate(ctx context.Context) error {
|
||||||
if _, err := db.ExecContext(ctx, `
|
conn, err := db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if _, err := conn.ExecContext(ctx, `
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
applied_at INTEGER NOT NULL
|
applied_at INTEGER NOT NULL
|
||||||
@@ -104,7 +135,7 @@ func (db *DB) migrate(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applied := map[string]bool{}
|
applied := map[string]bool{}
|
||||||
rows, err := db.QueryContext(ctx, `SELECT name FROM schema_migrations`)
|
rows, err := conn.QueryContext(ctx, `SELECT name FROM schema_migrations`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read schema_migrations: %w", err)
|
return fmt.Errorf("read schema_migrations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -136,27 +167,69 @@ func (db *DB) migrate(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := db.applyMigration(ctx, conn, name, string(body)); err != nil {
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, string(body)); err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
return fmt.Errorf("migration %s: %w", name, err)
|
|
||||||
}
|
|
||||||
if _, err := tx.ExecContext(ctx,
|
|
||||||
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
return fmt.Errorf("record migration %s: %w", name, err)
|
|
||||||
}
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) applyMigration(ctx context.Context, conn *sql.Conn, name, body string) error {
|
||||||
|
if strings.Contains(body, rebuildMarker) {
|
||||||
|
if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
||||||
|
return fmt.Errorf("migration %s: disable foreign keys: %w", name, err)
|
||||||
|
}
|
||||||
|
// Back on whatever happens below: this connection goes back to the pool.
|
||||||
|
defer conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, body); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, rebuildMarker) {
|
||||||
|
if err := foreignKeyCheck(ctx, tx); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// foreignKeyCheck reports the first row that references something that is not
|
||||||
|
// there. It names the table and the rowid, because a rebuild that drops rows on the
|
||||||
|
// floor is otherwise indistinguishable from one that worked.
|
||||||
|
func foreignKeyCheck(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
rows, err := tx.QueryContext(ctx, `PRAGMA foreign_key_check`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("foreign key check: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
if rows.Next() {
|
||||||
|
var table, parent string
|
||||||
|
var rowid sql.NullInt64
|
||||||
|
var fkid int
|
||||||
|
if err := rows.Scan(&table, &rowid, &parent, &fkid); err != nil {
|
||||||
|
return fmt.Errorf("foreign key check: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("foreign key check: %s row %d references a missing %s",
|
||||||
|
table, rowid.Int64, parent)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Tx runs fn inside a transaction, rolling back on error or panic.
|
// Tx runs fn inside a transaction, rolling back on error or panic.
|
||||||
func (db *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error {
|
func (db *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// openTest gives each test its own database file. In-memory SQLite would be faster,
|
// openTest gives each test its own database file. In-memory SQLite would be faster,
|
||||||
@@ -410,3 +414,162 @@ func ftsCount(t *testing.T, db *DB, query string) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The rebuild in 0008 is the one migration that does not just add: it copies the
|
||||||
|
// tasks table, drops the original and renames the copy back. Every test above this
|
||||||
|
// one starts from an empty database, where a rebuild that loses rows looks exactly
|
||||||
|
// like a rebuild that works — so this one carries data across it.
|
||||||
|
//
|
||||||
|
// What can go wrong, in the order it would: the drop cascades and takes the child
|
||||||
|
// rows with it; the copy misses a column; the FTS index is left keyed on rowids the
|
||||||
|
// rebuilt table no longer hands out; and finally the thing the migration is for,
|
||||||
|
// which is that deleting an account stops deleting other people's work.
|
||||||
|
func TestTheTasksRebuildCarriesTheDataAcross(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "upgrade.db")
|
||||||
|
|
||||||
|
// A database at 0007: the schema as it shipped in v0.10.2.
|
||||||
|
sqlDB, err := sql.Open("sqlite", dsn(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
applied_at INTEGER NOT NULL
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries, err := fs.Glob(migrationFS, "migrations/*.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sort.Strings(entries)
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := filepath.Base(entry)
|
||||||
|
if name >= "0008" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
body, err := migrationFS.ReadFile(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, string(body)); err != nil {
|
||||||
|
t.Fatalf("apply %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.ExecContext(ctx,
|
||||||
|
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two people, one project each, and a task in each project written by the
|
||||||
|
// other — which is the shape the migration exists for. Plus a label, a comment
|
||||||
|
// and a sub-task, so the child tables have something to lose.
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`INSERT INTO users (id, email, name, password_hash, avatar_color, is_admin, created_at, updated_at)
|
||||||
|
VALUES ('u1', 'en@example.dk', 'En', 'x', 'graphite', 1, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `),
|
||||||
|
('u2', 'to@example.dk', 'To', 'x', 'graphite', 0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||||
|
`INSERT INTO projects (id, owner_id, name, color, created_at, updated_at)
|
||||||
|
VALUES ('p1', 'u1', 'Ens', 'graphite', ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `),
|
||||||
|
('p2', 'u2', 'Tos', 'graphite', ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||||
|
`INSERT INTO tasks (id, project_id, content, description, created_by, priority, sort_order, created_at, updated_at)
|
||||||
|
VALUES ('t1', 'p1', 'grøn hæk', 'i ens eget', 'u1', 2, 1.0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `),
|
||||||
|
('t2', 'p2', 'skrevet af en anden', '', 'u1', 4, 2.0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||||
|
`INSERT INTO tasks (id, project_id, parent_id, content, created_by, sort_order, created_at, updated_at)
|
||||||
|
VALUES ('t3', 'p1', 't1', 'undertask', 'u1', 3.0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||||
|
`INSERT INTO labels (id, user_id, name, created_at) VALUES ('l1', 'u1', 'have', ` + fmt.Sprint(now) + `)`,
|
||||||
|
`INSERT INTO task_labels (task_id, label_id) VALUES ('t1', 'l1')`,
|
||||||
|
`INSERT INTO comments (id, task_id, user_id, body, created_at, updated_at)
|
||||||
|
VALUES ('c1', 't1', 'u1', 'en kommentar', ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||||
|
} {
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, stmt); err != nil {
|
||||||
|
t.Fatalf("seed: %v\n%s", err, stmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sqlDB.Close()
|
||||||
|
|
||||||
|
// Now the upgrade, through the real runner.
|
||||||
|
db, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upgrade: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var tasks, labels, comments int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM tasks`).Scan(&tasks); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM task_labels`).Scan(&labels); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM comments`).Scan(&comments); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tasks != 3 || labels != 1 || comments != 1 {
|
||||||
|
t.Fatalf("after the rebuild: %d tasks, %d labels, %d comments — want 3, 1, 1. "+
|
||||||
|
"A drop that cascaded would look exactly like this", tasks, labels, comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every column came across, including the generated one and the sub-task's
|
||||||
|
// self-reference.
|
||||||
|
var content, description, parent string
|
||||||
|
var priority int
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT content, description, priority FROM tasks WHERE id = 't1'`).
|
||||||
|
Scan(&content, &description, &priority); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if content != "grøn hæk" || description != "i ens eget" || priority != 2 {
|
||||||
|
t.Errorf("t1 came across as %q/%q/%d", content, description, priority)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT parent_id FROM tasks WHERE id = 't3'`).Scan(&parent); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if parent != "t1" {
|
||||||
|
t.Errorf("the sub-task lost its parent: %q", parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The FTS index was rebuilt against the new rowids. Searching the fold column
|
||||||
|
// is the sharpest check: it is generated, so a copy that skipped it would still
|
||||||
|
// have the row and not the index entry.
|
||||||
|
for _, q := range []string{"grøn", "gron", "haek"} {
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM tasks_fts WHERE tasks_fts MATCH ?`, q).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("search %q: %v", q, err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("search for %q found %d rows, want 1 — the index is keyed on rowids "+
|
||||||
|
"the rebuilt table no longer hands out", q, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the point of the whole thing.
|
||||||
|
if err := db.DeleteUser(ctx, "u1"); err != nil {
|
||||||
|
t.Fatalf("delete user: %v", err)
|
||||||
|
}
|
||||||
|
var survived int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE id = 't2'`).Scan(&survived); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if survived != 1 {
|
||||||
|
t.Fatal("a task written in somebody else's project went with the account")
|
||||||
|
}
|
||||||
|
var author sql.NullString
|
||||||
|
if err := db.QueryRow(`SELECT created_by FROM tasks WHERE id = 't2'`).Scan(&author); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if author.Valid {
|
||||||
|
t.Errorf("created_by = %q, want null", author.String)
|
||||||
|
}
|
||||||
|
// Their own project still goes, and everything in it with it.
|
||||||
|
var own int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE project_id = 'p1'`).Scan(&own); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if own != 0 {
|
||||||
|
t.Errorf("%d tasks survived in the deleted account's own project", own)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+47
-13
@@ -29,11 +29,23 @@ type Task struct {
|
|||||||
AssigneeID string
|
AssigneeID string
|
||||||
CompletedAt *time.Time
|
CompletedAt *time.Time
|
||||||
CompletedBy string
|
CompletedBy string
|
||||||
CreatedBy string
|
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
||||||
SortOrder float64
|
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
||||||
Labels []string
|
CreatedBy string
|
||||||
CreatedAt time.Time
|
SortOrder float64
|
||||||
UpdatedAt time.Time
|
// SubtaskCount and SubtaskDone are what a row can say without being opened:
|
||||||
|
// that there is something underneath it, and how much of it is left. Counted
|
||||||
|
// for every task in a list, in the same query — a hundred extra lookups to draw
|
||||||
|
// a badge is not a badge worth having.
|
||||||
|
SubtaskCount int
|
||||||
|
SubtaskDone int
|
||||||
|
// AttachmentCount is the same idea for files. A task with a drawing on it looks
|
||||||
|
// exactly like one without, and the whole reason to attach something is that
|
||||||
|
// somebody should find it.
|
||||||
|
AttachmentCount int
|
||||||
|
Labels []string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) Completed() bool { return t.CompletedAt != nil }
|
func (t *Task) Completed() bool { return t.CompletedAt != nil }
|
||||||
@@ -173,7 +185,7 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
|||||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||||
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||||
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||||
t.created_by, t.sort_order, t.created_at, t.updated_at
|
t.created_by, t.sort_order, t.created_at, t.updated_at,` + taskCounts + `
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN projects p ON p.id = t.project_id
|
JOIN projects p ON p.id = t.project_id
|
||||||
WHERE ` + strings.Join(where, " AND ") + `
|
WHERE ` + strings.Join(where, " AND ") + `
|
||||||
@@ -264,11 +276,11 @@ func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, project_id, section_id, parent_id, content, description,
|
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||||
priority, due_date, due_datetime, due_timezone, duration_min,
|
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||||
recurrence_rule, assignee_id, completed_at, completed_by,
|
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||||
created_by, sort_order, created_at, updated_at
|
t.created_by, t.sort_order, t.created_at, t.updated_at,`+taskCounts+`
|
||||||
FROM tasks WHERE id = ? AND deleted_at IS NULL`, taskID)
|
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -317,9 +329,29 @@ func (db *DB) taskLabels(ctx context.Context, taskID string) ([]string, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// taskCounts are what a row can say about itself without being opened.
|
||||||
|
//
|
||||||
|
// Correlated subqueries rather than a second round of lookups: a list of fifty
|
||||||
|
// tasks would otherwise be a hundred more queries, and the whole point is that a
|
||||||
|
// row shows this without anybody asking for it. `idx_tasks_parent` covers the
|
||||||
|
// first two; the attachment count is a small table with a task_id on it.
|
||||||
|
//
|
||||||
|
// Sub-tasks are counted whether they are open or closed, and the finished ones
|
||||||
|
// again separately, because "3" and "1 of 3" are different sentences and only the
|
||||||
|
// second one says whether there is anything left to do.
|
||||||
|
const taskCounts = `
|
||||||
|
(SELECT count(*) FROM tasks c
|
||||||
|
WHERE c.parent_id = t.id AND c.deleted_at IS NULL),
|
||||||
|
(SELECT count(*) FROM tasks c
|
||||||
|
WHERE c.parent_id = t.id AND c.deleted_at IS NULL AND c.completed_at IS NOT NULL),
|
||||||
|
(SELECT count(*) FROM attachments a WHERE a.task_id = t.id)`
|
||||||
|
|
||||||
func scanTask(rows *sql.Rows) (Task, error) {
|
func scanTask(rows *sql.Rows) (Task, error) {
|
||||||
var t Task
|
var t Task
|
||||||
var sectionID, parentID, dueDate, dueTZ, recurrence, assignee, completedBy sql.NullString
|
var sectionID, parentID, dueDate, dueTZ, recurrence, assignee, completedBy sql.NullString
|
||||||
|
// createdBy is null on a task whose author's account has been deleted. The work
|
||||||
|
// stays; it loses its author.
|
||||||
|
var createdBy sql.NullString
|
||||||
var dueDatetime, completedAt sql.NullInt64
|
var dueDatetime, completedAt sql.NullInt64
|
||||||
var duration sql.NullInt64
|
var duration sql.NullInt64
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
@@ -327,13 +359,15 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
|||||||
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
||||||
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
||||||
&recurrence, &assignee, &completedAt, &completedBy,
|
&recurrence, &assignee, &completedAt, &completedBy,
|
||||||
&t.CreatedBy, &t.SortOrder, &created, &updated)
|
&createdBy, &t.SortOrder, &created, &updated,
|
||||||
|
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return t, err
|
return t, err
|
||||||
}
|
}
|
||||||
|
|
||||||
t.SectionID = sectionID.String
|
t.SectionID = sectionID.String
|
||||||
t.ParentID = parentID.String
|
t.ParentID = parentID.String
|
||||||
|
t.CreatedBy = createdBy.String
|
||||||
t.DueDate = dueDate.String
|
t.DueDate = dueDate.String
|
||||||
t.DueTimezone = dueTZ.String
|
t.DueTimezone = dueTZ.String
|
||||||
t.RecurrenceRule = recurrence.String
|
t.RecurrenceRule = recurrence.String
|
||||||
@@ -384,7 +418,7 @@ func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) erro
|
|||||||
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
|
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
|
||||||
t.Content, t.Description, t.Priority, nullString(t.DueDate),
|
t.Content, t.Description, t.Priority, nullString(t.DueDate),
|
||||||
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
|
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
|
||||||
nullString(t.RecurrenceRule), nullString(t.AssigneeID), t.CreatedBy,
|
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
|
||||||
t.SortOrder, now.Unix(), now.Unix())
|
t.SortOrder, now.Unix(), now.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+68
-24
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,8 +29,15 @@ type User struct {
|
|||||||
Timezone string
|
Timezone string
|
||||||
Locale string
|
Locale string
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
CreatedAt time.Time
|
// SidebarCollapsed is the sidebar headings this person has folded away, by
|
||||||
UpdatedAt time.Time
|
// key. On the account rather than in localStorage, for the same reason a
|
||||||
|
// project group carries `collapsed` on its row: folding a heading is a
|
||||||
|
// statement about the work — "I am not in Etiketter at the moment" — and that
|
||||||
|
// is as true on the laptop as on the desktop. The sidebar's *width* is the
|
||||||
|
// opposite case and is stored per browser.
|
||||||
|
SidebarCollapsed []string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
|
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
|
||||||
@@ -96,7 +104,7 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
||||||
avatar_color, timezone, locale, is_admin, created_at, updated_at`
|
avatar_color, timezone, locale, is_admin, sidebar_collapsed, created_at, updated_at`
|
||||||
|
|
||||||
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
||||||
var u User
|
var u User
|
||||||
@@ -104,9 +112,11 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
|||||||
var totpEnabled, isAdmin int
|
var totpEnabled, isAdmin int
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
|
|
||||||
|
var collapsed string
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx, query, args...).Scan(
|
err := db.QueryRowContext(ctx, query, args...).Scan(
|
||||||
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
||||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &created, &updated)
|
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &created, &updated)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -117,11 +127,41 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
|||||||
u.TOTPSecret = secret.String
|
u.TOTPSecret = secret.String
|
||||||
u.TOTPEnabled = totpEnabled == 1
|
u.TOTPEnabled = totpEnabled == 1
|
||||||
u.IsAdmin = isAdmin == 1
|
u.IsAdmin = isAdmin == 1
|
||||||
|
// A malformed value is read as "nothing folded" rather than as an error: the
|
||||||
|
// worst case is a sidebar that opens fully, which is the state it shipped in.
|
||||||
|
u.SidebarCollapsed = []string{}
|
||||||
|
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
|
||||||
u.CreatedAt = time.Unix(created, 0).UTC()
|
u.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSidebarCollapsed records which sidebar headings are folded.
|
||||||
|
//
|
||||||
|
// Its own method rather than a field on UpdateProfile: that one backs a form with
|
||||||
|
// a save button and validates three fields together, and this is a toggle that
|
||||||
|
// writes on every click. Sharing the path would mean a fold sending a name and a
|
||||||
|
// timezone it was never asked to change.
|
||||||
|
func (db *DB) SetSidebarCollapsed(ctx context.Context, userID string, sections []string) error {
|
||||||
|
if sections == nil {
|
||||||
|
sections = []string{}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(sections)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE users SET sidebar_collapsed = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
string(raw), time.Now().Unix(), userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Person is the public half of a user: what somebody else is allowed to see when
|
// Person is the public half of a user: what somebody else is allowed to see when
|
||||||
// their name has to appear next to a task. No address, no hash, no timezone.
|
// their name has to appear next to a task. No address, no hash, no timezone.
|
||||||
type Person struct {
|
type Person struct {
|
||||||
@@ -175,12 +215,17 @@ type UserSummary struct {
|
|||||||
// ProjectCount excludes the Inbox: every account has one, so counting it would
|
// ProjectCount excludes the Inbox: every account has one, so counting it would
|
||||||
// make "1 project" mean "nothing".
|
// make "1 project" mean "nothing".
|
||||||
ProjectCount int
|
ProjectCount int
|
||||||
// TaskCount is everything a delete would destroy, which is not the same as
|
// TaskCount is everything a delete would destroy: the tasks in the projects
|
||||||
// "their tasks": it is the tasks in projects they own *plus* every task they
|
// they own, which go with those projects. Tasks they merely *wrote* in
|
||||||
// wrote anywhere, because tasks.created_by cascades. A number that counted
|
// somebody else's project are not counted, because they no longer go —
|
||||||
// only the first would understate the damage in exactly the case that matters
|
// `created_by` is ON DELETE SET NULL, so that work stays and loses its author.
|
||||||
// — a colleague leaving a shared project.
|
|
||||||
TaskCount int
|
TaskCount int
|
||||||
|
// AuthoredElsewhere is what a delete leaves behind unattributed: tasks they
|
||||||
|
// wrote in projects other people own. Separate from TaskCount because it is a
|
||||||
|
// different sentence — "this much disappears" and "this much stays without a
|
||||||
|
// name on it" — and running them together was what made the old number sound
|
||||||
|
// like a threat it no longer is.
|
||||||
|
AuthoredElsewhere int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUsers returns every account on the instance. Administrators only — this is
|
// ListUsers returns every account on the instance. Administrators only — this is
|
||||||
@@ -192,8 +237,9 @@ func (db *DB) ListUsers(ctx context.Context) ([]UserSummary, error) {
|
|||||||
(SELECT count(*) FROM projects p
|
(SELECT count(*) FROM projects p
|
||||||
WHERE p.owner_id = u.id AND p.is_inbox = 0 AND p.deleted_at IS NULL),
|
WHERE p.owner_id = u.id AND p.is_inbox = 0 AND p.deleted_at IS NULL),
|
||||||
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
|
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
|
||||||
WHERE t.deleted_at IS NULL
|
WHERE t.deleted_at IS NULL AND p.owner_id = u.id),
|
||||||
AND (p.owner_id = u.id OR t.created_by = u.id))
|
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
|
||||||
|
WHERE t.deleted_at IS NULL AND t.created_by = u.id AND p.owner_id <> u.id)
|
||||||
FROM users u
|
FROM users u
|
||||||
ORDER BY u.is_admin DESC, u.name`)
|
ORDER BY u.is_admin DESC, u.name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,7 +253,7 @@ func (db *DB) ListUsers(ctx context.Context) ([]UserSummary, error) {
|
|||||||
var isAdmin int
|
var isAdmin int
|
||||||
var created, lastSeen int64
|
var created, lastSeen int64
|
||||||
if err := rows.Scan(&s.ID, &s.Email, &s.Name, &s.AvatarColor, &isAdmin, &created,
|
if err := rows.Scan(&s.ID, &s.Email, &s.Name, &s.AvatarColor, &isAdmin, &created,
|
||||||
&lastSeen, &s.ProjectCount, &s.TaskCount); err != nil {
|
&lastSeen, &s.ProjectCount, &s.TaskCount, &s.AuthoredElsewhere); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.IsAdmin = isAdmin == 1
|
s.IsAdmin = isAdmin == 1
|
||||||
@@ -249,20 +295,18 @@ func (db *DB) SetUserAdmin(ctx context.Context, userID string, admin bool) error
|
|||||||
// there is no way back — which is why the handler sends the counts to the
|
// there is no way back — which is why the handler sends the counts to the
|
||||||
// interface first.
|
// interface first.
|
||||||
//
|
//
|
||||||
// What goes is wider than it looks. `projects.owner_id` cascades, so their
|
// What goes: `projects.owner_id` cascades, so their projects go and every task in
|
||||||
// projects go and every task in them goes too. But `tasks.created_by` cascades as
|
// them goes too. That is the whole of it, and the handler sends the count to the
|
||||||
// well, so **every task they ever wrote goes, including in projects somebody else
|
// interface first.
|
||||||
// owns**. Retiring a colleague's account therefore removes their contributions
|
|
||||||
// from shared projects rather than leaving them behind unattributed.
|
|
||||||
//
|
//
|
||||||
// That is the schema as it stands rather than a decision made here. Changing it —
|
// What stays: everything they wrote in somebody else's project. `tasks.created_by`
|
||||||
// `created_by` as ON DELETE SET NULL, so the work stays and loses its author —
|
// used to cascade as well, which meant retiring a colleague's account removed
|
||||||
// means rebuilding the tasks table, which carries the FTS triggers with it. Worth
|
// their contributions from shared work — see migration 0008, which rebuilt the
|
||||||
// doing; not worth doing quietly as part of adding a screen.
|
// table to make it ON DELETE SET NULL. The task survives and loses its author,
|
||||||
|
// which is the honest record of what happened.
|
||||||
//
|
//
|
||||||
// What survives: tasks merely *assigned* to them, which are unassigned by
|
// Tasks merely *assigned* to them are unassigned by `assignee_id ON DELETE SET
|
||||||
// `assignee_id ON DELETE SET NULL`, and completions, by the same rule on
|
// NULL`, and completions by the same rule on `completed_by`.
|
||||||
// `completed_by`.
|
|
||||||
func (db *DB) DeleteUser(ctx context.Context, userID string) error {
|
func (db *DB) DeleteUser(ctx context.Context, userID string) error {
|
||||||
res, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
|
res, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -68,18 +68,26 @@
|
|||||||
/**
|
/**
|
||||||
* Deleting an account is the only thing in this app that cannot be undone.
|
* Deleting an account is the only thing in this app that cannot be undone.
|
||||||
*
|
*
|
||||||
* Everything else is soft-deleted with a trash behind it. This is not, and it
|
* Everything else is soft-deleted with a trash behind it. This is not: the
|
||||||
* takes more than the person's own work: `tasks.created_by` cascades, so what
|
* person's own projects go, and every task in them. What they wrote in somebody
|
||||||
* they wrote in projects somebody else owns goes too. The confirmation says the
|
* else's project stays and loses its author — `tasks.created_by` is ON DELETE
|
||||||
* numbers out loud, which is why the list carries them.
|
* SET NULL since migration 0008. Both numbers are said out loud, because they
|
||||||
|
* are two different sentences and the second one is a reassurance rather than a
|
||||||
|
* warning.
|
||||||
*/
|
*/
|
||||||
async function remove(user) {
|
async function remove(user) {
|
||||||
const parts = [`Slet ${user.name} <${user.email}> for altid?`, ''];
|
const parts = [`Slet ${user.name} <${user.email}> for altid?`, ''];
|
||||||
if (user.project_count || user.task_count) {
|
if (user.project_count || user.task_count) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`Det sletter ${count(user.project_count, 'projekt', 'projekter')} og ` +
|
`Det sletter ${count(user.project_count, 'projekt', 'projekter')} og ` +
|
||||||
`${count(user.task_count, 'opgave', 'opgaver')} — også opgaver, personen har ` +
|
`${count(user.task_count, 'opgave', 'opgaver')} i dem.`,
|
||||||
`skrevet i projekter, andre ejer.`,
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (user.authored_elsewhere) {
|
||||||
|
parts.push(
|
||||||
|
`${count(user.authored_elsewhere, 'opgave', 'opgaver')} i andres projekter ` +
|
||||||
|
`bliver stående uden forfatter.`,
|
||||||
''
|
''
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user