Files
minstrel/internal/db/queries/users.sql
T
bvandeusen 46d38afe2d feat(server/m7-user-mgmt): admin user CRUD endpoints (U2)
Four new admin endpoints under existing RequireAdmin middleware:

- POST /api/admin/users — admin-creates-user. Body
  {username, password, display_name?, is_admin?}. Same username
  + password validation as the public /register handler. 409
  on duplicate. Audits ActionCreateUserAdmin.

- DELETE /api/admin/users/{id} — hard delete. Last-admin guard
  refuses delete when target is the only admin (409). Schema's
  ON DELETE CASCADE on user-FK tables handles plays/likes/
  sessions cleanup. Audits ActionDeleteUser with target's
  username + was_admin flag.

- POST /api/admin/users/{id}/reset-password — body
  {password}. 8-char minimum. Admin sets a new password
  without knowing the old one. Audits ActionPasswordResetAdmin.

- PUT /api/admin/users/{id}/auto-approve — body
  {auto_approve: bool}. Toggles the per-user flag added in T1
  (the #355 sub-feature surface). Audits ActionAutoApproveToggle.

adminUserView shape extended with auto_approve_requests so the
list and toggle responses carry the flag. ListUsers SQL query
updated to include auto_approve_requests; generated Go updated
to match. Tests cover happy paths, last-admin guard on delete,
password validation, duplicate username, and the auto-approve
round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 12:17:14 -04:00

117 lines
4.0 KiB
SQL

-- name: CreateUser :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: CreateUserFirstAdminRace :one
-- Inserts a new user; sets is_admin=true ONLY when no users currently
-- exist. The (SELECT NOT EXISTS ...) subquery is evaluated at INSERT
-- time within the same statement, so the result reflects committed
-- state at that moment.
--
-- Race semantics: two concurrent calls in an empty-users state may
-- both observe "no users" and both insert with is_admin=true. That's
-- benign — having two admins from the gate is fine; what matters is
-- that there's AT LEAST one. If the two calls happened to share the
-- same username, the unique constraint on users.username arbitrates
-- and the second caller's INSERT fails with a unique violation. The
-- caller (registration handler) can retry as a regular non-admin in
-- that case (or surface a "username taken" error to the user).
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
VALUES (
$1, $2, $3,
(SELECT NOT EXISTS (SELECT 1 FROM users)),
$4
)
RETURNING *;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE username = $1;
-- name: GetUserByAPIToken :one
SELECT * FROM users WHERE api_token = $1;
-- name: CountUsers :one
SELECT count(*) FROM users;
-- name: SetSubsonicPassword :exec
-- Stores (or clears with NULL) the per-user Subsonic legacy credential used
-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003.
UPDATE users SET subsonic_password = $2 WHERE id = $1;
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;
-- name: SetListenBrainzToken :exec
UPDATE users
SET listenbrainz_token = $2,
listenbrainz_enabled = CASE WHEN COALESCE($2, '') = '' THEN FALSE ELSE listenbrainz_enabled END
WHERE id = $1;
-- name: SetListenBrainzEnabled :exec
UPDATE users
SET listenbrainz_enabled = $2
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
FROM users
ORDER BY created_at DESC;
-- name: CountAdmins :one
SELECT count(*) FROM users WHERE is_admin = true;
-- name: UpdateUserAdmin :one
-- Sets is_admin to the given value. Returns the updated row so the
-- handler can echo it back to the caller.
UPDATE users
SET is_admin = $2
WHERE id = $1
RETURNING *;
-- name: GetListenBrainzConfig :one
-- Returns the user's LB token + enabled flag and the most recent
-- play_events.scrobbled_at for last-scrobbled-at status.
SELECT
u.listenbrainz_token,
u.listenbrainz_enabled,
(SELECT MAX(pe.scrobbled_at)::timestamptz
FROM play_events pe
WHERE pe.user_id = u.id) AS last_scrobbled_at
FROM users u
WHERE u.id = $1;
-- name: CreateUserAdmin :one
-- Admin-driven user creation. Distinct from CreateUser/CreateUserFirstAdminRace:
-- the caller (an admin) supplies all five fields explicitly including is_admin,
-- so an admin can promote on creation. Used by POST /api/admin/users.
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: DeleteUser :exec
-- Hard delete with cascade. The schema's ON DELETE CASCADE foreign keys
-- handle plays/likes/sessions/etc. Caller must enforce the last-admin
-- guard at the HTTP layer (count admins, refuse if target is the only
-- admin). The lidarr_quarantine, general_likes*, play_events,
-- sessions, and other user-FK tables all have ON DELETE CASCADE so
-- this is a clean drop.
DELETE FROM users WHERE id = $1;
-- name: ResetUserPassword :exec
-- Admin-driven password reset: caller writes a new hashed password to
-- the target user's row. No knowledge of the old password is required
-- (this is the admin path; self-service "knows current password" is
-- a separate U3 query).
UPDATE users
SET password_hash = $2
WHERE id = $1;
-- name: UpdateUserAutoApprove :one
-- Toggle the per-user auto_approve_requests flag.
UPDATE users
SET auto_approve_requests = $2
WHERE id = $1
RETURNING *;