diff --git a/internal/api/admin_users.go b/internal/api/admin_users.go index 550014f8..57eb8010 100644 --- a/internal/api/admin_users.go +++ b/internal/api/admin_users.go @@ -22,6 +22,7 @@ type adminUserView struct { DisplayName *string `json:"display_name"` IsAdmin bool `json:"is_admin"` AutoApproveRequests bool `json:"auto_approve_requests"` + DebugModeEnabled bool `json:"debug_mode_enabled"` CreatedAt string `json:"created_at"` } @@ -44,6 +45,7 @@ func (h *handlers) handleAdminListUsers(w http.ResponseWriter, r *http.Request) DisplayName: row.DisplayName, IsAdmin: row.IsAdmin, AutoApproveRequests: row.AutoApproveRequests, + DebugModeEnabled: row.DebugModeEnabled, CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339), } out = append(out, v) @@ -120,6 +122,7 @@ func (h *handlers) handleUpdateUserAdmin(w http.ResponseWriter, r *http.Request) DisplayName: updated.DisplayName, IsAdmin: updated.IsAdmin, AutoApproveRequests: updated.AutoApproveRequests, + DebugModeEnabled: updated.DebugModeEnabled, CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339), }) } @@ -189,6 +192,7 @@ func (h *handlers) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) DisplayName: user.DisplayName, IsAdmin: user.IsAdmin, AutoApproveRequests: user.AutoApproveRequests, + DebugModeEnabled: user.DebugModeEnabled, CreatedAt: user.CreatedAt.Time.UTC().Format(time.RFC3339), }) } @@ -329,6 +333,7 @@ func (h *handlers) handleAdminAutoApproveToggle(w http.ResponseWriter, r *http.R DisplayName: updated.DisplayName, IsAdmin: updated.IsAdmin, AutoApproveRequests: updated.AutoApproveRequests, + DebugModeEnabled: updated.DebugModeEnabled, CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339), }) } diff --git a/internal/api/api.go b/internal/api/api.go index 4639cca2..5a7e4bfe 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -135,6 +135,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev // load failures). Admin-only inbox; any user can report. authed.Post("/playback-errors", h.handleReportPlaybackError) + // Device diagnostics ingest (M9). Any signed-in user can + // POST a batch, but events are only stored when the + // account's debug_mode_enabled flag is on (handler no-ops + // otherwise). Admin views live under /admin/diagnostics. + authed.Post("/diagnostics", h.handleReportDiagnostics) + // Self-hosted in-app update channel (#397). Auth-gated to // prevent anonymous bandwidth abuse on the APK stream; // /apk additionally per-user rate-limited. @@ -181,6 +187,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Delete("/users/{id}", h.handleAdminDeleteUser) admin.Post("/users/{id}/reset-password", h.handleAdminResetPassword) admin.Put("/users/{id}/auto-approve", h.handleAdminAutoApproveToggle) + admin.Put("/users/{id}/debug-mode", h.handleAdminDebugModeToggle) + + // Device diagnostics timeline + device overview (M9). + admin.Get("/diagnostics", h.handleListAdminDiagnostics) + admin.Get("/diagnostics/devices", h.handleListAdminDiagnosticDevices) admin.Get("/cover-sources", h.handleListCoverSources) admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource) diff --git a/internal/api/diagnostics.go b/internal/api/diagnostics.go new file mode 100644 index 00000000..23dbfe92 --- /dev/null +++ b/internal/api/diagnostics.go @@ -0,0 +1,345 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// validDiagnosticKinds is the COARSE category whitelist. Mirrors the +// CHECK constraint in migration 0036 — keep both in sync (per the +// enum-CHECK-whitelist rule). The finer event discriminator + all +// event-specific fields live inside each event's payload, so new event +// variants do NOT require a new category here. +var validDiagnosticKinds = map[string]struct{}{ + "connectivity": {}, + "upnp_sync": {}, + "power": {}, + "lifecycle": {}, + "heartbeat": {}, + "http": {}, +} + +// maxDiagnosticBatch caps a single ingest call. The client buffers and +// flushes in batches of ~100; 500 leaves generous headroom while +// bounding a misbehaving client's per-request work. +const maxDiagnosticBatch = 500 + +// Diagnostics list paging. Larger than the generic parsePaging caps +// because the admin timeline is meant to be exported as a slice and +// handed off for analysis — a heartbeat-dense window has many rows. +const ( + defaultDiagnosticPageSize = 500 + maxDiagnosticPageSize = 5000 +) + +// diagnosticEventIn is one event in a POST /api/diagnostics batch. +// OccurredAt is the client device clock as epoch milliseconds (may be +// skewed — the server also stamps received_at). Payload is opaque JSON +// carrying the event's sub-type + fields. +type diagnosticEventIn struct { + Kind string `json:"kind"` + OccurredAt int64 `json:"occurred_at"` + Payload json.RawMessage `json:"payload"` +} + +// reportDiagnosticsRequest is the body of POST /api/diagnostics — one +// batch from one device. +type reportDiagnosticsRequest struct { + ClientID string `json:"client_id"` + AppVersion *string `json:"app_version,omitempty"` + OsVersion *string `json:"os_version,omitempty"` + Events []diagnosticEventIn `json:"events"` +} + +// handleReportDiagnostics implements POST /api/diagnostics. Any signed-in +// user can call it, but events are only stored when the account's +// debug_mode_enabled flag is on; otherwise the call is a 204 no-op so the +// client can safely drop the batch (it gates locally on /api/me, this is +// the race-safe backstop for "admin just disabled debug"). +func (h *handlers) handleReportDiagnostics(w http.ResponseWriter, r *http.Request) { + user, ok := requireUser(w, r) + if !ok { + return + } + if !user.DebugModeEnabled { + w.WriteHeader(http.StatusNoContent) + return + } + var req reportDiagnosticsRequest + if !decodeBody(w, r, &req) { + return + } + if strings.TrimSpace(req.ClientID) == "" { + writeErr(w, apierror.BadRequest("bad_request", "client_id required")) + return + } + if len(req.Events) == 0 { + writeJSON(w, http.StatusAccepted, map[string]int{"accepted": 0}) + return + } + if len(req.Events) > maxDiagnosticBatch { + writeErr(w, apierror.BadRequest("batch_too_large", "too many events in one batch")) + return + } + for i := range req.Events { + if _, valid := validDiagnosticKinds[req.Events[i].Kind]; !valid { + writeErr(w, apierror.BadRequest("invalid_kind", "unknown diagnostic kind")) + return + } + } + + err := pgx.BeginFunc(r.Context(), h.pool, func(tx pgx.Tx) error { + q := dbq.New(tx) + for i := range req.Events { + ev := req.Events[i] + payload := ev.Payload + if len(payload) == 0 { + payload = json.RawMessage("{}") + } + if _, err := q.InsertDiagnosticEvent(r.Context(), dbq.InsertDiagnosticEventParams{ + UserID: user.ID, + ClientID: req.ClientID, + AppVersion: req.AppVersion, + OsVersion: req.OsVersion, + Kind: ev.Kind, + Payload: payload, + OccurredAt: pgtype.Timestamptz{Time: time.UnixMilli(ev.OccurredAt).UTC(), Valid: true}, + }); err != nil { + return err + } + } + return nil + }) + if err != nil { + writeErrWithLog(w, h.logger, "diagnostics: insert batch failed", apierror.Internal(err)) + return + } + writeJSON(w, http.StatusAccepted, map[string]int{"accepted": len(req.Events)}) +} + +// adminDiagnosticView is one row in the admin timeline response. Payload +// is passed through verbatim as raw JSON so the SPA can render / export +// the structured event without the server re-shaping it. +type adminDiagnosticView struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Username string `json:"username"` + ClientID string `json:"client_id"` + AppVersion *string `json:"app_version,omitempty"` + OsVersion *string `json:"os_version,omitempty"` + Kind string `json:"kind"` + Payload json.RawMessage `json:"payload"` + OccurredAt string `json:"occurred_at"` + ReceivedAt string `json:"received_at"` +} + +// handleListAdminDiagnostics implements GET /api/admin/diagnostics with +// optional filters user_id, client_id, kind, from, to (RFC3339 or epoch +// millis) plus limit/offset. Newest-first; the SPA reverses for a +// chronological timeline + export. +func (h *handlers) handleListAdminDiagnostics(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + params, ok := h.buildDiagnosticsFilter(w, q) + if !ok { + return + } + rows, err := dbq.New(h.pool).ListAdminDiagnostics(r.Context(), params) + if err != nil { + writeErrWithLog(w, h.logger, "admin: list diagnostics failed", apierror.Internal(err)) + return + } + out := make([]adminDiagnosticView, 0, len(rows)) + for _, row := range rows { + out = append(out, adminDiagnosticView{ + ID: uuidToString(row.ID), + UserID: uuidToString(row.UserID), + Username: row.Username, + ClientID: row.ClientID, + AppVersion: row.AppVersion, + OsVersion: row.OsVersion, + Kind: row.Kind, + Payload: json.RawMessage(row.Payload), + OccurredAt: formatTimestamp(row.OccurredAt), + ReceivedAt: formatTimestamp(row.ReceivedAt), + }) + } + writeJSON(w, http.StatusOK, out) +} + +// buildDiagnosticsFilter parses the query string into a query param +// struct, writing a 400 and returning ok=false on a malformed time. +func (h *handlers) buildDiagnosticsFilter( + w http.ResponseWriter, q map[string][]string, +) (dbq.ListAdminDiagnosticsParams, bool) { + get := func(k string) string { + if v, present := q[k]; present && len(v) > 0 { + return v[0] + } + return "" + } + limit, offset, err := parseDiagPaging(get("limit"), get("offset")) + if err != nil { + writeErr(w, apierror.BadRequest("bad_request", "invalid limit or offset")) + return dbq.ListAdminDiagnosticsParams{}, false + } + from, okFrom := parseDiagTime(get("from")) + to, okTo := parseDiagTime(get("to")) + if !okFrom || !okTo { + writeErr(w, apierror.BadRequest("bad_request", "invalid from/to timestamp")) + return dbq.ListAdminDiagnosticsParams{}, false + } + params := dbq.ListAdminDiagnosticsParams{ + ClientID: optionalQuery(get("client_id")), + Kind: optionalQuery(get("kind")), + FromTs: from, + ToTs: to, + Off: int32(offset), + Lim: int32(limit), + } + if uid, valid := parseUUID(get("user_id")); valid { + params.UserID = uid + } + return params, true +} + +// adminDiagnosticDeviceView is one device in the "who is reporting" +// overview / device filter dropdown. +type adminDiagnosticDeviceView struct { + ClientID string `json:"client_id"` + UserID string `json:"user_id"` + Username string `json:"username"` + AppVersion string `json:"app_version"` + OsVersion string `json:"os_version"` + LastSeen string `json:"last_seen"` + EventCount int64 `json:"event_count"` +} + +// handleListAdminDiagnosticDevices implements GET +// /api/admin/diagnostics/devices?user_id=. Lists distinct reporting +// devices so the admin UI can populate its device filter. +func (h *handlers) handleListAdminDiagnosticDevices(w http.ResponseWriter, r *http.Request) { + var userID pgtype.UUID + if uid, valid := parseUUID(r.URL.Query().Get("user_id")); valid { + userID = uid + } + rows, err := dbq.New(h.pool).ListDiagnosticDevices(r.Context(), userID) + if err != nil { + writeErrWithLog(w, h.logger, "admin: list diagnostic devices failed", apierror.Internal(err)) + return + } + out := make([]adminDiagnosticDeviceView, 0, len(rows)) + for _, row := range rows { + out = append(out, adminDiagnosticDeviceView{ + ClientID: row.ClientID, + UserID: uuidToString(row.UserID), + Username: row.Username, + AppVersion: row.AppVersion, + OsVersion: row.OsVersion, + LastSeen: formatTimestamp(row.LastSeen), + EventCount: row.EventCount, + }) + } + writeJSON(w, http.StatusOK, out) +} + +// adminDebugModeReq is the body of PUT /api/admin/users/{id}/debug-mode. +type adminDebugModeReq struct { + Enabled bool `json:"enabled"` +} + +// handleAdminDebugModeToggle implements PUT +// /api/admin/users/{id}/debug-mode — flip an account's diagnostics +// opt-in remotely while a bug is live. Echoes the updated user. +func (h *handlers) handleAdminDebugModeToggle(w http.ResponseWriter, r *http.Request) { + targetID, ok := requireURLUUID(w, r, "id") + if !ok { + return + } + var req adminDebugModeReq + if !decodeBody(w, r, &req) { + return + } + updated, err := dbq.New(h.pool).SetDebugMode(r.Context(), dbq.SetDebugModeParams{ + ID: targetID, + DebugModeEnabled: req.Enabled, + }) + if err != nil { + writeErrWithLog(w, h.logger, "admin: set debug mode failed", apierror.Internal(err)) + return + } + h.logger.Info("admin: debug mode toggled", + "target_user", uuidToString(targetID), "enabled", req.Enabled) + writeJSON(w, http.StatusOK, adminUserView{ + ID: uuidToString(updated.ID), + Username: updated.Username, + DisplayName: updated.DisplayName, + IsAdmin: updated.IsAdmin, + AutoApproveRequests: updated.AutoApproveRequests, + DebugModeEnabled: updated.DebugModeEnabled, + CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339), + }) +} + +// parseDiagPaging reads limit/offset with diagnostics-specific caps. +// Blank → defaults; out-of-range clamps; non-numeric → error. +func parseDiagPaging(rawLimit, rawOffset string) (limit, offset int, err error) { + limit = defaultDiagnosticPageSize + if s := strings.TrimSpace(rawLimit); s != "" { + n, perr := strconv.Atoi(s) + if perr != nil { + return 0, 0, perr + } + if n < 1 { + n = 1 + } + if n > maxDiagnosticPageSize { + n = maxDiagnosticPageSize + } + limit = n + } + if s := strings.TrimSpace(rawOffset); s != "" { + n, perr := strconv.Atoi(s) + if perr != nil { + return 0, 0, perr + } + if n > 0 { + offset = n + } + } + return limit, offset, nil +} + +// optionalQuery returns nil for an absent/blank query value so the sqlc +// NULL-or-equals filter treats it as "no filter." +func optionalQuery(raw string) *string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + return &raw +} + +// parseDiagTime accepts RFC3339 or epoch-millis. Empty = absent (NULL +// filter, ok=true). Returns ok=false only on a non-empty unparseable value. +func parseDiagTime(raw string) (pgtype.Timestamptz, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return pgtype.Timestamptz{}, true + } + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return pgtype.Timestamptz{Time: t.UTC(), Valid: true}, true + } + if ms, err := strconv.ParseInt(raw, 10, 64); err == nil { + return pgtype.Timestamptz{Time: time.UnixMilli(ms).UTC(), Valid: true}, true + } + return pgtype.Timestamptz{}, false +} diff --git a/internal/api/me_profile.go b/internal/api/me_profile.go index 349fa165..ce5f64cf 100644 --- a/internal/api/me_profile.go +++ b/internal/api/me_profile.go @@ -35,6 +35,10 @@ type meProfileResp struct { DisplayName *string `json:"display_name"` Email *string `json:"email"` IsAdmin bool `json:"is_admin"` + // DebugModeEnabled is the account's diagnostics opt-in. The client + // reads it here to decide whether to run the on-device diagnostics + // reporter (M9). Admin-set; the client also has a local OFF switch. + DebugModeEnabled bool `json:"debug_mode_enabled"` } func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request) { @@ -120,10 +124,11 @@ func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request) func profileViewFromUser(u dbq.User) meProfileResp { return meProfileResp{ - ID: uuidToString(u.ID), - Username: u.Username, - DisplayName: u.DisplayName, - Email: u.Email, - IsAdmin: u.IsAdmin, + ID: uuidToString(u.ID), + Username: u.Username, + DisplayName: u.DisplayName, + Email: u.Email, + IsAdmin: u.IsAdmin, + DebugModeEnabled: u.DebugModeEnabled, } } diff --git a/internal/db/dbq/diagnostics.sql.go b/internal/db/dbq/diagnostics.sql.go new file mode 100644 index 00000000..f8814239 --- /dev/null +++ b/internal/db/dbq/diagnostics.sql.go @@ -0,0 +1,221 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: diagnostics.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const gcPruneDiagnostics = `-- name: GcPruneDiagnostics :execrows +DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days' +` + +// Retention sweep for the gc worker. Deletes diagnostic_events older +// than 30 days by server clock (received_at) so a skewed client clock +// can't keep rows alive. Matches the other gc lifecycle sweeps' +// hardcoded-threshold style; diagnostics are debug telemetry, not user +// data, and 30 days is ample to investigate a live incident after the +// fact. Returns the affected row count for the sweep log line. +func (q *Queries) GcPruneDiagnostics(ctx context.Context) (int64, error) { + result, err := q.db.Exec(ctx, gcPruneDiagnostics) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const insertDiagnosticEvent = `-- name: InsertDiagnosticEvent :one +INSERT INTO diagnostic_events ( + user_id, client_id, app_version, os_version, kind, payload, occurred_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, received_at +` + +type InsertDiagnosticEventParams struct { + UserID pgtype.UUID + ClientID string + AppVersion *string + OsVersion *string + Kind string + Payload []byte + OccurredAt pgtype.Timestamptz +} + +type InsertDiagnosticEventRow struct { + ID pgtype.UUID + ReceivedAt pgtype.Timestamptz +} + +// Records one client-reported diagnostic event. The writer validates the +// `kind` category against its whitelist before this runs; the CHECK +// constraint is the belt-and-braces guard. payload carries the finer +// event discriminator + all event-specific fields. Returns received_at +// so the caller can confirm ingest. +func (q *Queries) InsertDiagnosticEvent(ctx context.Context, arg InsertDiagnosticEventParams) (InsertDiagnosticEventRow, error) { + row := q.db.QueryRow(ctx, insertDiagnosticEvent, + arg.UserID, + arg.ClientID, + arg.AppVersion, + arg.OsVersion, + arg.Kind, + arg.Payload, + arg.OccurredAt, + ) + var i InsertDiagnosticEventRow + err := row.Scan(&i.ID, &i.ReceivedAt) + return i, err +} + +const listAdminDiagnostics = `-- name: ListAdminDiagnostics :many +SELECT + de.id AS id, + de.user_id AS user_id, + u.username AS username, + de.client_id AS client_id, + de.app_version AS app_version, + de.os_version AS os_version, + de.kind AS kind, + de.payload AS payload, + de.occurred_at AS occurred_at, + de.received_at AS received_at +FROM diagnostic_events de +JOIN users u ON u.id = de.user_id +WHERE ($1::uuid IS NULL OR de.user_id = $1::uuid) + AND ($2::text IS NULL OR de.client_id = $2::text) + AND ($3::text IS NULL OR de.kind = $3::text) + AND ($4::timestamptz IS NULL + OR de.occurred_at >= $4::timestamptz) + AND ($5::timestamptz IS NULL + OR de.occurred_at <= $5::timestamptz) +ORDER BY de.occurred_at DESC +LIMIT $7::int OFFSET $6::int +` + +type ListAdminDiagnosticsParams struct { + UserID pgtype.UUID + ClientID *string + Kind *string + FromTs pgtype.Timestamptz + ToTs pgtype.Timestamptz + Off int32 + Lim int32 +} + +type ListAdminDiagnosticsRow struct { + ID pgtype.UUID + UserID pgtype.UUID + Username string + ClientID string + AppVersion *string + OsVersion *string + Kind string + Payload []byte + OccurredAt pgtype.Timestamptz + ReceivedAt pgtype.Timestamptz +} + +// Admin timeline query. All filters are optional (NULL = no filter): +// account (user_id), device (client_id), category (kind), and the +// occurred_at window (from_ts/to_ts). Newest-first; the SPA reverses for +// a chronological timeline / export. Joined with users for the username +// so the view renders without a second round-trip. +func (q *Queries) ListAdminDiagnostics(ctx context.Context, arg ListAdminDiagnosticsParams) ([]ListAdminDiagnosticsRow, error) { + rows, err := q.db.Query(ctx, listAdminDiagnostics, + arg.UserID, + arg.ClientID, + arg.Kind, + arg.FromTs, + arg.ToTs, + arg.Off, + arg.Lim, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAdminDiagnosticsRow + for rows.Next() { + var i ListAdminDiagnosticsRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Username, + &i.ClientID, + &i.AppVersion, + &i.OsVersion, + &i.Kind, + &i.Payload, + &i.OccurredAt, + &i.ReceivedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDiagnosticDevices = `-- name: ListDiagnosticDevices :many +SELECT + de.client_id AS client_id, + de.user_id AS user_id, + u.username AS username, + max(de.app_version)::text AS app_version, + max(de.os_version)::text AS os_version, + max(de.occurred_at)::timestamptz AS last_seen, + count(*) AS event_count +FROM diagnostic_events de +JOIN users u ON u.id = de.user_id +WHERE ($1::uuid IS NULL OR de.user_id = $1::uuid) +GROUP BY de.client_id, de.user_id, u.username +ORDER BY last_seen DESC +` + +type ListDiagnosticDevicesRow struct { + ClientID string + UserID pgtype.UUID + Username string + AppVersion string + OsVersion string + LastSeen pgtype.Timestamptz + EventCount int64 +} + +// Distinct devices that have reported, for the admin device filter + +// "who is currently reporting" overview. Optionally scoped to one account. +func (q *Queries) ListDiagnosticDevices(ctx context.Context, userID pgtype.UUID) ([]ListDiagnosticDevicesRow, error) { + rows, err := q.db.Query(ctx, listDiagnosticDevices, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDiagnosticDevicesRow + for rows.Next() { + var i ListDiagnosticDevicesRow + if err := rows.Scan( + &i.ClientID, + &i.UserID, + &i.Username, + &i.AppVersion, + &i.OsVersion, + &i.LastSeen, + &i.EventCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index a30f8d86..bdf64e72 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -265,6 +265,18 @@ type CoverArtSourcesMetum struct { LastRegisteredProvidersHash string } +type DiagnosticEvent struct { + ID pgtype.UUID + UserID pgtype.UUID + ClientID string + AppVersion *string + OsVersion *string + Kind string + Payload []byte + OccurredAt pgtype.Timestamptz + ReceivedAt pgtype.Timestamptz +} + type GeneralLike struct { UserID pgtype.UUID TrackID pgtype.UUID @@ -552,6 +564,7 @@ type User struct { Email *string Timezone string TimezoneUpdatedAt pgtype.Timestamptz + DebugModeEnabled bool } type UserInvite struct { diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index 3f1b9722..fe5fea6f 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -54,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) { const createUser = `-- name: CreateUser :one INSERT INTO users (username, password_hash, api_token, is_admin, display_name) VALUES ($1, $2, $3, $4, $5) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled ` type CreateUserParams struct { @@ -89,6 +89,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.Email, &i.Timezone, &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, ) return i, err } @@ -96,7 +97,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e const createUserAdmin = `-- name: CreateUserAdmin :one INSERT INTO users (username, password_hash, api_token, is_admin, display_name) VALUES ($1, $2, $3, $4, $5) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled ` type CreateUserAdminParams struct { @@ -134,6 +135,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams &i.Email, &i.Timezone, &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, ) return i, err } @@ -145,7 +147,7 @@ VALUES ( (SELECT NOT EXISTS (SELECT 1 FROM users)), $4 ) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled ` type CreateUserFirstAdminRaceParams struct { @@ -191,6 +193,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi &i.Email, &i.Timezone, &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, ) return i, err } @@ -237,7 +240,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge } const getUserByAPIToken = `-- name: GetUserByAPIToken :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE api_token = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE api_token = $1 ` func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) { @@ -258,12 +261,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, &i.Email, &i.Timezone, &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, ) return i, err } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE lower(email) = lower($1) +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE lower(email) = lower($1) ` // Used by forgot-password lookup. Lowercase comparison both sides @@ -287,12 +291,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error &i.Email, &i.Timezone, &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE id = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { @@ -313,12 +318,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) &i.Email, &i.Timezone, &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE username = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE username = $1 ` func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { @@ -339,6 +345,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, &i.Email, &i.Timezone, &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, ) return i, err } @@ -382,7 +389,8 @@ func (q *Queries) ListActiveUsersWithTimezones(ctx context.Context) ([]ListActiv } const listUsers = `-- name: ListUsers :many -SELECT id, username, display_name, is_admin, auto_approve_requests, created_at +SELECT id, username, display_name, is_admin, auto_approve_requests, + debug_mode_enabled, created_at FROM users ORDER BY created_at DESC ` @@ -393,6 +401,7 @@ type ListUsersRow struct { DisplayName *string IsAdmin bool AutoApproveRequests bool + DebugModeEnabled bool CreatedAt pgtype.Timestamptz } @@ -412,6 +421,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { &i.DisplayName, &i.IsAdmin, &i.AutoApproveRequests, + &i.DebugModeEnabled, &i.CreatedAt, ); err != nil { return nil, err @@ -426,7 +436,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { const regenerateApiToken = `-- name: RegenerateApiToken :one UPDATE users SET api_token = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled ` type RegenerateApiTokenParams struct { @@ -454,6 +464,7 @@ func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiToken &i.Email, &i.Timezone, &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, ) return i, err } @@ -478,6 +489,44 @@ func (q *Queries) ResetUserPassword(ctx context.Context, arg ResetUserPasswordPa return err } +const setDebugMode = `-- name: SetDebugMode :one +UPDATE users + SET debug_mode_enabled = $2 + WHERE id = $1 +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled +` + +type SetDebugModeParams struct { + ID pgtype.UUID + DebugModeEnabled bool +} + +// Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven +// from /admin (flip remotely while a bug is live) or self-driven OFF from +// the client. Returns the updated row so the handler can echo it. +func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (User, error) { + row := q.db.QueryRow(ctx, setDebugMode, arg.ID, arg.DebugModeEnabled) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.ApiToken, + &i.IsAdmin, + &i.CreatedAt, + &i.SubsonicPassword, + &i.ListenbrainzToken, + &i.ListenbrainzEnabled, + &i.DisplayName, + &i.AutoApproveRequests, + &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, + ) + return i, err +} + const setListenBrainzEnabled = `-- name: SetListenBrainzEnabled :exec UPDATE users SET listenbrainz_enabled = $2 @@ -531,7 +580,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one UPDATE users SET is_admin = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled ` type UpdateUserAdminParams struct { @@ -559,6 +608,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams &i.Email, &i.Timezone, &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, ) return i, err } @@ -567,7 +617,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one UPDATE users SET auto_approve_requests = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled ` type UpdateUserAutoApproveParams struct { @@ -594,6 +644,7 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA &i.Email, &i.Timezone, &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, ) return i, err } @@ -603,7 +654,7 @@ UPDATE users SET display_name = $2, email = $3 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled ` type UpdateUserProfileParams struct { @@ -632,6 +683,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa &i.Email, &i.Timezone, &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, ) return i, err } diff --git a/internal/db/migrations/0036_diagnostics.down.sql b/internal/db/migrations/0036_diagnostics.down.sql new file mode 100644 index 00000000..b53c33ba --- /dev/null +++ b/internal/db/migrations/0036_diagnostics.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN IF EXISTS debug_mode_enabled; +DROP TABLE IF EXISTS diagnostic_events; diff --git a/internal/db/migrations/0036_diagnostics.up.sql b/internal/db/migrations/0036_diagnostics.up.sql new file mode 100644 index 00000000..37876893 --- /dev/null +++ b/internal/db/migrations/0036_diagnostics.up.sql @@ -0,0 +1,54 @@ +-- Device diagnostics / debug-reporting subsystem (M9). +-- +-- When an account has users.debug_mode_enabled = true, that account's +-- client(s) stream a timeseries of device state here: connectivity + +-- server-health transitions (roaming / poor-data recovery), UPnP/Sonos +-- sync events (locked-phone desync), and power/lifecycle state (Doze / +-- battery-optimization, the prime suspect for the desync). The admin +-- /admin/diagnostics view filters by account + device + time window and +-- exports the slice as JSON for offline analysis. +-- +-- `kind` is a COARSE category gated by a CHECK whitelist; the finer +-- event discriminator + all event-specific fields live in the `payload` +-- jsonb. This keeps the enum small and stable so new event variants +-- don't require a migration (per the enum-CHECK-whitelist rule, only a +-- new *category* would). If you add a category here, mirror it in the +-- handler whitelist in internal/api/diagnostics.go. +-- +-- Two timestamps on purpose: occurred_at is the client device clock +-- (when the event happened on the phone — may be skewed/wrong, e.g. in +-- a dead zone), received_at is the server clock at ingest. Retention +-- pruning is keyed on received_at so a bad client clock can't keep rows +-- alive forever. + +CREATE TABLE diagnostic_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id text NOT NULL, + app_version text, + os_version text, + kind text NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + occurred_at timestamptz NOT NULL, + received_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT diagnostic_events_kind_check + CHECK (kind IN ( + 'connectivity', 'upnp_sync', 'power', 'lifecycle', + 'heartbeat', 'http' + )) +); + +-- Admin reads filter by account then device, ordered newest-first. +CREATE INDEX idx_diagnostic_events_user + ON diagnostic_events (user_id, occurred_at DESC); +CREATE INDEX idx_diagnostic_events_client + ON diagnostic_events (client_id, occurred_at DESC); +-- Retention pruner deletes by server-clock age. +CREATE INDEX idx_diagnostic_events_received + ON diagnostic_events (received_at); + +-- Per-account opt-in for diagnostics reporting. Admin-set primarily +-- (flip remotely while a bug is live); the client also exposes a local +-- OFF switch. Default false: no account reports until explicitly enabled. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS debug_mode_enabled boolean NOT NULL DEFAULT false; diff --git a/internal/db/queries/diagnostics.sql b/internal/db/queries/diagnostics.sql new file mode 100644 index 00000000..2aa36f74 --- /dev/null +++ b/internal/db/queries/diagnostics.sql @@ -0,0 +1,66 @@ +-- name: InsertDiagnosticEvent :one +-- Records one client-reported diagnostic event. The writer validates the +-- `kind` category against its whitelist before this runs; the CHECK +-- constraint is the belt-and-braces guard. payload carries the finer +-- event discriminator + all event-specific fields. Returns received_at +-- so the caller can confirm ingest. +INSERT INTO diagnostic_events ( + user_id, client_id, app_version, os_version, kind, payload, occurred_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, received_at; + +-- name: ListAdminDiagnostics :many +-- Admin timeline query. All filters are optional (NULL = no filter): +-- account (user_id), device (client_id), category (kind), and the +-- occurred_at window (from_ts/to_ts). Newest-first; the SPA reverses for +-- a chronological timeline / export. Joined with users for the username +-- so the view renders without a second round-trip. +SELECT + de.id AS id, + de.user_id AS user_id, + u.username AS username, + de.client_id AS client_id, + de.app_version AS app_version, + de.os_version AS os_version, + de.kind AS kind, + de.payload AS payload, + de.occurred_at AS occurred_at, + de.received_at AS received_at +FROM diagnostic_events de +JOIN users u ON u.id = de.user_id +WHERE (sqlc.narg(user_id)::uuid IS NULL OR de.user_id = sqlc.narg(user_id)::uuid) + AND (sqlc.narg(client_id)::text IS NULL OR de.client_id = sqlc.narg(client_id)::text) + AND (sqlc.narg(kind)::text IS NULL OR de.kind = sqlc.narg(kind)::text) + AND (sqlc.narg(from_ts)::timestamptz IS NULL + OR de.occurred_at >= sqlc.narg(from_ts)::timestamptz) + AND (sqlc.narg(to_ts)::timestamptz IS NULL + OR de.occurred_at <= sqlc.narg(to_ts)::timestamptz) +ORDER BY de.occurred_at DESC +LIMIT sqlc.arg(lim)::int OFFSET sqlc.arg(off)::int; + +-- name: ListDiagnosticDevices :many +-- Distinct devices that have reported, for the admin device filter + +-- "who is currently reporting" overview. Optionally scoped to one account. +SELECT + de.client_id AS client_id, + de.user_id AS user_id, + u.username AS username, + max(de.app_version)::text AS app_version, + max(de.os_version)::text AS os_version, + max(de.occurred_at)::timestamptz AS last_seen, + count(*) AS event_count +FROM diagnostic_events de +JOIN users u ON u.id = de.user_id +WHERE (sqlc.narg(user_id)::uuid IS NULL OR de.user_id = sqlc.narg(user_id)::uuid) +GROUP BY de.client_id, de.user_id, u.username +ORDER BY last_seen DESC; + +-- name: GcPruneDiagnostics :execrows +-- Retention sweep for the gc worker. Deletes diagnostic_events older +-- than 30 days by server clock (received_at) so a skewed client clock +-- can't keep rows alive. Matches the other gc lifecycle sweeps' +-- hardcoded-threshold style; diagnostics are debug telemetry, not user +-- data, and 30 days is ample to investigate a live incident after the +-- fact. Returns the affected row count for the sweep log line. +DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days'; diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index 800e1b7a..971a60b3 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -55,7 +55,8 @@ WHERE id = $1; -- name: ListUsers :many -- Admin user-management list. Sort newest-first. -SELECT id, username, display_name, is_admin, auto_approve_requests, created_at +SELECT id, username, display_name, is_admin, auto_approve_requests, + debug_mode_enabled, created_at FROM users ORDER BY created_at DESC; @@ -115,6 +116,15 @@ UPDATE users WHERE id = $1 RETURNING *; +-- name: SetDebugMode :one +-- Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven +-- from /admin (flip remotely while a bug is live) or self-driven OFF from +-- the client. Returns the updated row so the handler can echo it. +UPDATE users + SET debug_mode_enabled = $2 + WHERE id = $1 +RETURNING *; + -- name: ChangeUserPassword :exec -- Self-service password change. Caller (HTTP handler) verifies the -- current password before calling this. Distinct from diff --git a/internal/gc/worker.go b/internal/gc/worker.go index 22b42269..b133e6c4 100644 --- a/internal/gc/worker.go +++ b/internal/gc/worker.go @@ -16,6 +16,7 @@ // - GcExpireScrobbleQueueFailedRows (#567) // - GcResetStuckSystemPlaylistRuns (#574) // - GcDeleteExpiredPasswordResets (#575) +// - GcPruneDiagnostics (M9 — diagnostics 30d retention) package gc import ( @@ -82,6 +83,7 @@ func (w *Worker) tickOnce(ctx context.Context) { w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows) w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns) w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets) + w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics) } // runSweep is a small adapter so each sweep call site is a one-liner