feat(server/diagnostics): device debug-reporting ingest + admin timeline + retention (M9)
test-go / test (push) Successful in 37s
test-go / integration (push) Successful in 4m44s

New diagnostic_events table + per-account users.debug_mode_enabled flag.
When an account's flag is on, its client(s) POST a batch timeseries of
connectivity / UPnP-sync / power / lifecycle events to /api/diagnostics
(no-op 204 when off, kind whitelist mirrors the CHECK constraint).

Admin surface: GET /api/admin/diagnostics (optional account/device/kind/
time-window filters, RFC3339-or-epoch-ms, export-sized paging) + a
/diagnostics/devices overview + PUT /api/admin/users/{id}/debug-mode to
flip an account remotely while a bug is live. debug_mode_enabled is now
exposed on /api/me (client gate) and the admin user views.

Retention: a 30-day gc-worker sweep (GcPruneDiagnostics), keyed on the
server clock so a skewed device clock can't keep rows alive.

Refs Scribe M9 (#119), tasks #1172 #1173.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
This commit is contained in:
2026-06-29 18:38:56 -04:00
parent f4f4df7708
commit 4ed831d9c3
12 changed files with 804 additions and 18 deletions
+5
View File
@@ -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),
})
}
+11
View File
@@ -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)
+345
View File
@@ -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
}
+5
View File
@@ -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) {
@@ -125,5 +129,6 @@ func profileViewFromUser(u dbq.User) meProfileResp {
DisplayName: u.DisplayName,
Email: u.Email,
IsAdmin: u.IsAdmin,
DebugModeEnabled: u.DebugModeEnabled,
}
}
+221
View File
@@ -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
}
+13
View File
@@ -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 {
+64 -12
View File
@@ -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
}
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN IF EXISTS debug_mode_enabled;
DROP TABLE IF EXISTS diagnostic_events;
@@ -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;
+66
View File
@@ -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';
+11 -1
View File
@@ -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
+2
View File
@@ -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