From e93b1f1fd7017a2a196baa85fc3d5ea8e7f3ff0c Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sun, 3 May 2026 15:10:46 -0600 Subject: [PATCH] feat(api): expose family settings (#1645) * feat(api): expose family settings * test(api): assert family settings moniker * test(api): align family settings api key helper * fix(api): tighten family settings schema --- .../api/v1/family_settings_controller.rb | 15 ++++ .../api/v1/family_settings/show.json.jbuilder | 16 ++++ config/routes.rb | 1 + docs/api/openapi.yaml | 86 +++++++++++++++++++ spec/requests/api/v1/family_settings_spec.rb | 83 ++++++++++++++++++ spec/swagger_helper.rb | 23 +++++ .../api/v1/family_settings_controller_test.rb | 84 ++++++++++++++++++ 7 files changed, 308 insertions(+) create mode 100644 app/controllers/api/v1/family_settings_controller.rb create mode 100644 app/views/api/v1/family_settings/show.json.jbuilder create mode 100644 spec/requests/api/v1/family_settings_spec.rb create mode 100644 test/controllers/api/v1/family_settings_controller_test.rb diff --git a/app/controllers/api/v1/family_settings_controller.rb b/app/controllers/api/v1/family_settings_controller.rb new file mode 100644 index 000000000..bf5ece2f3 --- /dev/null +++ b/app/controllers/api/v1/family_settings_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Api::V1::FamilySettingsController < Api::V1::BaseController + before_action :ensure_read_scope + + def show + @family = current_resource_owner.family + end + + private + + def ensure_read_scope + authorize_scope!(:read) + end +end diff --git a/app/views/api/v1/family_settings/show.json.jbuilder b/app/views/api/v1/family_settings/show.json.jbuilder new file mode 100644 index 000000000..025e82dfe --- /dev/null +++ b/app/views/api/v1/family_settings/show.json.jbuilder @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +json.id @family.id +json.name @family.name +json.currency @family.currency +json.locale @family.locale +json.date_format @family.date_format +json.country @family.country +json.timezone @family.timezone +json.month_start_day @family.month_start_day +json.moniker @family.moniker +json.default_account_sharing @family.default_account_sharing +json.custom_enabled_currencies @family.custom_enabled_currencies? +json.enabled_currencies @family.enabled_currency_codes +json.created_at @family.created_at.iso8601 +json.updated_at @family.updated_at.iso8601 diff --git a/config/routes.rb b/config/routes.rb index 4a5e11443..12481e711 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -441,6 +441,7 @@ Rails.application.routes.draw do resources :imports, only: [ :index, :show, :create ] resource :usage, only: [ :show ], controller: :usage resource :balance_sheet, only: [ :show ], controller: :balance_sheet + resource :family_settings, only: [ :show ], controller: :family_settings post :sync, to: "sync#create" resources :chats, only: [ :index, :show, :create, :update, :destroy ] do diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7533b638c..8e9a15b3d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -401,6 +401,65 @@ components: "$ref": "#/components/schemas/AccountDetail" pagination: "$ref": "#/components/schemas/Pagination" + FamilySettings: + type: object + required: + - id + - currency + - locale + - date_format + - month_start_day + - moniker + - default_account_sharing + - custom_enabled_currencies + - enabled_currencies + - created_at + - updated_at + properties: + id: + type: string + format: uuid + name: + type: string + nullable: true + currency: + type: string + locale: + type: string + date_format: + type: string + country: + type: string + nullable: true + timezone: + type: string + nullable: true + month_start_day: + type: integer + minimum: 1 + maximum: 28 + moniker: + type: string + enum: + - Family + - Group + default_account_sharing: + type: string + enum: + - shared + - private + custom_enabled_currencies: + type: boolean + enabled_currencies: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time Category: type: object required: @@ -2488,6 +2547,33 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/family_settings": + get: + summary: Retrieve family settings + description: Retrieve a read-only snapshot of non-secret family configuration. + tags: + - Family Settings + security: + - apiKeyAuth: [] + responses: + '200': + description: family settings retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/FamilySettings" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/holdings": get: summary: List holdings diff --git a/spec/requests/api/v1/family_settings_spec.rb b/spec/requests/api/v1/family_settings_spec.rb new file mode 100644 index 000000000..771a9f19e --- /dev/null +++ b/spec/requests/api/v1/family_settings_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Family Settings', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y', + country: 'US', + timezone: 'America/New_York', + month_start_day: 1 + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + display_key: key, + scopes: %w[read_write], + source: 'web' + ) + end + + let(:api_key_without_read_scope) do + key = ApiKey.generate_secure_key + # Empty scopes intentionally bypass validation so the 403 response can be documented. + ApiKey.new( + user: user, + name: 'No Read Docs Key', + key: key, + display_key: key, + scopes: [], + source: 'web' + ).tap { |api_key| api_key.save!(validate: false) } + end + + let(:'X-Api-Key') { api_key.plain_key } + + path '/api/v1/family_settings' do + get 'Retrieve family settings' do + description 'Retrieve a read-only snapshot of non-secret family configuration.' + tags 'Family Settings' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'family settings retrieved' do + schema '$ref' => '#/components/schemas/FamilySettings' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index aaeedd32b..8ea9b45c1 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -274,6 +274,29 @@ RSpec.configure do |config| pagination: { '$ref' => '#/components/schemas/Pagination' } } }, + FamilySettings: { + type: :object, + required: %w[id currency locale date_format month_start_day moniker default_account_sharing custom_enabled_currencies enabled_currencies created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string, nullable: true }, + currency: { type: :string }, + locale: { type: :string }, + date_format: { type: :string }, + country: { type: :string, nullable: true }, + timezone: { type: :string, nullable: true }, + month_start_day: { type: :integer, minimum: 1, maximum: 28 }, + moniker: { type: :string, enum: Family::MONIKERS }, + default_account_sharing: { type: :string, enum: %w[shared private] }, + custom_enabled_currencies: { type: :boolean }, + enabled_currencies: { + type: :array, + items: { type: :string } + }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, Category: { type: :object, required: %w[id name color icon], diff --git a/test/controllers/api/v1/family_settings_controller_test.rb b/test/controllers/api/v1/family_settings_controller_test.rb new file mode 100644 index 000000000..e1fb8565c --- /dev/null +++ b/test/controllers/api/v1/family_settings_controller_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::FamilySettingsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + @family.update!( + currency: "SGD", + enabled_currencies: [ "USD" ], + locale: "en", + date_format: "%Y-%m-%d", + country: "SG", + timezone: "Asia/Singapore", + month_start_day: 15, + moniker: "Family", + default_account_sharing: "private" + ) + + @user.api_keys.active.destroy_all + @api_key = ApiKey.create!( + user: @user, + name: "Test Read Key", + scopes: [ "read" ], + source: "web", + display_key: "test_read_#{SecureRandom.hex(8)}" + ) + Redis.new.del("api_rate_limit:#{@api_key.id}") + end + + test "shows current family settings snapshot" do + get api_v1_family_settings_url, headers: api_headers(@api_key) + + assert_response :success + response_body = JSON.parse(response.body) + + assert_equal @family.id, response_body["id"] + assert_equal @family.name, response_body["name"] + assert_equal "SGD", response_body["currency"] + assert_equal "en", response_body["locale"] + assert_equal "%Y-%m-%d", response_body["date_format"] + assert_equal "SG", response_body["country"] + assert_equal "Asia/Singapore", response_body["timezone"] + assert_equal 15, response_body["month_start_day"] + assert_equal "Family", response_body["moniker"] + assert_equal "private", response_body["default_account_sharing"] + assert_equal true, response_body["custom_enabled_currencies"] + assert_equal @family.enabled_currency_codes, response_body["enabled_currencies"] + assert_equal @family.created_at.iso8601, response_body["created_at"] + assert_equal @family.updated_at.iso8601, response_body["updated_at"] + assert_not response_body.key?("stripe_customer_id") + assert_not response_body.key?("vector_store_id") + end + + test "requires authentication" do + get api_v1_family_settings_url + + assert_response :unauthorized + end + + test "requires read scope" do + api_key_without_read = ApiKey.new( + user: @user, + name: "No Read Key", + scopes: [], + source: "web", + display_key: "no_read_#{SecureRandom.hex(8)}" + ) + api_key_without_read.save!(validate: false) + + get api_v1_family_settings_url, headers: api_headers(api_key_without_read) + + assert_response :forbidden + ensure + api_key_without_read&.destroy + end + + private + + def api_headers(api_key) + { "X-Api-Key" => api_key.plain_key } + end +end