diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index d85d8d3f..929c1027 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -491,6 +491,7 @@ type User struct { ListenbrainzToken *string ListenbrainzEnabled bool DisplayName *string + AutoApproveRequests bool } type UserInvite struct { diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index 8c12b9b2..531b3d80 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -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 } diff --git a/internal/db/migrations/0023_user_auto_approve.down.sql b/internal/db/migrations/0023_user_auto_approve.down.sql new file mode 100644 index 00000000..65295c6c --- /dev/null +++ b/internal/db/migrations/0023_user_auto_approve.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN IF EXISTS auto_approve_requests; diff --git a/internal/db/migrations/0023_user_auto_approve.up.sql b/internal/db/migrations/0023_user_auto_approve.up.sql new file mode 100644 index 00000000..9dadaae3 --- /dev/null +++ b/internal/db/migrations/0023_user_auto_approve.up.sql @@ -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; diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index 10553629..7170cfca 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -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 *;