feat(api): expose reset status polling (#1598)

* feat(api): expose reset status polling

* fix(api): hide reset enqueue exception details

* fix(api): use stable reset authorization message

* fix(api): narrow reset enqueue error handling

* fix(api): document reset enqueue failures

* docs(api): regenerate reset status OpenAPI

* fix(api): address reset polling review feedback
This commit is contained in:
ghost
2026-05-02 14:56:42 -06:00
committed by GitHub
parent 95c2208bdb
commit a8425a2488
6 changed files with 330 additions and 10 deletions

View File

@@ -1,12 +1,44 @@
# frozen_string_literal: true
class Api::V1::UsersController < Api::V1::BaseController
before_action :ensure_write_scope
before_action :ensure_admin, only: :reset
before_action :ensure_read_scope, only: :reset_status
before_action :ensure_write_scope, except: :reset_status
before_action :ensure_admin, only: %i[reset reset_status]
def reset
FamilyResetJob.perform_later(Current.family)
render json: { message: "Account reset has been initiated" }
family = current_resource_owner.family
begin
job = FamilyResetJob.perform_later(family)
rescue StandardError => e
Rails.logger.error "Failed to enqueue FamilyResetJob for family #{family.id}: #{e.message}"
render json: {
error: "reset_enqueue_failed",
message: "Account reset could not be queued"
}, status: :internal_server_error
return
end
render json: {
message: "Account reset has been initiated",
status: "queued",
job_id: job.job_id,
family_id: family.id,
status_url: api_v1_users_reset_status_path
}
end
def reset_status
family = current_resource_owner.family
counts = reset_target_counts(family)
reset_complete = counts.values.sum.zero?
render json: {
status: reset_complete ? "complete" : "data_remaining",
family_id: family.id,
reset_complete: reset_complete,
counts: counts
}
end
def destroy
@@ -26,10 +58,26 @@ class Api::V1::UsersController < Api::V1::BaseController
authorize_scope!(:write)
end
def ensure_read_scope
authorize_scope!(:read)
end
def ensure_admin
return true if current_resource_owner&.admin?
render_json({ error: "forbidden", message: I18n.t("users.reset.unauthorized") }, status: :forbidden)
render_json({ error: "forbidden", message: "You are not authorized to perform this action" }, status: :forbidden)
false
end
def reset_target_counts(family)
{
accounts: family.accounts.count,
categories: family.categories.count,
tags: family.tags.count,
merchants: family.merchants.count,
plaid_items: family.plaid_items.count,
imports: family.imports.count,
budgets: family.budgets.count
}
end
end

View File

@@ -441,6 +441,7 @@ Rails.application.routes.draw do
end
end
get "users/reset/status", to: "users#reset_status"
delete "users/reset", to: "users#reset"
delete "users/me", to: "users#destroy"

View File

@@ -1259,6 +1259,85 @@ components:
properties:
message:
type: string
ResetInitiatedResponse:
type: object
required:
- message
- status
- job_id
- family_id
- status_url
properties:
message:
type: string
status:
type: string
enum:
- queued
job_id:
type: string
description: Informational Active Job identifier returned by the queue adapter;
reset status is family-scoped, not job-scoped.
family_id:
type: string
format: uuid
description: UUID of the family being reset.
status_url:
type: string
ResetStatusResponse:
type: object
required:
- status
- family_id
- reset_complete
- counts
properties:
status:
type: string
enum:
- complete
- data_remaining
description: Counts-based family reset status at response time.
family_id:
type: string
format: uuid
description: UUID of the family whose reset target counts were checked.
reset_complete:
type: boolean
description: True when all reset target counts are zero at response time.
This is a family data snapshot, not a durable per-job completion record.
counts:
type: object
required:
- accounts
- categories
- tags
- merchants
- plaid_items
- imports
- budgets
properties:
accounts:
type: integer
minimum: 0
categories:
type: integer
minimum: 0
tags:
type: integer
minimum: 0
merchants:
type: integer
minimum: 0
plaid_items:
type: integer
minimum: 0
imports:
type: integer
minimum: 0
budgets:
type: integer
minimum: 0
paths:
"/api/v1/accounts":
get:
@@ -3752,7 +3831,8 @@ paths:
- Users
description: Resets all financial data (accounts, categories, merchants, tags,
etc.) for the current user's family while keeping the user account intact.
The reset runs asynchronously in the background. Requires admin role.
The reset runs asynchronously in the background. The returned job_id is informational
only; reset status is family-scoped, not job-scoped. Requires admin role.
security:
- apiKeyAuth: []
responses:
@@ -3761,11 +3841,55 @@ paths:
content:
application/json:
schema:
"$ref": "#/components/schemas/SuccessMessage"
"$ref": "#/components/schemas/ResetInitiatedResponse"
'401':
description: unauthorized
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'403':
description: forbidden - requires read_write scope and admin role
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'500':
description: reset enqueue failed
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/users/reset/status":
get:
summary: Retrieve reset status
tags:
- Users
description: Returns counts of family-owned data targeted by account reset.
Use this after DELETE /api/v1/users/reset to decide whether reset materialization
has completed. Completion is a counts-based family snapshot and may change
if new data is created after reset.
security:
- apiKeyAuth: []
responses:
'200':
description: reset status returned
content:
application/json:
schema:
"$ref": "#/components/schemas/ResetStatusResponse"
'401':
description: unauthorized
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'403':
description: forbidden - requires admin role
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/users/me":
delete:
summary: Delete account

View File

@@ -42,23 +42,28 @@ RSpec.describe 'API V1 Users', type: :request do
description 'Resets all financial data (accounts, categories, merchants, tags, etc.) ' \
'for the current user\'s family while keeping the user account intact. ' \
'The reset runs asynchronously in the background. ' \
'The returned job_id is informational only; reset status is family-scoped, not job-scoped. ' \
'Requires admin role.'
security [ { apiKeyAuth: [] } ]
produces 'application/json'
response '200', 'account reset initiated' do
schema '$ref' => '#/components/schemas/SuccessMessage'
schema '$ref' => '#/components/schemas/ResetInitiatedResponse'
run_test!
end
response '401', 'unauthorized' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:'X-Api-Key') { 'invalid-key' }
run_test!
end
response '403', 'forbidden - requires read_write scope and admin role' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:api_key) do
key = ApiKey.generate_secure_key
ApiKey.create!(
@@ -72,6 +77,49 @@ RSpec.describe 'API V1 Users', type: :request do
run_test!
end
response '500', 'reset enqueue failed' do
schema '$ref' => '#/components/schemas/ErrorResponse'
before do
allow(FamilyResetJob).to receive(:perform_later).and_raise(StandardError, 'queue down')
end
run_test!
end
end
end
path '/api/v1/users/reset/status' do
get 'Retrieve reset status' do
tags 'Users'
description 'Returns counts of family-owned data targeted by account reset. ' \
'Use this after DELETE /api/v1/users/reset to decide whether reset materialization has completed. ' \
'Completion is a counts-based family snapshot and may change if new data is created after reset.'
security [ { apiKeyAuth: [] } ]
produces 'application/json'
response '200', 'reset status returned' do
schema '$ref' => '#/components/schemas/ResetStatusResponse'
run_test!
end
response '401', 'unauthorized' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:'X-Api-Key') { 'invalid-key' }
run_test!
end
response '403', 'forbidden - requires admin role' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:role) { :member }
run_test!
end
end
end
@@ -114,6 +162,7 @@ RSpec.describe 'API V1 Users', type: :request do
schema '$ref' => '#/components/schemas/ErrorResponse'
before do
api_key
allow_any_instance_of(User).to receive(:deactivate).and_return(false)
allow_any_instance_of(User).to receive(:errors).and_return(
double(full_messages: [ 'Cannot deactivate admin with other users' ])

View File

@@ -727,6 +727,49 @@ RSpec.configure do |config|
properties: {
message: { type: :string }
}
},
ResetInitiatedResponse: {
type: :object,
required: %w[message status job_id family_id status_url],
properties: {
message: { type: :string },
status: { type: :string, enum: %w[queued] },
job_id: {
type: :string,
description: 'Informational Active Job identifier returned by the queue adapter; reset status is family-scoped, not job-scoped.'
},
family_id: { type: :string, format: :uuid, description: 'UUID of the family being reset.' },
status_url: { type: :string }
}
},
ResetStatusResponse: {
type: :object,
required: %w[status family_id reset_complete counts],
properties: {
status: {
type: :string,
enum: %w[complete data_remaining],
description: 'Counts-based family reset status at response time.'
},
family_id: { type: :string, format: :uuid, description: 'UUID of the family whose reset target counts were checked.' },
reset_complete: {
type: :boolean,
description: 'True when all reset target counts are zero at response time. This is a family data snapshot, not a durable per-job completion record.'
},
counts: {
type: :object,
required: %w[accounts categories tags merchants plaid_items imports budgets],
properties: {
accounts: { type: :integer, minimum: 0 },
categories: { type: :integer, minimum: 0 },
tags: { type: :integer, minimum: 0 },
merchants: { type: :integer, minimum: 0 },
plaid_items: { type: :integer, minimum: 0 },
imports: { type: :integer, minimum: 0 },
budgets: { type: :integer, minimum: 0 }
}
}
}
}
}
}

View File

@@ -12,6 +12,7 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
user: @user,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_rw_#{SecureRandom.hex(8)}"
)
@@ -56,6 +57,7 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
user: users(:family_member),
name: "Member Read-Write Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_member_#{SecureRandom.hex(8)}"
)
@@ -69,13 +71,65 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
end
test "reset enqueues FamilyResetJob and returns 200" do
assert_enqueued_with(job: FamilyResetJob) do
assert_enqueued_with(job: FamilyResetJob, args: [ @user.family ]) do
delete "/api/v1/users/reset", headers: api_headers(@api_key)
end
assert_response :ok
body = JSON.parse(response.body)
assert_equal "Account reset has been initiated", body["message"]
assert_equal "queued", body["status"]
assert_equal @user.family.id, body["family_id"]
assert body["job_id"].present?
assert_equal "/api/v1/users/reset/status", body["status_url"]
end
test "reset returns controlled error when enqueue fails" do
FamilyResetJob.stub(:perform_later, ->(_family) { raise StandardError, "queue down" }) do
delete "/api/v1/users/reset", headers: api_headers(@api_key)
end
assert_response :internal_server_error
body = JSON.parse(response.body)
assert_equal "reset_enqueue_failed", body["error"]
assert_equal "Account reset could not be queued", body["message"]
assert_not_includes response.body, "queue down"
end
test "reset status requires authentication" do
get "/api/v1/users/reset/status"
assert_response :unauthorized
end
test "reset status requires admin role" do
non_admin_api_key = ApiKey.create!(
user: users(:family_member),
name: "Member Read Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_member_read_#{SecureRandom.hex(8)}"
)
get "/api/v1/users/reset/status", headers: api_headers(non_admin_api_key)
assert_response :forbidden
end
test "reset status returns family data counts" do
get "/api/v1/users/reset/status", headers: api_headers(@read_only_api_key)
assert_response :ok
body = JSON.parse(response.body)
assert_equal @user.family.id, body["family_id"]
assert_includes %w[complete data_remaining], body["status"]
assert_equal body["counts"].values.sum.zero?, body["reset_complete"]
assert body["counts"].key?("accounts")
assert body["counts"].key?("categories")
assert body["counts"].key?("tags")
assert body["counts"].key?("merchants")
assert body["counts"].key?("plaid_items")
assert body["counts"].key?("imports")
assert body["counts"].key?("budgets")
end
# -- Delete account --------------------------------------------------------
@@ -92,6 +146,7 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
user: solo_user,
name: "Solo Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_solo_#{SecureRandom.hex(8)}"
)
@@ -129,6 +184,6 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.display_key }
{ "X-Api-Key" => api_key.plain_key }
end
end