Files
verdande/internal/store/activity.go
T
Kristian WindandClaude Opus 5 85f7b2488d 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>
2026-08-18 09:04:12 +02:00

255 lines
8.4 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
)
type Activity struct {
ID string
ProjectID 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
Payload map[string]any
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
// than a selection of whatever somebody remembered to instrument. It deliberately
// returns an error the caller is free to ignore: failing to record that a task was
// renamed must not fail the rename.
func (db *DB) RecordActivity(ctx context.Context, projectID, taskID, userID, event string, payload map[string]any) error {
encoded := "{}"
if len(payload) > 0 {
if raw, err := json.Marshal(payload); err == nil {
encoded = string(raw)
}
}
_, err := db.ExecContext(ctx,
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
NewID(), projectID, nullString(taskID), nullString(userID), event, encoded, time.Now().Unix())
return err
}
func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]Activity, error) {
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, 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)
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, &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()
}
// 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.
func (db *DB) PurgeActivity(ctx context.Context, olderThan time.Duration) (int64, error) {
res, err := db.ExecContext(ctx,
`DELETE FROM activity WHERE created_at < ?`, time.Now().Add(-olderThan).Unix())
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// --- small lookups used by handlers ---------------------------------------------
// ProjectOwner works on trashed projects too, which is what restoring needs: a
// project in the trash is invisible to ProjectRole by design.
func (db *DB) ProjectOwner(ctx context.Context, projectID string) (string, error) {
var owner string
err := db.QueryRowContext(ctx, `SELECT owner_id FROM projects WHERE id = ?`, projectID).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return owner, err
}
// ProjectByName resolves a "#name" from quick add. Case-insensitive, and it only
// searches projects the user can actually write to — typing "#Arbejde" must not
// find somebody else's project of that name.
func (db *DB) ProjectByName(ctx context.Context, userID, name string) (string, error) {
var id string
err := db.QueryRowContext(ctx, `
SELECT p.id FROM projects p
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
WHERE lower(p.name) = lower(?)
AND p.deleted_at IS NULL AND p.archived = 0
AND (p.owner_id = ? OR m.role IN ('owner', 'editor'))
ORDER BY p.owner_id = ? DESC
LIMIT 1`, userID, name, userID, userID).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return id, err
}