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
This commit is contained in:
ghost
2026-05-03 15:10:46 -06:00
committed by GitHub
parent 911aa34ba9
commit e93b1f1fd7
7 changed files with 308 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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],

View File

@@ -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