Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 53 additions & 5 deletions app/controllers/api/v1/users_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,10 +58,26 @@ def ensure_write_scope
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@
end
end

get "users/reset/status", to: "users#reset_status"
delete "users/reset", to: "users#reset"
delete "users/me", to: "users#destroy"

Expand Down
128 changes: 126 additions & 2 deletions docs/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
51 changes: 50 additions & 1 deletion spec/requests/api/v1/users_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,23 +42,28 @@
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!(
Expand All @@ -72,6 +77,49 @@

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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end
end

Expand Down Expand Up @@ -114,6 +162,7 @@
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' ])
Expand Down
43 changes: 43 additions & 0 deletions spec/swagger_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,49 @@
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 }
}
}
}
}
}
}
Expand Down
Loading
Loading