feat(db/m7-user-mgmt): migration 0023 + admin-user CRUD queries

Adds users.auto_approve_requests boolean default false (the #355
sub-feature surface; the request-flow handler that honors this
flag is U2.5 follow-up work).

Four new sqlc queries for the U2 admin endpoints:
- CreateUserAdmin: admin-driven user creation, accepts all five
  fields explicitly.
- DeleteUser: hard delete; schema's ON DELETE CASCADE foreign
  keys handle plays/likes/sessions cleanup. Last-admin guard
  lives in the HTTP layer (next task).
- ResetUserPassword: admin sets a new hashed password without
  knowing the old one.
- UpdateUserAutoApprove: toggles the new boolean.

Self-service equivalents (knows-current-password change, etc.)
are U3 work and use distinct queries.
This commit is contained in:
2026-05-07 12:12:18 -04:00
parent 97d4dce7ee
commit 6b07eaa44d
5 changed files with 166 additions and 6 deletions
+1
View File
@@ -491,6 +491,7 @@ type User struct {
ListenbrainzToken *string
ListenbrainzEnabled bool
DisplayName *string
AutoApproveRequests bool
}
type UserInvite struct {
+121 -6
View File
@@ -36,7 +36,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
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests
`
type CreateUserParams struct {
@@ -67,6 +67,49 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
)
return i, err
}
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
`
type CreateUserAdminParams struct {
Username string
PasswordHash string
ApiToken string
IsAdmin bool
DisplayName *string
}
// 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.
func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams) (User, error) {
row := q.db.QueryRow(ctx, createUserAdmin,
arg.Username,
arg.PasswordHash,
arg.ApiToken,
arg.IsAdmin,
arg.DisplayName,
)
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,
)
return i, err
}
@@ -78,7 +121,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
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests
`
type CreateUserFirstAdminRaceParams struct {
@@ -120,10 +163,26 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
)
return i, err
}
const deleteUser = `-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1
`
// 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.
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteUser, id)
return err
}
const getListenBrainzConfig = `-- name: GetListenBrainzConfig :one
SELECT
u.listenbrainz_token,
@@ -151,7 +210,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 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 FROM users WHERE api_token = $1
`
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) {
@@ -168,12 +227,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
)
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 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 FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
@@ -190,12 +250,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
)
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 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 FROM users WHERE username = $1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
@@ -212,6 +273,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
)
return i, err
}
@@ -257,6 +319,26 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
return items, nil
}
const resetUserPassword = `-- name: ResetUserPassword :exec
UPDATE users
SET password_hash = $2
WHERE id = $1
`
type ResetUserPasswordParams struct {
ID pgtype.UUID
PasswordHash string
}
// 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).
func (q *Queries) ResetUserPassword(ctx context.Context, arg ResetUserPasswordParams) error {
_, err := q.db.Exec(ctx, resetUserPassword, arg.ID, arg.PasswordHash)
return err
}
const setListenBrainzEnabled = `-- name: SetListenBrainzEnabled :exec
UPDATE users
SET listenbrainz_enabled = $2
@@ -310,7 +392,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
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests
`
type UpdateUserAdminParams struct {
@@ -334,6 +416,39 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
)
return i, err
}
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
`
type UpdateUserAutoApproveParams struct {
ID pgtype.UUID
AutoApproveRequests bool
}
// Toggle the per-user auto_approve_requests flag.
func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoApproveParams) (User, error) {
row := q.db.QueryRow(ctx, updateUserAutoApprove, arg.ID, arg.AutoApproveRequests)
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,
)
return i, err
}
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS auto_approve_requests;
@@ -0,0 +1,10 @@
-- Adds users.auto_approve_requests for the per-user auto-approve toggle
-- (#355 sub-feature surface). The handler that honors this flag in
-- the request submission flow is sibling work (U2.5 follow-up); this
-- migration just lands the schema + the column default.
--
-- Default false: existing users keep manual approval, opt-in via the
-- admin user-management UI.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS auto_approve_requests boolean NOT NULL DEFAULT false;
+33
View File
@@ -81,3 +81,36 @@ SELECT
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 *;