feat(admin): what has been done on this server, across every project
The other half of the error log. That one is what broke; this is what was done, and nobody could ask it: `ListActivity` takes one project id, and an administrator is not necessarily a member of the project they need to look into. Paged with a keyset cursor rather than an offset. The log is written to constantly and read rarely, so a page fetched a minute after the one before it would be shifted by everything written in between — one row shown twice and the row behind it skipped, which is the single failure an audit log must not have. Both halves of the cursor matter: the log is written in whole seconds, so a timestamp alone cannot separate two rows written in the same one. Migration 0009 makes `activity.user_id` SET NULL for the same reason 0008 did it to `created_by`. A log whose rows vanish with the account they describe answers "what has happened here" with everything except the part somebody had a reason to remove. `project_id` keeps cascading on purpose: the trash is the undo, and past it the deletion was deliberate. The event names moved to `web/src/lib/events.js`, because two surfaces read the same log now and two copies of nineteen strings is two places for them to drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ebdc8eacf2
commit
85f7b2488d
+141
-4
@@ -5,13 +5,19 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
ID string
|
||||
ProjectID string
|
||||
TaskID string
|
||||
// ProjectName is filled by the instance-wide log, where a row without it says
|
||||
// nothing: "renamed a section" needs to say which project's section.
|
||||
ProjectName string
|
||||
TaskID string
|
||||
// UserID and UserName are empty when the account has been deleted. The record
|
||||
// of what was done outlives whoever did it — see migration 0009.
|
||||
UserID string
|
||||
UserName string
|
||||
Event string
|
||||
@@ -19,6 +25,23 @@ type Activity struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AuditFilter narrows the instance-wide log. Zero values mean "do not filter".
|
||||
type AuditFilter struct {
|
||||
UserID string
|
||||
ProjectID string
|
||||
// Event matches exactly, e.g. "task.completed". The interface offers the set
|
||||
// that actually occurs rather than a text field, so there is no prefix or
|
||||
// wildcard to support here.
|
||||
Event string
|
||||
// Before pages backwards. Keyed on (created_at, id) rather than on created_at
|
||||
// alone: the log is written in whole seconds, so a busy moment puts several
|
||||
// rows on the same timestamp and a cursor that only knew the time would either
|
||||
// skip them or repeat them.
|
||||
BeforeAt int64
|
||||
BeforeID string
|
||||
Limit int
|
||||
}
|
||||
|
||||
// RecordActivity appends to a project's history.
|
||||
//
|
||||
// Every mutation writes one, which is what makes the activity log complete rather
|
||||
@@ -35,7 +58,7 @@ func (db *DB) RecordActivity(ctx context.Context, projectID, taskID, userID, eve
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
NewID(), projectID, nullString(taskID), userID, event, encoded, time.Now().Unix())
|
||||
NewID(), projectID, nullString(taskID), nullString(userID), event, encoded, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -43,9 +66,12 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
// LEFT JOIN, not JOIN: a row whose author has been deleted still happened, and
|
||||
// an inner join would drop it from the history silently.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT a.id, a.project_id, a.task_id, a.user_id, u.name, a.event, a.payload_json, a.created_at
|
||||
FROM activity a JOIN users u ON u.id = a.user_id
|
||||
SELECT a.id, a.project_id, a.task_id, COALESCE(a.user_id, ''), COALESCE(u.name, ''),
|
||||
a.event, a.payload_json, a.created_at
|
||||
FROM activity a LEFT JOIN users u ON u.id = a.user_id
|
||||
WHERE a.project_id = ?
|
||||
ORDER BY a.created_at DESC, a.id DESC
|
||||
LIMIT ?`, projectID, limit)
|
||||
@@ -72,6 +98,117 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListAuditLog is the same history read across the whole instance rather than one
|
||||
// project at a time.
|
||||
//
|
||||
// The per-project log answers "what happened here", which is a question a project's
|
||||
// members ask about their own work. This one answers "what has happened on this
|
||||
// server", which only an administrator asks and which nothing in the API could
|
||||
// answer before: ListActivity takes one project id, and an administrator is not
|
||||
// necessarily a member of the project they need to look into.
|
||||
//
|
||||
// Reading is a keyset walk rather than an OFFSET. The log is append-heavy and read
|
||||
// rarely, so a page fetched a minute after the one before it would otherwise be
|
||||
// shifted by everything written in between — showing a row twice and skipping the
|
||||
// one behind it, which is precisely the failure an audit log must not have.
|
||||
func (db *DB) ListAuditLog(ctx context.Context, f AuditFilter) ([]Activity, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
if f.UserID != "" {
|
||||
where = append(where, "a.user_id = ?")
|
||||
args = append(args, f.UserID)
|
||||
}
|
||||
if f.ProjectID != "" {
|
||||
where = append(where, "a.project_id = ?")
|
||||
args = append(args, f.ProjectID)
|
||||
}
|
||||
if f.Event != "" {
|
||||
where = append(where, "a.event = ?")
|
||||
args = append(args, f.Event)
|
||||
}
|
||||
if f.BeforeAt > 0 {
|
||||
// A row value, so the tie-break on id is part of the comparison rather
|
||||
// than a second clause that would have to repeat the timestamp.
|
||||
where = append(where, "(a.created_at, a.id) < (?, ?)")
|
||||
args = append(args, f.BeforeAt, f.BeforeID)
|
||||
}
|
||||
clause := ""
|
||||
if len(where) > 0 {
|
||||
clause = "WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT a.id, a.project_id, p.name, a.task_id,
|
||||
COALESCE(a.user_id, ''), COALESCE(u.name, ''),
|
||||
a.event, a.payload_json, a.created_at
|
||||
FROM activity a
|
||||
JOIN projects p ON p.id = a.project_id
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
`+clause+`
|
||||
ORDER BY a.created_at DESC, a.id DESC
|
||||
LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Activity{}
|
||||
for rows.Next() {
|
||||
var a Activity
|
||||
var taskID sql.NullString
|
||||
var payload string
|
||||
var created int64
|
||||
if err := rows.Scan(&a.ID, &a.ProjectID, &a.ProjectName, &taskID, &a.UserID,
|
||||
&a.UserName, &a.Event, &payload, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.TaskID = taskID.String
|
||||
a.CreatedAt = time.Unix(created, 0).UTC()
|
||||
_ = json.Unmarshal([]byte(payload), &a.Payload)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AuditEvents is the set of event names the log actually contains, most frequent
|
||||
// first, with how many of each.
|
||||
//
|
||||
// The filter offers these rather than a text field. Event names are an internal
|
||||
// vocabulary — "member.role_changed", not a sentence anybody would guess — so a
|
||||
// field to type them into is a field that returns nothing until you have read the
|
||||
// source. The counts are worth having on their own: they are the shape of what the
|
||||
// instance does.
|
||||
func (db *DB) AuditEvents(ctx context.Context) ([]EventCount, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT event, count(*) FROM activity
|
||||
GROUP BY event ORDER BY count(*) DESC, event`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []EventCount{}
|
||||
for rows.Next() {
|
||||
var e EventCount
|
||||
if err := rows.Scan(&e.Event, &e.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type EventCount struct {
|
||||
Event string
|
||||
Count int
|
||||
}
|
||||
|
||||
// PurgeActivity drops history older than the retention window. An activity log that
|
||||
// grows without limit turns a 10 MB database into a 2 GB one over a couple of years
|
||||
// of ordinary use, on hardware that is somebody's spare machine.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- `activity.user_id` stops cascading, and the log gets an index it can be read by.
|
||||
--
|
||||
-- Two changes with the same cause: there is now an instance-wide audit log, and
|
||||
-- the table it reads was only ever built to answer "what happened in this
|
||||
-- project".
|
||||
--
|
||||
-- The cascade first. An audit log whose rows disappear when the account they
|
||||
-- describe is deleted answers "what has happened on this server" with everything
|
||||
-- except the part somebody had a reason to remove. That is the wrong answer to the
|
||||
-- question, and it is the same mistake migration 0008 fixed on `tasks.created_by`:
|
||||
-- the record outlives the person. SET NULL, and the reader shows "en slettet
|
||||
-- konto" rather than a name.
|
||||
--
|
||||
-- `project_id` keeps cascading on purpose. A project purged from the trash is
|
||||
-- genuinely gone — the trash is the undo, and past it the deletion was deliberate
|
||||
-- and complete. Activity has its own retention window besides.
|
||||
--
|
||||
-- Then the index. `idx_activity_project` leads with project_id, so a query that
|
||||
-- wants the newest rows across every project cannot use it and scans the table to
|
||||
-- sort it. The new one is ordered the way the audit log reads, and the compound
|
||||
-- (created_at, id) matches the keyset the handler pages with — two rows written in
|
||||
-- the same second must not be able to hide each other across a page boundary.
|
||||
--
|
||||
-- verdande:rebuild-tables
|
||||
|
||||
CREATE TABLE activity_rebuilt (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
task_id TEXT REFERENCES tasks (id) ON DELETE SET NULL,
|
||||
-- Nullable: the record of what was done outlives whoever did it.
|
||||
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||
event TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO activity_rebuilt (id, project_id, task_id, user_id, event, payload_json, created_at)
|
||||
SELECT id, project_id, task_id, user_id, event, payload_json, created_at FROM activity;
|
||||
|
||||
DROP TABLE activity;
|
||||
|
||||
ALTER TABLE activity_rebuilt RENAME TO activity;
|
||||
|
||||
CREATE INDEX idx_activity_project ON activity (project_id, created_at DESC);
|
||||
CREATE INDEX idx_activity_task ON activity (task_id) WHERE task_id IS NOT NULL;
|
||||
CREATE INDEX idx_activity_recent ON activity (created_at DESC, id DESC);
|
||||
Reference in New Issue
Block a user