From cce7dc3d18f0cfa95489f1479f6a327eef55778e Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Tue, 19 May 2026 08:44:01 -0700 Subject: [PATCH 01/22] wip --- .../admin_api/anon_proxy_member_contacts.rb | 2 +- .../admin_api/anon_proxy_vendor_accounts.rb | 2 +- .../anon_proxy_vendor_configurations.rb | 4 +- lib/suma/admin_api/cards.rb | 2 +- lib/suma/admin_api/charges.rb | 6 +- .../admin_api/commerce_offering_products.rb | 2 +- lib/suma/admin_api/commerce_offerings.rb | 8 +- lib/suma/admin_api/commerce_orders.rb | 2 +- lib/suma/admin_api/commerce_products.rb | 8 +- lib/suma/admin_api/common_endpoints.rb | 25 ++++++ lib/suma/admin_api/eligibility_attributes.rb | 6 +- .../admin_api/eligibility_requirements.rb | 8 +- lib/suma/admin_api/entities.rb | 55 +++++++++---- lib/suma/admin_api/financials.rb | 6 +- lib/suma/admin_api/funding_transactions.rb | 6 +- lib/suma/admin_api/marketing_lists.rb | 4 +- .../admin_api/marketing_sms_broadcasts.rb | 4 +- lib/suma/admin_api/members.rb | 36 ++++----- .../organization_membership_verifications.rb | 4 +- .../admin_api/organization_memberships.rb | 2 +- .../organization_registration_links.rb | 2 +- lib/suma/admin_api/organizations.rb | 10 +-- lib/suma/admin_api/payment_ledgers.rb | 7 +- lib/suma/admin_api/payment_triggers.rb | 6 +- lib/suma/admin_api/payout_transactions.rb | 4 +- lib/suma/admin_api/programs.rb | 10 +-- lib/suma/admin_api/roles.rb | 6 +- .../admin_api/vendor_service_categories.rb | 2 +- lib/suma/admin_api/vendor_service_rates.rb | 2 +- lib/suma/admin_api/vendor_services.rb | 6 +- lib/suma/admin_api/vendors.rb | 6 +- lib/suma/eligibility/requirement.rb | 7 ++ lib/suma/member.rb | 6 +- lib/suma/payment/card.rb | 12 ++- lib/suma/payment/platform_status.rb | 13 ++- lib/suma/postgres/model_utilities.rb | 11 +++ lib/suma/service.rb | 2 + lib/suma/service/collection.rb | 37 ++++++--- .../anon_proxy_vendor_configurations_spec.rb | 2 +- .../suma/admin_api/commerce_offerings_spec.rb | 6 +- spec/suma/admin_api/common_endpoints_spec.rb | 4 +- spec/suma/admin_api/members_spec.rb | 23 +++--- spec/suma/admin_api/organizations_spec.rb | 2 +- spec/suma/admin_api/payment_triggers_spec.rb | 3 +- spec/suma/admin_api/programs_spec.rb | 4 +- .../admin_api/vendor_service_rates_spec.rb | 2 +- spec/suma/admin_api/vendor_services_spec.rb | 4 +- spec/suma/admin_api/vendors_spec.rb | 4 +- spec/suma/admin_api_spec.rb | 79 +++++++++++++++++++ spec/suma/payment/card_spec.rb | 10 +++ spec/suma/postgres/model_spec.rb | 14 ++++ 51 files changed, 348 insertions(+), 150 deletions(-) diff --git a/lib/suma/admin_api/anon_proxy_member_contacts.rb b/lib/suma/admin_api/anon_proxy_member_contacts.rb index a5f3e2ff9..92141ba05 100644 --- a/lib/suma/admin_api/anon_proxy_member_contacts.rb +++ b/lib/suma/admin_api/anon_proxy_member_contacts.rb @@ -13,7 +13,7 @@ class DetailedMemberContactEntity < AnonProxyMemberContactEntity expose :phone expose :email expose :external_relay_id - expose :vendor_accounts, with: AnonProxyVendorAccountEntity + expose_related :vendor_accounts, with: AnonProxyVendorAccountEntity end resource :anon_proxy_member_contacts do diff --git a/lib/suma/admin_api/anon_proxy_vendor_accounts.rb b/lib/suma/admin_api/anon_proxy_vendor_accounts.rb index 58311dde4..98598d11e 100644 --- a/lib/suma/admin_api/anon_proxy_vendor_accounts.rb +++ b/lib/suma/admin_api/anon_proxy_vendor_accounts.rb @@ -24,7 +24,7 @@ class DetailedVendorAccountEntity < AnonProxyVendorAccountEntity expose :latest_access_code_magic_link expose :pending_closure expose :contact, with: AnonProxyMemberContactEntity - expose :registrations, with: VendorAccountRegistrationEntity + expose_related :registrations, with: VendorAccountRegistrationEntity end resource :anon_proxy_vendor_accounts do diff --git a/lib/suma/admin_api/anon_proxy_vendor_configurations.rb b/lib/suma/admin_api/anon_proxy_vendor_configurations.rb index c696ada76..d194f9e4c 100644 --- a/lib/suma/admin_api/anon_proxy_vendor_configurations.rb +++ b/lib/suma/admin_api/anon_proxy_vendor_configurations.rb @@ -10,8 +10,8 @@ class DetailedVendorConfigurationEntity < AnonProxyVendorConfigurationEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :audit_activities, with: ActivityEntity - expose :programs, with: ProgramEntity + expose_related :audit_activities, with: ActivityEntity + expose_related :programs, with: ProgramEntity expose :description_text, with: TranslatedTextEntity expose :help_text, with: TranslatedTextEntity expose :terms_text, with: TranslatedTextEntity diff --git a/lib/suma/admin_api/cards.rb b/lib/suma/admin_api/cards.rb index faf1280b5..4b4e9826d 100644 --- a/lib/suma/admin_api/cards.rb +++ b/lib/suma/admin_api/cards.rb @@ -18,7 +18,7 @@ class DetailedCardEntity < CardEntity expose :stripe_id expose :member, with: MemberEntity - expose :originated_funding_transactions, with: FundingTransactionEntity + expose_related :originated_funding_transactions, with: FundingTransactionEntity end resource :cards do diff --git a/lib/suma/admin_api/charges.rb b/lib/suma/admin_api/charges.rb index ed95a79e2..544da3010 100644 --- a/lib/suma/admin_api/charges.rb +++ b/lib/suma/admin_api/charges.rb @@ -13,9 +13,9 @@ class DetailedChargeEntity < ChargeWithPricesEntity expose :member, with: MemberEntity expose :mobility_trip, with: MobilityTripEntity expose :commerce_order, with: OrderEntity - expose :line_items, with: ChargeLineItemEntity - expose :associated_funding_transactions, with: FundingTransactionEntity - expose :contributing_book_transactions, with: BookTransactionEntity + expose_related :line_items, with: ChargeLineItemEntity + expose_related :associated_funding_transactions, with: FundingTransactionEntity + expose_related :contributing_book_transactions, with: BookTransactionEntity end class ChargeEntityWithMember < ChargeEntity diff --git a/lib/suma/admin_api/commerce_offering_products.rb b/lib/suma/admin_api/commerce_offering_products.rb index d366a3764..bddfb06ab 100644 --- a/lib/suma/admin_api/commerce_offering_products.rb +++ b/lib/suma/admin_api/commerce_offering_products.rb @@ -17,7 +17,7 @@ class DetailedCommerceOfferingProductEntity < BaseEntity expose :undiscounted_price, with: MoneyEntity expose :closed_at - expose :orders, with: OrderEntity + expose_related :orders, with: OrderEntity end resource :commerce_offering_products do diff --git a/lib/suma/admin_api/commerce_offerings.rb b/lib/suma/admin_api/commerce_offerings.rb index 0e8c76eba..5e95f7b77 100644 --- a/lib/suma/admin_api/commerce_offerings.rb +++ b/lib/suma/admin_api/commerce_offerings.rb @@ -20,7 +20,7 @@ class DetailedOfferingEntity < OfferingEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity expose :confirmation_template expose :description, with: TranslatedTextEntity expose :fulfillment_prompt, with: TranslatedTextEntity @@ -29,9 +29,9 @@ class DetailedOfferingEntity < OfferingEntity expose :fulfillment_options, with: OfferingFulfillmentOptionEntity expose :begin_fulfillment_at expose_image :image - expose :offering_products, with: OfferingProductEntity - expose :orders, with: OrderInOfferingEntity - expose :programs, with: ProgramEntity + expose_related :offering_products, with: OfferingProductEntity + expose_related :orders, with: OrderInOfferingEntity + expose_related :programs, with: ProgramEntity expose :max_ordered_items_cumulative expose :max_ordered_items_per_member end diff --git a/lib/suma/admin_api/commerce_orders.rb b/lib/suma/admin_api/commerce_orders.rb index 763d616cf..a7c89c769 100644 --- a/lib/suma/admin_api/commerce_orders.rb +++ b/lib/suma/admin_api/commerce_orders.rb @@ -43,7 +43,7 @@ class DetailedCommerceOrderEntity < BaseEntity expose :admin_status_label, as: :status_label expose :serial expose :charge, with: ChargeWithPricesEntity - expose :audit_logs, with: AuditLogEntity + expose_related :audit_logs, with: AuditLogEntity expose :offering, with: OfferingEntity, &self.delegate_to(:checkout, :cart, :offering) expose :checkout, with: CheckoutEntity expose :items, with: CheckoutItemEntity, &self.delegate_to(:checkout, :items) diff --git a/lib/suma/admin_api/commerce_products.rb b/lib/suma/admin_api/commerce_products.rb index 2b696e63b..0751c68a2 100644 --- a/lib/suma/admin_api/commerce_products.rb +++ b/lib/suma/admin_api/commerce_products.rb @@ -25,11 +25,11 @@ class DetailedEntity < ProductEntity expose :quantity_on_hand, &self.delegate_to(:inventory!, :quantity_on_hand) expose :quantity_pending_fulfillment, &self.delegate_to(:inventory!, :quantity_pending_fulfillment) end - expose :offerings, with: OfferingEntity - expose :orders, with: OrderEntity - expose :offering_products, with: OfferingProductWithOfferingEntity + expose_related :offerings, with: OfferingEntity + expose_related :orders, with: OrderEntity + expose_related :offering_products, with: OfferingProductWithOfferingEntity expose_image :image - expose :vendor_service_categories, with: VendorServiceCategoryEntity + expose_related :vendor_service_categories, with: VendorServiceCategoryEntity end resource :commerce_products do diff --git a/lib/suma/admin_api/common_endpoints.rb b/lib/suma/admin_api/common_endpoints.rb index 1ee527fdf..c80f145c6 100644 --- a/lib/suma/admin_api/common_endpoints.rb +++ b/lib/suma/admin_api/common_endpoints.rb @@ -238,6 +238,31 @@ def self.list( end end + def self.related( + route_def, + model_type, + related_model_type, + related_entity, + association_name + ) + route_def.instance_exec do + route_param :id, type: Integer do + params do + use :pagination + end + get association_name do + check_admin_role_access!(:read, model_type) + check_admin_role_access!(:read, related_model_type) + (m = model_type[params[:id]]) or forbidden! + assoc = m.class.association_reflections.fetch(association_name) + ds = m.send(assoc.fetch(:dataset_method)) + ds = paginate(ds, params) + present_collection ds, with: related_entity + end + end + end + end + def self.get_one(route_def, model_type, entity) route_def.instance_exec do route_param :id, type: Integer do diff --git a/lib/suma/admin_api/eligibility_attributes.rb b/lib/suma/admin_api/eligibility_attributes.rb index e1d3f457b..07b86f7de 100644 --- a/lib/suma/admin_api/eligibility_attributes.rb +++ b/lib/suma/admin_api/eligibility_attributes.rb @@ -10,9 +10,9 @@ class DetailedEligibilityAttribute < EligibilityAttributeEntity include AutoExposeDetail expose :description - expose :children, with: EligibilityAttributeEntity - expose :assignments, with: EligibilityAssignmentEntity - expose :referenced_requirements, with: EligibilityRequirementEntity + expose_related :children, with: EligibilityAttributeEntity + expose_related :assignments, with: EligibilityAssignmentEntity + expose_related :referenced_requirements, with: EligibilityRequirementEntity end resource :eligibility_attributes do diff --git a/lib/suma/admin_api/eligibility_requirements.rb b/lib/suma/admin_api/eligibility_requirements.rb index ae72f721b..223268977 100644 --- a/lib/suma/admin_api/eligibility_requirements.rb +++ b/lib/suma/admin_api/eligibility_requirements.rb @@ -9,8 +9,8 @@ class DetailedEligibilityRequirement < EligibilityRequirementEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :programs, with: ProgramEntity - expose :payment_triggers, with: PaymentTriggerEntity + expose_related :programs, with: ProgramEntity + expose_related :payment_triggers, with: PaymentTriggerEntity expose :expression, &self.delegate_to(:expression, :serialize) expose :expression_tokens, &self.delegate_to(:expression, :tokenize) end @@ -60,7 +60,7 @@ class EditorExpressionEvaluationEntity < BaseEntity EligibilityRequirementEntity, around: lambda do |_rt, m, &b| b.call - m.all_resources.each { |r| r.audit_activity("addeligibility", action: m) } + m.each_resource { |r| r.audit_activity("addeligibility", action: m) } end, ) do params do @@ -108,7 +108,7 @@ class EditorExpressionEvaluationEntity < BaseEntity Suma::Eligibility::Requirement, DetailedEligibilityRequirement, around: lambda do |_rt, m, &b| - m.all_resources.each { |r| r.audit_activity("removedeligibility", action: m) } + m.each_resource { |r| r.audit_activity("removedeligibility", action: m) } b.call end, ) diff --git a/lib/suma/admin_api/entities.rb b/lib/suma/admin_api/entities.rb index 83d86b614..1011e588f 100644 --- a/lib/suma/admin_api/entities.rb +++ b/lib/suma/admin_api/entities.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true -require "grape_entity" - require "suma/service/entities" +require "suma/service/collection" module Suma::AdminAPI::Entities class MoneyEntity < Suma::Service::Entities::Money; end @@ -25,13 +24,42 @@ class ImageEntity < Suma::Service::Entities::Base end class BaseEntity < Suma::Service::Entities::Base - def self.expose_image(name, &block) - self.expose(name, with: ImageEntity) do |instance, options| - evaluate_exposure(name, block, instance, options) + class << self + def expose_image(name, &block) + self.expose(name, with: ImageEntity) do |instance, options| + evaluate_exposure(name, block, instance, options) + end + self.expose("#{name}_caption", with: TranslatedTextEntity) do |instance, options| + img = evaluate_exposure(name, block, instance, options) + img&.caption + end end - self.expose("#{name}_caption", with: TranslatedTextEntity) do |instance, options| - img = evaluate_exposure(name, block, instance, options) - img&.caption + + # Expose a list field of this entity. + # The field is exposed with a Collection entity so it can be paginated. + # + # NOTE: Callers must implement these collection endpoints. + # See CommonEndpoints.related. + # + # If dataset_method is given, it is the name of the method that returns + # the dataset for this exposure. + # Otherwise, _dataset is used if defined, otherwise is assumed to be an association + # and its configured dataset method is called. + def expose_related(name, with:, as: nil, dataset_method: nil) + collection_entity = Suma::Service::Collection.prepare_entity(with) + self.expose(name, as:, with: collection_entity) do |instance, options| + ds_method = dataset_method || "#{name}_dataset" + unless instance.respond_to?(ds_method) + assoc = instance.class.association_reflections[name] + raise ArgumentError, "#{instance} does not has association #{name} or dataset #{ds_method}" if assoc.nil? + ds_method = assoc.fetch(:dataset_method) + end + ds = instance.send(ds_method) + ds = ds.paginate(1, Suma::Service.related_list_size) + collection = Suma::Service::Collection.from_dataset(ds) + collection.url = options[:env].fetch("PATH_INFO") + "/#{name}" + collection + end end end end @@ -196,7 +224,6 @@ class EligibilityAssignmentEntity < BaseEntity class EligibilityRequirementEntity < BaseEntity include AutoExposeBase - expose :all_resources, as: :resources, with: AutoExposedBaseEntity expose :cached_expression_string, as: :expression_formula_str end @@ -367,8 +394,8 @@ class DetailedPaymentAccountLedgerEntity < BaseEntity include AutoExposeDetail expose :currency - expose :vendor_service_categories, with: VendorServiceCategoryEntity - expose :combined_book_transactions, with: BookTransactionEntity + expose_related :vendor_service_categories, with: VendorServiceCategoryEntity + expose_related :combined_book_transactions, with: BookTransactionEntity expose :balance, with: MoneyEntity end @@ -379,10 +406,10 @@ class DetailedPaymentAccountEntity < BaseEntity expose :member, with: MemberEntity expose :vendor, with: VendorEntity expose :is_platform_account - expose :ledgers, with: DetailedPaymentAccountLedgerEntity + expose_related :ledgers, with: DetailedPaymentAccountLedgerEntity expose :total_balance, with: MoneyEntity - expose :originated_funding_transactions, with: FundingTransactionEntity - expose :originated_payout_transactions, with: PayoutTransactionEntity + expose_related :originated_funding_transactions, with: FundingTransactionEntity + expose_related :originated_payout_transactions, with: PayoutTransactionEntity end class PaymentTriggerEntity < BaseEntity diff --git a/lib/suma/admin_api/financials.rb b/lib/suma/admin_api/financials.rb index 9c1bbe289..a2342e83c 100644 --- a/lib/suma/admin_api/financials.rb +++ b/lib/suma/admin_api/financials.rb @@ -39,9 +39,9 @@ class PlatformStatusEntity < BaseEntity expose :member_liabilities, with: MoneyEntity expose :assets, with: MoneyEntity expose :platform_ledgers, with: LedgerEntity - expose :unbalanced_ledgers, with: LedgerEntity - expose :off_platform_funding_transactions, with: OffPlatformTransactionEntity - expose :off_platform_payout_transactions, with: OffPlatformTransactionEntity + expose_related :unbalanced_ledgers, with: LedgerEntity + expose_related :off_platform_funding_transactions, with: OffPlatformTransactionEntity + expose_related :off_platform_payout_transactions, with: OffPlatformTransactionEntity end resource :financials do diff --git a/lib/suma/admin_api/funding_transactions.rb b/lib/suma/admin_api/funding_transactions.rb index ad891cda8..466c47c0c 100644 --- a/lib/suma/admin_api/funding_transactions.rb +++ b/lib/suma/admin_api/funding_transactions.rb @@ -15,12 +15,12 @@ class DetailedFundingTransactionEntity < FundingTransactionEntity expose :can_refund?, as: :can_refund expose :refundable_amount, with: MoneyEntity expose :refunded_amount, with: MoneyEntity - expose :refund_payout_transactions, with: PayoutTransactionEntity + expose_related :refund_payout_transactions, with: PayoutTransactionEntity expose :platform_ledger, with: SimpleLedgerEntity expose :originated_book_transaction, with: BookTransactionEntity expose :reversal_book_transaction, with: BookTransactionEntity - expose :audit_activities, with: ActivityEntity - expose :audit_logs, with: AuditLogEntity + expose_related :audit_activities, with: ActivityEntity + expose_related :audit_logs, with: AuditLogEntity expose :strategy, with: PaymentStrategyEntity end diff --git a/lib/suma/admin_api/marketing_lists.rb b/lib/suma/admin_api/marketing_lists.rb index d9ef0622d..6afdad997 100644 --- a/lib/suma/admin_api/marketing_lists.rb +++ b/lib/suma/admin_api/marketing_lists.rb @@ -9,8 +9,8 @@ class DetailedListEntity < MarketingListEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :members, with: MarketingMemberEntity - expose :sms_broadcasts, with: MarketingSmsBroadcastEntity + expose_related :members, with: MarketingMemberEntity + expose_related :sms_broadcasts, with: MarketingSmsBroadcastEntity end resource :marketing_lists do diff --git a/lib/suma/admin_api/marketing_sms_broadcasts.rb b/lib/suma/admin_api/marketing_sms_broadcasts.rb index c26874fe0..bc368c673 100644 --- a/lib/suma/admin_api/marketing_sms_broadcasts.rb +++ b/lib/suma/admin_api/marketing_sms_broadcasts.rb @@ -15,14 +15,14 @@ class DetailedSmsBroadcastEntity < MarketingSmsBroadcastEntity expose :sending_number_formatted expose :preferences_optout_field expose :preferences_optout_name - expose :lists, with: MarketingListEntity + expose_related :lists, with: MarketingListEntity expose :all_lists, with: MarketingListEntity do |_inst| Suma::Marketing::List.dataset.order(:label).all end expose :preview do |instance, opts| instance.preview(opts.fetch(:env).fetch("yosoy").authenticated_object!.member) end - expose :sms_dispatches, with: MarketingSmsDispatchEntity + expose_related :sms_dispatches, with: MarketingSmsDispatchEntity expose :available_sending_numbers do |_instance| Suma::Marketing::SmsBroadcast.available_sending_numbers end diff --git a/lib/suma/admin_api/members.rb b/lib/suma/admin_api/members.rb index 07e1e0ec9..0d4faf5e5 100644 --- a/lib/suma/admin_api/members.rb +++ b/lib/suma/admin_api/members.rb @@ -100,34 +100,34 @@ class DetailedMemberEntity < MemberEntity include AutoExposeDetail expose :opaque_id - expose :roles, with: RoleEntity + expose_related :roles, with: RoleEntity expose :onboarding_verified?, as: :onboarding_verified expose :previous_phones do |instance| instance.previous_phones.map { |s| Suma::PhoneNumber.format_display(s) } end expose :previous_emails - expose :activities, with: ActivityEntity - expose :audit_activities, with: ActivityEntity + expose_related :activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity expose :legal_entity, with: LegalEntityEntity expose :payment_account, with: DetailedPaymentAccountEntity - expose :charges, with: ChargeEntity - expose :eligibility_assignments, with: EligibilityAssignmentEntity - expose :expanded_eligibility_assignments, with: EligibilityMemberAssignmentEntity + expose_related :charges, with: ChargeEntity + expose_related :eligibility_assignments, with: EligibilityAssignmentEntity + expose_related :expanded_eligibility_assignments, with: EligibilityMemberAssignmentEntity expose :referral, with: ReferralEntity - expose :reset_codes, with: MemberResetCodeEntity - expose :sessions, with: MemberSessionEntity - expose :orders, with: MemberOrderEntity - expose :payment_instruments, with: PaymentInstrumentEntity - expose :message_deliveries, with: MessageDeliveryEntity - expose :combined_notes, as: :notes, with: SupportNoteEntity + expose_related :reset_codes, with: MemberResetCodeEntity + expose_related :sessions, with: MemberSessionEntity + expose_related :orders, with: MemberOrderEntity + expose_related :payment_instruments, with: PaymentInstrumentEntity + expose_related :message_deliveries, with: MessageDeliveryEntity + expose_related :combined_notes, as: :notes, with: SupportNoteEntity expose :preferences!, as: :preferences, with: PreferencesEntity - expose :anon_proxy_vendor_accounts, as: :vendor_accounts, with: MemberVendorAccountEntity - expose :anon_proxy_contacts, as: :member_contacts, with: MemberContactEntity - expose :organization_memberships, with: OrganizationMembershipEntity - expose :marketing_lists, with: MarketingListEntity - expose :marketing_sms_dispatches, with: MarketingSmsDispatchEntity - expose :mobility_trips, with: MobilityTripEntity + expose_related :anon_proxy_vendor_accounts, as: :vendor_accounts, with: MemberVendorAccountEntity + expose_related :anon_proxy_contacts, as: :member_contacts, with: MemberContactEntity + expose_related :organization_memberships, with: OrganizationMembershipEntity + expose_related :marketing_lists, with: MarketingListEntity + expose_related :marketing_sms_dispatches, with: MarketingSmsDispatchEntity + expose_related :mobility_trips, with: MobilityTripEntity end ALL_TIMEZONES = Set.new(TZInfo::Timezone.all_identifiers) diff --git a/lib/suma/admin_api/organization_membership_verifications.rb b/lib/suma/admin_api/organization_membership_verifications.rb index fe96063d3..3ac00dbf9 100644 --- a/lib/suma/admin_api/organization_membership_verifications.rb +++ b/lib/suma/admin_api/organization_membership_verifications.rb @@ -16,7 +16,7 @@ class VerificationListEntity < OrganizationMembershipVerificationEntity expose :available_events, &self.delegate_to(:state_machine, :available_events) expose :front_partner_conversation_status expose :front_member_conversation_status - expose :combined_notes, as: :notes, with: SupportNoteEntity + expose_related :combined_notes, as: :notes, with: SupportNoteEntity expose :duplicate_risk end @@ -30,7 +30,7 @@ class DetailedMembershipVerificationEntity < VerificationListEntity expose :address, with: AddressEntity, &self.delegate_to(:membership, :member, :legal_entity, :address, safe: true) expose :organization_name expose :organization_name_editable?, as: :organization_name_editable - expose :audit_logs, with: AuditLogEntity + expose_related :audit_logs, with: AuditLogEntity expose :partner_outreach_front_conversation_id expose :member_outreach_front_conversation_id expose :duplicates do |instance| diff --git a/lib/suma/admin_api/organization_memberships.rb b/lib/suma/admin_api/organization_memberships.rb index dab0336c2..0312b43ef 100644 --- a/lib/suma/admin_api/organization_memberships.rb +++ b/lib/suma/admin_api/organization_memberships.rb @@ -12,7 +12,7 @@ class DetailedOrganizationMembershipEntity < OrganizationMembershipEntity expose :matched_organization, with: OrganizationEntity expose :verification, with: OrganizationMembershipVerificationEntity - expose :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity end resource :organization_memberships do diff --git a/lib/suma/admin_api/organization_registration_links.rb b/lib/suma/admin_api/organization_registration_links.rb index 33584a23b..4756f889a 100644 --- a/lib/suma/admin_api/organization_registration_links.rb +++ b/lib/suma/admin_api/organization_registration_links.rb @@ -20,7 +20,7 @@ class DetailedOrganizationRegistrationLinkEntity < OrganizationRegistrationLinkE expose :durable_url expose :durable_url_qr_code_data_url, as: :durable_url_qr_code expose :intro, with: TranslatedTextEntity - expose :memberships, with: OrganizationMembershipEntity + expose_related :memberships, with: OrganizationMembershipEntity expose :scheduled_availabilities, with: ScheduledAvailabilityEntity end diff --git a/lib/suma/admin_api/organizations.rb b/lib/suma/admin_api/organizations.rb index d5d7af968..99a65a3d0 100644 --- a/lib/suma/admin_api/organizations.rb +++ b/lib/suma/admin_api/organizations.rb @@ -14,11 +14,11 @@ class DetailedOrganizationEntity < OrganizationEntity expose :membership_verification_email expose :membership_verification_front_template_id expose :membership_verification_member_outreach_template, with: TranslatedTextEntity - expose :audit_activities, with: ActivityEntity - expose :memberships, with: OrganizationMembershipEntity - expose :former_memberships, with: OrganizationMembershipEntity - expose :eligibility_assignments, with: EligibilityAssignmentEntity - expose :roles, with: RoleEntity + expose_related :audit_activities, with: ActivityEntity + expose_related :memberships, with: OrganizationMembershipEntity + expose_related :former_memberships, with: OrganizationMembershipEntity + expose_related :eligibility_assignments, with: EligibilityAssignmentEntity + expose_related :roles, with: RoleEntity end resource :organizations do diff --git a/lib/suma/admin_api/payment_ledgers.rb b/lib/suma/admin_api/payment_ledgers.rb index 04a7da007..68ec8847f 100644 --- a/lib/suma/admin_api/payment_ledgers.rb +++ b/lib/suma/admin_api/payment_ledgers.rb @@ -28,9 +28,10 @@ class UnbalancedCounterpartyEntity < BaseEntity class DetailedLedgerEntity < LedgerEntity include AutoExposeDetail - expose :vendor_service_categories, with: VendorServiceCategoryEntity - expose :combined_book_transactions, with: BookTransactionEntity - expose :find_unbalanced_counterparty_ledgers, as: :unbalanced_counterparties, with: UnbalancedCounterpartyEntity + expose_related :vendor_service_categories, with: VendorServiceCategoryEntity + expose_related :combined_book_transactions, with: BookTransactionEntity + expose_related :find_unbalanced_counterparty_ledgers, as: :unbalanced_counterparties, + with: UnbalancedCounterpartyEntity end resource :payment_ledgers do diff --git a/lib/suma/admin_api/payment_triggers.rb b/lib/suma/admin_api/payment_triggers.rb index 0af74f63f..a68d77efa 100644 --- a/lib/suma/admin_api/payment_triggers.rb +++ b/lib/suma/admin_api/payment_triggers.rb @@ -21,7 +21,7 @@ class DetailedPaymentTriggerEntity < PaymentTriggerEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity expose :match_multiplier, &self.delegate_to(:match_multiplier, :to_f) expose :match_fraction, &self.delegate_to(:match_fraction, :to_f) expose :payer_fraction, &self.delegate_to(:payer_fraction, :to_f) @@ -32,8 +32,8 @@ class DetailedPaymentTriggerEntity < PaymentTriggerEntity expose :originating_ledger, with: SimpleLedgerEntity expose :receiving_ledger_name expose :receiving_ledger_contribution_text, with: TranslatedTextEntity - expose :executions, with: PaymentTriggerExecutionEntity - expose :eligibility_requirements, with: EligibilityRequirementEntity + expose_related :executions, with: PaymentTriggerExecutionEntity + expose_related :eligibility_requirements, with: EligibilityRequirementEntity end resource :payment_triggers do diff --git a/lib/suma/admin_api/payout_transactions.rb b/lib/suma/admin_api/payout_transactions.rb index 2c8c9118b..b57450d13 100644 --- a/lib/suma/admin_api/payout_transactions.rb +++ b/lib/suma/admin_api/payout_transactions.rb @@ -17,8 +17,8 @@ class DetailedPayoutTransactionEntity < PayoutTransactionEntity expose :originated_book_transaction, with: BookTransactionEntity expose :reversal_book_transaction, with: BookTransactionEntity expose :refunded_funding_transaction, with: FundingTransactionEntity - expose :audit_activities, with: ActivityEntity - expose :audit_logs, with: AuditLogEntity + expose_related :audit_activities, with: ActivityEntity + expose_related :audit_logs, with: AuditLogEntity expose :strategy, with: PaymentStrategyEntity end diff --git a/lib/suma/admin_api/programs.rb b/lib/suma/admin_api/programs.rb index 8c842ee72..9c13139b9 100644 --- a/lib/suma/admin_api/programs.rb +++ b/lib/suma/admin_api/programs.rb @@ -11,11 +11,11 @@ class DetailedProgramEntity < ProgramEntity expose_image :image expose :lyft_pass_program_id - expose :commerce_offerings, with: OfferingEntity - expose :pricings, with: ProgramPricingEntity - expose :anon_proxy_vendor_configurations, as: :configurations, with: AnonProxyVendorConfigurationEntity - expose :eligibility_requirements, with: EligibilityRequirementEntity - expose :audit_activities, with: ActivityEntity + expose_related :commerce_offerings, with: OfferingEntity + expose_related :pricings, with: ProgramPricingEntity + expose_related :anon_proxy_vendor_configurations, as: :configurations, with: AnonProxyVendorConfigurationEntity + expose_related :eligibility_requirements, with: EligibilityRequirementEntity + expose_related :audit_activities, with: ActivityEntity end resource :programs do diff --git a/lib/suma/admin_api/roles.rb b/lib/suma/admin_api/roles.rb index 29c230cdf..d694413e3 100644 --- a/lib/suma/admin_api/roles.rb +++ b/lib/suma/admin_api/roles.rb @@ -18,9 +18,9 @@ class DetailedRoleEntity < RoleEntity include AutoExposeDetail expose :description - expose :members, with: Suma::AdminAPI::Entities::MemberEntity - expose :organizations, with: Suma::AdminAPI::Entities::OrganizationEntity - expose :eligibility_assignments, with: EligibilityAssignmentEntity + expose_related :members, with: Suma::AdminAPI::Entities::MemberEntity + expose_related :organizations, with: Suma::AdminAPI::Entities::OrganizationEntity + expose_related :eligibility_assignments, with: EligibilityAssignmentEntity end resource :roles do diff --git a/lib/suma/admin_api/vendor_service_categories.rb b/lib/suma/admin_api/vendor_service_categories.rb index 1e2f5df6f..113d63e63 100644 --- a/lib/suma/admin_api/vendor_service_categories.rb +++ b/lib/suma/admin_api/vendor_service_categories.rb @@ -16,7 +16,7 @@ class DetailedVendorServiceCategoryEntity < VendorServiceCategoryEntity include AutoExposeDetail expose :parent, with: VendorServiceCategoryEntity - expose :children, with: VendorServiceCategoryEntity + expose_related :children, with: VendorServiceCategoryEntity end resource :vendor_service_categories do diff --git a/lib/suma/admin_api/vendor_service_rates.rb b/lib/suma/admin_api/vendor_service_rates.rb index f51388c8f..2a63d1d85 100644 --- a/lib/suma/admin_api/vendor_service_rates.rb +++ b/lib/suma/admin_api/vendor_service_rates.rb @@ -12,7 +12,7 @@ class DetailedVendorServiceRateEntity < VendorServiceRateEntity expose :unit_offset expose :ordinal expose :undiscounted_rate, with: VendorServiceRateEntity - expose :program_pricings, with: ProgramPricingEntity + expose_related :program_pricings, with: ProgramPricingEntity end resource :vendor_service_rates do diff --git a/lib/suma/admin_api/vendor_services.rb b/lib/suma/admin_api/vendor_services.rb index a9ae58bc2..569272f00 100644 --- a/lib/suma/admin_api/vendor_services.rb +++ b/lib/suma/admin_api/vendor_services.rb @@ -9,9 +9,9 @@ class DetailedVendorServiceEntity < VendorServiceEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :audit_activities, with: ActivityEntity - expose :vendor_service_categories, as: :categories, with: VendorServiceCategoryEntity - expose :program_pricings, with: ProgramPricingEntity + expose_related :audit_activities, with: ActivityEntity + expose_related :categories, with: VendorServiceCategoryEntity + expose_related :program_pricings, with: ProgramPricingEntity expose_image :image expose :constraints diff --git a/lib/suma/admin_api/vendors.rb b/lib/suma/admin_api/vendors.rb index 105eb4ab7..79c096c81 100644 --- a/lib/suma/admin_api/vendors.rb +++ b/lib/suma/admin_api/vendors.rb @@ -11,9 +11,9 @@ class DetailedVendorEntity < VendorEntity include AutoExposeDetail expose :slug - expose :services, with: VendorServiceEntity - expose :products, with: ProductEntity - expose :configurations, with: AnonProxyVendorConfigurationEntity + expose_related :services, with: VendorServiceEntity + expose_related :products, with: ProductEntity + expose_related :configurations, with: AnonProxyVendorConfigurationEntity expose_image :image end diff --git a/lib/suma/eligibility/requirement.rb b/lib/suma/eligibility/requirement.rb index 08e0da45a..a18e9a163 100644 --- a/lib/suma/eligibility/requirement.rb +++ b/lib/suma/eligibility/requirement.rb @@ -29,6 +29,13 @@ class Suma::Eligibility::Requirement < Suma::Postgres::Model(:eligibility_requir def all_resources = self.programs + self.payment_triggers + def each_resource(&block) + [:programs, :payment_triggers].each do |assoc| + iter = self.associations[assoc] || self.send("#{assoc}_dataset") + iter.each(&block) + end + end + # Replace an expression with a serialized version (usually from an endpoint). # If the serialized version is the same as the current serialized expression, noop. # Otherwise, delete the current expression (and all children) diff --git a/lib/suma/member.rb b/lib/suma/member.rb index 7eab91712..9980eba60 100644 --- a/lib/suma/member.rb +++ b/lib/suma/member.rb @@ -276,15 +276,17 @@ def default_payment_instrument return self.public_payment_instruments.find { |pi| pi.status == :ok } end - def combined_notes + def combined_notes_dataset ds = Suma::Support::Note.combine_datasets( Sequel[members: self], Sequel[organization_membership_verifications: Suma::Organization::Membership::Verification. where(membership: self.organization_memberships_dataset)], ) - return ds.all + return ds end + def combined_notes = self.combined_notes_dataset.all + # @return [Suma::Member::StripeAttributes] def stripe return @stripe ||= Suma::Member::StripeAttributes.new(self) diff --git a/lib/suma/payment/card.rb b/lib/suma/payment/card.rb index 26d673fbf..1d16ae8e1 100644 --- a/lib/suma/payment/card.rb +++ b/lib/suma/payment/card.rb @@ -17,6 +17,15 @@ class Suma::Payment::Card < Suma::Postgres::Model(:payment_cards) one_to_many :originated_funding_stripe_card_strategies, key: :originating_card_id, class: "Suma::Payment::FundingTransaction::StripeCardStrategy" + many_through_many :originated_funding_transactions, + [ + [:payment_funding_transaction_stripe_card_strategies, :originating_card_id, :id], + ], + class: "Suma::Payment::FundingTransaction", + left_primary_key: :id, + right_primary_key: :stripe_card_strategy_id, + read_only: true, + order: [:created_at, :id] dataset_module do def usable_for_funding = self.unexpired_as_of(Time.now) @@ -63,9 +72,6 @@ def refetch_remote_data @stripe_data = nil end - # Could move this to an association later - def originated_funding_transactions = self.originated_funding_stripe_card_strategies.map(&:funding_transaction) - def _external_links_self return [ self._external_link( diff --git a/lib/suma/payment/platform_status.rb b/lib/suma/payment/platform_status.rb index 973fdc048..acb758cf1 100644 --- a/lib/suma/payment/platform_status.rb +++ b/lib/suma/payment/platform_status.rb @@ -18,11 +18,8 @@ class Suma::Payment::PlatformStatus def platform_ledgers @platform_ledgers ||= Suma::Payment::Account.lookup_platform_account.ledgers.sort_by(&:name) end - # Unbalanced ledgers. These do not belong to the platform account, - # since unbalanced member ledgers always mean unbalanced platform ledgers. - attr_accessor :unbalanced_ledgers - attr_accessor :off_platform_funding_transactions, :off_platform_payout_transactions + attr_accessor :off_platform_funding_transactions_dataset, :off_platform_payout_transactions_dataset def calculate funding_ds = Suma::Payment::FundingTransaction.dataset @@ -34,9 +31,9 @@ def calculate self.funding_count -= self.refund_count self.member_liabilities = self.platform_ledgers.sum(&:balance) * -1 self.assets = self.funding - self.payouts - self.unbalanced_ledgers = self.find_unbalanced_ledgers_ds.all - self.off_platform_funding_transactions = offplatform_ds(funding_ds).all - self.off_platform_payout_transactions = offplatform_ds(payout_ds).all + self.unbalanced_ledgers_dataset = self.find_unbalanced_ledgers_ds + self.off_platform_funding_transactions_dataset = offplatform_ds(funding_ds) + self.off_platform_payout_transactions_dataset = offplatform_ds(payout_ds) return self end @@ -82,5 +79,7 @@ def calculate return Suma::Payment::Ledger.order(:account_id, :name, :id).where(id: unbalanced_ids) end + # Unbalanced ledgers. These do not belong to the platform account, + # since unbalanced member ledgers always mean unbalanced platform ledgers. def unbalanced_ledgers_dataset = self.find_unbalanced_ledgers_ds end diff --git a/lib/suma/postgres/model_utilities.rb b/lib/suma/postgres/model_utilities.rb index ac6d7d897..d244e0ce3 100644 --- a/lib/suma/postgres/model_utilities.rb +++ b/lib/suma/postgres/model_utilities.rb @@ -334,6 +334,17 @@ def set_ambiguous_association(assocs, v) end raise TypeError, "invalid association type: #{v.class}(#{v})" end + + # Yield each row in the association to the block. + # Use this method when iterating associations which may be large; + # if the association is already loaded, it can be used (loaded: :reuse) + # or an error can be raised (loaded: :raise, default). + # If the association is not loaded, paginate with each_cursor_page. + def each_row_efficient(association) + assoc = self.class.association_reflections.fetch(association) + + + end end module DatasetMethods diff --git a/lib/suma/service.rb b/lib/suma/service.rb index f8372dcd1..5a47f1a36 100644 --- a/lib/suma/service.rb +++ b/lib/suma/service.rb @@ -54,6 +54,8 @@ class Suma::Service < Grape::API setting :endpoint_caching, false + setting :related_list_size, 20 + setting :verify_localized_error_codes, false setting :swagger_enabled, ENV["RACK_ENV"] == "development" diff --git a/lib/suma/service/collection.rb b/lib/suma/service/collection.rb index d7ed89fff..ee5791976 100644 --- a/lib/suma/service/collection.rb +++ b/lib/suma/service/collection.rb @@ -13,6 +13,9 @@ class Suma::Service::Collection attr_reader :current_page, :items, :page_count, :total_count, :last_page + # Url for the collection. Use the current URL (PATH_INFO) if nil. + attr_accessor :url + class BaseEntity < Suma::Service::Entities::Base expose :object do |_| "list" @@ -21,6 +24,9 @@ class BaseEntity < Suma::Service::Entities::Base expose :page_count expose :total_count expose :more?, as: :has_more + expose :url do |inst, opts| + inst.url || opts[:env].fetch("PATH_INFO") + end # expose :items do |_| # raise "this must be exposed by the subclass, like: `expose :items, with: MyEntity`" # end @@ -43,6 +49,24 @@ def self.from_array(array) return self.new(array, current_page: 1, page_count: 1, total_count: array.size, last_page: true) end + # Given the entity for an item in the collection, + # return the collection entity using that subentity for each item. + def self.prepare_entity(item_entity) + # We can't use is_a? here, Grape entity is weird. + if item_entity&.ancestors&.include?(Suma::Service::Collection::BaseEntity) + collection_entity = item_entity + else + collection_entity = Suma::Service::Collection.collection_entity_cache[item_entity] + if collection_entity.nil? + collection_entity = Class.new(Suma::Service::Collection::BaseEntity) do + expose :items, using: item_entity + end + Suma::Service::Collection.collection_entity_cache[item_entity] = collection_entity + end + end + return collection_entity + end + def initialize(items, current_page:, page_count:, total_count:, last_page:) @items = items @current_page = current_page @@ -57,18 +81,7 @@ def more? = !@last_page module Helpers def present_collection(collection, opts={}) passed_entity = opts.delete(:with) || opts.delete(:using) - # We can't use is_a? here, Grape entity is weird. - if passed_entity&.ancestors&.include?(Suma::Service::Collection::BaseEntity) - collection_entity = passed_entity - else - collection_entity = Suma::Service::Collection.collection_entity_cache[passed_entity] - if collection_entity.nil? - collection_entity = Class.new(Suma::Service::Collection::BaseEntity) do - expose :items, using: passed_entity - end - Suma::Service::Collection.collection_entity_cache[passed_entity] = collection_entity - end - end + collection_entity = Suma::Service::Collection.prepare_entity(passed_entity) opts[:with] = collection_entity wrapped = diff --git a/spec/suma/admin_api/anon_proxy_vendor_configurations_spec.rb b/spec/suma/admin_api/anon_proxy_vendor_configurations_spec.rb index 5ea3030e6..76946a9a4 100644 --- a/spec/suma/admin_api/anon_proxy_vendor_configurations_spec.rb +++ b/spec/suma/admin_api/anon_proxy_vendor_configurations_spec.rb @@ -68,7 +68,7 @@ def make_non_matching_items expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes(id: config.id) expect(last_response).to have_json_body. - that_includes(programs: have_same_ids_as(to_add)) + that_includes(programs: include(items: have_same_ids_as(to_add))) end it "403s if the constraint does not exist" do diff --git a/spec/suma/admin_api/commerce_offerings_spec.rb b/spec/suma/admin_api/commerce_offerings_spec.rb index 03c53f986..d68a9f33e 100644 --- a/spec/suma/admin_api/commerce_offerings_spec.rb +++ b/spec/suma/admin_api/commerce_offerings_spec.rb @@ -134,8 +134,8 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: o.id, - orders: have_length(1), - offering_products: have_length(1), + orders: include(items: have_length(1)), + offering_products: include(items: have_length(1)), ) end @@ -277,7 +277,7 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body. - that_includes(programs: have_same_ids_as(new_program)) + that_includes(programs: include(items: have_same_ids_as(new_program))) end it "403s if program does not exist" do diff --git a/spec/suma/admin_api/common_endpoints_spec.rb b/spec/suma/admin_api/common_endpoints_spec.rb index 2c25aef2b..508537c3c 100644 --- a/spec/suma/admin_api/common_endpoints_spec.rb +++ b/spec/suma/admin_api/common_endpoints_spec.rb @@ -143,8 +143,8 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: v.id, - services: have_same_ids_as(service), - products: have_same_ids_as(*product_objs), + services: include(items: have_same_ids_as(service)), + products: include(items: have_same_ids_as(*product_objs)), ) end diff --git a/spec/suma/admin_api/members_spec.rb b/spec/suma/admin_api/members_spec.rb index ac93927fb..d3a5956e4 100644 --- a/spec/suma/admin_api/members_spec.rb +++ b/spec/suma/admin_api/members_spec.rb @@ -110,7 +110,7 @@ def make_item(i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( - sessions: contain_exactly(include(:ip_lookup_link)), + sessions: include(items: contain_exactly(include(:ip_lookup_link))), preferences: include(preferred_language_name: "Spanish"), ) end @@ -127,9 +127,7 @@ def make_item(i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( - reset_codes: contain_exactly( - include(token: rc.token), - ), + reset_codes: include(items: contain_exactly(include(token: rc.token))), ) end @@ -142,11 +140,8 @@ def make_item(i) get "/v1/members/#{rc.member.id}" expect(last_response).to have_status(200) - expect(last_response).to have_json_body.that_includes( - reset_codes: contain_exactly( - include(token: "******"), - ), - ) + expect(last_response).to have_json_body. + that_includes(reset_codes: include(items: contain_exactly(include(token: "******")))) end end end @@ -341,7 +336,9 @@ def make_item(i) expect(last_response).to have_status(200) expect(last_response).to have_json_body. - that_includes(notes: contain_exactly(include(content: "hello", author: include(id: admin.id)))) + that_includes( + notes: include(items: contain_exactly(include(content: "hello", author: include(id: admin.id)))), + ) end it "errors without role access" do @@ -364,7 +361,11 @@ def make_item(i) expect(last_response).to have_status(200) expect(last_response).to have_json_body. - that_includes(notes: contain_exactly(include(content: "hello", author: include(id: admin.id)))) + that_includes( + notes: include( + items: contain_exactly(include(content: "hello", author: include(id: admin.id))), + ), + ) end it "errors without role access" do diff --git a/spec/suma/admin_api/organizations_spec.rb b/spec/suma/admin_api/organizations_spec.rb index c538da5e7..b2f9e9cb9 100644 --- a/spec/suma/admin_api/organizations_spec.rb +++ b/spec/suma/admin_api/organizations_spec.rb @@ -79,7 +79,7 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: organization.id, - memberships: have_same_ids_as(membership), + memberships: include(items: have_same_ids_as(membership)), ) end diff --git a/spec/suma/admin_api/payment_triggers_spec.rb b/spec/suma/admin_api/payment_triggers_spec.rb index bd8017ea3..c0f67f31e 100644 --- a/spec/suma/admin_api/payment_triggers_spec.rb +++ b/spec/suma/admin_api/payment_triggers_spec.rb @@ -75,7 +75,8 @@ def make_item(i) get "/v1/payment_triggers/#{o.id}" expect(last_response).to have_status(200) - expect(last_response).to have_json_body.that_includes(id: o.id, executions: have_length(1)) + expect(last_response).to have_json_body. + that_includes(id: o.id, executions: include(items: have_length(1))) end it "403s if the item does not exist" do diff --git a/spec/suma/admin_api/programs_spec.rb b/spec/suma/admin_api/programs_spec.rb index e4a239aef..a27bbb1c8 100644 --- a/spec/suma/admin_api/programs_spec.rb +++ b/spec/suma/admin_api/programs_spec.rb @@ -82,7 +82,7 @@ def make_item(_i) expect(last_response).to have_status(200) expect(Suma::Program.all).to have_length(1) expect(last_response).to have_json_body.that_includes( - commerce_offerings: contain_exactly(include(id: offering.id)), + commerce_offerings: include(items: contain_exactly(include(id: offering.id))), ) end end @@ -97,7 +97,7 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: program.id, - commerce_offerings: contain_exactly(include(id: o.id)), + commerce_offerings: include(items: have_same_ids_as(o)), ) end diff --git a/spec/suma/admin_api/vendor_service_rates_spec.rb b/spec/suma/admin_api/vendor_service_rates_spec.rb index ce4fc6ed0..1fba15111 100644 --- a/spec/suma/admin_api/vendor_service_rates_spec.rb +++ b/spec/suma/admin_api/vendor_service_rates_spec.rb @@ -71,7 +71,7 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: rate.id, - program_pricings: have_same_ids_as(pricing), + program_pricings: include(items: have_same_ids_as(pricing)), ) end diff --git a/spec/suma/admin_api/vendor_services_spec.rb b/spec/suma/admin_api/vendor_services_spec.rb index ff9bee05c..ff829b58a 100644 --- a/spec/suma/admin_api/vendor_services_spec.rb +++ b/spec/suma/admin_api/vendor_services_spec.rb @@ -108,7 +108,7 @@ def make_item(_i) expect(last_response).to have_json_body.that_includes( id: service.id, vendor: include(id: vendor.id), - program_pricings: have_same_ids_as(pricing), + program_pricings: include(items: have_same_ids_as(pricing)), ) end @@ -120,7 +120,7 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: service.id, - categories: contain_exactly(include(name: "Mobility")), + categories: include(items: contain_exactly(include(name: "Mobility"))), mobility_adapter_setting: "internal", ) end diff --git a/spec/suma/admin_api/vendors_spec.rb b/spec/suma/admin_api/vendors_spec.rb index 1fa7f1f4e..e440801a6 100644 --- a/spec/suma/admin_api/vendors_spec.rb +++ b/spec/suma/admin_api/vendors_spec.rb @@ -84,8 +84,8 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: v.id, - services: have_same_ids_as(service), - products: have_same_ids_as(*product_objs), + services: include(items: have_same_ids_as(service)), + products: include(items: have_same_ids_as(*product_objs)), ) end diff --git a/spec/suma/admin_api_spec.rb b/spec/suma/admin_api_spec.rb index e1fe709ec..3b26d677b 100644 --- a/spec/suma/admin_api_spec.rb +++ b/spec/suma/admin_api_spec.rb @@ -32,6 +32,38 @@ class Suma::AdminAPI::TestV1API < Suma::AdminAPI::V1 get :invalid_precond do raise Suma::InvalidPrecondition, "hello" end + + class ChildEntity < Suma::Service::Entities::Base + expose :id + end + + class EntityWithRelated < Suma::AdminAPI::Entities::BaseEntity + expose :name + expose_related :products, with: ChildEntity + expose_related :products, as: :children, with: ChildEntity + expose_related :defed_alias, with: ChildEntity + end + + route_setting :skip_role_check, true + resource :model_with_related do + route_param :id do + get do + vendor = Suma::Vendor.find!(id: params[:id]) + vendor.instance_exec do + def defed_alias_dataset = self.products_dataset + end + present vendor, with: EntityWithRelated + end + end + + Suma::AdminAPI::CommonEndpoints.related( + self, + Suma::Vendor, + Suma::Commerce::Product, + ChildEntity, + :products, + ) + end end RSpec.describe Suma::AdminAPI, :db do @@ -110,6 +142,53 @@ def method_missing(*); end expect(last_response).to have_json_body. that_includes(error: include(message: "Hello")) end + + describe "related lists", reset_configuration: Suma::Service do + before(:each) do + Suma::Service.related_list_size = 4 + end + + let(:vendor) do + vendor = Suma::Fixtures.vendor.create(name: "foo") + Array.new(5) { Suma::Fixtures.product.create(vendor:) } + vendor + end + + it "is exposed on the entity" do + get "/v1/model_with_related/#{vendor.id}" + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body. + that_includes( + name: "foo", + products: include( + current_page: 1, + page_count: 2, + total_count: 5, + has_more: true, + url: "/v1/model_with_related/#{vendor.id}/products", + items: have_length(4), + ), + children: include(items: have_length(4)), + defed_alias: include(items: have_length(4)), + ) + end + + it "can expose a paginated list endpoint" do + get "/v1/model_with_related/#{vendor.id}/products", page: 2, per_page: 2 + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body. + that_includes( + current_page: 2, + page_count: 3, + total_count: 5, + has_more: true, + url: "/v1/model_with_related/#{vendor.id}/products", + items: have_length(2), + ) + end + end end describe "entities" do diff --git a/spec/suma/payment/card_spec.rb b/spec/suma/payment/card_spec.rb index 23fa0db3f..ec4c83342 100644 --- a/spec/suma/payment/card_spec.rb +++ b/spec/suma/payment/card_spec.rb @@ -7,6 +7,16 @@ it_behaves_like "a payment instrument" + describe "associations" do + it "knows originated funding transactions" do + card = Suma::Fixtures.card.create + strategy = Suma::Payment::FundingTransaction::StripeCardStrategy.create(originating_card: card) + xaction = Suma::Fixtures.funding_transaction(strategy:).create + + expect(card.originated_funding_transactions).to have_same_ids_as(xaction) + end + end + it "knows when it is usable for funding and payouts" do c = Suma::Fixtures.card.create expect(c).to be_usable_for_funding diff --git a/spec/suma/postgres/model_spec.rb b/spec/suma/postgres/model_spec.rb index 67fe613e2..363a5a08c 100755 --- a/spec/suma/postgres/model_spec.rb +++ b/spec/suma/postgres/model_spec.rb @@ -578,6 +578,20 @@ def inspect = "MyCls" end end + describe "each_row_efficient" do + it "yields each item without loading the association" do + + end + + it "yields each item in the association dataset" do + + end + + it "can raise if the association is loaded" do + + end + end + describe "one_to_many" do Suma::Postgres::Model.descendants.reject(&:anonymous?).each do |host_class| describe host_class.name do From 5485d939058517bdb3e7d8e18fc1209709855d96 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Thu, 21 May 2026 16:37:28 -0700 Subject: [PATCH 02/22] expose_related across the backend --- lib/suma/admin_api/payment_ledgers.rb | 3 +-- lib/suma/eligibility/requirement.rb | 4 ++-- lib/suma/member.rb | 19 ++++++++----------- .../organization/membership/verification.rb | 10 ++++++++-- lib/suma/payment/platform_status.rb | 1 - lib/suma/postgres/model_utilities.rb | 4 +--- lib/suma/support/note.rb | 7 ------- .../eligibility_requirements_spec.rb | 2 +- spec/suma/member_spec.rb | 2 +- spec/suma/postgres/model_spec.rb | 3 --- 10 files changed, 22 insertions(+), 33 deletions(-) diff --git a/lib/suma/admin_api/payment_ledgers.rb b/lib/suma/admin_api/payment_ledgers.rb index 68ec8847f..f9ff7c0a2 100644 --- a/lib/suma/admin_api/payment_ledgers.rb +++ b/lib/suma/admin_api/payment_ledgers.rb @@ -30,8 +30,7 @@ class DetailedLedgerEntity < LedgerEntity expose_related :vendor_service_categories, with: VendorServiceCategoryEntity expose_related :combined_book_transactions, with: BookTransactionEntity - expose_related :find_unbalanced_counterparty_ledgers, as: :unbalanced_counterparties, - with: UnbalancedCounterpartyEntity + expose :find_unbalanced_counterparty_ledgers, as: :unbalanced_counterparties, with: UnbalancedCounterpartyEntity end resource :payment_ledgers do diff --git a/lib/suma/eligibility/requirement.rb b/lib/suma/eligibility/requirement.rb index a18e9a163..1058c4d1d 100644 --- a/lib/suma/eligibility/requirement.rb +++ b/lib/suma/eligibility/requirement.rb @@ -29,10 +29,10 @@ class Suma::Eligibility::Requirement < Suma::Postgres::Model(:eligibility_requir def all_resources = self.programs + self.payment_triggers - def each_resource(&block) + def each_resource(&) [:programs, :payment_triggers].each do |assoc| iter = self.associations[assoc] || self.send("#{assoc}_dataset") - iter.each(&block) + iter.each(&) end end diff --git a/lib/suma/member.rb b/lib/suma/member.rb index 9980eba60..1d449816a 100644 --- a/lib/suma/member.rb +++ b/lib/suma/member.rb @@ -125,6 +125,14 @@ def initialize(reason) join_table: :support_notes_members, left_key: :member_id, order: order_desc + many_to_many :combined_notes, + class: "Suma::Support::Note" do |_ds| + Suma::Support::Note.combine_datasets( + Sequel[members: self], + Sequel[organization_membership_verifications: Suma::Organization::Membership::Verification. + where(membership: Suma::Organization::Membership.where(member: self))], + ) + end one_to_many :eligibility_assignments, class: "Suma::Eligibility::Assignment", order: order_desc one_to_many :expanded_eligibility_assignments, @@ -276,17 +284,6 @@ def default_payment_instrument return self.public_payment_instruments.find { |pi| pi.status == :ok } end - def combined_notes_dataset - ds = Suma::Support::Note.combine_datasets( - Sequel[members: self], - Sequel[organization_membership_verifications: Suma::Organization::Membership::Verification. - where(membership: self.organization_memberships_dataset)], - ) - return ds - end - - def combined_notes = self.combined_notes_dataset.all - # @return [Suma::Member::StripeAttributes] def stripe return @stripe ||= Suma::Member::StripeAttributes.new(self) diff --git a/lib/suma/organization/membership/verification.rb b/lib/suma/organization/membership/verification.rb index 78ef03e13..fc545c0c5 100644 --- a/lib/suma/organization/membership/verification.rb +++ b/lib/suma/organization/membership/verification.rb @@ -44,6 +44,14 @@ class Suma::Organization::Membership::Verification < Suma::Postgres::Model(:orga join_table: :support_notes_organization_membership_verifications, left_key: :verification_id, order: order_desc + many_to_many :combined_notes, + class: "Suma::Support::Note" do |_ds| + Suma::Support::Note.combine_datasets( + Sequel[members: self.membership.member], + Sequel[organization_membership_verifications: self], + ) + end + many_to_one :owner, class: "Suma::Member" many_to_one :front_partner_conversation, @@ -340,8 +348,6 @@ def find_duplicates = DuplicateFinder.lookup_matches(self) # Duplicates are stored sorted so we can use the 0th item. def duplicate_risk = self.find_duplicates.first&.max_risk - def combined_notes = Suma::Support::Note.combine_instances(self.notes, self.membership.member.notes) - def rel_admin_link = "/membership-verification/#{self.id}" def hybrid_search_fields diff --git a/lib/suma/payment/platform_status.rb b/lib/suma/payment/platform_status.rb index acb758cf1..bf37351c0 100644 --- a/lib/suma/payment/platform_status.rb +++ b/lib/suma/payment/platform_status.rb @@ -31,7 +31,6 @@ def calculate self.funding_count -= self.refund_count self.member_liabilities = self.platform_ledgers.sum(&:balance) * -1 self.assets = self.funding - self.payouts - self.unbalanced_ledgers_dataset = self.find_unbalanced_ledgers_ds self.off_platform_funding_transactions_dataset = offplatform_ds(funding_ds) self.off_platform_payout_transactions_dataset = offplatform_ds(payout_ds) return self diff --git a/lib/suma/postgres/model_utilities.rb b/lib/suma/postgres/model_utilities.rb index d244e0ce3..e38f54169 100644 --- a/lib/suma/postgres/model_utilities.rb +++ b/lib/suma/postgres/model_utilities.rb @@ -341,9 +341,7 @@ def set_ambiguous_association(assocs, v) # or an error can be raised (loaded: :raise, default). # If the association is not loaded, paginate with each_cursor_page. def each_row_efficient(association) - assoc = self.class.association_reflections.fetch(association) - - + self.class.association_reflections.fetch(association) end end diff --git a/lib/suma/support/note.rb b/lib/suma/support/note.rb index ab390370d..cb3f5b2ba 100644 --- a/lib/suma/support/note.rb +++ b/lib/suma/support/note.rb @@ -25,13 +25,6 @@ def combine_datasets(*exprs) ds = ds.order(Sequel.desc(Sequel.function(:coalesce, :edited_at, :created_at)), :id) return ds end - - def combine_instances(*arrays) - notes = [].concat(*arrays) - notes.sort_by! { |n| [n.authored_at, -n.id] } - notes.reverse! - return notes - end end # Return content rendered as markdown html. diff --git a/spec/suma/admin_api/eligibility_requirements_spec.rb b/spec/suma/admin_api/eligibility_requirements_spec.rb index 515e159e1..b7d074538 100644 --- a/spec/suma/admin_api/eligibility_requirements_spec.rb +++ b/spec/suma/admin_api/eligibility_requirements_spec.rb @@ -68,7 +68,7 @@ def make_non_matching_items expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( - id: requirement.id, resources: [], + id: requirement.id, programs: include(:items), ) end diff --git a/spec/suma/member_spec.rb b/spec/suma/member_spec.rb index f1a8a9e2b..0d7fe5b49 100644 --- a/spec/suma/member_spec.rb +++ b/spec/suma/member_spec.rb @@ -373,7 +373,7 @@ def skip_verification?(c, list=nil) end end - describe "#combine_notes" do + describe "#combined_notes" do it "selects and sorts all notes from related resources" do m = Suma::Fixtures.member.create v_fac = Suma::Fixtures.organization_membership_verification.member(m) diff --git a/spec/suma/postgres/model_spec.rb b/spec/suma/postgres/model_spec.rb index 363a5a08c..331f5231a 100755 --- a/spec/suma/postgres/model_spec.rb +++ b/spec/suma/postgres/model_spec.rb @@ -580,15 +580,12 @@ def inspect = "MyCls" describe "each_row_efficient" do it "yields each item without loading the association" do - end it "yields each item in the association dataset" do - end it "can raise if the association is loaded" do - end end From f78eb7089e04c898dcb2b062245f52e0529ef89c Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Fri, 22 May 2026 09:26:33 -0700 Subject: [PATCH 03/22] Add large association warning plugin --- .../plugins/large_association_warning.rb | 55 +++++++++++++++++++ lib/suma/postgres/model.rb | 16 ++++++ .../plugins/large_association_warning_spec.rb | 48 ++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 lib/sequel/plugins/large_association_warning.rb create mode 100644 spec/sequel/plugins/large_association_warning_spec.rb diff --git a/lib/sequel/plugins/large_association_warning.rb b/lib/sequel/plugins/large_association_warning.rb new file mode 100644 index 000000000..63225173d --- /dev/null +++ b/lib/sequel/plugins/large_association_warning.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Sequel::Plugins::LargeAssociationWarning + DEFAULT_CALLBACK = lambda do |m, assoc, array| + m.logger.warn( + "large_assocation_loaded", + model_pk: m.primary_key, + model_type: m.class.name, + model_association: assoc, + model_association_size: array.size, + ) + end + + DEFAULT_OPTIONS = { + threshold: 100, + callback: DEFAULT_CALLBACK, + }.freeze + + class << self + attr_reader :warned_associations + + def configure(model, opts=DEFAULT_OPTIONS) + opts = DEFAULT_OPTIONS.merge(opts) + model.large_association_warning_threshold = opts[:threshold] + model.large_association_warning_callback = opts[:callback] + @warned_associations = Set.new + end + end + + module ClassMethods + attr_accessor :large_association_warning_threshold, :large_association_warning_callback + + def inherited(subclass) + super + [:large_association_warning_threshold, :large_association_warning_callback].each do |m| + subclass.send("#{m}=", self.send(m)) + end + end + end + + module InstanceMethods + def load_associated_objects(opts, dynamic_opts={}) + results = super + if results.is_a?(Array) && results.size > model.large_association_warning_threshold + assoc = opts.fetch(:name) + warn_key = [self.class, assoc] + unless Sequel::Plugins::LargeAssociationWarning.warned_associations.include?(warn_key) + Sequel::Plugins::LargeAssociationWarning.warned_associations.add(warn_key) + model.large_association_warning_callback[self, assoc, results] + end + end + return results + end + end +end diff --git a/lib/suma/postgres/model.rb b/lib/suma/postgres/model.rb index 5c38735ed..4541da120 100644 --- a/lib/suma/postgres/model.rb +++ b/lib/suma/postgres/model.rb @@ -35,6 +35,8 @@ class Suma::Postgres::Model setting :extension_schema, "public" + setting :large_association_warning_threshold, 500 + # The number of (Float) seconds that should be considered "slow" for a # single query; queries that take longer than this amount of time will be logged # at `warn` level. @@ -68,6 +70,20 @@ class Suma::Postgres::Model db.extension(:pg_interval) db.extension(:pretty_table) self.db = db + + plugin :large_association_warning, + threshold: self.large_association_warning_threshold, + callback: lambda { |m, assoc, array| + Sentry.capture_message("Large association loaded") do |scope| + scope.set_extras( + model_pk: m.pk, + model_type: m.class.name, + model_association: assoc, + model_association_size: array.size, + ) + end + Sequel::Plugins::LargeAssociationWarning::DEFAULT_CALLBACK[m, assoc, array] + } end end diff --git a/spec/sequel/plugins/large_association_warning_spec.rb b/spec/sequel/plugins/large_association_warning_spec.rb new file mode 100644 index 000000000..d221bf949 --- /dev/null +++ b/spec/sequel/plugins/large_association_warning_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "sequel/plugins/large_association_warning" + +RSpec.describe Sequel::Plugins::LargeAssociationWarning, :db do + before(:all) do + @db = Sequel.connect(ENV.fetch("DATABASE_URL")) + @db.drop_table?(:largeassocwarn) + @db.create_table(:largeassocwarn) do + primary_key :id + foreign_key :parent_id, :largeassocwarn + end + end + after(:all) do + @db.disconnect + end + + it "undefines generated setters and skips their saving" do + calls = [] + cls = Class.new(Sequel::Model(:largeassocwarn)) do + plugin :large_association_warning, threshold: 5, callback: ->(*args) { calls << args } + many_to_one :parent, class: self + one_to_many :children, class: self, key: :parent_id + end + + parent = cls.create + Array.new(5) { cls.create(parent:) } + parent.refresh.children + expect(calls).to be_empty + cls.create(parent:) + expect(calls).to be_empty + parent.refresh.children + expect(calls).to contain_exactly([parent, :children, have_length(6)]) + # Do not re-warn + parent.refresh.children + expect(calls).to contain_exactly([parent, :children, have_length(6)]) + end + + it "copies configuration to subclasses" do + base = Class.new(Sequel::Model(:largeassocwarn)) do + plugin :large_association_warning, threshold: 5 + end + sub = Class.new(base) + + expect(base.large_association_warning_threshold).to eq(5) + expect(sub.large_association_warning_threshold).to eq(5) + end +end From 723d9668b647d66dad30c268abc2a3a534a9824c Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Fri, 22 May 2026 10:36:27 -0700 Subject: [PATCH 04/22] efficient each plugin --- lib/sequel/plugins/efficient_each.rb | 85 +++++++++++++ lib/suma/payment/ledger.rb | 2 +- lib/suma/postgres/model.rb | 4 + lib/suma/postgres/model_utilities.rb | 51 -------- spec/sequel/plugins/efficient_each_spec.rb | 135 +++++++++++++++++++++ spec/suma/postgres/model_spec.rb | 79 ------------ 6 files changed, 225 insertions(+), 131 deletions(-) create mode 100644 lib/sequel/plugins/efficient_each.rb create mode 100644 spec/sequel/plugins/efficient_each_spec.rb diff --git a/lib/sequel/plugins/efficient_each.rb b/lib/sequel/plugins/efficient_each.rb new file mode 100644 index 000000000..4bca33ab5 --- /dev/null +++ b/lib/sequel/plugins/efficient_each.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Sequel::Plugins::EfficientEach + class UnknownAssociation < ArgumentError; end + + DEFAULT_OPTIONS = { + page_size: 100, + }.freeze + + class << self + def configure(model, opts=DEFAULT_OPTIONS) + opts = DEFAULT_OPTIONS.merge(opts) + model.efficient_each_page_size = opts[:page_size] + end + end + + module ClassMethods + attr_accessor :efficient_each_page_size + + def inherited(subclass) + super + [:efficient_each_page_size].each do |m| + subclass.send("#{m}=", self.send(m)) + end + end + end + + module DatasetMethods + # Call a block for each row in a dataset. + # This is the same as paged_each or use_cursor.each, except that for each page, + # rows are re-fetched using self.where(primary_key => [pks]).all to enable eager loading. + # + # @param page_size [Integer] Size of each page. Smaller uses less memory. + # @param order [Symbol] Column to order by. Default to primary key. + # @param yield_page [true,false] If true, yield the page to the block, rather than individual rows. + # Helpful when bulk processing. + # + # (Note that paged_each does not do eager loading, which makes enumerating model associations very slow) + def each_cursor_page(page_size: nil, order: nil, yield_page: false, &block) + raise LocalJumpError unless block + raise "dataset requires a use_cursor method, class may need `extension(:pagination)`" unless + self.respond_to?(:use_cursor) + model = self.model + page_size ||= model.efficient_each_page_size + pk = model.primary_key + order ||= pk + current_chunk_pks = [] + order = [order] unless order.respond_to?(:to_ary) + self.naked.select(pk).order(*order).use_cursor(rows_per_fetch: page_size, hold: true).each do |row| + current_chunk_pks << row[pk] + next if current_chunk_pks.length < page_size + page = model.where(pk => current_chunk_pks).order(*order).all + current_chunk_pks.clear + yield_page ? yield(page) : page.each(&block) + end + remainder = model.where(pk => current_chunk_pks).order(*order).all + yield_page && !remainder.empty? ? yield(remainder) : remainder.each(&block) + end + end + + module InstanceMethods + def efficient_each(association_name, &) + return enum_for(:efficient_each, association_name) unless block_given? + + assoc = self.class.association_reflection(association_name) + raise UnknownAssociation, "#{self.class.name} has no association :#{association_name}" if + assoc.nil? + loaded = self.associations[association_name] + unless loaded.nil? + loaded.each(&) + return nil + end + dataset = self.send(assoc.fetch(:dataset_method)) + pagecount = 0 + prev_page = [] + dataset.each_cursor_page(yield_page: true) do |page| + pagecount += 1 + prev_page = page + page.each(&) + end + self.associations[association_name] = prev_page if pagecount < 2 + return nil + end + end +end diff --git a/lib/suma/payment/ledger.rb b/lib/suma/payment/ledger.rb index 535a179d2..31abefa41 100644 --- a/lib/suma/payment/ledger.rb +++ b/lib/suma/payment/ledger.rb @@ -230,7 +230,7 @@ def category_used_to_purchase(has_vnd_svc_categories) def find_unbalanced_counterparty_ledgers(include_all: false) platform_account = Suma::Payment::Account.lookup_platform_account totals_by_ledger = {} - self.combined_book_transactions.each do |bx| + self.efficient_each(:combined_book_transactions).each do |bx| if bx.originating_ledger === self counterparty = bx.receiving_ledger amount = bx.amount * -1 diff --git a/lib/suma/postgres/model.rb b/lib/suma/postgres/model.rb index 4541da120..5620e56f2 100644 --- a/lib/suma/postgres/model.rb +++ b/lib/suma/postgres/model.rb @@ -84,6 +84,10 @@ class Suma::Postgres::Model end Sequel::Plugins::LargeAssociationWarning::DEFAULT_CALLBACK[m, assoc, array] } + plugin :efficient_each, + # Use a page size of the large warning threshold, + # as this is what we consider a reasonable page size. + page_size: self.large_association_warning_threshold end end diff --git a/lib/suma/postgres/model_utilities.rb b/lib/suma/postgres/model_utilities.rb index e38f54169..c976dbe67 100644 --- a/lib/suma/postgres/model_utilities.rb +++ b/lib/suma/postgres/model_utilities.rb @@ -370,57 +370,6 @@ def reduce_expr(op_symbol, operands, method: :where) return self.send(method, full_op) end - # Call a block for each row in a dataset. - # This is the same as paged_each or use_cursor.each, except that for each page, - # rows are re-fetched using self.where(primary_key => [pks]).all to enable eager loading. - # - # @param page_size [Integer] Size of each page. Smaller uses less memory. - # @param order [Symbol] Column to order by. Default to primary key. - # @param yield_page [true,false] If true, yield the page to the block, rather than individual rows. - # Helpful when bulk processing. - # - # (Note that paged_each does not do eager loading, which makes enumerating model associations very slow) - def each_cursor_page(page_size: 500, order: nil, yield_page: false, &block) - raise LocalJumpError unless block - raise "dataset requires a use_cursor method, class may need `extension(:pagination)`" unless - self.respond_to?(:use_cursor) - model = self.model - pk = model.primary_key - order ||= pk - current_chunk_pks = [] - order = [order] unless order.respond_to?(:to_ary) - self.naked.select(pk).order(*order).use_cursor(rows_per_fetch: page_size, hold: true).each do |row| - current_chunk_pks << row[pk] - next if current_chunk_pks.length < page_size - page = model.where(pk => current_chunk_pks).order(*order).all - current_chunk_pks.clear - yield_page ? yield(page) : page.each(&block) - end - remainder = model.where(pk => current_chunk_pks).order(*order).all - yield_page ? yield(remainder) : remainder.each(&block) - end - - # See each_cursor_page, but takes an additional action on each chunk of returned rows. - # The action is called with pages of return values from the block when a page is is reached. - # Each call to action should return nil, a result, or an array of results (nil results are ignored). - # - # The most common case is for ETL: process one dataset, map it in a block to return new row values, - # and multi_insert it into a different table. - def each_cursor_page_action(action:, page_size: 500, order: :id) - raise LocalJumpError unless block_given? - returned_rows_chunk = [] - self.each_cursor_page(page_size:, order:) do |instance| - new_row = yield(instance) - next if action.nil? || new_row.nil? - new_row.respond_to?(:to_ary) ? returned_rows_chunk.concat(new_row) : returned_rows_chunk.push(new_row) - if returned_rows_chunk.length >= page_size - action.call(returned_rows_chunk) - returned_rows_chunk.clear - end - end - action&.call(returned_rows_chunk) - end - # Reselect is shorthandle for "ds.select(Sequel[ds.model.table_name][Sequel.lit("*")])". # This is useful after a join that is used in the query, but we only want to return the original model. def reselect diff --git a/spec/sequel/plugins/efficient_each_spec.rb b/spec/sequel/plugins/efficient_each_spec.rb new file mode 100644 index 000000000..3f597708c --- /dev/null +++ b/spec/sequel/plugins/efficient_each_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "sequel/plugins/efficient_each" + +RSpec.describe Sequel::Plugins::EfficientEach, :db do + before(:all) do + @db = Sequel.connect(ENV.fetch("DATABASE_URL")) + @db.drop_table?(:efeach) + @db.create_table(:efeach) do + primary_key :id + text :name + foreign_key :parent_id, :efeach + end + end + after(:all) do + @db.disconnect + end + + let(:cls) do + Class.new(Sequel::Model(:efeach)) do + plugin :efficient_each, page_size: 2 + many_to_one :parent, class: self + one_to_many :children, class: self, key: :parent_id + end + end + + it "errors for an unknown association" do + n = cls.create + expect { n.efficient_each(:foo).first }.to raise_error(described_class::UnknownAssociation) + end + + it "copies configuration to subclasses" do + sub = Class.new(cls) + + expect(cls.efficient_each_page_size).to eq(2) + expect(sub.efficient_each_page_size).to eq(2) + end + + it "can work with a block or return an enumerator" do + parent = cls.create + ch = cls.create(parent:) + expect(parent.children).to contain_exactly(ch) + + expect(parent.efficient_each(:children).to_a).to contain_exactly(ch) + + calls = [] + parent.efficient_each(:children) { |r| calls << r } + expect(calls).to contain_exactly(ch) + end + + describe "with a loaded association" do + let(:parent) { cls.create } + let!(:children) { Array.new(3) { cls.create(parent:) } } + before(:each) do + parent.associations[:children] = children + end + + it "yields each item in the dataset" do + got = parent.efficient_each(:children).to_a + expect(got).to eq(children) + end + end + + describe "without a loaded association" do + let(:parent) { cls.create } + let!(:children) { Array.new(3) { cls.create(parent:) } } + before(:each) do + parent.refresh + end + + it "streams pages from the dataset" do + got = parent.efficient_each(:children).to_a + expect(got).to contain_exactly(be === children[0], be === children[1], be === children[2]) + end + + it "stores the association if there is only one page" do + children[2].destroy + got = parent.efficient_each(:children).to_a + expect(got).to contain_exactly(be === children[0], be === children[1]) + expect(parent.associations).to include(children: have_length(2)) + end + + it "handles an empty association array" do + children.each(&:destroy) + got = parent.efficient_each(:children).to_a + expect(got).to be_empty + expect(parent.associations).to include(children: []) + end + + it "does not store the association if there is more than one page" do + got = parent.efficient_each(:children).to_a + expect(got).to contain_exactly(be === children[0], be === children[1], be === children[2]) + expect(parent.associations).to be_empty + end + end + + describe "dataset method each_cursor_page" do + names = ["a", "b", "c", "d"] + let(:ds) { cls.dataset } + + before(:each) do + names.each { |n| cls.create(name: n) } + end + + it "yields each item to the block" do + result = [] + cls.dataset.each_cursor_page { |r| result << r.name } + expect(result).to eq(names) + end + + it "can order by a column" do + result = [] + cls.dataset.each_cursor_page(order: Sequel.desc(:name)) { |r| result << r.name } + expect(result).to eq(names.reverse) + end + + it "can order by multiple columns" do + result = [] + cls.dataset.each_cursor_page(order: [Sequel.desc(:name), :id]) { |r| result << r.name } + expect(result).to eq(names.reverse) + end + + it "can yield the full page rather than a row" do + result = [] + cls.dataset.each_cursor_page(yield_page: true) { |page| result << page.map(&:name) } + expect(result).to eq([["a", "b"], ["c", "d"]]) + end + + it "can use an override page size" do + result = [] + cls.dataset.each_cursor_page(yield_page: true, page_size: 4) { |page| result << page.map(&:name) } + expect(result).to eq([["a", "b", "c", "d"]]) + end + end +end diff --git a/spec/suma/postgres/model_spec.rb b/spec/suma/postgres/model_spec.rb index 331f5231a..1e2b00714 100755 --- a/spec/suma/postgres/model_spec.rb +++ b/spec/suma/postgres/model_spec.rb @@ -510,85 +510,6 @@ def inspect = "MyCls" end end - describe "each_cursor_page" do - names = ["a", "b", "c", "d"] - cls = Suma::Postgres::TestingPixie - let(:ds) { cls.dataset } - - before(:each) do - names.each { |n| cls.create(name: n) } - end - - it "chunks pages and calls each item in the block" do - result = [] - cls.each_cursor_page(page_size: 2) { |r| result << r.name } - expect(result).to eq(names) - end - - it "can order by a column" do - result = [] - cls.each_cursor_page(page_size: 2, order: Sequel.desc(:name)) { |r| result << r.name } - expect(result).to eq(names.reverse) - end - - it "can order by multiple columns" do - result = [] - cls.each_cursor_page(page_size: 2, order: [Sequel.desc(:name), :id]) { |r| result << r.name } - expect(result).to eq(names.reverse) - end - - it "can yield the full page rather than a row" do - result = [] - cls.each_cursor_page(page_size: 3, yield_page: true) { |page| page.map(&:name).each { |n| result << n } } - expect(result).to eq(names) - end - - it "can perform an action on the returned values of each chunk" do - clean_ds = ds.exclude(Sequel.like(:name, "%prime")) # Avoid re-selecting the stuff we just inserted - clean_ds.each_cursor_page_action(page_size: 3, action: ds.method(:multi_insert)) do |tp| - {name: tp.name + "prime"} - end - expect(ds.order(:id).all.map(&:name)).to eq( - ["a", "b", "c", "d", "aprime", "bprime", "cprime", "dprime"], - ) - end - - it "can handle multiple return rows" do - action_calls = 0 - action = lambda { |v| - action_calls += 1 - ds.multi_insert(v) - } - cls.each_cursor_page_action(page_size: 3, action:) do |tp| - tp.name == "a" ? Array.new(10) { |i| {name: "a#{i}"} } : nil - end - expect(ds.order(:id).all.map(&:name)).to eq( - ["a", "b", "c", "d", "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9"], - ) - expect(action_calls).to eq(2) - end - - it "ignores nil results returned from the block" do - cls.each_cursor_page_action(page_size: 1, action: ds.method(:multi_insert)) do |tp| - tp.name >= "c" ? nil : {name: tp.name + "prime"} - end - expect(ds.order(:id).all.map(&:name)).to eq( - ["a", "b", "c", "d", "aprime", "bprime"], - ) - end - end - - describe "each_row_efficient" do - it "yields each item without loading the association" do - end - - it "yields each item in the association dataset" do - end - - it "can raise if the association is loaded" do - end - end - describe "one_to_many" do Suma::Postgres::Model.descendants.reject(&:anonymous?).each do |host_class| describe host_class.name do From 3639cbe72f9c85bd321bb826160e9a44216542c4 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Fri, 22 May 2026 10:44:52 -0700 Subject: [PATCH 05/22] fix specs --- lib/suma/service/collection.rb | 1 + spec/suma/payment/platform_status_spec.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/suma/service/collection.rb b/lib/suma/service/collection.rb index ee5791976..acdde8701 100644 --- a/lib/suma/service/collection.rb +++ b/lib/suma/service/collection.rb @@ -59,6 +59,7 @@ def self.prepare_entity(item_entity) collection_entity = Suma::Service::Collection.collection_entity_cache[item_entity] if collection_entity.nil? collection_entity = Class.new(Suma::Service::Collection::BaseEntity) do + def self.name = "Suma::Service::Collection::Entity" expose :items, using: item_entity end Suma::Service::Collection.collection_entity_cache[item_entity] = collection_entity diff --git a/spec/suma/payment/platform_status_spec.rb b/spec/suma/payment/platform_status_spec.rb index adbbef092..219d8b3ab 100644 --- a/spec/suma/payment/platform_status_spec.rb +++ b/spec/suma/payment/platform_status_spec.rb @@ -20,6 +20,6 @@ f1 = Suma::Fixtures.funding_transaction.with_fake_strategy.create f2 = Suma::Fixtures.funding_transaction.create(strategy: Suma::Fixtures.off_platform_payment_strategy.create) ps = described_class.new.calculate - expect(ps).to have_attributes(off_platform_funding_transactions: contain_exactly(have_attributes(id: f2.id))) + expect(ps.off_platform_funding_transactions_dataset.all).to have_same_ids_as(f2) end end From 3fc8f870600e6c514b38f6f832fd9781e7bd380f Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Fri, 22 May 2026 11:34:29 -0700 Subject: [PATCH 06/22] start working on related list; need to add related endpoints --- .../LedgerBookTransactionRelatedList.jsx | 7 +- adminapp/src/components/RelatedListRemote.jsx | 140 ++++++++++++++++++ .../src/pages/PaymentLedgerDetailPage.jsx | 36 ++--- lib/suma/service.rb | 2 +- 4 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 adminapp/src/components/RelatedListRemote.jsx diff --git a/adminapp/src/components/LedgerBookTransactionRelatedList.jsx b/adminapp/src/components/LedgerBookTransactionRelatedList.jsx index c89f41fa6..136d78e77 100644 --- a/adminapp/src/components/LedgerBookTransactionRelatedList.jsx +++ b/adminapp/src/components/LedgerBookTransactionRelatedList.jsx @@ -4,18 +4,19 @@ import Money from "../shared/react/Money"; import AdminLink from "./AdminLink"; import ForwardTo from "./ForwardTo"; import RelatedList from "./RelatedList"; +import RelatedListRemote from "./RelatedListRemote"; import React from "react"; -export default function LedgerBookTransactionsRelatedList({ ledger, title, rows }) { +export default function LedgerBookTransactionsRelatedList({ ledger, title, collection }) { return ( - {title} } headers={["Id", "Applied", "Amount", "Originating", "Receiving"]} - rows={rows} + collection={collection} keyRowAttr="id" toCells={(row) => [ , diff --git a/adminapp/src/components/RelatedListRemote.jsx b/adminapp/src/components/RelatedListRemote.jsx new file mode 100644 index 000000000..8fb0b12ef --- /dev/null +++ b/adminapp/src/components/RelatedListRemote.jsx @@ -0,0 +1,140 @@ +import api from "../api"; +import useErrorSnackbar from "../hooks/useErrorSnackbar"; +import useRoleAccess from "../hooks/useRoleAccess"; +import useToggle from "../shared/react/useToggle"; +import Link from "./Link"; +import "./RelatedList.css"; +import SimpleTable from "./SimpleTable"; +import ListAltIcon from "@mui/icons-material/ListAlt"; +import { Card, CardContent, Chip, Stack } from "@mui/material"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import clsx from "clsx"; +import isEmpty from "lodash/isEmpty"; +import merge from "lodash/merge"; +import React from "react"; + +const PAGE_SIZE = 100; + +export default function RelatedListRemote({ + title, + headers, + pushLeft, + addNewLabel, + addNewLink, + addNewRole, + emptyState, + cardProps, + className, + onAddNewClick, + collection, + ...rest +}) { + const { enqueueErrorSnackbar } = useErrorSnackbar(); + const [allRows, setAllRows] = React.useState(collection.items); + const [latestCollection, setLatestCollection] = React.useState(collection); + const [nextPage, setNextPage] = React.useState(1); + const [pageLoading, setPageLoading] = React.useState(false); + const [loadAll, setLoadAll] = React.useState(false); + + const { canWriteResource } = useRoleAccess(); + + const loadNextPage = React.useCallback(() => { + setPageLoading(true); + api + .post(latestCollection.url, { page: nextPage, pageSize: PAGE_SIZE }) + .then((r) => { + setAllRows([...allRows, r.data.items]); + setLatestCollection(r.data); + setNextPage(nextPage + 1); + if (!r.data.hasMore) { + setPageLoading(false); + return; + } + if (loadAll) { + return loadNextPage(); + } + setPageLoading(false); + }) + .catch((e) => { + enqueueErrorSnackbar(e); + setPageLoading(false); + setLoadAll(false); + }); + }, []); + function handleLoadMore(e) { + e.preventDefault(); + loadNextPage(); + } + function handleLoadAll(e) { + e.preventDefault(); + setLoadAll(true); + handleLoadMore(e); + } + console.log(collection); + // const topRef = React.useRef(); + + // if (pushLeft === undefined) { + // pushLeft = headers?.length <= 2; + // } + const addNew = Boolean(addNewLink || onAddNewClick) && canWriteResource(addNewRole); + + if (!collection.totalCount && !addNew && !emptyState) { + return null; + } + + const disableLoadButtons = pageLoading || !latestCollection.hasMore; + + return ( + + + {title && ( + + {title} + + )} + {addNew && ( + + + {addNewLabel} + + )} + {isEmpty(allRows) ? ( + emptyState + ) : ( + + )} + + + + + + + ); +} + +function ListCount({ count }) { + if (!count) { + return null; + } + return ( + + ); +} diff --git a/adminapp/src/pages/PaymentLedgerDetailPage.jsx b/adminapp/src/pages/PaymentLedgerDetailPage.jsx index fa4da477a..e8c21202c 100644 --- a/adminapp/src/pages/PaymentLedgerDetailPage.jsx +++ b/adminapp/src/pages/PaymentLedgerDetailPage.jsx @@ -28,28 +28,28 @@ export default function PaymentLedgerDetailPage() { ]} > {(model) => [ - [row.id, row.name, row.slug]} - />, + // [row.id, row.name, row.slug]} + // />, , - row.ledger.id} - toCells={(row) => [ - {row.ledger.label}, - {row.amount}, - ]} + collection={model.combinedBookTransactions} />, + // row.ledger.id} + // toCells={(row) => [ + // {row.ledger.label}, + // {row.amount}, + // ]} + // />, ]} ); diff --git a/lib/suma/service.rb b/lib/suma/service.rb index 5a47f1a36..28b85aec0 100644 --- a/lib/suma/service.rb +++ b/lib/suma/service.rb @@ -54,7 +54,7 @@ class Suma::Service < Grape::API setting :endpoint_caching, false - setting :related_list_size, 20 + setting :related_list_size, 5 setting :verify_localized_error_codes, false From d91b4017c8e9c11fa7223abf7fd246119a2b8618 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Fri, 22 May 2026 15:50:15 -0700 Subject: [PATCH 07/22] collection use request_path, not path_info --- lib/suma/admin_api/entities.rb | 2 +- lib/suma/service/collection.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/suma/admin_api/entities.rb b/lib/suma/admin_api/entities.rb index 1011e588f..9d85f1618 100644 --- a/lib/suma/admin_api/entities.rb +++ b/lib/suma/admin_api/entities.rb @@ -57,7 +57,7 @@ def expose_related(name, with:, as: nil, dataset_method: nil) ds = instance.send(ds_method) ds = ds.paginate(1, Suma::Service.related_list_size) collection = Suma::Service::Collection.from_dataset(ds) - collection.url = options[:env].fetch("PATH_INFO") + "/#{name}" + collection.url = options[:env].fetch("REQUEST_PATH") + "/#{name}" collection end end diff --git a/lib/suma/service/collection.rb b/lib/suma/service/collection.rb index acdde8701..d2abb7491 100644 --- a/lib/suma/service/collection.rb +++ b/lib/suma/service/collection.rb @@ -25,7 +25,7 @@ class BaseEntity < Suma::Service::Entities::Base expose :total_count expose :more?, as: :has_more expose :url do |inst, opts| - inst.url || opts[:env].fetch("PATH_INFO") + inst.url || opts[:env].fetch("REQUEST_PATH") end # expose :items do |_| # raise "this must be exposed by the subclass, like: `expose :items, with: MyEntity`" From 471364be3c1c55516249e8d90665d0919669443c Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sat, 23 May 2026 21:50:55 -0700 Subject: [PATCH 08/22] automatic endpoint exposures --- adminapp/src/components/AuditActivityList.jsx | 6 +- adminapp/src/components/AuditLogs.jsx | 5 +- .../EligibilityAssignmentsRelatedList.jsx | 6 +- .../LedgerBookTransactionRelatedList.jsx | 1 - .../components/PaymentAccountRelatedLists.jsx | 15 +- adminapp/src/components/RelatedListRemote.jsx | 85 ++++----- adminapp/src/pages/MemberDetailPage.jsx | 66 ++++--- adminapp/src/pages/OrganizationDetailPage.jsx | 15 +- .../src/pages/PaymentLedgerDetailPage.jsx | 35 ++-- lib/suma/admin_api/access.rb | 1 + .../admin_api/anon_proxy_vendor_accounts.rb | 4 +- lib/suma/admin_api/bank_accounts.rb | 1 + lib/suma/admin_api/cards.rb | 1 + .../admin_api/commerce_offering_products.rb | 7 +- lib/suma/admin_api/commerce_orders.rb | 13 +- lib/suma/admin_api/common_endpoints.rb | 31 +++- lib/suma/admin_api/entities.rb | 167 ++++++++++++------ lib/suma/admin_api/financials.rb | 3 +- lib/suma/admin_api/members.rb | 30 ++-- .../admin_api/off_platform_transactions.rb | 9 +- .../organization_membership_verifications.rb | 9 +- lib/suma/admin_api/payment_ledgers.rb | 3 +- lib/suma/admin_api/payment_triggers.rb | 6 +- lib/suma/admin_api/payout_transactions.rb | 6 +- lib/suma/service.rb | 4 + lib/suma/service/collection.rb | 2 +- .../anon_proxy_member_contacts_spec.rb | 6 + .../anon_proxy_vendor_accounts_spec.rb | 6 + .../anon_proxy_vendor_configurations_spec.rb | 6 + spec/suma/admin_api/bank_accounts_spec.rb | 6 + spec/suma/admin_api/book_transactions_spec.rb | 6 + spec/suma/admin_api/cards_spec.rb | 6 + spec/suma/admin_api/charges_spec.rb | 6 + .../commerce_offering_products_spec.rb | 6 + .../suma/admin_api/commerce_offerings_spec.rb | 6 + spec/suma/admin_api/commerce_orders_spec.rb | 6 + spec/suma/admin_api/commerce_products_spec.rb | 6 + .../admin_api/eligibility_assignments_spec.rb | 6 + .../admin_api/eligibility_attributes_spec.rb | 6 + .../eligibility_requirements_spec.rb | 6 + .../admin_api/funding_transactions_spec.rb | 6 + spec/suma/admin_api/marketing_lists_spec.rb | 6 + .../marketing_sms_broadcasts_spec.rb | 6 + .../marketing_sms_dispatches_spec.rb | 6 + spec/suma/admin_api/members_spec.rb | 6 + .../suma/admin_api/message_deliveries_spec.rb | 6 + spec/suma/admin_api/mobility_spec.rb | 110 ------------ spec/suma/admin_api/mobility_trips_spec.rb | 8 + .../off_platform_transactions_spec.rb | 6 + ...anization_membership_verifications_spec.rb | 6 + .../organization_memberships_spec.rb | 6 + .../organization_registration_links_spec.rb | 6 + spec/suma/admin_api/organizations_spec.rb | 6 + spec/suma/admin_api/payment_ledgers_spec.rb | 6 + spec/suma/admin_api/payment_triggers_spec.rb | 6 + .../admin_api/payout_transactions_spec.rb | 6 + spec/suma/admin_api/program_pricings_spec.rb | 6 + spec/suma/admin_api/programs_spec.rb | 6 + spec/suma/admin_api/roles_spec.rb | 6 + .../vendor_service_categories_spec.rb | 6 + .../admin_api/vendor_service_rates_spec.rb | 6 + spec/suma/admin_api/vendor_services_spec.rb | 6 + spec/suma/admin_api/vendors_spec.rb | 6 + spec/suma/admin_api_spec.rb | 10 +- spec/suma/api/behaviors.rb | 23 +++ 65 files changed, 558 insertions(+), 334 deletions(-) delete mode 100644 spec/suma/admin_api/mobility_spec.rb diff --git a/adminapp/src/components/AuditActivityList.jsx b/adminapp/src/components/AuditActivityList.jsx index a284c5d4e..7fba6ce4a 100644 --- a/adminapp/src/components/AuditActivityList.jsx +++ b/adminapp/src/components/AuditActivityList.jsx @@ -1,16 +1,16 @@ import formatDate from "../modules/formatDate"; import AdminLink from "./AdminLink"; import AdminMarkdown from "./AdminMarkdown"; -import RelatedList from "./RelatedList"; +import RelatedListRemote from "./RelatedListRemote"; import { makeStyles } from "@mui/styles"; import React from "react"; export default function AuditActivityList({ activities }) { return ( - [ formatDate(row.createdAt), diff --git a/adminapp/src/components/AuditLogs.jsx b/adminapp/src/components/AuditLogs.jsx index 880c9fc55..afb01f936 100644 --- a/adminapp/src/components/AuditLogs.jsx +++ b/adminapp/src/components/AuditLogs.jsx @@ -1,15 +1,16 @@ import { dayjs } from "../modules/dayConfig"; import AdminLink from "./AdminLink"; import RelatedList from "./RelatedList"; +import RelatedListRemote from "./RelatedListRemote"; import isEmpty from "lodash/isEmpty"; import React from "react"; export default function AuditLogs({ auditLogs }) { return ( - [dayjs(row.at).format("lll"), event(row), by(row), context(row)]} /> diff --git a/adminapp/src/components/EligibilityAssignmentsRelatedList.jsx b/adminapp/src/components/EligibilityAssignmentsRelatedList.jsx index a6760ba5f..8dbbe1e34 100644 --- a/adminapp/src/components/EligibilityAssignmentsRelatedList.jsx +++ b/adminapp/src/components/EligibilityAssignmentsRelatedList.jsx @@ -1,13 +1,13 @@ import createRelativeUrl from "../shared/createRelativeUrl"; import AdminLink from "./AdminLink"; -import RelatedList from "./RelatedList"; +import RelatedListRemote from "./RelatedListRemote"; import React from "react"; export default function EligibilityAssignmentsRelatedList({ model, type, title }) { return ( - - [ @@ -36,9 +37,9 @@ export default function PaymentAccountRelatedLists({ paymentAccount }) { {row.amount}, ]} /> - [ @@ -48,10 +49,10 @@ export default function PaymentAccountRelatedLists({ paymentAccount }) { {row.amount}, ]} /> - { const cells = [ @@ -83,7 +84,7 @@ export default function PaymentAccountRelatedLists({ paymentAccount }) { ledger={ledger} title={`Ledger ${ledger.label} (${ledger.id}) - ${formatMoney(ledger.balance)}`} key={ledger.id} - rows={ledger.combinedBookTransactions} + collection={ledger.combinedBookTransactions} /> ))} diff --git a/adminapp/src/components/RelatedListRemote.jsx b/adminapp/src/components/RelatedListRemote.jsx index 8fb0b12ef..8c48ba303 100644 --- a/adminapp/src/components/RelatedListRemote.jsx +++ b/adminapp/src/components/RelatedListRemote.jsx @@ -1,7 +1,6 @@ import api from "../api"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; import useRoleAccess from "../hooks/useRoleAccess"; -import useToggle from "../shared/react/useToggle"; import Link from "./Link"; import "./RelatedList.css"; import SimpleTable from "./SimpleTable"; @@ -9,9 +8,7 @@ import ListAltIcon from "@mui/icons-material/ListAlt"; import { Card, CardContent, Chip, Stack } from "@mui/material"; import Button from "@mui/material/Button"; import Typography from "@mui/material/Typography"; -import clsx from "clsx"; import isEmpty from "lodash/isEmpty"; -import merge from "lodash/merge"; import React from "react"; const PAGE_SIZE = 100; @@ -35,48 +32,50 @@ export default function RelatedListRemote({ const [latestCollection, setLatestCollection] = React.useState(collection); const [nextPage, setNextPage] = React.useState(1); const [pageLoading, setPageLoading] = React.useState(false); - const [loadAll, setLoadAll] = React.useState(false); const { canWriteResource } = useRoleAccess(); - const loadNextPage = React.useCallback(() => { - setPageLoading(true); - api - .post(latestCollection.url, { page: nextPage, pageSize: PAGE_SIZE }) - .then((r) => { - setAllRows([...allRows, r.data.items]); - setLatestCollection(r.data); - setNextPage(nextPage + 1); - if (!r.data.hasMore) { + const loadNextPage = React.useCallback( + ({ loadAll, page = nextPage, rows = allRows }) => { + setPageLoading(true); + api + .get(latestCollection.url, { page, pageSize: PAGE_SIZE }) + .then((r) => { + // Replace page 1 since we only have a partial list initially. + const newRows = page === 1 ? r.data.items : [...rows, ...r.data.items]; + setAllRows(newRows); + setLatestCollection(r.data); + setNextPage(page + 1); + if (!r.data.hasMore) { + setPageLoading(false); + return; + } + if (loadAll) { + return loadNextPage({ loadAll, page: page + 1, rows: newRows }); + } setPageLoading(false); - return; - } - if (loadAll) { - return loadNextPage(); - } - setPageLoading(false); - }) - .catch((e) => { - enqueueErrorSnackbar(e); - setPageLoading(false); - setLoadAll(false); - }); - }, []); + }) + .catch((e) => { + enqueueErrorSnackbar(e); + setPageLoading(false); + }); + }, + [allRows, enqueueErrorSnackbar, latestCollection.url, nextPage] + ); + function handleLoadMore(e) { e.preventDefault(); - loadNextPage(); + loadNextPage({ loadAll: false }); } + function handleLoadAll(e) { e.preventDefault(); - setLoadAll(true); - handleLoadMore(e); + loadNextPage({ loadAll: true }); } - console.log(collection); - // const topRef = React.useRef(); - // if (pushLeft === undefined) { - // pushLeft = headers?.length <= 2; - // } + if (pushLeft === undefined) { + pushLeft = headers?.length <= 2; + } const addNew = Boolean(addNewLink || onAddNewClick) && canWriteResource(addNewRole); if (!collection.totalCount && !addNew && !emptyState) { @@ -111,14 +110,16 @@ export default function RelatedListRemote({ {...rest} /> )} - - - - + {collection.hasMore && ( + + + + + )} ); diff --git a/adminapp/src/pages/MemberDetailPage.jsx b/adminapp/src/pages/MemberDetailPage.jsx index c9d0bce03..fc0b18c81 100644 --- a/adminapp/src/pages/MemberDetailPage.jsx +++ b/adminapp/src/pages/MemberDetailPage.jsx @@ -10,6 +10,7 @@ import InlineEditField from "../components/InlineEditField"; import OrganizationMembership from "../components/OrganizationMembership"; import PaymentAccountRelatedLists from "../components/PaymentAccountRelatedLists"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail, { ResourceSummary } from "../components/ResourceDetail"; import ResponsiveStack from "../components/ResponsiveStack"; import SupportNoteModal from "../components/SupportNoteModal"; @@ -99,7 +100,7 @@ export default function MemberDetailPage() { }, { label: "Roles", - children: model.roles.map((role) => ( + children: model.roles.items.map((role) => ( )), hideEmpty: true, @@ -182,20 +183,20 @@ export default function MemberDetailPage() { , , , - [ , {row.label}, ]} />, - [ , @@ -240,10 +241,10 @@ function LegalEntity({ address }) { function OrganizationMemberships({ memberships, model }) { return ( - - [ @@ -348,10 +349,10 @@ function ExpandedEligibilityAssignments({ assignments }) { function Activities({ activities }) { return ( - [ formatDate(row.createdAt), @@ -366,10 +367,10 @@ function Activities({ activities }) { function ResetCodes({ resetCodes }) { return ( - [ formatDate(row.createdAt), @@ -413,14 +414,14 @@ function Sessions({ member, setMember, sessions }) { - [ formatDate(row.createdAt), @@ -436,9 +437,9 @@ function Sessions({ member, setMember, sessions }) { function Orders({ orders }) { return ( - [ @@ -456,9 +457,9 @@ function Orders({ orders }) { function MobilityTrips({ mobilityTrips }) { return ( - [ @@ -478,11 +479,11 @@ function MobilityTrips({ mobilityTrips }) { function Charges({ charges }) { return ( - [ , formatDate(row.createdAt), @@ -496,10 +497,10 @@ function Charges({ charges }) { function PaymentInstruments({ instruments }) { return ( - `${r.id}-${r.paymentMethodType}`} toCells={(row) => [ @@ -514,9 +515,6 @@ function PaymentInstruments({ instruments }) { } function MessagePreferences({ preferences }) { - if (!preferences) { - return null; - } const { subscriptions, publicUrl } = preferences; return ( @@ -559,10 +557,10 @@ function MessagePreferences({ preferences }) { function MessageDeliveries({ messageDeliveries }) { return ( - [ , @@ -577,7 +575,7 @@ function MessageDeliveries({ messageDeliveries }) { function VendorAccounts({ vendorAccounts }) { return ( - [ , @@ -611,10 +609,10 @@ function VendorAccounts({ vendorAccounts }) { function MemberContacts({ memberContacts }) { return ( - [, row.formattedAddress]} /> diff --git a/adminapp/src/pages/OrganizationDetailPage.jsx b/adminapp/src/pages/OrganizationDetailPage.jsx index 18280d130..b2b4c7404 100644 --- a/adminapp/src/pages/OrganizationDetailPage.jsx +++ b/adminapp/src/pages/OrganizationDetailPage.jsx @@ -7,6 +7,7 @@ import { OrganizationMembershipVerifiedIcon, } from "../components/OrganizationMembership"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -39,7 +40,7 @@ export default function OrganizationDetailPage() { }, { label: "Roles", - children: model.roles.map((role) => ( + children: model.roles.items.map((role) => ( )), hideEmpty: true, @@ -47,14 +48,14 @@ export default function OrganizationDetailPage() { ]} > {(model) => [ - -  Memberships ({model.memberships.length}){" "} + Memberships } - rows={model.memberships} + collection={model.memberships} addNewLabel="Create another membership" addNewLink={createRelativeUrl(`/membership/new`, { organizationId: model.id, @@ -71,14 +72,14 @@ export default function OrganizationDetailPage() { formatDate(row.createdAt), ]} />, - -  Former Memberships ({model.formerMemberships.length}) + Former Memberships } - rows={model.formerMemberships} + collection={model.formerMemberships} headers={["Id", "Member", "Added At", "Removed At"]} keyRowAttr="id" toCells={(row) => [ diff --git a/adminapp/src/pages/PaymentLedgerDetailPage.jsx b/adminapp/src/pages/PaymentLedgerDetailPage.jsx index e8c21202c..fd6f730a5 100644 --- a/adminapp/src/pages/PaymentLedgerDetailPage.jsx +++ b/adminapp/src/pages/PaymentLedgerDetailPage.jsx @@ -2,6 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import LedgerBookTransactionsRelatedList from "../components/LedgerBookTransactionRelatedList"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import Money from "../shared/react/Money"; @@ -28,28 +29,28 @@ export default function PaymentLedgerDetailPage() { ]} > {(model) => [ - // [row.id, row.name, row.slug]} - // />, + [row.id, row.name, row.slug]} + />, , - // row.ledger.id} - // toCells={(row) => [ - // {row.ledger.label}, - // {row.amount}, - // ]} - // />, + row.ledger.id} + toCells={(row) => [ + {row.ledger.label}, + {row.amount}, + ]} + />, ]} ); diff --git a/lib/suma/admin_api/access.rb b/lib/suma/admin_api/access.rb index 7023ec6ca..1f2b51e38 100644 --- a/lib/suma/admin_api/access.rb +++ b/lib/suma/admin_api/access.rb @@ -29,6 +29,7 @@ class Suma::AdminAPI::Access Suma::Marketing::SmsBroadcast => [:marketing_sms_broadcast, MARKETING_SMS, MARKETING_SMS], Suma::Marketing::SmsDispatch => [:marketing_sms_dispatch, MARKETING_SMS, MARKETING_SMS], Suma::Member => [:member, MEMBERS, MEMBERS], + Suma::Member::Activity => [:member_activity, MEMBERS, MEMBERS], Suma::Member::Session => [:member_session, MEMBERS, MEMBERS], Suma::Message::Delivery => [:message_delivery, MEMBERS, MANAGEMENT], Suma::Mobility::Trip => [:mobility_trip, COMMERCE, MANAGEMENT], diff --git a/lib/suma/admin_api/anon_proxy_vendor_accounts.rb b/lib/suma/admin_api/anon_proxy_vendor_accounts.rb index 98598d11e..4fe84a3ba 100644 --- a/lib/suma/admin_api/anon_proxy_vendor_accounts.rb +++ b/lib/suma/admin_api/anon_proxy_vendor_accounts.rb @@ -6,10 +6,12 @@ class Suma::AdminAPI::AnonProxyVendorAccounts < Suma::AdminAPI::V1 include Suma::Service::Types include Suma::AdminAPI::Entities - class VendorAccountRegistrationEntity < BaseEntity + class VendorAccountRegistrationEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::AnonProxy::VendorAccountRegistration + expose :external_program_id expose :external_registration_id end diff --git a/lib/suma/admin_api/bank_accounts.rb b/lib/suma/admin_api/bank_accounts.rb index 07f043812..028680652 100644 --- a/lib/suma/admin_api/bank_accounts.rb +++ b/lib/suma/admin_api/bank_accounts.rb @@ -6,6 +6,7 @@ class Suma::AdminAPI::BankAccounts < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities class BankAccountEntity < PaymentInstrumentEntity + model Suma::Payment::BankAccount expose :verified_at expose :routing_number expose :masked_account_number diff --git a/lib/suma/admin_api/cards.rb b/lib/suma/admin_api/cards.rb index 4b4e9826d..3e91182ac 100644 --- a/lib/suma/admin_api/cards.rb +++ b/lib/suma/admin_api/cards.rb @@ -6,6 +6,7 @@ class Suma::AdminAPI::Cards < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities class CardEntity < PaymentInstrumentEntity + model Suma::Payment::Card expose :last4 expose :brand expose :exp_month diff --git a/lib/suma/admin_api/commerce_offering_products.rb b/lib/suma/admin_api/commerce_offering_products.rb index bddfb06ab..5c7b36401 100644 --- a/lib/suma/admin_api/commerce_offering_products.rb +++ b/lib/suma/admin_api/commerce_offering_products.rb @@ -6,17 +6,12 @@ class Suma::AdminAPI::CommerceOfferingProducts < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities - class DetailedCommerceOfferingProductEntity < BaseEntity + class DetailedCommerceOfferingProductEntity < OfferingProductEntity include Suma::AdminAPI::Entities - include AutoExposeBase include AutoExposeDetail expose :offering, with: OfferingEntity expose :product, with: ProductEntity - expose :customer_price, with: MoneyEntity - expose :undiscounted_price, with: MoneyEntity - expose :closed_at - expose_related :orders, with: OrderEntity end diff --git a/lib/suma/admin_api/commerce_orders.rb b/lib/suma/admin_api/commerce_orders.rb index a7c89c769..7bef16cd0 100644 --- a/lib/suma/admin_api/commerce_orders.rb +++ b/lib/suma/admin_api/commerce_orders.rb @@ -33,21 +33,20 @@ class CheckoutEntity < BaseEntity expose :fulfillment_option, with: OfferingFulfillmentOptionEntity end - class DetailedCommerceOrderEntity < BaseEntity + class OrderAuditLogEntity < AuditLogEntity + model Suma::Commerce::OrderAuditLog + end + + class DetailedCommerceOrderEntity < OrderEntity include Suma::AdminAPI::Entities - include AutoExposeBase include AutoExposeDetail - expose :order_status - expose :fulfillment_status - expose :admin_status_label, as: :status_label expose :serial expose :charge, with: ChargeWithPricesEntity - expose_related :audit_logs, with: AuditLogEntity + expose_related :audit_logs, with: OrderAuditLogEntity expose :offering, with: OfferingEntity, &self.delegate_to(:checkout, :cart, :offering) expose :checkout, with: CheckoutEntity expose :items, with: CheckoutItemEntity, &self.delegate_to(:checkout, :items) - expose :member, with: MemberEntity, &self.delegate_to(:checkout, :cart, :member) end resource :commerce_orders do diff --git a/lib/suma/admin_api/common_endpoints.rb b/lib/suma/admin_api/common_endpoints.rb index c80f145c6..1594489fd 100644 --- a/lib/suma/admin_api/common_endpoints.rb +++ b/lib/suma/admin_api/common_endpoints.rb @@ -238,6 +238,19 @@ def self.list( end end + def self.get_one(route_def, model_type, entity, expose_related: true) + route_def.instance_exec do + route_param :id, type: Integer do + get do + check_admin_role_access!(:read, model_type) + (m = model_type[params[:id]]) or forbidden! + present m, with: entity + end + end + end + self.all_related(route_def, entity) if expose_related + end + def self.related( route_def, model_type, @@ -263,15 +276,15 @@ def self.related( end end - def self.get_one(route_def, model_type, entity) - route_def.instance_exec do - route_param :id, type: Integer do - get do - check_admin_role_access!(:read, model_type) - (m = model_type[params[:id]]) or forbidden! - present m, with: entity - end - end + def self.all_related(route_def, entity) + entity.exposed_related.each do |h| + self.related( + route_def, + entity.model, + h.fetch(:with).model, + h.fetch(:with), + h.fetch(:name), + ) end end diff --git a/lib/suma/admin_api/entities.rb b/lib/suma/admin_api/entities.rb index 9d85f1618..23756c39d 100644 --- a/lib/suma/admin_api/entities.rb +++ b/lib/suma/admin_api/entities.rb @@ -34,6 +34,26 @@ def expose_image(name, &block) img&.caption end end + end + end + + # Base class for models. Allows exposure of related associations via #expore_related, + # and automatic route create during CommonEndpoints.get_one. + class BaseModelEntity < BaseEntity + class << self + attr_accessor :exposed_related + + def inherited(subclass) + super + subclass.exposed_related = self.exposed_related.dup + subclass.model(self.model) + end + + def model(type=nil) + return @model if type.nil? + @model = type + @exposed_related = [] + end # Expose a list field of this entity. # The field is exposed with a Collection entity so it can be paginated. @@ -45,19 +65,26 @@ def expose_image(name, &block) # the dataset for this exposure. # Otherwise, _dataset is used if defined, otherwise is assumed to be an association # and its configured dataset method is called. - def expose_related(name, with:, as: nil, dataset_method: nil) + def expose_related(name, with:, as: nil, dataset_method: nil, all: false) collection_entity = Suma::Service::Collection.prepare_entity(with) + ds_method = (dataset_method || "#{name}_dataset").to_sym + unless self.model.method_defined?(ds_method) + raise ArgumentError, "must call #model before using expose_related, got: #{self.model.inspect}" unless + self.model.respond_to?(:association_reflections) + assoc = self.model.association_reflections[name] + raise ArgumentError, "#{self.model} does not has association #{name} or dataset #{ds_method}" if assoc.nil? + ds_method = assoc.fetch(:dataset_method) + end + @exposed_related << {name:, with:} self.expose(name, as:, with: collection_entity) do |instance, options| - ds_method = dataset_method || "#{name}_dataset" - unless instance.respond_to?(ds_method) - assoc = instance.class.association_reflections[name] - raise ArgumentError, "#{instance} does not has association #{name} or dataset #{ds_method}" if assoc.nil? - ds_method = assoc.fetch(:dataset_method) - end ds = instance.send(ds_method) - ds = ds.paginate(1, Suma::Service.related_list_size) - collection = Suma::Service::Collection.from_dataset(ds) - collection.url = options[:env].fetch("REQUEST_PATH") + "/#{name}" + if all + collection = Suma::Service::Collection.from_array(ds.all) + else + ds = ds.paginate(1, Suma::Service.related_list_size) + collection = Suma::Service::Collection.from_dataset(ds) + end + collection.url = Suma::Service.request_path(options[:env]) + "/#{name}" collection end end @@ -106,21 +133,24 @@ class CurrentMemberEntity < Suma::Service::Entities::CurrentMember end end - class RoleEntity < BaseEntity + class RoleEntity < BaseModelEntity include AutoExposeBase + model Suma::Role expose :name end - class OrganizationEntity < BaseEntity + class OrganizationEntity < BaseModelEntity include AutoExposeBase + model Suma::Organization expose :name end - class PaymentInstrumentEntity < BaseEntity + class PaymentInstrumentEntity < BaseModelEntity include AutoExposeBase + model Suma::Payment::Instrument expose :payment_method_type expose :legal_entity, with: LegalEntityEntity expose :institution_name @@ -136,7 +166,7 @@ class AuditMemberEntity < BaseEntity expose :admin_link end - class AuditLogEntity < BaseEntity + class AuditLogEntity < BaseModelEntity expose :id expose :at expose :event @@ -147,20 +177,22 @@ class AuditLogEntity < BaseEntity expose :actor, with: AuditMemberEntity end - class SupportNoteEntity < BaseEntity + class SupportNoteEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Support::Note expose :author, with: AuditMemberEntity expose :authored_at expose :content expose :content_html end - class ActivityEntity < BaseEntity + class ActivityEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Member::Activity expose :member, with: AuditMemberEntity expose :message_name expose :message_vars @@ -168,9 +200,10 @@ class ActivityEntity < BaseEntity expose :summary_md end - class MemberEntity < BaseEntity + class MemberEntity < BaseModelEntity include AutoExposeBase + model Suma::Member expose :email expose :name expose :phone @@ -179,9 +212,10 @@ class MemberEntity < BaseEntity expose :onboarding_verified_at end - class MessageDeliveryEntity < BaseEntity + class MessageDeliveryEntity < BaseModelEntity include AutoExposeBase + model Suma::Message::Delivery expose :template expose :transport_type expose :carrier_key @@ -193,9 +227,10 @@ class MessageDeliveryEntity < BaseEntity expose :recipient, with: MemberEntity end - class ProgramEntity < BaseEntity + class ProgramEntity < BaseModelEntity include AutoExposeBase + model Suma::Program expose :name, with: TranslatedTextEntity expose :description, with: TranslatedTextEntity expose :period_begin @@ -205,37 +240,42 @@ class ProgramEntity < BaseEntity expose :app_link_text, with: TranslatedTextEntity end - class EligibilityAttributeEntity < BaseEntity + class EligibilityAttributeEntity < BaseModelEntity include AutoExposeBase + model Suma::Eligibility::Attribute expose :name expose :parent, with: self end - class EligibilityAssignmentEntity < BaseEntity + class EligibilityAssignmentEntity < BaseModelEntity include AutoExposeBase + model Suma::Eligibility::Assignment expose :assignee, with: AutoExposedBaseEntity expose :assignee_label expose :assignee_type expose :attribute, with: EligibilityAttributeEntity end - class EligibilityRequirementEntity < BaseEntity + class EligibilityRequirementEntity < BaseModelEntity include AutoExposeBase + model Suma::Eligibility::Requirement expose :cached_expression_string, as: :expression_formula_str end - class VendorEntity < BaseEntity + class VendorEntity < BaseModelEntity include AutoExposeBase + model Suma::Vendor expose :name end - class VendorServiceEntity < BaseEntity + class VendorServiceEntity < BaseModelEntity include AutoExposeBase + model Suma::Vendor::Service expose :internal_name expose :external_name expose :vendor, with: VendorEntity @@ -243,9 +283,10 @@ class VendorServiceEntity < BaseEntity expose :period_end end - class VendorServiceCategoryTerminalEntity < BaseEntity + class VendorServiceCategoryTerminalEntity < BaseModelEntity include AutoExposeBase + model Suma::Vendor::ServiceCategory expose :name expose :slug end @@ -259,9 +300,10 @@ class VendorServiceRateUndiscountedrateEntity < BaseEntity expose :internal_name end - class VendorServiceRateEntity < BaseEntity + class VendorServiceRateEntity < BaseModelEntity include AutoExposeBase + model Suma::Vendor::ServiceRate expose :internal_name expose :external_name expose :unit_amount, with: MoneyEntity @@ -269,27 +311,30 @@ class VendorServiceRateEntity < BaseEntity expose :undiscounted_rate, with: VendorServiceRateUndiscountedrateEntity end - class ProgramPricingEntity < BaseEntity + class ProgramPricingEntity < BaseModelEntity include AutoExposeBase + model Suma::Program::Pricing expose :program, with: ProgramEntity expose :vendor_service, with: VendorServiceEntity expose :vendor_service_rate, with: VendorServiceRateEntity end - class AnonProxyVendorConfigurationEntity < BaseEntity + class AnonProxyVendorConfigurationEntity < BaseModelEntity include AutoExposeBase + model Suma::AnonProxy::VendorConfiguration expose :vendor, with: VendorEntity expose :app_install_link expose :auth_to_vendor_key expose :enabled end - class AnonProxyMemberContactEntity < BaseEntity + class AnonProxyMemberContactEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::AnonProxy::MemberContact expose :member, with: MemberEntity expose :formatted_address expose :relay_key @@ -302,18 +347,20 @@ class AnonProxyVendorAccountMemberContactEntity < BaseEntity expose :formatted_address end - class AnonProxyVendorAccountEntity < BaseEntity + class AnonProxyVendorAccountEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::AnonProxy::VendorAccount expose :member, with: MemberEntity expose :configuration, with: AnonProxyVendorConfigurationEntity expose :contact, with: AnonProxyVendorAccountMemberContactEntity end - class ChargeEntity < BaseEntity + class ChargeEntity < BaseModelEntity include AutoExposeBase + model Suma::Charge expose :opaque_id expose :discounted_subtotal, with: MoneyEntity expose :undiscounted_subtotal, with: MoneyEntity @@ -325,9 +372,10 @@ class ChargeWithPricesEntity < ChargeEntity expose :noncash_paid_from_ledger, with: MoneyEntity end - class MobilityTripEntity < BaseEntity + class MobilityTripEntity < BaseModelEntity include AutoExposeBase + model Suma::Mobility::Trip expose :vehicle_id expose :begin_lat expose :begin_lng @@ -360,26 +408,29 @@ class PaymentStrategyEntity < BaseEntity expose :admin_details_typed, as: :admin_details end - class FundingTransactionEntity < BaseEntity + class FundingTransactionEntity < BaseModelEntity include AutoExposeBase + model Suma::Payment::FundingTransaction expose :status expose :amount, with: MoneyEntity expose :originating_payment_account, with: SimplePaymentAccountEntity end - class PayoutTransactionEntity < BaseEntity + class PayoutTransactionEntity < BaseModelEntity include AutoExposeBase + model Suma::Payment::PayoutTransaction expose :status expose :classification expose :amount, with: MoneyEntity expose :originating_payment_account, with: SimplePaymentAccountEntity end - class BookTransactionEntity < BaseEntity + class BookTransactionEntity < BaseModelEntity include AutoExposeBase + model Suma::Payment::BookTransaction expose :apply_at expose :amount, with: MoneyEntity expose :memo, with: TranslatedTextEntity @@ -389,46 +440,50 @@ class BookTransactionEntity < BaseEntity expose :actor, with: AuditMemberEntity end - class DetailedPaymentAccountLedgerEntity < BaseEntity + class DetailedPaymentAccountLedgerEntity < BaseModelEntity include AutoExposeBase include AutoExposeDetail + model Suma::Payment::Ledger expose :currency expose_related :vendor_service_categories, with: VendorServiceCategoryEntity expose_related :combined_book_transactions, with: BookTransactionEntity expose :balance, with: MoneyEntity end - class DetailedPaymentAccountEntity < BaseEntity + class DetailedPaymentAccountEntity < BaseModelEntity include AutoExposeBase include AutoExposeDetail + model Suma::Payment::Account expose :member, with: MemberEntity expose :vendor, with: VendorEntity expose :is_platform_account - expose_related :ledgers, with: DetailedPaymentAccountLedgerEntity + expose :ledgers, with: DetailedPaymentAccountLedgerEntity expose :total_balance, with: MoneyEntity expose_related :originated_funding_transactions, with: FundingTransactionEntity expose_related :originated_payout_transactions, with: PayoutTransactionEntity end - class PaymentTriggerEntity < BaseEntity + class PaymentTriggerEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Payment::Trigger expose :active_during_begin expose :active_during_end end - class OfferingEntity < BaseEntity + class OfferingEntity < BaseModelEntity include AutoExposeBase + model Suma::Commerce::Offering expose :description, with: TranslatedTextEntity expose :period_end expose :period_begin end - class OfferingFulfillmentOptionEntity < BaseEntity + class OfferingFulfillmentOptionEntity < BaseModelEntity include AutoExposeBase expose :description, with: TranslatedTextEntity @@ -438,9 +493,10 @@ class OfferingFulfillmentOptionEntity < BaseEntity expose :address, with: AddressEntity, safe: true end - class OfferingProductEntity < BaseEntity + class OfferingProductEntity < BaseModelEntity include AutoExposeBase + model Suma::Commerce::Offering expose :closed_at expose :product_id expose_translated :product_name, &self.delegate_to(:product, :name) @@ -450,17 +506,19 @@ class OfferingProductEntity < BaseEntity expose :closed?, as: :is_closed end - class ProductEntity < BaseEntity + class ProductEntity < BaseModelEntity include AutoExposeBase + model Suma::Commerce::Product expose :vendor, with: VendorEntity expose :name, with: TranslatedTextEntity expose :description, with: TranslatedTextEntity end - class OrderEntity < BaseEntity + class OrderEntity < BaseModelEntity include AutoExposeBase + model Suma::Commerce::Order expose :order_status expose :fulfillment_status expose :admin_status_label, as: :status_label @@ -476,9 +534,10 @@ class BaseOrganizationMembershipVerificationEntity < BaseEntity expose :owner, with: MemberEntity end - class OrganizationMembershipEntity < BaseEntity + class OrganizationMembershipEntity < BaseModelEntity include AutoExposeBase + model Suma::Organization::Membership expose :member, with: MemberEntity expose :verified_organization, with: OrganizationEntity expose :unverified_organization_name @@ -489,17 +548,19 @@ class OrganizationMembershipEntity < BaseEntity expose :verification, with: BaseOrganizationMembershipVerificationEntity end - class OrganizationMembershipVerificationEntity < BaseEntity + class OrganizationMembershipVerificationEntity < BaseModelEntity include AutoExposeBase + model Suma::Organization::Membership::Verification expose :status expose :membership, with: OrganizationMembershipEntity expose :owner, with: MemberEntity end - class OrganizationRegistrationLinkEntity < BaseEntity + class OrganizationRegistrationLinkEntity < BaseModelEntity include AutoExposeBase + model Suma::Organization::RegistrationLink expose :organization, with: OrganizationEntity expose :opaque_id expose :ical_dtstart @@ -507,9 +568,10 @@ class OrganizationRegistrationLinkEntity < BaseEntity expose :ical_rrule end - class ChargeLineItemEntity < BaseEntity + class ChargeLineItemEntity < BaseModelEntity include AutoExposeBase + model Suma::Charge::LineItem expose :charge_id expose :amount, with: MoneyEntity expose :memo, with: TranslatedTextEntity @@ -523,22 +585,25 @@ class MarketingMemberEntity < MemberEntity expose :admin_link end - class MarketingListEntity < BaseEntity + class MarketingListEntity < BaseModelEntity include AutoExposeBase + model Suma::Marketing::List expose :managed end - class MarketingSmsBroadcastEntity < BaseEntity + class MarketingSmsBroadcastEntity < BaseModelEntity include AutoExposeBase + model Suma::Marketing::SmsBroadcast expose :sent_at end - class MarketingSmsDispatchEntity < BaseEntity + class MarketingSmsDispatchEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Marketing::SmsDispatch expose :member, with: MarketingMemberEntity expose :sms_broadcast, with: MarketingSmsBroadcastEntity expose :sent_at diff --git a/lib/suma/admin_api/financials.rb b/lib/suma/admin_api/financials.rb index a2342e83c..d6723ec62 100644 --- a/lib/suma/admin_api/financials.rb +++ b/lib/suma/admin_api/financials.rb @@ -27,9 +27,10 @@ class OffPlatformTransactionEntity < BaseEntity expose :check_or_transaction_number, &self.delegate_to(:strategy, :check_or_transaction_number) end - class PlatformStatusEntity < BaseEntity + class PlatformStatusEntity < BaseModelEntity include Suma::AdminAPI::Entities + model Suma::Payment::PlatformStatus expose :funding, with: MoneyEntity expose :funding_count expose :payouts, with: MoneyEntity diff --git a/lib/suma/admin_api/members.rb b/lib/suma/admin_api/members.rb index 0d4faf5e5..932679df2 100644 --- a/lib/suma/admin_api/members.rb +++ b/lib/suma/admin_api/members.rb @@ -8,10 +8,11 @@ class Suma::AdminAPI::Members < Suma::AdminAPI::V1 include Suma::Service::Types include Suma::AdminAPI::Entities - class MemberResetCodeEntity < BaseEntity + class MemberResetCodeEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Member::ResetCode expose :transport expose :used expose :expire_at @@ -23,10 +24,11 @@ class MemberResetCodeEntity < BaseEntity expose :message_delivery, with: MessageDeliveryEntity end - class MemberSessionEntity < BaseEntity + class MemberSessionEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Member::Session expose :user_agent expose :peer_ip, &self.delegate_to(:peer_ip, :to_s) expose :logged_out_at @@ -42,21 +44,13 @@ class MemberOrderEntity < OrderEntity expose :offering, with: OfferingEntity, &self.delegate_to(:checkout, :cart, :offering) end - class MemberContactEntity < BaseEntity - include Suma::AdminAPI::Entities - include AutoExposeBase - - expose :formatted_address - end - - class MemberVendorAccountEntity < BaseEntity + class MemberVendorAccountEntity < AnonProxyVendorAccountEntity include Suma::AdminAPI::Entities include AutoExposeBase expose :latest_access_code expose :latest_access_code_magic_link expose :vendor, with: VendorEntity, &self.delegate_to(:configuration, :vendor) - expose :contact, with: MemberContactEntity end class PreferencesSubscriptionEntity < BaseEntity @@ -65,7 +59,11 @@ class PreferencesSubscriptionEntity < BaseEntity expose :editable_state end - class PreferencesEntity < BaseEntity + class PreferencesEntity < BaseModelEntity + include Suma::AdminAPI::Entities + include AutoExposeBase + + model Suma::Message::Preferences expose :public_url expose :subscriptions, with: PreferencesSubscriptionEntity expose :preferred_language_name @@ -73,18 +71,20 @@ class PreferencesEntity < BaseEntity expose :sms_undeliverable?, as: :sms_undeliverable end - class ReferralEntity < BaseEntity + class ReferralEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Member::Referral expose :source expose :campaign expose :medium end - class EligibilityMemberAssignmentEntity < BaseEntity + class EligibilityMemberAssignmentEntity < BaseModelEntity include Suma::AdminAPI::Entities + model Suma::Eligibility::MemberAssignment expose :unique_key expose :member, with: MemberEntity expose :attribute, with: EligibilityAttributeEntity @@ -123,7 +123,7 @@ class DetailedMemberEntity < MemberEntity expose_related :combined_notes, as: :notes, with: SupportNoteEntity expose :preferences!, as: :preferences, with: PreferencesEntity expose_related :anon_proxy_vendor_accounts, as: :vendor_accounts, with: MemberVendorAccountEntity - expose_related :anon_proxy_contacts, as: :member_contacts, with: MemberContactEntity + expose_related :anon_proxy_contacts, as: :member_contacts, with: AnonProxyMemberContactEntity expose_related :organization_memberships, with: OrganizationMembershipEntity expose_related :marketing_lists, with: MarketingListEntity expose_related :marketing_sms_dispatches, with: MarketingSmsDispatchEntity diff --git a/lib/suma/admin_api/off_platform_transactions.rb b/lib/suma/admin_api/off_platform_transactions.rb index eeeb3e3e7..bc119d1fd 100644 --- a/lib/suma/admin_api/off_platform_transactions.rb +++ b/lib/suma/admin_api/off_platform_transactions.rb @@ -5,16 +5,17 @@ class Suma::AdminAPI::OffPlatformTransactions < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities - class DetailedOffPlatformTransactionEntity < BaseEntity + class DetailedOffPlatformTransactionEntity < BaseModelEntity include Suma::AdminAPI::Entities - include AutoExposeDetail + include AutoExposeBase include AutoExposeDetail + model Suma::Payment::OffPlatformStrategy expose :funding_transaction, with: FundingTransactionEntity expose :payout_transaction, with: PayoutTransactionEntity - expose :transaction_admin_link, &self.delegate_to(:transaction, :admin_link) + expose :transaction_admin_link, &self.delegate_to(:transaction, :admin_link, safe: true) expose :type - expose :amount, with: MoneyEntity, &self.delegate_to(:transaction, :amount) + expose :amount, with: MoneyEntity, &self.delegate_to(:transaction, :amount, safe: true) expose :transacted_at expose :note expose :check_or_transaction_number diff --git a/lib/suma/admin_api/organization_membership_verifications.rb b/lib/suma/admin_api/organization_membership_verifications.rb index 3ac00dbf9..5ccf234fc 100644 --- a/lib/suma/admin_api/organization_membership_verifications.rb +++ b/lib/suma/admin_api/organization_membership_verifications.rb @@ -16,7 +16,7 @@ class VerificationListEntity < OrganizationMembershipVerificationEntity expose :available_events, &self.delegate_to(:state_machine, :available_events) expose :front_partner_conversation_status expose :front_member_conversation_status - expose_related :combined_notes, as: :notes, with: SupportNoteEntity + expose_related :combined_notes, as: :notes, with: SupportNoteEntity, all: true expose :duplicate_risk end @@ -100,6 +100,13 @@ class DetailedMembershipVerificationEntity < VerificationListEntity Suma::Organization::Membership::Verification, DetailedMembershipVerificationEntity, ) + Suma::AdminAPI::CommonEndpoints.related( + self, + Suma::Organization::Membership::Verification, + Suma::Support::Note, + SupportNoteEntity, + :combined_notes, + ) Suma::AdminAPI::CommonEndpoints.update( self, diff --git a/lib/suma/admin_api/payment_ledgers.rb b/lib/suma/admin_api/payment_ledgers.rb index f9ff7c0a2..8a84db2ac 100644 --- a/lib/suma/admin_api/payment_ledgers.rb +++ b/lib/suma/admin_api/payment_ledgers.rb @@ -7,10 +7,11 @@ class Suma::AdminAPI::PaymentLedgers < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities - class LedgerEntity < BaseEntity + class LedgerEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Payment::Ledger expose :name expose :is_platform_account, &self.delegate_to(:account, :is_platform_account) expose :currency diff --git a/lib/suma/admin_api/payment_triggers.rb b/lib/suma/admin_api/payment_triggers.rb index a68d77efa..b4dccab34 100644 --- a/lib/suma/admin_api/payment_triggers.rb +++ b/lib/suma/admin_api/payment_triggers.rb @@ -7,11 +7,11 @@ class Suma::AdminAPI::PaymentTriggers < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities - class PaymentTriggerExecutionEntity < BaseEntity + class PaymentTriggerExecutionEntity < BaseModelEntity include Suma::AdminAPI::Entities + include AutoExposeBase - expose :id - expose :admin_link, &self.delegate_to(:book_transaction, :admin_link) + model Suma::Payment::Trigger::Execution expose :book_transaction_id expose :at, &self.delegate_to(:book_transaction, :created_at) expose :receiving_ledger, with: SimpleLedgerEntity, &self.delegate_to(:book_transaction, :receiving_ledger) diff --git a/lib/suma/admin_api/payout_transactions.rb b/lib/suma/admin_api/payout_transactions.rb index b57450d13..1df4ae36a 100644 --- a/lib/suma/admin_api/payout_transactions.rb +++ b/lib/suma/admin_api/payout_transactions.rb @@ -7,6 +7,10 @@ class Suma::AdminAPI::PayoutTransactions < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities + class PayoutAuditLogEntity < AuditLogEntity + model Suma::Payment::PayoutTransaction::AuditLog + end + class DetailedPayoutTransactionEntity < PayoutTransactionEntity include Suma::AdminAPI::Entities include AutoExposeDetail @@ -18,7 +22,7 @@ class DetailedPayoutTransactionEntity < PayoutTransactionEntity expose :reversal_book_transaction, with: BookTransactionEntity expose :refunded_funding_transaction, with: FundingTransactionEntity expose_related :audit_activities, with: ActivityEntity - expose_related :audit_logs, with: AuditLogEntity + expose_related :audit_logs, with: PayoutAuditLogEntity expose :strategy, with: PaymentStrategyEntity end diff --git a/lib/suma/service.rb b/lib/suma/service.rb index 28b85aec0..b2da4290e 100644 --- a/lib/suma/service.rb +++ b/lib/suma/service.rb @@ -105,6 +105,10 @@ def self.encode_cookie(h) return s end + def self.request_path(env) + return env["SCRIPT_NAME"].to_s + env["PATH_INFO"].to_s + end + # Build the Rack app according to the configured environment. # Call build_app_pre and build_app_post with the Rack::Builder, # in case the app needs to run additional middleware. diff --git a/lib/suma/service/collection.rb b/lib/suma/service/collection.rb index d2abb7491..ed57d60ac 100644 --- a/lib/suma/service/collection.rb +++ b/lib/suma/service/collection.rb @@ -25,7 +25,7 @@ class BaseEntity < Suma::Service::Entities::Base expose :total_count expose :more?, as: :has_more expose :url do |inst, opts| - inst.url || opts[:env].fetch("REQUEST_PATH") + inst.url || Suma::Service.request_path(opts[:env]) end # expose :items do |_| # raise "this must be exposed by the subclass, like: `expose :items, with: MyEntity`" diff --git a/spec/suma/admin_api/anon_proxy_member_contacts_spec.rb b/spec/suma/admin_api/anon_proxy_member_contacts_spec.rb index 1aa9a8122..0268c76e1 100644 --- a/spec/suma/admin_api/anon_proxy_member_contacts_spec.rb +++ b/spec/suma/admin_api/anon_proxy_member_contacts_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/anon_proxy_member_contacts/#{Suma::Fixtures.anon_proxy_member_contact.create.id}" + end + end + describe "GET /v1/anon_proxy_member_contacts" do it "returns all anon proxy vendor accounts" do objs = Array.new(2) { Suma::Fixtures.anon_proxy_member_contact.create } diff --git a/spec/suma/admin_api/anon_proxy_vendor_accounts_spec.rb b/spec/suma/admin_api/anon_proxy_vendor_accounts_spec.rb index 58a1b64b4..f0f742ce7 100644 --- a/spec/suma/admin_api/anon_proxy_vendor_accounts_spec.rb +++ b/spec/suma/admin_api/anon_proxy_vendor_accounts_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/anon_proxy_vendor_accounts/#{Suma::Fixtures.anon_proxy_vendor_account.create.id}" + end + end + describe "GET /v1/anon_proxy_vendor_accounts" do it "returns all anon proxy vendor accounts" do objs = Array.new(2) { Suma::Fixtures.anon_proxy_vendor_account.create } diff --git a/spec/suma/admin_api/anon_proxy_vendor_configurations_spec.rb b/spec/suma/admin_api/anon_proxy_vendor_configurations_spec.rb index 76946a9a4..4a5f3d680 100644 --- a/spec/suma/admin_api/anon_proxy_vendor_configurations_spec.rb +++ b/spec/suma/admin_api/anon_proxy_vendor_configurations_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/anon_proxy_vendor_configurations/#{Suma::Fixtures.anon_proxy_vendor_configuration.create.id}" + end + end + describe "GET /v1/anon_proxy_vendor_configurations" do it "returns all anon proxy vendor configurations" do objs = Array.new(2) { Suma::Fixtures.anon_proxy_vendor_configuration.create } diff --git a/spec/suma/admin_api/bank_accounts_spec.rb b/spec/suma/admin_api/bank_accounts_spec.rb index 5b610771f..0a3c9df08 100644 --- a/spec/suma/admin_api/bank_accounts_spec.rb +++ b/spec/suma/admin_api/bank_accounts_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/bank_accounts/#{Suma::Fixtures.bank_account.create.id}" + end + end + describe "GET /v1/bank_accounts" do it "returns all bank_accounts" do c = Array.new(2) { Suma::Fixtures.bank_account.create } diff --git a/spec/suma/admin_api/book_transactions_spec.rb b/spec/suma/admin_api/book_transactions_spec.rb index 871a65a7b..d143b4fda 100644 --- a/spec/suma/admin_api/book_transactions_spec.rb +++ b/spec/suma/admin_api/book_transactions_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/book_transactions/#{Suma::Fixtures.book_transaction.create.id}" + end + end + describe "GET /v1/book_transactions" do it "returns all transactions" do u = Array.new(2) { Suma::Fixtures.book_transaction.create } diff --git a/spec/suma/admin_api/cards_spec.rb b/spec/suma/admin_api/cards_spec.rb index d5293631f..121bfed2d 100644 --- a/spec/suma/admin_api/cards_spec.rb +++ b/spec/suma/admin_api/cards_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/cards/#{Suma::Fixtures.card.create.id}" + end + end + describe "GET /v1/cards" do it "returns all cards" do c = Array.new(2) { Suma::Fixtures.card.create } diff --git a/spec/suma/admin_api/charges_spec.rb b/spec/suma/admin_api/charges_spec.rb index 3d7cc775d..5cc2e6c88 100644 --- a/spec/suma/admin_api/charges_spec.rb +++ b/spec/suma/admin_api/charges_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/charges/#{Suma::Fixtures.charge.create.id}" + end + end + describe "GET /v1/charges" do it "returns all charges" do c = Array.new(2) { Suma::Fixtures.charge.create } diff --git a/spec/suma/admin_api/commerce_offering_products_spec.rb b/spec/suma/admin_api/commerce_offering_products_spec.rb index 8bf469287..0807b05b8 100644 --- a/spec/suma/admin_api/commerce_offering_products_spec.rb +++ b/spec/suma/admin_api/commerce_offering_products_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/commerce_offering_products/#{Suma::Fixtures.offering_product.create.id}" + end + end + describe "POST /v1/commerce_offering_products/create" do it "creates the offering product" do o = Suma::Fixtures.offering.create diff --git a/spec/suma/admin_api/commerce_offerings_spec.rb b/spec/suma/admin_api/commerce_offerings_spec.rb index d68a9f33e..bde590cbc 100644 --- a/spec/suma/admin_api/commerce_offerings_spec.rb +++ b/spec/suma/admin_api/commerce_offerings_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/commerce_offerings/#{Suma::Fixtures.offering.create.id}" + end + end + describe "GET /v1/commerce_offerings" do it "returns all offerings" do objs = Array.new(2) { Suma::Fixtures.offering.create } diff --git a/spec/suma/admin_api/commerce_orders_spec.rb b/spec/suma/admin_api/commerce_orders_spec.rb index b0ad67a73..6140f7a1b 100644 --- a/spec/suma/admin_api/commerce_orders_spec.rb +++ b/spec/suma/admin_api/commerce_orders_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/commerce_orders/#{Suma::Fixtures.order.create.id}" + end + end + describe "GET /v1/commerce_orders" do it "returns all orders" do objs = Array.new(2) { Suma::Fixtures.order.create } diff --git a/spec/suma/admin_api/commerce_products_spec.rb b/spec/suma/admin_api/commerce_products_spec.rb index 50b1363bd..ab92d57fd 100644 --- a/spec/suma/admin_api/commerce_products_spec.rb +++ b/spec/suma/admin_api/commerce_products_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/commerce_products/#{Suma::Fixtures.product.create.id}" + end + end + describe "GET /v1/commerce_products" do it "returns all products" do objs = Array.new(2) { Suma::Fixtures.product.create } diff --git a/spec/suma/admin_api/eligibility_assignments_spec.rb b/spec/suma/admin_api/eligibility_assignments_spec.rb index ef60fe6dc..accf17b5a 100644 --- a/spec/suma/admin_api/eligibility_assignments_spec.rb +++ b/spec/suma/admin_api/eligibility_assignments_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/eligibility_assignments/#{Suma::Fixtures.eligibility_assignment.create.id}" + end + end + describe "GET /v1/eligibility_assignments" do it "returns all instances" do objs = Array.new(2) { Suma::Fixtures.eligibility_assignment.to(Suma::Fixtures.member.create).create } diff --git a/spec/suma/admin_api/eligibility_attributes_spec.rb b/spec/suma/admin_api/eligibility_attributes_spec.rb index 4b60142bd..5b269cb80 100644 --- a/spec/suma/admin_api/eligibility_attributes_spec.rb +++ b/spec/suma/admin_api/eligibility_attributes_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/eligibility_attributes/#{Suma::Fixtures.eligibility_attribute.create.id}" + end + end + describe "GET /v1/eligibility_attributes" do it "returns all rows" do objs = Array.new(2) { Suma::Fixtures.eligibility_attribute.create } diff --git a/spec/suma/admin_api/eligibility_requirements_spec.rb b/spec/suma/admin_api/eligibility_requirements_spec.rb index b7d074538..70fe9e3ae 100644 --- a/spec/suma/admin_api/eligibility_requirements_spec.rb +++ b/spec/suma/admin_api/eligibility_requirements_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/eligibility_requirements/#{Suma::Fixtures.eligibility_requirement.create.id}" + end + end + describe "GET /v1/eligibility_requirements" do it "returns all instances" do objs = Array.new(2) { Suma::Fixtures.eligibility_requirement.create } diff --git a/spec/suma/admin_api/funding_transactions_spec.rb b/spec/suma/admin_api/funding_transactions_spec.rb index c7505433a..baabc83bb 100644 --- a/spec/suma/admin_api/funding_transactions_spec.rb +++ b/spec/suma/admin_api/funding_transactions_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/funding_transactions/#{Suma::Fixtures.funding_transaction.with_fake_strategy.create.id}" + end + end + describe "GET /v1/funding_transactions" do it "returns all transactions" do u = Array.new(2) { Suma::Fixtures.funding_transaction.with_fake_strategy.create } diff --git a/spec/suma/admin_api/marketing_lists_spec.rb b/spec/suma/admin_api/marketing_lists_spec.rb index b630693f7..59283c0cf 100644 --- a/spec/suma/admin_api/marketing_lists_spec.rb +++ b/spec/suma/admin_api/marketing_lists_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/marketing_lists/#{Suma::Fixtures.marketing_list.create.id}" + end + end + describe "GET /v1/marketing_lists" do it "returns all objects" do u = Array.new(2) { Suma::Fixtures.marketing_list.create } diff --git a/spec/suma/admin_api/marketing_sms_broadcasts_spec.rb b/spec/suma/admin_api/marketing_sms_broadcasts_spec.rb index 99c40b221..57e40365a 100644 --- a/spec/suma/admin_api/marketing_sms_broadcasts_spec.rb +++ b/spec/suma/admin_api/marketing_sms_broadcasts_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/marketing_sms_broadcasts/#{Suma::Fixtures.marketing_sms_broadcast.create.id}" + end + end + describe "GET /v1/marketing_sms_broadcasts" do it "returns all objects" do u = Array.new(2) { Suma::Fixtures.marketing_sms_broadcast.create } diff --git a/spec/suma/admin_api/marketing_sms_dispatches_spec.rb b/spec/suma/admin_api/marketing_sms_dispatches_spec.rb index 19b53e50a..056c4a6a5 100644 --- a/spec/suma/admin_api/marketing_sms_dispatches_spec.rb +++ b/spec/suma/admin_api/marketing_sms_dispatches_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/marketing_sms_dispatches/#{Suma::Fixtures.marketing_sms_dispatch.create.id}" + end + end + describe "GET /v1/marketing_sms_dispatches" do it "returns all objects" do u = Array.new(2) { Suma::Fixtures.marketing_sms_dispatch.create } diff --git a/spec/suma/admin_api/members_spec.rb b/spec/suma/admin_api/members_spec.rb index d3a5956e4..67417bedb 100644 --- a/spec/suma/admin_api/members_spec.rb +++ b/spec/suma/admin_api/members_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/members/#{Suma::Fixtures.member.create.id}" + end + end + describe "GET /v1/members" do it "returns all members" do u = Array.new(2) { Suma::Fixtures.member.create } diff --git a/spec/suma/admin_api/message_deliveries_spec.rb b/spec/suma/admin_api/message_deliveries_spec.rb index 31bfd8b80..de681f224 100644 --- a/spec/suma/admin_api/message_deliveries_spec.rb +++ b/spec/suma/admin_api/message_deliveries_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/message_deliveries/#{Suma::Fixtures.message_delivery.create.id}" + end + end + describe "GET /v1/message_deliveries" do it "returns all deliveries (no bodies)" do deliveries = Array.new(2) { Suma::Fixtures.message_delivery.create } diff --git a/spec/suma/admin_api/mobility_spec.rb b/spec/suma/admin_api/mobility_spec.rb deleted file mode 100644 index 98dc7eb26..000000000 --- a/spec/suma/admin_api/mobility_spec.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -require "suma/admin_api/mobility_trips" -require "suma/api/behaviors" - -RSpec.describe Suma::AdminAPI::MobilityTrips, :db do - include Rack::Test::Methods - - let(:app) { described_class.build_app } - let(:admin) { Suma::Fixtures.member.admin.create } - - before(:each) do - login_as(admin) - end - - describe "GET /v1/mobility_trips" do - it "returns all mobility trips" do - objs = Array.new(2) { Suma::Fixtures.mobility_trip.create } - - get "/v1/mobility_trips" - - expect(last_response).to have_status(200) - expect(last_response).to have_json_body. - that_includes(items: have_same_ids_as(*objs)) - end - - it_behaves_like "an endpoint capable of search" do - let(:url) { "/v1/mobility_trips" } - let(:search_term) { "zzz" } - - def make_matching_items - return [ - Suma::Fixtures.mobility_trip(external_trip_id: "zzz").create, - ] - end - - def make_non_matching_items - return [ - Suma::Fixtures.mobility_trip(external_trip_id: translated_text("wibble wobble")).create, - ] - end - end - - it_behaves_like "an endpoint with pagination" do - let(:url) { "/v1/mobility_trips" } - def make_item(i) - # Sorting is newest first, so the first items we create need to the the oldest. - created = Time.now - i.days - return Suma::Fixtures.mobility_trip.create(created_at: created) - end - end - - it_behaves_like "an endpoint with member-supplied ordering" do - let(:url) { "/v1/mobility_trips" } - let(:order_by_field) { "id" } - def make_item(_i) - return Suma::Fixtures.mobility_trip.create( - created_at: Time.now + rand(1..100).days, - ) - end - end - end - - describe "GET /v1/mobility_trips/:id" do - it "returns the mobility trip" do - rate = Suma::Fixtures.vendor_service_rate.create - service = Suma::Fixtures.vendor_service.mobility_deeplink.create - charge = Suma::Fixtures.charge(member: admin).create - trip = Suma::Fixtures.mobility_trip.create(vendor_service: service, vendor_service_rate: rate, member: admin) - trip.charge = charge - trip.save_changes - - get "/v1/mobility_trips/#{trip.refresh.id}" - - expect(last_response).to have_status(200) - expect(last_response).to have_json_body.that_includes( - id: trip.id, - vendor_service: include(id: service.id), - rate: include(id: rate.id), - member: include(id: admin.id), - charge: include(id: charge.id), - ) - end - - it "403s if the item does not exist" do - get "/v1/mobility_trips/0" - - expect(last_response).to have_status(403) - end - end - - describe "POST /v1/mobility_trips/:id" do - it "updates a mobility trip" do - trip = Suma::Fixtures.mobility_trip.create - - post "/v1/mobility_trips/#{trip.id}", - period_begin: "2024-07-01T00:00:00-0700", - period_end: "2024-07-02T00:00:00-0700", - begin_lat: 1, - begin_lng: 1, - end_lat: 2, - end_lng: 2, - began_at: "2024-07-01T00:00:00-0700", - ended_at: "2024-07-01T00:00:00-0700" - - expect(last_response).to have_status(200) - expect(trip.refresh).to have_attributes(begin_lat: 1, end_lat: 2) - end - end -end diff --git a/spec/suma/admin_api/mobility_trips_spec.rb b/spec/suma/admin_api/mobility_trips_spec.rb index 64c9722a2..40233ec88 100644 --- a/spec/suma/admin_api/mobility_trips_spec.rb +++ b/spec/suma/admin_api/mobility_trips_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/mobility_trips/#{Suma::Fixtures.mobility_trip.create.id}" + end + end + describe "GET /v1/mobility_trips" do it "returns all mobility trips" do objs = Array.new(2) { Suma::Fixtures.mobility_trip.create } @@ -95,6 +101,8 @@ def make_item(_i) trip = Suma::Fixtures.mobility_trip.create post "/v1/mobility_trips/#{trip.id}", + period_begin: "2024-07-01T00:00:00-0700", + period_end: "2024-07-02T00:00:00-0700", begin_lat: 1, begin_lng: 1, end_lat: 2, diff --git a/spec/suma/admin_api/off_platform_transactions_spec.rb b/spec/suma/admin_api/off_platform_transactions_spec.rb index 99b7bae27..46ad062a4 100644 --- a/spec/suma/admin_api/off_platform_transactions_spec.rb +++ b/spec/suma/admin_api/off_platform_transactions_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/off_platform_transactions/#{Suma::Fixtures.off_platform_payment_strategy.create.id}" + end + end + describe "POST /v1/off_platform_transactions/create" do it "can create and process an off-platform funding transaction", :i18n do post "/v1/off_platform_transactions/create", diff --git a/spec/suma/admin_api/organization_membership_verifications_spec.rb b/spec/suma/admin_api/organization_membership_verifications_spec.rb index a8210eb8f..a43923fb8 100644 --- a/spec/suma/admin_api/organization_membership_verifications_spec.rb +++ b/spec/suma/admin_api/organization_membership_verifications_spec.rb @@ -11,6 +11,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/organization_membership_verifications/#{Suma::Fixtures.organization_membership_verification.create.id}" + end + end + describe "GET /v1/organization_membership_verifications" do it "returns all organization memberships" do objs = Array.new(2) { Suma::Fixtures.organization_membership_verification.create } diff --git a/spec/suma/admin_api/organization_memberships_spec.rb b/spec/suma/admin_api/organization_memberships_spec.rb index 11abe243e..ab682c207 100644 --- a/spec/suma/admin_api/organization_memberships_spec.rb +++ b/spec/suma/admin_api/organization_memberships_spec.rb @@ -11,6 +11,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/organization_memberships/#{Suma::Fixtures.organization_membership.verified.create.id}" + end + end + describe "GET /v1/organization_memberships" do it "returns all organization memberships" do memberships = Array.new(2) { Suma::Fixtures.organization_membership.unverified.create } diff --git a/spec/suma/admin_api/organization_registration_links_spec.rb b/spec/suma/admin_api/organization_registration_links_spec.rb index 4abf53207..a832f9aac 100644 --- a/spec/suma/admin_api/organization_registration_links_spec.rb +++ b/spec/suma/admin_api/organization_registration_links_spec.rb @@ -11,6 +11,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/organization_registration_links/#{Suma::Fixtures.registration_link.create.id}" + end + end + describe "GET /v1/organization_registration_links" do it "returns all rows" do registration_links = Array.new(2) { Suma::Fixtures.registration_link.create } diff --git a/spec/suma/admin_api/organizations_spec.rb b/spec/suma/admin_api/organizations_spec.rb index b2f9e9cb9..97159aa20 100644 --- a/spec/suma/admin_api/organizations_spec.rb +++ b/spec/suma/admin_api/organizations_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/organizations/#{Suma::Fixtures.organization.create.id}" + end + end + describe "GET /v1/organizations" do it "returns all organizations" do orgs = Array.new(2) { Suma::Fixtures.organization.create } diff --git a/spec/suma/admin_api/payment_ledgers_spec.rb b/spec/suma/admin_api/payment_ledgers_spec.rb index 603b6aadf..906156320 100644 --- a/spec/suma/admin_api/payment_ledgers_spec.rb +++ b/spec/suma/admin_api/payment_ledgers_spec.rb @@ -14,6 +14,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/payment_ledgers/#{Suma::Fixtures.ledger.create.id}" + end + end + describe "GET /v1/payment_ledgers" do it "errors without role access" do replace_roles(admin, Suma::Role.cache.noop_admin) diff --git a/spec/suma/admin_api/payment_triggers_spec.rb b/spec/suma/admin_api/payment_triggers_spec.rb index c0f67f31e..b5bf1257f 100644 --- a/spec/suma/admin_api/payment_triggers_spec.rb +++ b/spec/suma/admin_api/payment_triggers_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/payment_triggers/#{Suma::Fixtures.payment_trigger.create.id}" + end + end + describe "GET /v1/payment_triggers" do it "returns all objects" do u = Array.new(2) { Suma::Fixtures.payment_trigger.create } diff --git a/spec/suma/admin_api/payout_transactions_spec.rb b/spec/suma/admin_api/payout_transactions_spec.rb index c7e2014b8..84021f1ed 100644 --- a/spec/suma/admin_api/payout_transactions_spec.rb +++ b/spec/suma/admin_api/payout_transactions_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/payout_transactions/#{Suma::Fixtures.payout_transaction.with_fake_strategy.create.id}" + end + end + describe "GET /v1/payout_transactions" do it "returns all transactions" do u = Array.new(2) { Suma::Fixtures.payout_transaction.with_fake_strategy.create } diff --git a/spec/suma/admin_api/program_pricings_spec.rb b/spec/suma/admin_api/program_pricings_spec.rb index 45f022647..433f4ff09 100644 --- a/spec/suma/admin_api/program_pricings_spec.rb +++ b/spec/suma/admin_api/program_pricings_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/program_pricings/#{Suma::Fixtures.program_pricing.create.id}" + end + end + describe "POST /v1/program_pricings/create" do it "creates a model" do program = Suma::Fixtures.program.create diff --git a/spec/suma/admin_api/programs_spec.rb b/spec/suma/admin_api/programs_spec.rb index a27bbb1c8..84588c010 100644 --- a/spec/suma/admin_api/programs_spec.rb +++ b/spec/suma/admin_api/programs_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/programs/#{Suma::Fixtures.program.create.id}" + end + end + describe "GET /v1/programs" do it "returns all programs" do objs = Array.new(2) { Suma::Fixtures.program.create } diff --git a/spec/suma/admin_api/roles_spec.rb b/spec/suma/admin_api/roles_spec.rb index b64c8f542..c3c33f983 100644 --- a/spec/suma/admin_api/roles_spec.rb +++ b/spec/suma/admin_api/roles_spec.rb @@ -12,6 +12,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/roles/#{Suma::Fixtures.role.create.id}" + end + end + describe "GET /v1/roles" do it "returns all roles" do c = Suma::Role.create(name: "c") diff --git a/spec/suma/admin_api/vendor_service_categories_spec.rb b/spec/suma/admin_api/vendor_service_categories_spec.rb index d13abe32f..0460a3a08 100644 --- a/spec/suma/admin_api/vendor_service_categories_spec.rb +++ b/spec/suma/admin_api/vendor_service_categories_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/vendor_service_categories/#{Suma::Fixtures.vendor_service_category.create.id}" + end + end + describe "GET /v1/vendor_service_categories" do it "returns all objects" do objs = Array.new(2) { Suma::Fixtures.vendor_service_category.create } diff --git a/spec/suma/admin_api/vendor_service_rates_spec.rb b/spec/suma/admin_api/vendor_service_rates_spec.rb index 1fba15111..0588de551 100644 --- a/spec/suma/admin_api/vendor_service_rates_spec.rb +++ b/spec/suma/admin_api/vendor_service_rates_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/vendor_service_rates/#{Suma::Fixtures.vendor_service_rate.create.id}" + end + end + describe "GET /v1/vendor_service_rates" do it "returns all objects" do objs = Array.new(2) { Suma::Fixtures.vendor_service_rate.create } diff --git a/spec/suma/admin_api/vendor_services_spec.rb b/spec/suma/admin_api/vendor_services_spec.rb index ff829b58a..13249f1b4 100644 --- a/spec/suma/admin_api/vendor_services_spec.rb +++ b/spec/suma/admin_api/vendor_services_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/vendor_services/#{Suma::Fixtures.vendor_service.create.id}" + end + end + describe "GET /v1/vendor_services" do it "returns all vendor services" do objs = Array.new(2) { Suma::Fixtures.vendor_service.create } diff --git a/spec/suma/admin_api/vendors_spec.rb b/spec/suma/admin_api/vendors_spec.rb index e440801a6..096ba8b3c 100644 --- a/spec/suma/admin_api/vendors_spec.rb +++ b/spec/suma/admin_api/vendors_spec.rb @@ -15,6 +15,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/vendors/#{Suma::Fixtures.vendor.create.id}" + end + end + describe "GET /v1/vendors" do it "returns all vendors" do objs = Array.new(2) { Suma::Fixtures.vendor.create } diff --git a/spec/suma/admin_api_spec.rb b/spec/suma/admin_api_spec.rb index 3b26d677b..66754623f 100644 --- a/spec/suma/admin_api_spec.rb +++ b/spec/suma/admin_api_spec.rb @@ -37,11 +37,11 @@ class ChildEntity < Suma::Service::Entities::Base expose :id end - class EntityWithRelated < Suma::AdminAPI::Entities::BaseEntity + class ModelEntity < Suma::AdminAPI::Entities::BaseModelEntity + model Suma::Vendor expose :name expose_related :products, with: ChildEntity expose_related :products, as: :children, with: ChildEntity - expose_related :defed_alias, with: ChildEntity end route_setting :skip_role_check, true @@ -49,10 +49,7 @@ class EntityWithRelated < Suma::AdminAPI::Entities::BaseEntity route_param :id do get do vendor = Suma::Vendor.find!(id: params[:id]) - vendor.instance_exec do - def defed_alias_dataset = self.products_dataset - end - present vendor, with: EntityWithRelated + present vendor, with: ModelEntity end end @@ -170,7 +167,6 @@ def method_missing(*); end items: have_length(4), ), children: include(items: have_length(4)), - defed_alias: include(items: have_length(4)), ) end diff --git a/spec/suma/api/behaviors.rb b/spec/suma/api/behaviors.rb index 34400ddb0..ca4d10398 100644 --- a/spec/suma/api/behaviors.rb +++ b/spec/suma/api/behaviors.rb @@ -156,3 +156,26 @@ def reindex(items) end end end + +RSpec.shared_examples "an endpoint with subroutes for related resources" do + let(:detail_route) { super() } + let(:api_class) { described_class } + + it "has defined routes for all expose_related associations" do + get detail_route + + expect(last_response).to have_status(200) + + supported_routes = described_class.instances.first.routes.map(&:origin) + + missing = [] + j = last_response_json_body + j.each_value do |v| + next unless v.is_a?(Hash) && v[:object] == "list" + related = v[:url].split("/").last + next if supported_routes.any? { |sr| sr.end_with?(related) } + missing << v[:url] + end + expect(missing).to be_empty, "the following related routes are not defined, use CommonEndpoints#related: #{missing}" + end +end From 8a23bf63de8d5e3ae50bfc36e98ae042c4030ac6 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 24 May 2026 09:24:18 -0700 Subject: [PATCH 09/22] keep fixing admin pages --- .../src/components/CategoriesRelatedList.jsx | 6 ++-- .../EligibilityRequirementsRelatedList.jsx | 6 ++-- adminapp/src/components/Programs.jsx | 6 ++-- .../src/pages/AnonMemberContactDetailPage.jsx | 5 +-- adminapp/src/pages/ChargeDetailPage.jsx | 14 ++++---- adminapp/src/pages/FinancialsPage.jsx | 17 +++++----- .../pages/FundingTransactionDetailPage.jsx | 5 +-- adminapp/src/pages/OfferingDetailPage.jsx | 31 +++++++++--------- adminapp/src/pages/OrderDetailPage.jsx | 5 +-- ...zationMembershipVerificationDetailPage.jsx | 7 ++-- ...nizationMembershipVerificationListPage.jsx | 4 +-- .../src/pages/PaymentTriggerDetailPage.jsx | 32 ++----------------- adminapp/src/pages/ProductDetailPage.jsx | 13 ++++---- .../src/pages/RegistrationLinkDetailPage.jsx | 5 +-- .../src/pages/VendorAccountDetailPage.jsx | 28 +++++----------- adminapp/src/pages/VendorDetailPage.jsx | 14 ++++---- lib/suma/admin_api/access.rb | 1 + .../admin_api/anon_proxy_vendor_accounts.rb | 17 ++++++++++ lib/suma/admin_api/charges.rb | 2 +- lib/suma/admin_api/commerce_offerings.rb | 2 +- lib/suma/admin_api/entities.rb | 1 + lib/suma/admin_api/financials.rb | 2 +- lib/suma/admin_api/funding_transactions.rb | 2 +- lib/suma/payment/platform_status.rb | 9 +++--- 24 files changed, 111 insertions(+), 123 deletions(-) diff --git a/adminapp/src/components/CategoriesRelatedList.jsx b/adminapp/src/components/CategoriesRelatedList.jsx index 1a94a81e2..577823685 100644 --- a/adminapp/src/components/CategoriesRelatedList.jsx +++ b/adminapp/src/components/CategoriesRelatedList.jsx @@ -1,12 +1,12 @@ import AdminLink from "./AdminLink"; -import RelatedList from "./RelatedList"; +import RelatedListRemote from "./RelatedListRemote"; import React from "react"; export default function CategoriesRelatedList({ categories }) { return ( - [ diff --git a/adminapp/src/components/EligibilityRequirementsRelatedList.jsx b/adminapp/src/components/EligibilityRequirementsRelatedList.jsx index e0a641415..1d295d3a3 100644 --- a/adminapp/src/components/EligibilityRequirementsRelatedList.jsx +++ b/adminapp/src/components/EligibilityRequirementsRelatedList.jsx @@ -1,13 +1,13 @@ import createRelativeUrl from "../shared/createRelativeUrl"; import AdminLink from "./AdminLink"; -import RelatedList from "./RelatedList"; +import RelatedListRemote from "./RelatedListRemote"; import React from "react"; export default function EligibilityRequirementsRelatedList({ model, type }) { return ( - (combinedProgramStates[c.id] = true)); + programs.items.forEach((c) => (combinedProgramStates[c.id] = true)); merge(combinedProgramStates, newProgramStates); if (editing.isOff) { @@ -58,8 +58,8 @@ export default function Programs({ // whether the program is associated with the resource. // If all programs aren't loaded yet, // show just the ones associated with the resource. - const iterablePrograms = allPrograms || programs; - iterablePrograms?.forEach((c) => + const iterablePrograms = allPrograms || programs.items; + iterablePrograms.forEach((c) => displayables.push({ key: c.id, label: c.name.en || c.name, diff --git a/adminapp/src/pages/AnonMemberContactDetailPage.jsx b/adminapp/src/pages/AnonMemberContactDetailPage.jsx index 13a55d3d6..5e9b1ce92 100644 --- a/adminapp/src/pages/AnonMemberContactDetailPage.jsx +++ b/adminapp/src/pages/AnonMemberContactDetailPage.jsx @@ -3,6 +3,7 @@ import AdminLink from "../components/AdminLink"; import Copyable from "../components/Copyable"; import ExternalLinks from "../components/ExternalLinks"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import React from "react"; @@ -30,9 +31,9 @@ export default function AnonMemberContactDetailPage() { > {(model) => [ , - [ diff --git a/adminapp/src/pages/ChargeDetailPage.jsx b/adminapp/src/pages/ChargeDetailPage.jsx index ddf1b13a2..cd867f635 100644 --- a/adminapp/src/pages/ChargeDetailPage.jsx +++ b/adminapp/src/pages/ChargeDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import CommerceOrderDetailGrid from "../components/CommerceOrderDetailGrid"; import MobilityTripDetailGrid from "../components/MobilityTripDetailGrid"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -51,10 +51,10 @@ export default function ChargeDetailPage() { {(model) => [ , , - [ row.id, @@ -64,9 +64,9 @@ export default function ChargeDetailPage() { row.memo.en, ]} />, - [ @@ -79,9 +79,9 @@ export default function ChargeDetailPage() { , ]} />, - [ diff --git a/adminapp/src/pages/FinancialsPage.jsx b/adminapp/src/pages/FinancialsPage.jsx index 6fc664c78..f1b1c7eab 100644 --- a/adminapp/src/pages/FinancialsPage.jsx +++ b/adminapp/src/pages/FinancialsPage.jsx @@ -4,6 +4,7 @@ import DetailGrid from "../components/DetailGrid"; import HelmetTitle from "../components/HelmetTitle"; import Link from "../components/Link"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import formatDate from "../modules/formatDate"; import Money from "../shared/react/Money"; import useAsyncFetch from "../shared/react/useAsyncFetch"; @@ -69,9 +70,9 @@ export default function FinancialsPage() { - [ @@ -83,9 +84,9 @@ export default function FinancialsPage() { ...ledgerMonies(row), ]} /> - [ @@ -94,9 +95,9 @@ export default function FinancialsPage() { ...ledgerMonies(row), ]} /> - [ @@ -106,9 +107,9 @@ export default function FinancialsPage() { row.note, ]} /> - [ diff --git a/adminapp/src/pages/FundingTransactionDetailPage.jsx b/adminapp/src/pages/FundingTransactionDetailPage.jsx index 61b2c5014..5dec9d897 100644 --- a/adminapp/src/pages/FundingTransactionDetailPage.jsx +++ b/adminapp/src/pages/FundingTransactionDetailPage.jsx @@ -6,6 +6,7 @@ import BookTransactionDetail from "../components/BookTransactionDetail"; import ExternalLinks from "../components/ExternalLinks"; import PaymentStrategyDetailGrid from "../components/PaymentStrategyDetailGrid"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -50,9 +51,9 @@ export default function FundingTransactionDetailPage() { title="Reversal Book Transaction" transaction={model.reversaldBookTransaction} />, - {(model, setModel) => [ - [ @@ -88,22 +89,22 @@ export default function OfferingDetailPage() { row.address && oneLineAddress(row.address, false), ]} />, - , - , + (row.closedAt ? classes.closed : "")} @@ -117,9 +118,9 @@ export default function OfferingDetailPage() { {row.undiscountedPrice}, ]} />, - [ diff --git a/adminapp/src/pages/OrderDetailPage.jsx b/adminapp/src/pages/OrderDetailPage.jsx index 934b71263..02a99cc49 100644 --- a/adminapp/src/pages/OrderDetailPage.jsx +++ b/adminapp/src/pages/OrderDetailPage.jsx @@ -4,6 +4,7 @@ import AuditLogs from "../components/AuditLogs"; import ChargeDetailGrid from "../components/ChargeDetailGrid"; import DetailGrid from "../components/DetailGrid"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import Money from "../shared/react/Money"; @@ -62,9 +63,9 @@ export default function OrderDetailPage() { ]} />, , - {(model, setModel) => [ - [ @@ -137,7 +138,7 @@ export default function OrganizationMembershipVerificationDetailPage() {
, formatDate(row.createdAt), - {row.creator.name} + {row.creator?.name} , ]} />, diff --git a/adminapp/src/pages/OrganizationMembershipVerificationListPage.jsx b/adminapp/src/pages/OrganizationMembershipVerificationListPage.jsx index 123b3282f..ae1ab4273 100644 --- a/adminapp/src/pages/OrganizationMembershipVerificationListPage.jsx +++ b/adminapp/src/pages/OrganizationMembershipVerificationListPage.jsx @@ -519,12 +519,12 @@ function NotesViewer({ verification, makeApiCall }) { )} - {isEmpty(verification?.notes) && ( + {isEmpty(verification?.notes?.items) && ( Add a note using the note editor. )} - {verification?.notes.map((note) => ( + {verification?.notes.items.map((note) => (
diff --git a/adminapp/src/pages/PaymentTriggerDetailPage.jsx b/adminapp/src/pages/PaymentTriggerDetailPage.jsx index cb79cbfb3..208c3369a 100644 --- a/adminapp/src/pages/PaymentTriggerDetailPage.jsx +++ b/adminapp/src/pages/PaymentTriggerDetailPage.jsx @@ -4,6 +4,7 @@ import AuditActivityList from "../components/AuditActivityList"; import EligibilityRequirementsRelatedList from "../components/EligibilityRequirementsRelatedList"; import Link from "../components/Link"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; @@ -74,9 +75,9 @@ export default function PaymentTriggerDetailPage() { > {(model) => [ , - [ @@ -89,33 +90,6 @@ export default function PaymentTriggerDetailPage() { , ]} />, - [ - row.id, - formatDate(row.createdAt), - - {row.vendor.name} - , - - {row.appInstallLink} - , - row.usesEmail ? "Yes" : "No", - row.usesSms ? "Yes" : "No", - row.enabled ? "Yes" : "No", - ]} - />, , ]} diff --git a/adminapp/src/pages/ProductDetailPage.jsx b/adminapp/src/pages/ProductDetailPage.jsx index 5261e403d..3b206d5de 100644 --- a/adminapp/src/pages/ProductDetailPage.jsx +++ b/adminapp/src/pages/ProductDetailPage.jsx @@ -2,6 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import CategoriesRelatedList from "../components/CategoriesRelatedList"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; @@ -43,15 +44,15 @@ export default function ProductDetailPage() { > {(model) => [ , - [ @@ -64,9 +65,9 @@ export default function ProductDetailPage() { row.isClosed ? dayjs(row.closedAt).format("lll") : "", ]} />, - [ diff --git a/adminapp/src/pages/RegistrationLinkDetailPage.jsx b/adminapp/src/pages/RegistrationLinkDetailPage.jsx index c648041ab..ac9098af9 100644 --- a/adminapp/src/pages/RegistrationLinkDetailPage.jsx +++ b/adminapp/src/pages/RegistrationLinkDetailPage.jsx @@ -2,6 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import Copyable from "../components/Copyable"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -48,9 +49,9 @@ export default function RegistrationLinkDetailPage() { keyRowAttr="startTime" toCells={(row) => [formatDate(row.startTime), formatDate(row.endTime)]} />, - [ diff --git a/adminapp/src/pages/VendorAccountDetailPage.jsx b/adminapp/src/pages/VendorAccountDetailPage.jsx index d613b16f9..32f410248 100644 --- a/adminapp/src/pages/VendorAccountDetailPage.jsx +++ b/adminapp/src/pages/VendorAccountDetailPage.jsx @@ -3,7 +3,7 @@ import AdminActions from "../components/AdminActions"; import AdminLink from "../components/AdminLink"; import BoolCheckmark from "../components/BoolCheckmark"; import DetailGrid from "../components/DetailGrid"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -98,9 +98,9 @@ export default function VendorAccountDetailPage() { /> ), , - [ @@ -110,29 +110,17 @@ export default function VendorAccountDetailPage() { row.externalRegistrationId, ]} />, - [ row.id, - formatDate(row.createdAt), - row.messageContent, + formatDate(row.messageTimestamp), row.messageFrom, row.messageTo, - row.messageHandlerKey, - row.relayKey, - formatDate(row.messageTimestamp), + , ]} />, ]} diff --git a/adminapp/src/pages/VendorDetailPage.jsx b/adminapp/src/pages/VendorDetailPage.jsx index 20c537151..b176ca159 100644 --- a/adminapp/src/pages/VendorDetailPage.jsx +++ b/adminapp/src/pages/VendorDetailPage.jsx @@ -1,7 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import BoolCheckmark from "../components/BoolCheckmark"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; @@ -22,9 +22,9 @@ export default function VendorDetailPage() { ]} > {(model) => [ - [ @@ -32,9 +32,9 @@ export default function VendorDetailPage() { {row.internalName}, ]} />, - [ @@ -44,9 +44,9 @@ export default function VendorDetailPage() { {row.enabled}, ]} />, - [ diff --git a/lib/suma/admin_api/access.rb b/lib/suma/admin_api/access.rb index 1f2b51e38..625f86584 100644 --- a/lib/suma/admin_api/access.rb +++ b/lib/suma/admin_api/access.rb @@ -15,6 +15,7 @@ class Suma::AdminAPI::Access MAPPING = { Suma::AnonProxy::MemberContact => [:member_contact, COMMERCE, COMMERCE], Suma::AnonProxy::VendorAccount => [:vendor_account, COMMERCE, COMMERCE], + Suma::AnonProxy::VendorAccountMessage => [:vendor_account_message, COMMERCE, COMMERCE], Suma::AnonProxy::VendorConfiguration => [:vendor_configuration, COMMERCE, COMMERCE], Suma::Charge => [:charge, PAYMENTS, PAYMENTS], Suma::Commerce::OfferingProduct => [:offering_product, COMMERCE, COMMERCE], diff --git a/lib/suma/admin_api/anon_proxy_vendor_accounts.rb b/lib/suma/admin_api/anon_proxy_vendor_accounts.rb index 4fe84a3ba..50da9a6b6 100644 --- a/lib/suma/admin_api/anon_proxy_vendor_accounts.rb +++ b/lib/suma/admin_api/anon_proxy_vendor_accounts.rb @@ -16,6 +16,22 @@ class VendorAccountRegistrationEntity < BaseModelEntity expose :external_registration_id end + class VendorAccountMessageEntity < BaseModelEntity + include Suma::AdminAPI::Entities + include AutoExposeBase + + model Suma::AnonProxy::VendorAccountMessage + + expose :message_id + expose :message_from + expose :message_to + expose :message_content + expose :message_timestamp + expose :relay_key + expose :message_handler_key + expose :outbound_delivery, with: MessageDeliveryEntity + end + class DetailedVendorAccountEntity < AnonProxyVendorAccountEntity include Suma::AdminAPI::Entities include AutoExposeDetail @@ -27,6 +43,7 @@ class DetailedVendorAccountEntity < AnonProxyVendorAccountEntity expose :pending_closure expose :contact, with: AnonProxyMemberContactEntity expose_related :registrations, with: VendorAccountRegistrationEntity + expose_related :messages, with: VendorAccountMessageEntity end resource :anon_proxy_vendor_accounts do diff --git a/lib/suma/admin_api/charges.rb b/lib/suma/admin_api/charges.rb index 544da3010..8f429e183 100644 --- a/lib/suma/admin_api/charges.rb +++ b/lib/suma/admin_api/charges.rb @@ -13,7 +13,7 @@ class DetailedChargeEntity < ChargeWithPricesEntity expose :member, with: MemberEntity expose :mobility_trip, with: MobilityTripEntity expose :commerce_order, with: OrderEntity - expose_related :line_items, with: ChargeLineItemEntity + expose_related :line_items, with: ChargeLineItemEntity, all: true expose_related :associated_funding_transactions, with: FundingTransactionEntity expose_related :contributing_book_transactions, with: BookTransactionEntity end diff --git a/lib/suma/admin_api/commerce_offerings.rb b/lib/suma/admin_api/commerce_offerings.rb index 5e95f7b77..a954afa0d 100644 --- a/lib/suma/admin_api/commerce_offerings.rb +++ b/lib/suma/admin_api/commerce_offerings.rb @@ -26,7 +26,7 @@ class DetailedOfferingEntity < OfferingEntity expose :fulfillment_prompt, with: TranslatedTextEntity expose :fulfillment_instructions, with: TranslatedTextEntity expose :fulfillment_confirmation, with: TranslatedTextEntity - expose :fulfillment_options, with: OfferingFulfillmentOptionEntity + expose_related :fulfillment_options, with: OfferingFulfillmentOptionEntity, all: true expose :begin_fulfillment_at expose_image :image expose_related :offering_products, with: OfferingProductEntity diff --git a/lib/suma/admin_api/entities.rb b/lib/suma/admin_api/entities.rb index 23756c39d..4fd0b77e8 100644 --- a/lib/suma/admin_api/entities.rb +++ b/lib/suma/admin_api/entities.rb @@ -486,6 +486,7 @@ class OfferingEntity < BaseModelEntity class OfferingFulfillmentOptionEntity < BaseModelEntity include AutoExposeBase + model Suma::Commerce::OfferingFulfillmentOption expose :description, with: TranslatedTextEntity expose :type expose :ordinal diff --git a/lib/suma/admin_api/financials.rb b/lib/suma/admin_api/financials.rb index d6723ec62..5396457d8 100644 --- a/lib/suma/admin_api/financials.rb +++ b/lib/suma/admin_api/financials.rb @@ -39,7 +39,7 @@ class PlatformStatusEntity < BaseModelEntity expose :refund_count expose :member_liabilities, with: MoneyEntity expose :assets, with: MoneyEntity - expose :platform_ledgers, with: LedgerEntity + expose_related :platform_ledgers, with: LedgerEntity expose_related :unbalanced_ledgers, with: LedgerEntity expose_related :off_platform_funding_transactions, with: OffPlatformTransactionEntity expose_related :off_platform_payout_transactions, with: OffPlatformTransactionEntity diff --git a/lib/suma/admin_api/funding_transactions.rb b/lib/suma/admin_api/funding_transactions.rb index 466c47c0c..ffe99a33e 100644 --- a/lib/suma/admin_api/funding_transactions.rb +++ b/lib/suma/admin_api/funding_transactions.rb @@ -15,7 +15,7 @@ class DetailedFundingTransactionEntity < FundingTransactionEntity expose :can_refund?, as: :can_refund expose :refundable_amount, with: MoneyEntity expose :refunded_amount, with: MoneyEntity - expose_related :refund_payout_transactions, with: PayoutTransactionEntity + expose_related :refund_payout_transactions, with: PayoutTransactionEntity, all: true expose :platform_ledger, with: SimpleLedgerEntity expose :originated_book_transaction, with: BookTransactionEntity expose :reversal_book_transaction, with: BookTransactionEntity diff --git a/lib/suma/payment/platform_status.rb b/lib/suma/payment/platform_status.rb index bf37351c0..acc20ba3f 100644 --- a/lib/suma/payment/platform_status.rb +++ b/lib/suma/payment/platform_status.rb @@ -15,9 +15,7 @@ class Suma::Payment::PlatformStatus attr_accessor :assets # Ledgers belonging to the platform account. - def platform_ledgers - @platform_ledgers ||= Suma::Payment::Account.lookup_platform_account.ledgers.sort_by(&:name) - end + def platform_ledgers_dataset = Suma::Payment::Account.lookup_platform_account.ledgers_dataset.order(:name) attr_accessor :off_platform_funding_transactions_dataset, :off_platform_payout_transactions_dataset @@ -29,7 +27,8 @@ def calculate self.funding, self.funding_count = sumcnt(funding_ds) self.funding -= self.refunds self.funding_count -= self.refund_count - self.member_liabilities = self.platform_ledgers.sum(&:balance) * -1 + liability_cents = Suma::Payment::Ledger::Balance.where(ledger: self.platform_ledgers_dataset).sum(:balance_cents) + self.member_liabilities = Money.new((liability_cents || 0) * -1, Suma.default_currency) self.assets = self.funding - self.payouts self.off_platform_funding_transactions_dataset = offplatform_ds(funding_ds) self.off_platform_payout_transactions_dataset = offplatform_ds(payout_ds) @@ -73,7 +72,7 @@ def calculate unbalanced_ids = db. from(summed). exclude(total: 0). - exclude(ledger_id: self.platform_ledgers.map(&:id)). + exclude(ledger_id: self.platform_ledgers_dataset.select(:id)). select_map(:ledger_id) return Suma::Payment::Ledger.order(:account_id, :name, :id).where(id: unbalanced_ids) end From befd7867d53b58a04923e7e1062cec6c54573700 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 24 May 2026 13:08:47 -0700 Subject: [PATCH 10/22] more list work --- adminapp/src/components/AuditLogs.jsx | 1 - .../components/PaymentAccountRelatedLists.jsx | 5 +- adminapp/src/components/RelatedListRemote.jsx | 5 + .../src/pages/AnonMemberContactDetailPage.jsx | 1 - .../src/pages/BookTransactionDetailPage.jsx | 84 +++++++++++----- adminapp/src/pages/CardDetailPage.jsx | 6 +- .../pages/EligibilityAttributeDetailPage.jsx | 14 +-- .../EligibilityRequirementDetailPage.jsx | 10 +- ...EligibilityRequirementExpressionEditor.jsx | 12 +-- adminapp/src/pages/FinancialsPage.jsx | 1 - .../pages/FundingTransactionDetailPage.jsx | 1 - .../src/pages/MarketingListDetailPage.jsx | 10 +- .../pages/MarketingSmsBroadcastDetailPage.jsx | 10 +- adminapp/src/pages/MemberDetailPage.jsx | 10 +- adminapp/src/pages/OfferingDetailPage.jsx | 2 - adminapp/src/pages/OfferingForm.jsx | 2 +- .../src/pages/OfferingProductDetailPage.jsx | 8 +- adminapp/src/pages/OrderDetailPage.jsx | 3 +- adminapp/src/pages/OrganizationDetailPage.jsx | 1 - ...zationMembershipVerificationDetailPage.jsx | 4 +- ...nizationMembershipVerificationListPage.jsx | 2 +- .../src/pages/PaymentTriggerDetailPage.jsx | 2 - adminapp/src/pages/PaymentTriggerForm.jsx | 8 +- adminapp/src/pages/ProductDetailPage.jsx | 1 - adminapp/src/pages/ProgramDetailPage.jsx | 14 +-- .../src/pages/RegistrationLinkListPage.jsx | 2 +- adminapp/src/pages/RoleDetailPage.jsx | 10 +- adminapp/src/pages/SignInPage.jsx | 2 +- .../pages/VendorServiceCategoryDetailPage.jsx | 6 +- .../src/pages/VendorServiceDetailPage.jsx | 6 +- .../src/pages/VendorServiceRateDetailPage.jsx | 18 +++- adminapp/src/shared/react/useDebugEffect.jsx | 22 +++++ lib/suma/admin_api/access.rb | 6 +- .../anon_proxy_vendor_configurations.rb | 2 +- lib/suma/admin_api/charges.rb | 2 +- lib/suma/admin_api/commerce_offerings.rb | 5 +- lib/suma/admin_api/commerce_orders.rb | 20 +++- lib/suma/admin_api/common_endpoints.rb | 34 +++++-- lib/suma/admin_api/entities.rb | 99 ++++++++++++------- lib/suma/admin_api/funding_transactions.rb | 8 +- lib/suma/admin_api/members.rb | 42 ++++++-- .../organization_membership_verifications.rb | 11 +-- .../admin_api/organization_memberships.rb | 2 +- lib/suma/admin_api/organizations.rb | 2 +- lib/suma/admin_api/payment_accounts.rb | 39 ++++++++ lib/suma/admin_api/payment_ledgers.rb | 6 +- lib/suma/admin_api/payment_triggers.rb | 4 +- lib/suma/admin_api/payout_transactions.rb | 4 +- lib/suma/admin_api/programs.rb | 2 +- lib/suma/admin_api/vendor_service_rates.rb | 4 +- lib/suma/admin_api/vendor_services.rb | 2 +- lib/suma/apps.rb | 2 + lib/suma/commerce/order.rb | 2 + lib/suma/service.rb | 4 +- lib/suma/vendor/service_rate.rb | 1 + spec/suma/admin_api/commerce_orders_spec.rb | 5 +- spec/suma/admin_api/common_endpoints_spec.rb | 12 +++ spec/suma/admin_api/members_spec.rb | 25 +++++ spec/suma/admin_api/payment_accounts_spec.rb | 38 +++++++ 59 files changed, 463 insertions(+), 203 deletions(-) create mode 100644 adminapp/src/shared/react/useDebugEffect.jsx create mode 100644 lib/suma/admin_api/payment_accounts.rb create mode 100644 spec/suma/admin_api/payment_accounts_spec.rb diff --git a/adminapp/src/components/AuditLogs.jsx b/adminapp/src/components/AuditLogs.jsx index afb01f936..e6f90f7ad 100644 --- a/adminapp/src/components/AuditLogs.jsx +++ b/adminapp/src/components/AuditLogs.jsx @@ -1,6 +1,5 @@ import { dayjs } from "../modules/dayConfig"; import AdminLink from "./AdminLink"; -import RelatedList from "./RelatedList"; import RelatedListRemote from "./RelatedListRemote"; import isEmpty from "lodash/isEmpty"; import React from "react"; diff --git a/adminapp/src/components/PaymentAccountRelatedLists.jsx b/adminapp/src/components/PaymentAccountRelatedLists.jsx index f70747ae1..fa0e37c5b 100644 --- a/adminapp/src/components/PaymentAccountRelatedLists.jsx +++ b/adminapp/src/components/PaymentAccountRelatedLists.jsx @@ -5,7 +5,6 @@ import Money from "../shared/react/Money"; import AdminLink from "./AdminLink"; import LedgerBookTransactionsRelatedList from "./LedgerBookTransactionRelatedList"; import Link from "./Link"; -import RelatedList from "./RelatedList"; import RelatedListRemote from "./RelatedListRemote"; import first from "lodash/first"; import get from "lodash/get"; @@ -58,7 +57,7 @@ export default function PaymentAccountRelatedLists({ paymentAccount }) { const cells = [ , row.currency, - map(row.vendorServiceCategories, "name").join(", "), + AdminLink.Array(row.categories.items, (c) => ), {row.balance}, ]; if (canCreateBook) { @@ -79,7 +78,7 @@ export default function PaymentAccountRelatedLists({ paymentAccount }) { return cells; }} /> - {paymentAccount.ledgers.map((ledger) => ( + {paymentAccount.ledgers.items.map((ledger) => ( api.get(latestCollection.url, { page: 1, pageSize: 2 }), { + once: true, + }); + if (!collection.totalCount && !addNew && !emptyState) { return null; } diff --git a/adminapp/src/pages/AnonMemberContactDetailPage.jsx b/adminapp/src/pages/AnonMemberContactDetailPage.jsx index 5e9b1ce92..df2ee7087 100644 --- a/adminapp/src/pages/AnonMemberContactDetailPage.jsx +++ b/adminapp/src/pages/AnonMemberContactDetailPage.jsx @@ -2,7 +2,6 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import Copyable from "../components/Copyable"; import ExternalLinks from "../components/ExternalLinks"; -import RelatedList from "../components/RelatedList"; import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; diff --git a/adminapp/src/pages/BookTransactionDetailPage.jsx b/adminapp/src/pages/BookTransactionDetailPage.jsx index bc1bb87aa..3f7a974ec 100644 --- a/adminapp/src/pages/BookTransactionDetailPage.jsx +++ b/adminapp/src/pages/BookTransactionDetailPage.jsx @@ -1,7 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; -import RelatedList from "../components/RelatedList"; -import ResourceDetail from "../components/ResourceDetail"; +import DetailGrid from "../components/DetailGrid"; +import ResourceDetail, { ResourceSummary } from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; import formatDate from "../modules/formatDate"; @@ -53,30 +53,64 @@ export default function BookTransactionDetailPage() { ]} > {(model) => [ - [ - , - formatDate(row.createdAt), - row.status, - {row.amount}, - ]} - />, - [ - , - formatDate(row.createdAt), - {row.undiscountedSubtotal}, - row.opaqueId, - ]} - />, + relatedExternalTransaction( + "Originating Funding Transaction", + model.originatingFundingTransaction + ), + relatedExternalTransaction( + "Originating Payout Transaction", + model.originatingPayoutTransaction + ), + relatedExternalTransaction( + "Credited Payout Transaction", + model.creditedPayoutTransaction + ), + model.chargeContributedTo && ( + + }, + { label: "At", value: formatDate(model.chargeContributedTo.createdAt) }, + { + label: "Undiscounted Total", + value: {model.chargeContributedTo.undiscountedSubtotal}, + }, + { label: "Opaque ID", value: model.chargeContributedTo.opaqueId }, + ]} + /> + + ), ]} ); } + +function relatedExternalTransaction(title, model) { + if (!model) { + return null; + } + // Must return ResourceSummary unwrapped so child detection for layout works. + return ( + + , + }, + { + label: "Created", + value: formatDate(model.createdAt), + }, + { label: "Status", value: model.status }, + { + label: "Amount", + value: {model.amount}, + }, + ]} + /> + + ); +} diff --git a/adminapp/src/pages/CardDetailPage.jsx b/adminapp/src/pages/CardDetailPage.jsx index 16b6a9825..04155e68f 100644 --- a/adminapp/src/pages/CardDetailPage.jsx +++ b/adminapp/src/pages/CardDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import ExternalLinks from "../components/ExternalLinks"; import LegalEntity from "../components/LegalEntity"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail, { ResourceSummary } from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -33,9 +33,9 @@ export default function CardDetailPage() { , , - [ diff --git a/adminapp/src/pages/EligibilityAttributeDetailPage.jsx b/adminapp/src/pages/EligibilityAttributeDetailPage.jsx index c665e3f01..92b9e8ea2 100644 --- a/adminapp/src/pages/EligibilityAttributeDetailPage.jsx +++ b/adminapp/src/pages/EligibilityAttributeDetailPage.jsx @@ -1,6 +1,6 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import createRelativeUrl from "../shared/createRelativeUrl"; @@ -23,9 +23,9 @@ export default function EligibilityAttributeDetailPage() { ]} > {(model) => [ - [ @@ -33,9 +33,9 @@ export default function EligibilityAttributeDetailPage() { {row.name}, ]} />, - , - [ diff --git a/adminapp/src/pages/EligibilityRequirementDetailPage.jsx b/adminapp/src/pages/EligibilityRequirementDetailPage.jsx index 948a5c750..4d492fe18 100644 --- a/adminapp/src/pages/EligibilityRequirementDetailPage.jsx +++ b/adminapp/src/pages/EligibilityRequirementDetailPage.jsx @@ -1,7 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import Link from "../components/Link"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import createRelativeUrl from "../shared/createRelativeUrl"; @@ -40,9 +40,9 @@ export default function EligibilityRequirementDetailPage() { ]} > {(model) => [ - {row.label}, ]} />, -
), - [ @@ -73,9 +73,9 @@ export default function MarketingListDetailPage() { row.formattedPhone, ]} />, - [ diff --git a/adminapp/src/pages/MarketingSmsBroadcastDetailPage.jsx b/adminapp/src/pages/MarketingSmsBroadcastDetailPage.jsx index e2d515e0f..c7184855a 100644 --- a/adminapp/src/pages/MarketingSmsBroadcastDetailPage.jsx +++ b/adminapp/src/pages/MarketingSmsBroadcastDetailPage.jsx @@ -1,7 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import Link from "../components/Link"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -46,9 +46,9 @@ export default function MarketingSmsBroadcastDetailPage() { > Review and {model.sentAt ? "Re-Send" : "Send"} , - [ @@ -58,9 +58,9 @@ export default function MarketingSmsBroadcastDetailPage() {
, ]} />, - [ diff --git a/adminapp/src/pages/MemberDetailPage.jsx b/adminapp/src/pages/MemberDetailPage.jsx index fc0b18c81..fb1f5e12d 100644 --- a/adminapp/src/pages/MemberDetailPage.jsx +++ b/adminapp/src/pages/MemberDetailPage.jsx @@ -26,16 +26,16 @@ import useToggle from "../shared/react/useToggle"; import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; import { - Typography, - Switch, Button, Chip, - DialogTitle, Dialog, - DialogContent, DialogActions, - Stack, + DialogContent, DialogContentText, + DialogTitle, + Stack, + Switch, + Typography, } from "@mui/material"; import IconButton from "@mui/material/IconButton"; import TableCell from "@mui/material/TableCell"; diff --git a/adminapp/src/pages/OfferingDetailPage.jsx b/adminapp/src/pages/OfferingDetailPage.jsx index a1ca2ebde..1fb6cebca 100644 --- a/adminapp/src/pages/OfferingDetailPage.jsx +++ b/adminapp/src/pages/OfferingDetailPage.jsx @@ -2,8 +2,6 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import AuditActivityList from "../components/AuditActivityList"; import Link from "../components/Link"; -import Programs from "../components/Programs"; -import RelatedList from "../components/RelatedList"; import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; diff --git a/adminapp/src/pages/OfferingForm.jsx b/adminapp/src/pages/OfferingForm.jsx index 67583b2a4..5a0c04dad 100644 --- a/adminapp/src/pages/OfferingForm.jsx +++ b/adminapp/src/pages/OfferingForm.jsx @@ -15,10 +15,10 @@ import { Button, Divider, FormControl, + FormHelperText, FormLabel, Icon, InputLabel, - FormHelperText, MenuItem, Select, Stack, diff --git a/adminapp/src/pages/OfferingProductDetailPage.jsx b/adminapp/src/pages/OfferingProductDetailPage.jsx index 45117e065..100008f31 100644 --- a/adminapp/src/pages/OfferingProductDetailPage.jsx +++ b/adminapp/src/pages/OfferingProductDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import BackTo from "../components/BackTo"; import Link from "../components/Link"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; @@ -58,9 +58,9 @@ export default function OfferingProductDetailPage() { ]} > {(model) => [ - [ diff --git a/adminapp/src/pages/OrderDetailPage.jsx b/adminapp/src/pages/OrderDetailPage.jsx index 02a99cc49..fd25631d2 100644 --- a/adminapp/src/pages/OrderDetailPage.jsx +++ b/adminapp/src/pages/OrderDetailPage.jsx @@ -3,7 +3,6 @@ import AdminLink from "../components/AdminLink"; import AuditLogs from "../components/AuditLogs"; import ChargeDetailGrid from "../components/ChargeDetailGrid"; import DetailGrid from "../components/DetailGrid"; -import RelatedList from "../components/RelatedList"; import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; @@ -65,7 +64,7 @@ export default function OrderDetailPage() { , , formatDate(row.createdAt), - - {row.creator?.name} + + {row.author?.name} , ]} />, diff --git a/adminapp/src/pages/OrganizationMembershipVerificationListPage.jsx b/adminapp/src/pages/OrganizationMembershipVerificationListPage.jsx index ae1ab4273..43f53627b 100644 --- a/adminapp/src/pages/OrganizationMembershipVerificationListPage.jsx +++ b/adminapp/src/pages/OrganizationMembershipVerificationListPage.jsx @@ -529,7 +529,7 @@ function NotesViewer({ verification, makeApiCall }) {
- {note.creator?.name} at {formatDate(note.createdAt)} + {note.author?.name} at {formatDate(note.createdAt)} {note.editor && ( diff --git a/adminapp/src/pages/PaymentTriggerDetailPage.jsx b/adminapp/src/pages/PaymentTriggerDetailPage.jsx index 208c3369a..ddad4e9de 100644 --- a/adminapp/src/pages/PaymentTriggerDetailPage.jsx +++ b/adminapp/src/pages/PaymentTriggerDetailPage.jsx @@ -3,14 +3,12 @@ import AdminLink from "../components/AdminLink"; import AuditActivityList from "../components/AuditActivityList"; import EligibilityRequirementsRelatedList from "../components/EligibilityRequirementsRelatedList"; import Link from "../components/Link"; -import RelatedList from "../components/RelatedList"; import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; import formatDate from "../modules/formatDate"; import { formatMoney, intToMoney } from "../shared/money"; -import SafeExternalLink from "../shared/react/SafeExternalLink"; import useUrlMarshal from "../shared/react/useUrlMarshal"; import HorizontalSplitIcon from "@mui/icons-material/HorizontalSplit"; import React from "react"; diff --git a/adminapp/src/pages/PaymentTriggerForm.jsx b/adminapp/src/pages/PaymentTriggerForm.jsx index 14cef3f39..16cd3b69c 100644 --- a/adminapp/src/pages/PaymentTriggerForm.jsx +++ b/adminapp/src/pages/PaymentTriggerForm.jsx @@ -9,12 +9,12 @@ import config from "../config"; import { formatOrNull } from "../modules/dayConfig"; import { intToMoney } from "../shared/money"; import { - TextField, - Stack, + FormControl, + FormControlLabel, FormHelperText, + Stack, Switch, - FormControlLabel, - FormControl, + TextField, } from "@mui/material"; import React from "react"; diff --git a/adminapp/src/pages/ProductDetailPage.jsx b/adminapp/src/pages/ProductDetailPage.jsx index 3b206d5de..8bfbe52ee 100644 --- a/adminapp/src/pages/ProductDetailPage.jsx +++ b/adminapp/src/pages/ProductDetailPage.jsx @@ -1,7 +1,6 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import CategoriesRelatedList from "../components/CategoriesRelatedList"; -import RelatedList from "../components/RelatedList"; import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; diff --git a/adminapp/src/pages/ProgramDetailPage.jsx b/adminapp/src/pages/ProgramDetailPage.jsx index f6e75219e..6fdda5171 100644 --- a/adminapp/src/pages/ProgramDetailPage.jsx +++ b/adminapp/src/pages/ProgramDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import AuditActivityList from "../components/AuditActivityList"; import EligibilityRequirementsRelatedList from "../components/EligibilityRequirementsRelatedList"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; @@ -35,9 +35,9 @@ export default function ProgramDetailPage() { ]} > {(model) => [ - [ @@ -47,7 +47,7 @@ export default function ProgramDetailPage() { formatDate(row.periodEnd), ]} />, - [ @@ -68,9 +68,9 @@ export default function ProgramDetailPage() {
, ]} />, - , + render: (c) => , }, { id: "durableUrl", diff --git a/adminapp/src/pages/RoleDetailPage.jsx b/adminapp/src/pages/RoleDetailPage.jsx index f5ffe023a..4c1a27515 100644 --- a/adminapp/src/pages/RoleDetailPage.jsx +++ b/adminapp/src/pages/RoleDetailPage.jsx @@ -1,7 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import EligibilityAssignmentsRelatedList from "../components/EligibilityAssignmentsRelatedList"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import React from "react"; @@ -18,9 +18,9 @@ export default function RoleDetailPage() { ]} > {(model) => [ - [ @@ -29,9 +29,9 @@ export default function RoleDetailPage() { row.formattedPhone, ]} />, - [, row.name]} diff --git a/adminapp/src/pages/SignInPage.jsx b/adminapp/src/pages/SignInPage.jsx index 128da2a70..924570dd8 100644 --- a/adminapp/src/pages/SignInPage.jsx +++ b/adminapp/src/pages/SignInPage.jsx @@ -6,9 +6,9 @@ import { Button, Card, CardContent, + Container, FormControl, TextField, - Container, } from "@mui/material"; import { makeStyles } from "@mui/styles"; import React from "react"; diff --git a/adminapp/src/pages/VendorServiceCategoryDetailPage.jsx b/adminapp/src/pages/VendorServiceCategoryDetailPage.jsx index b738fd83f..fdd903ea9 100644 --- a/adminapp/src/pages/VendorServiceCategoryDetailPage.jsx +++ b/adminapp/src/pages/VendorServiceCategoryDetailPage.jsx @@ -1,6 +1,6 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import React from "react"; @@ -22,9 +22,9 @@ export default function VendorServiceCategoryDetailPage() { ]} > {(model) => [ - [ diff --git a/adminapp/src/pages/VendorServiceDetailPage.jsx b/adminapp/src/pages/VendorServiceDetailPage.jsx index 9133e86f8..8e41711ae 100644 --- a/adminapp/src/pages/VendorServiceDetailPage.jsx +++ b/adminapp/src/pages/VendorServiceDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import AuditActivityList from "../components/AuditActivityList"; import CategoriesRelatedList from "../components/CategoriesRelatedList"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; @@ -34,9 +34,9 @@ export default function VendorServiceDetailPage() { ]} > {(model) => [ - [ diff --git a/adminapp/src/pages/VendorServiceRateDetailPage.jsx b/adminapp/src/pages/VendorServiceRateDetailPage.jsx index 545c9700a..a5a4544fa 100644 --- a/adminapp/src/pages/VendorServiceRateDetailPage.jsx +++ b/adminapp/src/pages/VendorServiceRateDetailPage.jsx @@ -1,6 +1,6 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import Money from "../shared/react/Money"; @@ -31,9 +31,21 @@ export default function VendorServiceRateDetailPage() { ]} > {(model) => [ - [ + {row.id}, + {row.internalName}, + {row.surcharge}, + {row.unitAmount}, + ]} + />, + [ diff --git a/adminapp/src/shared/react/useDebugEffect.jsx b/adminapp/src/shared/react/useDebugEffect.jsx new file mode 100644 index 000000000..ed37ddbeb --- /dev/null +++ b/adminapp/src/shared/react/useDebugEffect.jsx @@ -0,0 +1,22 @@ +import React from "react"; + +/** + * Just a `React.useEffect(cb, [])` that does not fire unless in development mode. + * @param cb + * @param {Array=} deps Dependency list. If not given, use empty list. + * @param {boolean=} once If true, fire just once, even in strict mode. + */ +export default function useDebugEffect(cb, { deps, once } = {}) { + const calledRef = React.useRef(false); + React.useEffect(() => { + if (process.env.NODE_ENV !== "development") { + return; + } + if (once && calledRef.current) { + return; + } + cb(); + calledRef.current = true; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps || []); +} diff --git a/lib/suma/admin_api/access.rb b/lib/suma/admin_api/access.rb index 625f86584..500033193 100644 --- a/lib/suma/admin_api/access.rb +++ b/lib/suma/admin_api/access.rb @@ -16,6 +16,7 @@ class Suma::AdminAPI::Access Suma::AnonProxy::MemberContact => [:member_contact, COMMERCE, COMMERCE], Suma::AnonProxy::VendorAccount => [:vendor_account, COMMERCE, COMMERCE], Suma::AnonProxy::VendorAccountMessage => [:vendor_account_message, COMMERCE, COMMERCE], + Suma::AnonProxy::VendorAccountRegistration => [:vendor_account_registration, COMMERCE, COMMERCE], Suma::AnonProxy::VendorConfiguration => [:vendor_configuration, COMMERCE, COMMERCE], Suma::Charge => [:charge, PAYMENTS, PAYMENTS], Suma::Commerce::OfferingProduct => [:offering_product, COMMERCE, COMMERCE], @@ -24,13 +25,14 @@ class Suma::AdminAPI::Access Suma::Commerce::Product => [:product, COMMERCE, COMMERCE], Suma::Eligibility::Assignment => [:eligibility_assignment, ALL, MANAGEMENT], Suma::Eligibility::Attribute => [:eligibility_attribute, ALL, MANAGEMENT], + Suma::Eligibility::MemberAssignment => [:eligibility_member_assignment, ALL, MANAGEMENT], Suma::Eligibility::Requirement => [:eligibility_requirement, ALL, MANAGEMENT], Suma::I18n::StaticString => [:static_string, ALL, LOCALIZATION], Suma::Marketing::List => [:marketing_list, MARKETING_SMS, MARKETING_SMS], Suma::Marketing::SmsBroadcast => [:marketing_sms_broadcast, MARKETING_SMS, MARKETING_SMS], Suma::Marketing::SmsDispatch => [:marketing_sms_dispatch, MARKETING_SMS, MARKETING_SMS], Suma::Member => [:member, MEMBERS, MEMBERS], - Suma::Member::Activity => [:member_activity, MEMBERS, MEMBERS], + Suma::Member::ResetCode => [:member_reset_code, MEMBERS, MEMBERS], Suma::Member::Session => [:member_session, MEMBERS, MEMBERS], Suma::Message::Delivery => [:message_delivery, MEMBERS, MANAGEMENT], Suma::Mobility::Trip => [:mobility_trip, COMMERCE, MANAGEMENT], @@ -38,10 +40,12 @@ class Suma::AdminAPI::Access Suma::Organization::Membership::Verification => [:organization_membership_verification, MEMBERS, MEMBERS], Suma::Organization::RegistrationLink => [:organization_registration_link, MEMBERS, MEMBERS], Suma::Organization => [:organization, MEMBERS, MANAGEMENT], + Suma::Payment::Account => [:payment_account, PAYMENTS, PAYMENTS], Suma::Payment::BankAccount => [:bank_account, MEMBERS, MEMBERS], Suma::Payment::BookTransaction => [:book_transaction, PAYMENTS, PAYMENTS], Suma::Payment::Card => [:card, MEMBERS, MEMBERS], Suma::Payment::FundingTransaction => [:funding_transaction, PAYMENTS, PAYMENTS], + Suma::Payment::Instrument => [:payment_instrument, MEMBERS, MEMBERS], Suma::Payment::Ledger => [:ledger, PAYMENTS, PAYMENTS], Suma::Payment::PayoutTransaction => [:payout_transaction, PAYMENTS, PAYMENTS], Suma::Payment::OffPlatformStrategy => [:off_platform_payment, PAYMENTS, PAYMENTS], diff --git a/lib/suma/admin_api/anon_proxy_vendor_configurations.rb b/lib/suma/admin_api/anon_proxy_vendor_configurations.rb index d194f9e4c..11700ffbd 100644 --- a/lib/suma/admin_api/anon_proxy_vendor_configurations.rb +++ b/lib/suma/admin_api/anon_proxy_vendor_configurations.rb @@ -10,7 +10,7 @@ class DetailedVendorConfigurationEntity < AnonProxyVendorConfigurationEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose_related :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true expose_related :programs, with: ProgramEntity expose :description_text, with: TranslatedTextEntity expose :help_text, with: TranslatedTextEntity diff --git a/lib/suma/admin_api/charges.rb b/lib/suma/admin_api/charges.rb index 8f429e183..f6239cca7 100644 --- a/lib/suma/admin_api/charges.rb +++ b/lib/suma/admin_api/charges.rb @@ -13,7 +13,7 @@ class DetailedChargeEntity < ChargeWithPricesEntity expose :member, with: MemberEntity expose :mobility_trip, with: MobilityTripEntity expose :commerce_order, with: OrderEntity - expose_related :line_items, with: ChargeLineItemEntity, all: true + expose_related :line_items, with: ChargeLineItemEntity, all: true, inherit_permissions: true expose_related :associated_funding_transactions, with: FundingTransactionEntity expose_related :contributing_book_transactions, with: BookTransactionEntity end diff --git a/lib/suma/admin_api/commerce_offerings.rb b/lib/suma/admin_api/commerce_offerings.rb index a954afa0d..63e7c8888 100644 --- a/lib/suma/admin_api/commerce_offerings.rb +++ b/lib/suma/admin_api/commerce_offerings.rb @@ -20,13 +20,14 @@ class DetailedOfferingEntity < OfferingEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose_related :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true expose :confirmation_template expose :description, with: TranslatedTextEntity expose :fulfillment_prompt, with: TranslatedTextEntity expose :fulfillment_instructions, with: TranslatedTextEntity expose :fulfillment_confirmation, with: TranslatedTextEntity - expose_related :fulfillment_options, with: OfferingFulfillmentOptionEntity, all: true + expose_related :fulfillment_options, + with: OfferingFulfillmentOptionEntity, all: true, inherit_permissions: true expose :begin_fulfillment_at expose_image :image expose_related :offering_products, with: OfferingProductEntity diff --git a/lib/suma/admin_api/commerce_orders.rb b/lib/suma/admin_api/commerce_orders.rb index 7bef16cd0..7012259f2 100644 --- a/lib/suma/admin_api/commerce_orders.rb +++ b/lib/suma/admin_api/commerce_orders.rb @@ -10,19 +10,21 @@ class ListOrderEntity < OrderEntity expose :total_item_count end - class CheckoutItemEntity < BaseEntity + class CheckoutItemEntity < BaseModelEntity include Suma::AdminAPI::Entities + model Suma::Commerce::CheckoutItem expose :id expose :offering_product, with: OfferingProductEntity expose :quantity expose :checkout_id end - class CheckoutEntity < BaseEntity + class CheckoutEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Commerce::Checkout expose :undiscounted_cost, with: MoneyEntity expose :customer_cost, with: MoneyEntity expose :savings, with: MoneyEntity @@ -31,6 +33,7 @@ class CheckoutEntity < BaseEntity expose :total, with: MoneyEntity expose :payment_instrument, with: PaymentInstrumentEntity expose :fulfillment_option, with: OfferingFulfillmentOptionEntity + expose_related :items, with: CheckoutItemEntity, all: true, inherit_permissions: true end class OrderAuditLogEntity < AuditLogEntity @@ -43,10 +46,9 @@ class DetailedCommerceOrderEntity < OrderEntity expose :serial expose :charge, with: ChargeWithPricesEntity - expose_related :audit_logs, with: OrderAuditLogEntity + expose_related :audit_logs, with: OrderAuditLogEntity, inherit_permissions: true expose :offering, with: OfferingEntity, &self.delegate_to(:checkout, :cart, :offering) expose :checkout, with: CheckoutEntity - expose :items, with: CheckoutItemEntity, &self.delegate_to(:checkout, :items) end resource :commerce_orders do @@ -60,5 +62,15 @@ class DetailedCommerceOrderEntity < OrderEntity Suma::Commerce::Order, DetailedCommerceOrderEntity, ) + Suma::AdminAPI::CommonEndpoints.related( + self, + Suma::Commerce::Order, + Suma::Commerce::CheckoutItem, + CheckoutItemEntity, + :checkout_items, + inherit_permissions: true, + route_name: :items, + dataset_method: :checkout_items_dataset + ) end end diff --git a/lib/suma/admin_api/common_endpoints.rb b/lib/suma/admin_api/common_endpoints.rb index 1594489fd..17bf2aec8 100644 --- a/lib/suma/admin_api/common_endpoints.rb +++ b/lib/suma/admin_api/common_endpoints.rb @@ -248,27 +248,40 @@ def self.get_one(route_def, model_type, entity, expose_related: true) end end end - self.all_related(route_def, entity) if expose_related + self.related_children(route_def, entity) if expose_related end + # Expose a related sub-resources automatically, as /:id/, + # which uses pagination over the dataset or association. + # + # @param include_permissions [true,false] If true, use model_type for the role check, + # instead of the related entity model. Needed for related resources like support notes. + # @param route_name [Symbol] The route name to use instead of association_name. + # @param dataset_method [Symbol] The dataset method to use, where there is no associaiton with the given name. def self.related( route_def, model_type, related_model_type, related_entity, - association_name + association_name, + inherit_permissions: false, + route_name: nil, + dataset_method: nil ) route_def.instance_exec do route_param :id, type: Integer do params do use :pagination end - get association_name do + get route_name || association_name do check_admin_role_access!(:read, model_type) - check_admin_role_access!(:read, related_model_type) + check_admin_role_access!(:read, inherit_permissions ? model_type : related_model_type) (m = model_type[params[:id]]) or forbidden! - assoc = m.class.association_reflections.fetch(association_name) - ds = m.send(assoc.fetch(:dataset_method)) + if dataset_method.nil? + assoc = m.class.association_reflections.fetch(association_name) + dataset_method = assoc.fetch(:dataset_method) + end + ds = m.send(dataset_method) ds = paginate(ds, params) present_collection ds, with: related_entity end @@ -276,14 +289,17 @@ def self.related( end end - def self.all_related(route_def, entity) + # Expose related child entities, like `/charge/:id/items. + def self.related_children(route_def, entity) entity.exposed_related.each do |h| + related_entity = h.fetch(:with) self.related( route_def, entity.model, - h.fetch(:with).model, - h.fetch(:with), + related_entity.model, + related_entity, h.fetch(:name), + inherit_permissions: h.fetch(:inherit_permissions), ) end end diff --git a/lib/suma/admin_api/entities.rb b/lib/suma/admin_api/entities.rb index 4fd0b77e8..67874701f 100644 --- a/lib/suma/admin_api/entities.rb +++ b/lib/suma/admin_api/entities.rb @@ -52,22 +52,33 @@ def inherited(subclass) def model(type=nil) return @model if type.nil? @model = type - @exposed_related = [] + @exposed_related ||= [] end # Expose a list field of this entity. # The field is exposed with a Collection entity so it can be paginated. # - # NOTE: Callers must implement these collection endpoints. + # NOTE: Callers must implement these collection endpoints, usually through CommonEndpoints.get_one. # See CommonEndpoints.related. # - # If dataset_method is given, it is the name of the method that returns - # the dataset for this exposure. - # Otherwise, _dataset is used if defined, otherwise is assumed to be an association - # and its configured dataset method is called. - def expose_related(name, with:, as: nil, dataset_method: nil, all: false) + # @param name [Symbol] Related name. The subroute gets this name if exposed with CommonEndpoints.get_one. + # The instance must have a _dataset method or name must be an association. + # @param with [Class] Entity to use in the expsoure. + # @param as [Symbol] The field on the entity will get this name. + # @param all [true,false] If true, load all items from the dataset when loading the collection. + # This preserves the 'collection' format exposure but does not require pagination. + # Useful when we always want to load all resources in admin. + # @param inherit_permissions [true,false] Some resources, like notes or audit logs, + # should inherit the permissions of their parent. + # If inherit_permissions is true, the permissions of the parent model are used, + # rather than the subresource. + # @param to_path [Proc] If given, this is called with (instance, options) + # to get the PATH_INFO (ie, /members/123) part of the route. + # Used for nested related exposures, so /member/123 + # can nest to something like /payment_accounts/123/ledgers. + def expose_related(name, with:, as: nil, all: false, inherit_permissions: false, to_path: nil) collection_entity = Suma::Service::Collection.prepare_entity(with) - ds_method = (dataset_method || "#{name}_dataset").to_sym + ds_method = :"#{name}_dataset" unless self.model.method_defined?(ds_method) raise ArgumentError, "must call #model before using expose_related, got: #{self.model.inspect}" unless self.model.respond_to?(:association_reflections) @@ -75,7 +86,7 @@ def expose_related(name, with:, as: nil, dataset_method: nil, all: false) raise ArgumentError, "#{self.model} does not has association #{name} or dataset #{ds_method}" if assoc.nil? ds_method = assoc.fetch(:dataset_method) end - @exposed_related << {name:, with:} + self.exposed_related << {name:, with:, inherit_permissions:} self.expose(name, as:, with: collection_entity) do |instance, options| ds = instance.send(ds_method) if all @@ -84,7 +95,8 @@ def expose_related(name, with:, as: nil, dataset_method: nil, all: false) ds = ds.paginate(1, Suma::Service.related_list_size) collection = Suma::Service::Collection.from_dataset(ds) end - collection.url = Suma::Service.request_path(options[:env]) + "/#{name}" + path_info = to_path ? to_path[instance, options] : nil + collection.url = Suma::Service.request_path(options[:env], path_info) + "/#{name}" collection end end @@ -258,11 +270,16 @@ class EligibilityAssignmentEntity < BaseModelEntity expose :attribute, with: EligibilityAttributeEntity end + class EligibilityRequirementResourceEntity < BaseEntity + include AutoExposeBase + end + class EligibilityRequirementEntity < BaseModelEntity include AutoExposeBase model Suma::Eligibility::Requirement expose :cached_expression_string, as: :expression_formula_str + expose :all_resources, as: :resources, with: EligibilityRequirementResourceEntity end class VendorEntity < BaseModelEntity @@ -296,7 +313,7 @@ class VendorServiceCategoryEntity < VendorServiceCategoryTerminalEntity end class VendorServiceRateUndiscountedrateEntity < BaseEntity - expose :id + include AutoExposeBase expose :internal_name end @@ -306,6 +323,8 @@ class VendorServiceRateEntity < BaseModelEntity model Suma::Vendor::ServiceRate expose :internal_name expose :external_name + expose :unit_offset + expose :ordinal expose :unit_amount, with: MoneyEntity expose :surcharge, with: MoneyEntity expose :undiscounted_rate, with: VendorServiceRateUndiscountedrateEntity @@ -389,16 +408,19 @@ class MobilityTripEntity < BaseModelEntity expose :total_cost, with: MoneyEntity, &self.delegate_to(:charge, :discounted_subtotal, safe: true) end - class SimpleLedgerEntity < BaseEntity + class SimpleLedgerEntity < BaseModelEntity include AutoExposeBase + model Suma::Payment::Ledger expose :name + expose :currency expose :account_name, &self.delegate_to(:account, :display_name) end - class SimplePaymentAccountEntity < BaseEntity + class SimplePaymentAccountEntity < BaseModelEntity include AutoExposeBase + model Suma::Payment::Account expose :display_name end @@ -440,30 +462,31 @@ class BookTransactionEntity < BaseModelEntity expose :actor, with: AuditMemberEntity end - class DetailedPaymentAccountLedgerEntity < BaseModelEntity - include AutoExposeBase - include AutoExposeDetail - - model Suma::Payment::Ledger - expose :currency - expose_related :vendor_service_categories, with: VendorServiceCategoryEntity - expose_related :combined_book_transactions, with: BookTransactionEntity - expose :balance, with: MoneyEntity - end - - class DetailedPaymentAccountEntity < BaseModelEntity - include AutoExposeBase - include AutoExposeDetail - - model Suma::Payment::Account - expose :member, with: MemberEntity - expose :vendor, with: VendorEntity - expose :is_platform_account - expose :ledgers, with: DetailedPaymentAccountLedgerEntity - expose :total_balance, with: MoneyEntity - expose_related :originated_funding_transactions, with: FundingTransactionEntity - expose_related :originated_payout_transactions, with: PayoutTransactionEntity - end + # class DetailedPaymentAccountLedgerEntity < BaseModelEntity + # include AutoExposeBase + # include AutoExposeDetail + # + # model Suma::Payment::Ledger + # route :payment_ledgers + # expose :currency + # expose_related :vendor_service_categories, with: VendorServiceCategoryEntity + # expose_related :combined_book_transactions, with: BookTransactionEntity + # expose :balance, with: MoneyEntity + # end + # + # class DetailedPaymentAccountEntity < BaseModelEntity + # include AutoExposeBase + # include AutoExposeDetail + # + # model Suma::Payment::Account + # expose :member, with: MemberEntity + # expose :vendor, with: VendorEntity + # expose :is_platform_account + # expose :ledgers, with: DetailedPaymentAccountLedgerEntity + # expose :total_balance, with: MoneyEntity + # expose_related :originated_funding_transactions, with: FundingTransactionEntity + # expose_related :originated_payout_transactions, with: PayoutTransactionEntity + # end class PaymentTriggerEntity < BaseModelEntity include Suma::AdminAPI::Entities @@ -497,7 +520,7 @@ class OfferingFulfillmentOptionEntity < BaseModelEntity class OfferingProductEntity < BaseModelEntity include AutoExposeBase - model Suma::Commerce::Offering + model Suma::Commerce::OfferingProduct expose :closed_at expose :product_id expose_translated :product_name, &self.delegate_to(:product, :name) diff --git a/lib/suma/admin_api/funding_transactions.rb b/lib/suma/admin_api/funding_transactions.rb index ffe99a33e..ff2483996 100644 --- a/lib/suma/admin_api/funding_transactions.rb +++ b/lib/suma/admin_api/funding_transactions.rb @@ -7,6 +7,10 @@ class Suma::AdminAPI::FundingTransactions < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities + class FundingAuditLogEntity < AuditLogEntity + model Suma::Payment::FundingTransaction::AuditLog + end + class DetailedFundingTransactionEntity < FundingTransactionEntity include Suma::AdminAPI::Entities include AutoExposeDetail @@ -19,8 +23,8 @@ class DetailedFundingTransactionEntity < FundingTransactionEntity expose :platform_ledger, with: SimpleLedgerEntity expose :originated_book_transaction, with: BookTransactionEntity expose :reversal_book_transaction, with: BookTransactionEntity - expose_related :audit_activities, with: ActivityEntity - expose_related :audit_logs, with: AuditLogEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true + expose_related :audit_logs, with: FundingAuditLogEntity, inherit_permissions: true expose :strategy, with: PaymentStrategyEntity end diff --git a/lib/suma/admin_api/members.rb b/lib/suma/admin_api/members.rb index 932679df2..edfdd1edf 100644 --- a/lib/suma/admin_api/members.rb +++ b/lib/suma/admin_api/members.rb @@ -59,11 +59,10 @@ class PreferencesSubscriptionEntity < BaseEntity expose :editable_state end - class PreferencesEntity < BaseModelEntity + class PreferencesEntity < BaseEntity include Suma::AdminAPI::Entities include AutoExposeBase - model Suma::Message::Preferences expose :public_url expose :subscriptions, with: PreferencesSubscriptionEntity expose :preferred_language_name @@ -95,6 +94,37 @@ class EligibilityMemberAssignmentEntity < BaseModelEntity expose :source_membership, with: OrganizationMembershipEntity end + class MemberDetailLedgerEntity < SimpleLedgerEntity + include Suma::AdminAPI::Entities + include AutoExposeDetail + + expose :balance, with: MoneyEntity + expose_related :vendor_service_categories, as: :categories, with: VendorServiceCategoryEntity, all: true + # expose_related :combined_book_transactions, + # with: BookTransactionEntity, + # to_path: ->(inst, _) { "/v1/payment_ledgers/#{inst.id}" } + end + + class MemberDetailPaymentAccountEntity < SimplePaymentAccountEntity + include Suma::AdminAPI::Entities + include AutoExposeDetail + + expose :total_balance, with: MoneyEntity + expose_related :ledgers, + with: MemberDetailLedgerEntity, + all: true, + to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } + expose_related :originated_funding_transactions, + with: FundingTransactionEntity, + to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } + expose_related :originated_payout_transactions, + with: PayoutTransactionEntity, + to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } + expose_related :all_book_transactions, + with: BookTransactionEntity, + to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } + end + class DetailedMemberEntity < MemberEntity include Suma::AdminAPI::Entities include AutoExposeDetail @@ -107,10 +137,10 @@ class DetailedMemberEntity < MemberEntity end expose :previous_emails - expose_related :activities, with: ActivityEntity - expose_related :audit_activities, with: ActivityEntity + expose_related :activities, with: ActivityEntity, inherit_permissions: true + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true expose :legal_entity, with: LegalEntityEntity - expose :payment_account, with: DetailedPaymentAccountEntity + expose :payment_account, with: MemberDetailPaymentAccountEntity expose_related :charges, with: ChargeEntity expose_related :eligibility_assignments, with: EligibilityAssignmentEntity expose_related :expanded_eligibility_assignments, with: EligibilityMemberAssignmentEntity @@ -120,7 +150,7 @@ class DetailedMemberEntity < MemberEntity expose_related :orders, with: MemberOrderEntity expose_related :payment_instruments, with: PaymentInstrumentEntity expose_related :message_deliveries, with: MessageDeliveryEntity - expose_related :combined_notes, as: :notes, with: SupportNoteEntity + expose_related :combined_notes, as: :notes, with: SupportNoteEntity, inherit_permissions: true expose :preferences!, as: :preferences, with: PreferencesEntity expose_related :anon_proxy_vendor_accounts, as: :vendor_accounts, with: MemberVendorAccountEntity expose_related :anon_proxy_contacts, as: :member_contacts, with: AnonProxyMemberContactEntity diff --git a/lib/suma/admin_api/organization_membership_verifications.rb b/lib/suma/admin_api/organization_membership_verifications.rb index 5ccf234fc..46e3125f0 100644 --- a/lib/suma/admin_api/organization_membership_verifications.rb +++ b/lib/suma/admin_api/organization_membership_verifications.rb @@ -16,7 +16,7 @@ class VerificationListEntity < OrganizationMembershipVerificationEntity expose :available_events, &self.delegate_to(:state_machine, :available_events) expose :front_partner_conversation_status expose :front_member_conversation_status - expose_related :combined_notes, as: :notes, with: SupportNoteEntity, all: true + expose_related :combined_notes, as: :notes, with: SupportNoteEntity, all: true, inherit_permissions: true expose :duplicate_risk end @@ -30,7 +30,7 @@ class DetailedMembershipVerificationEntity < VerificationListEntity expose :address, with: AddressEntity, &self.delegate_to(:membership, :member, :legal_entity, :address, safe: true) expose :organization_name expose :organization_name_editable?, as: :organization_name_editable - expose_related :audit_logs, with: AuditLogEntity + expose_related :audit_logs, with: AuditLogEntity, inherit_permissions: true expose :partner_outreach_front_conversation_id expose :member_outreach_front_conversation_id expose :duplicates do |instance| @@ -100,13 +100,6 @@ class DetailedMembershipVerificationEntity < VerificationListEntity Suma::Organization::Membership::Verification, DetailedMembershipVerificationEntity, ) - Suma::AdminAPI::CommonEndpoints.related( - self, - Suma::Organization::Membership::Verification, - Suma::Support::Note, - SupportNoteEntity, - :combined_notes, - ) Suma::AdminAPI::CommonEndpoints.update( self, diff --git a/lib/suma/admin_api/organization_memberships.rb b/lib/suma/admin_api/organization_memberships.rb index 0312b43ef..13160f56e 100644 --- a/lib/suma/admin_api/organization_memberships.rb +++ b/lib/suma/admin_api/organization_memberships.rb @@ -12,7 +12,7 @@ class DetailedOrganizationMembershipEntity < OrganizationMembershipEntity expose :matched_organization, with: OrganizationEntity expose :verification, with: OrganizationMembershipVerificationEntity - expose_related :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true end resource :organization_memberships do diff --git a/lib/suma/admin_api/organizations.rb b/lib/suma/admin_api/organizations.rb index 99a65a3d0..3e1dfe445 100644 --- a/lib/suma/admin_api/organizations.rb +++ b/lib/suma/admin_api/organizations.rb @@ -14,7 +14,7 @@ class DetailedOrganizationEntity < OrganizationEntity expose :membership_verification_email expose :membership_verification_front_template_id expose :membership_verification_member_outreach_template, with: TranslatedTextEntity - expose_related :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true expose_related :memberships, with: OrganizationMembershipEntity expose_related :former_memberships, with: OrganizationMembershipEntity expose_related :eligibility_assignments, with: EligibilityAssignmentEntity diff --git a/lib/suma/admin_api/payment_accounts.rb b/lib/suma/admin_api/payment_accounts.rb new file mode 100644 index 000000000..ba97f3165 --- /dev/null +++ b/lib/suma/admin_api/payment_accounts.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "suma/admin_api" + +class Suma::AdminAPI::PaymentAccounts < Suma::AdminAPI::V1 + include Suma::AdminAPI::Entities + + class PaymentAccountEntity < SimplePaymentAccountEntity + include Suma::AdminAPI::Entities + + expose :member, with: MemberEntity + expose :vendor, with: VendorEntity + expose :is_platform_account + end + + class LedgerEntity < SimpleLedgerEntity + include Suma::AdminAPI::Entities + + expose :balance, with: MoneyEntity + end + + class DetailedPaymentAccountEntity < PaymentAccountEntity + include Suma::AdminAPI::Entities + include AutoExposeDetail + expose_related :ledgers, with: LedgerEntity + expose :total_balance, with: MoneyEntity + expose_related :ledgers, with: LedgerEntity + expose_related :originated_funding_transactions, with: FundingTransactionEntity + expose_related :originated_payout_transactions, with: PayoutTransactionEntity + end + + resource :payment_accounts do + Suma::AdminAPI::CommonEndpoints.get_one( + self, + Suma::Payment::Account, + DetailedPaymentAccountEntity, + ) + end +end diff --git a/lib/suma/admin_api/payment_ledgers.rb b/lib/suma/admin_api/payment_ledgers.rb index 8a84db2ac..2a08f33c1 100644 --- a/lib/suma/admin_api/payment_ledgers.rb +++ b/lib/suma/admin_api/payment_ledgers.rb @@ -7,14 +7,10 @@ class Suma::AdminAPI::PaymentLedgers < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities - class LedgerEntity < BaseModelEntity + class LedgerEntity < SimpleLedgerEntity include Suma::AdminAPI::Entities - include AutoExposeBase - model Suma::Payment::Ledger - expose :name expose :is_platform_account, &self.delegate_to(:account, :is_platform_account) - expose :currency expose :balance, with: MoneyEntity expose :member, with: MemberEntity, &self.delegate_to(:account, :member) end diff --git a/lib/suma/admin_api/payment_triggers.rb b/lib/suma/admin_api/payment_triggers.rb index b4dccab34..baf8dd51a 100644 --- a/lib/suma/admin_api/payment_triggers.rb +++ b/lib/suma/admin_api/payment_triggers.rb @@ -21,7 +21,7 @@ class DetailedPaymentTriggerEntity < PaymentTriggerEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose_related :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true expose :match_multiplier, &self.delegate_to(:match_multiplier, :to_f) expose :match_fraction, &self.delegate_to(:match_fraction, :to_f) expose :payer_fraction, &self.delegate_to(:payer_fraction, :to_f) @@ -32,7 +32,7 @@ class DetailedPaymentTriggerEntity < PaymentTriggerEntity expose :originating_ledger, with: SimpleLedgerEntity expose :receiving_ledger_name expose :receiving_ledger_contribution_text, with: TranslatedTextEntity - expose_related :executions, with: PaymentTriggerExecutionEntity + expose_related :executions, with: PaymentTriggerExecutionEntity, inherit_permissions: true expose_related :eligibility_requirements, with: EligibilityRequirementEntity end diff --git a/lib/suma/admin_api/payout_transactions.rb b/lib/suma/admin_api/payout_transactions.rb index 1df4ae36a..42b96746b 100644 --- a/lib/suma/admin_api/payout_transactions.rb +++ b/lib/suma/admin_api/payout_transactions.rb @@ -21,8 +21,8 @@ class DetailedPayoutTransactionEntity < PayoutTransactionEntity expose :originated_book_transaction, with: BookTransactionEntity expose :reversal_book_transaction, with: BookTransactionEntity expose :refunded_funding_transaction, with: FundingTransactionEntity - expose_related :audit_activities, with: ActivityEntity - expose_related :audit_logs, with: PayoutAuditLogEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true + expose_related :audit_logs, with: PayoutAuditLogEntity, inherit_permissions: true expose :strategy, with: PaymentStrategyEntity end diff --git a/lib/suma/admin_api/programs.rb b/lib/suma/admin_api/programs.rb index 9c13139b9..cc964895b 100644 --- a/lib/suma/admin_api/programs.rb +++ b/lib/suma/admin_api/programs.rb @@ -15,7 +15,7 @@ class DetailedProgramEntity < ProgramEntity expose_related :pricings, with: ProgramPricingEntity expose_related :anon_proxy_vendor_configurations, as: :configurations, with: AnonProxyVendorConfigurationEntity expose_related :eligibility_requirements, with: EligibilityRequirementEntity - expose_related :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true end resource :programs do diff --git a/lib/suma/admin_api/vendor_service_rates.rb b/lib/suma/admin_api/vendor_service_rates.rb index 2a63d1d85..25ced1efc 100644 --- a/lib/suma/admin_api/vendor_service_rates.rb +++ b/lib/suma/admin_api/vendor_service_rates.rb @@ -9,9 +9,7 @@ class DetailedVendorServiceRateEntity < VendorServiceRateEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :unit_offset - expose :ordinal - expose :undiscounted_rate, with: VendorServiceRateEntity + expose_related :discounted_rates, with: VendorServiceRateEntity expose_related :program_pricings, with: ProgramPricingEntity end diff --git a/lib/suma/admin_api/vendor_services.rb b/lib/suma/admin_api/vendor_services.rb index 569272f00..1cad6dcfb 100644 --- a/lib/suma/admin_api/vendor_services.rb +++ b/lib/suma/admin_api/vendor_services.rb @@ -9,7 +9,7 @@ class DetailedVendorServiceEntity < VendorServiceEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose_related :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true expose_related :categories, with: VendorServiceCategoryEntity expose_related :program_pricings, with: ProgramPricingEntity expose_image :image diff --git a/lib/suma/apps.rb b/lib/suma/apps.rb index 1a552a3ea..5b95eb692 100644 --- a/lib/suma/apps.rb +++ b/lib/suma/apps.rb @@ -64,6 +64,7 @@ require "suma/admin_api/organization_memberships" require "suma/admin_api/organization_registration_links" require "suma/admin_api/off_platform_transactions" +require "suma/admin_api/payment_accounts" require "suma/admin_api/payment_ledgers" require "suma/admin_api/payment_triggers" require "suma/admin_api/payout_transactions" @@ -140,6 +141,7 @@ class AdminAPI < Suma::Service mount Suma::AdminAPI::OrganizationMembershipVerifications mount Suma::AdminAPI::OrganizationRegistrationLinks mount Suma::AdminAPI::OffPlatformTransactions + mount Suma::AdminAPI::PaymentAccounts mount Suma::AdminAPI::PaymentLedgers mount Suma::AdminAPI::PaymentTriggers mount Suma::AdminAPI::PayoutTransactions diff --git a/lib/suma/commerce/order.rb b/lib/suma/commerce/order.rb index e7ada5843..dc8d34d43 100644 --- a/lib/suma/commerce/order.rb +++ b/lib/suma/commerce/order.rb @@ -125,6 +125,8 @@ def member = self.checkout.cart.member delegate :undiscounted_cost, :customer_cost, :savings, :handling, :taxable_cost, :tax, :total, to: :checkout + def checkout_items_dataset = self.checkout.items_dataset + def after_open_order_canceled return if self.fulfillment_status == "fulfilled" self.items_and_product_inventories.each do |ci, inv| diff --git a/lib/suma/service.rb b/lib/suma/service.rb index b2da4290e..4cbf89183 100644 --- a/lib/suma/service.rb +++ b/lib/suma/service.rb @@ -105,8 +105,8 @@ def self.encode_cookie(h) return s end - def self.request_path(env) - return env["SCRIPT_NAME"].to_s + env["PATH_INFO"].to_s + def self.request_path(env, path_info=nil) + return env["SCRIPT_NAME"].to_s + (path_info || env["PATH_INFO"]).to_s end # Build the Rack app according to the configured environment. diff --git a/lib/suma/vendor/service_rate.rb b/lib/suma/vendor/service_rate.rb index e724a1108..62a265c23 100644 --- a/lib/suma/vendor/service_rate.rb +++ b/lib/suma/vendor/service_rate.rb @@ -9,6 +9,7 @@ class Suma::Vendor::ServiceRate < Suma::Postgres::Model(:vendor_service_rates) plugin :money_fields, :unit_amount, :surcharge many_to_one :undiscounted_rate, key: :undiscounted_rate_id, class: "Suma::Vendor::ServiceRate" + one_to_many :discounted_rates, key: :undiscounted_rate_id, class: "Suma::Vendor::ServiceRate" one_to_many :program_pricings, class: "Suma::Program::Pricing", diff --git a/spec/suma/admin_api/commerce_orders_spec.rb b/spec/suma/admin_api/commerce_orders_spec.rb index 6140f7a1b..2b88e2024 100644 --- a/spec/suma/admin_api/commerce_orders_spec.rb +++ b/spec/suma/admin_api/commerce_orders_spec.rb @@ -76,7 +76,10 @@ def make_non_matching_items expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: o.id, - items: have_length(1), + checkout: include(items: include( + url: "/v1/commerce_orders/#{o.id}/items", + items: have_length(1), + )), ) end diff --git a/spec/suma/admin_api/common_endpoints_spec.rb b/spec/suma/admin_api/common_endpoints_spec.rb index 508537c3c..341a85ecc 100644 --- a/spec/suma/admin_api/common_endpoints_spec.rb +++ b/spec/suma/admin_api/common_endpoints_spec.rb @@ -172,6 +172,18 @@ def make_item(_i) expect(last_response).to have_status(403) expect(last_response).to have_json_body.that_includes(error: include(code: "role_check")) end + + it "automatically registers exposed related paths" do + v = Suma::Fixtures.vendor.create + service = Suma::Fixtures.vendor_service.create(vendor: v) + + get "/v1/vendors/#{v.id}/services" + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes( + items: have_same_ids_as(service), + ) + end end describe "update" do diff --git a/spec/suma/admin_api/members_spec.rb b/spec/suma/admin_api/members_spec.rb index 67417bedb..f8a413d3b 100644 --- a/spec/suma/admin_api/members_spec.rb +++ b/spec/suma/admin_api/members_spec.rb @@ -98,6 +98,31 @@ def make_item(i) expect(last_response).to have_json_body.that_includes(:roles, id: admin.id) end + it "returns proper paths to related resources" do + m = Suma::Fixtures.member.create + acct = Suma::Payment.as_account(m) + led = Suma::Payment.ensure_cash_ledger(m) + + get "/v1/members/#{m.id}" + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes( + roles: include(url: "/v1/members/#{m.id}/roles"), + activities: include(url: "/v1/members/#{m.id}/activities"), + payment_instruments: include(url: "/v1/members/#{m.id}/payment_instruments"), + notes: include(url: "/v1/members/#{m.id}/combined_notes"), + eligibility_assignments: include(url: "/v1/members/#{m.id}/eligibility_assignments"), + expanded_eligibility_assignments: include(url: "/v1/members/#{m.id}/expanded_eligibility_assignments"), + ) + expect(last_response_json_body[:payment_account]).to include( + originated_funding_transactions: include(url: "/v1/payment_accounts/#{acct.id}/originated_funding_transactions"), + ledgers: include(url: "/v1/payment_accounts/#{acct.id}/ledgers"), + ) + expect(last_response_json_body[:payment_account][:ledgers][:items].first).to include( + combined_book_transactions: include(url: "/v1/payment_ledgers/#{led.id}/combined_book_transactions"), + ) + end + it "403s if the member does not exist" do get "/v1/members/0" diff --git a/spec/suma/admin_api/payment_accounts_spec.rb b/spec/suma/admin_api/payment_accounts_spec.rb new file mode 100644 index 000000000..a9e7d253c --- /dev/null +++ b/spec/suma/admin_api/payment_accounts_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "suma/admin_api/payment_accounts" +require "suma/api/behaviors" + +RSpec.describe Suma::AdminAPI::PaymentAccounts, :db do + include Rack::Test::Methods + + let(:app) { described_class.build_app } + let(:admin) { Suma::Fixtures.member.admin.create } + + before(:each) do + login_as(admin) + end + + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/payment_accounts/#{Suma::Fixtures.payment_account.create.id}" + end + end + + describe "GET /v1/payment_accounts/:id" do + it "returns the object" do + acct = Suma::Fixtures.payment_account.create + + get "/v1/payment_accounts/#{acct.id}" + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes(id: acct.id) + end + + it "403s if the item does not exist" do + get "/v1/payment_accounts/0" + + expect(last_response).to have_status(403) + end + end +end From cf62ca3e8cf5b548cc068166647382a6395658f1 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 24 May 2026 13:35:46 -0700 Subject: [PATCH 11/22] Is it working!? --- .../components/PaymentAccountRelatedLists.jsx | 31 ++++++++++++------- lib/suma/admin_api/access.rb | 1 + lib/suma/admin_api/commerce_offerings.rb | 2 +- lib/suma/admin_api/commerce_orders.rb | 22 +++++++------ lib/suma/admin_api/common_endpoints.rb | 28 +++++++++-------- lib/suma/admin_api/entities.rb | 4 ++- lib/suma/admin_api/financials.rb | 17 ++++++---- lib/suma/admin_api/members.rb | 27 ++++++++-------- lib/suma/admin_api/payment_accounts.rb | 2 ++ lib/suma/payment/platform_status.rb | 10 ++++++ spec/suma/admin_api/financials_spec.rb | 10 ++++++ spec/suma/admin_api/members_spec.rb | 6 ++-- 12 files changed, 103 insertions(+), 57 deletions(-) diff --git a/adminapp/src/components/PaymentAccountRelatedLists.jsx b/adminapp/src/components/PaymentAccountRelatedLists.jsx index fa0e37c5b..23e91c402 100644 --- a/adminapp/src/components/PaymentAccountRelatedLists.jsx +++ b/adminapp/src/components/PaymentAccountRelatedLists.jsx @@ -1,14 +1,13 @@ import useRoleAccess from "../hooks/useRoleAccess"; import { dayjs } from "../modules/dayConfig"; -import { formatMoney } from "../shared/money"; +import formatDate from "../modules/formatDate"; +import { formatMoney, scaleMoney } from "../shared/money"; import Money from "../shared/react/Money"; import AdminLink from "./AdminLink"; -import LedgerBookTransactionsRelatedList from "./LedgerBookTransactionRelatedList"; import Link from "./Link"; import RelatedListRemote from "./RelatedListRemote"; import first from "lodash/first"; import get from "lodash/get"; -import map from "lodash/map"; import React from "react"; export default function PaymentAccountRelatedLists({ paymentAccount }) { @@ -78,14 +77,24 @@ export default function PaymentAccountRelatedLists({ paymentAccount }) { return cells; }} /> - {paymentAccount.ledgers.items.map((ledger) => ( - - ))} + [ + row.id, + formatDate(row.appliedAt), + + {scaleMoney( + row.amount, + row.originatingLedger.accountId === paymentAccount.id ? -1 : 1 + )} + , + , + , + ]} + /> ); } diff --git a/lib/suma/admin_api/access.rb b/lib/suma/admin_api/access.rb index 500033193..1ba4e44b0 100644 --- a/lib/suma/admin_api/access.rb +++ b/lib/suma/admin_api/access.rb @@ -48,6 +48,7 @@ class Suma::AdminAPI::Access Suma::Payment::Instrument => [:payment_instrument, MEMBERS, MEMBERS], Suma::Payment::Ledger => [:ledger, PAYMENTS, PAYMENTS], Suma::Payment::PayoutTransaction => [:payout_transaction, PAYMENTS, PAYMENTS], + Suma::Payment::PlatformStatus::Calculated => [:platform_status, PAYMENTS, PAYMENTS], Suma::Payment::OffPlatformStrategy => [:off_platform_payment, PAYMENTS, PAYMENTS], Suma::Payment::Trigger => [:payment_trigger, PAYMENTS, MANAGEMENT], Suma::Program => [:program, ALL, MANAGEMENT], diff --git a/lib/suma/admin_api/commerce_offerings.rb b/lib/suma/admin_api/commerce_offerings.rb index 63e7c8888..99f09f123 100644 --- a/lib/suma/admin_api/commerce_offerings.rb +++ b/lib/suma/admin_api/commerce_offerings.rb @@ -27,7 +27,7 @@ class DetailedOfferingEntity < OfferingEntity expose :fulfillment_instructions, with: TranslatedTextEntity expose :fulfillment_confirmation, with: TranslatedTextEntity expose_related :fulfillment_options, - with: OfferingFulfillmentOptionEntity, all: true, inherit_permissions: true + with: OfferingFulfillmentOptionEntity, all: true, inherit_permissions: true expose :begin_fulfillment_at expose_image :image expose_related :offering_products, with: OfferingProductEntity diff --git a/lib/suma/admin_api/commerce_orders.rb b/lib/suma/admin_api/commerce_orders.rb index 7012259f2..743a727cd 100644 --- a/lib/suma/admin_api/commerce_orders.rb +++ b/lib/suma/admin_api/commerce_orders.rb @@ -62,15 +62,17 @@ class DetailedCommerceOrderEntity < OrderEntity Suma::Commerce::Order, DetailedCommerceOrderEntity, ) - Suma::AdminAPI::CommonEndpoints.related( - self, - Suma::Commerce::Order, - Suma::Commerce::CheckoutItem, - CheckoutItemEntity, - :checkout_items, - inherit_permissions: true, - route_name: :items, - dataset_method: :checkout_items_dataset - ) + route_param :id, type: Integer do + Suma::AdminAPI::CommonEndpoints.related( + self, + Suma::Commerce::Order, + Suma::Commerce::CheckoutItem, + CheckoutItemEntity, + :checkout_items, + inherit_permissions: true, + route_name: :items, + dataset_method: :checkout_items_dataset, + ) + end end end diff --git a/lib/suma/admin_api/common_endpoints.rb b/lib/suma/admin_api/common_endpoints.rb index 17bf2aec8..65560a525 100644 --- a/lib/suma/admin_api/common_endpoints.rb +++ b/lib/suma/admin_api/common_endpoints.rb @@ -239,6 +239,7 @@ def self.list( end def self.get_one(route_def, model_type, entity, expose_related: true) + cend = self route_def.instance_exec do route_param :id, type: Integer do get do @@ -246,9 +247,9 @@ def self.get_one(route_def, model_type, entity, expose_related: true) (m = model_type[params[:id]]) or forbidden! present m, with: entity end + cend.related_children(route_def, entity) if expose_related end end - self.related_children(route_def, entity) if expose_related end # Expose a related sub-resources automatically, as /:id/, @@ -269,22 +270,23 @@ def self.related( dataset_method: nil ) route_def.instance_exec do - route_param :id, type: Integer do - params do - use :pagination - end - get route_name || association_name do - check_admin_role_access!(:read, model_type) - check_admin_role_access!(:read, inherit_permissions ? model_type : related_model_type) - (m = model_type[params[:id]]) or forbidden! - if dataset_method.nil? + params do + use :pagination + end + get route_name || association_name do + check_admin_role_access!(:read, model_type) + check_admin_role_access!(:read, inherit_permissions ? model_type : related_model_type) + (m = model_type[params[:id]]) or forbidden! + if dataset_method.nil? + dataset_method = :"#{association_name}_dataset" + unless m.respond_to?(dataset_method) assoc = m.class.association_reflections.fetch(association_name) dataset_method = assoc.fetch(:dataset_method) end - ds = m.send(dataset_method) - ds = paginate(ds, params) - present_collection ds, with: related_entity end + ds = m.send(dataset_method) + ds = paginate(ds, params) + present_collection ds, with: related_entity end end end diff --git a/lib/suma/admin_api/entities.rb b/lib/suma/admin_api/entities.rb index 67874701f..f02f0e4bd 100644 --- a/lib/suma/admin_api/entities.rb +++ b/lib/suma/admin_api/entities.rb @@ -52,7 +52,7 @@ def inherited(subclass) def model(type=nil) return @model if type.nil? @model = type - @exposed_related ||= [] + self.exposed_related ||= [] end # Expose a list field of this entity. @@ -314,6 +314,7 @@ class VendorServiceCategoryEntity < VendorServiceCategoryTerminalEntity class VendorServiceRateUndiscountedrateEntity < BaseEntity include AutoExposeBase + expose :internal_name end @@ -414,6 +415,7 @@ class SimpleLedgerEntity < BaseModelEntity model Suma::Payment::Ledger expose :name expose :currency + expose :account_id expose :account_name, &self.delegate_to(:account, :display_name) end diff --git a/lib/suma/admin_api/financials.rb b/lib/suma/admin_api/financials.rb index 5396457d8..5eb7f8ef8 100644 --- a/lib/suma/admin_api/financials.rb +++ b/lib/suma/admin_api/financials.rb @@ -17,10 +17,11 @@ class LedgerEntity < SimpleLedgerEntity expose :count_debits end - class OffPlatformTransactionEntity < BaseEntity + class OffPlatformTransactionEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Payment::OffPlatformStrategy expose :amount, with: MoneyEntity expose :transacted_at, &self.delegate_to(:strategy, :transacted_at) expose :note, &self.delegate_to(:strategy, :note) @@ -30,7 +31,7 @@ class OffPlatformTransactionEntity < BaseEntity class PlatformStatusEntity < BaseModelEntity include Suma::AdminAPI::Entities - model Suma::Payment::PlatformStatus + model Suma::Payment::PlatformStatus::Calculated expose :funding, with: MoneyEntity expose :funding_count expose :payouts, with: MoneyEntity @@ -46,10 +47,14 @@ class PlatformStatusEntity < BaseModelEntity end resource :financials do - get :platform_status do - check_admin_role_access!(:read, :admin_payments) - res = Suma::Payment::PlatformStatus.new.calculate - present res, with: PlatformStatusEntity + resource :platform_status do + get do + check_admin_role_access!(:read, :admin_payments) + res = Suma::Payment::PlatformStatus::Calculated.new + present res, with: PlatformStatusEntity + end + + Suma::AdminAPI::CommonEndpoints.related_children(self, PlatformStatusEntity) end end end diff --git a/lib/suma/admin_api/members.rb b/lib/suma/admin_api/members.rb index edfdd1edf..6d9f5bf5a 100644 --- a/lib/suma/admin_api/members.rb +++ b/lib/suma/admin_api/members.rb @@ -99,10 +99,11 @@ class MemberDetailLedgerEntity < SimpleLedgerEntity include AutoExposeDetail expose :balance, with: MoneyEntity - expose_related :vendor_service_categories, as: :categories, with: VendorServiceCategoryEntity, all: true - # expose_related :combined_book_transactions, - # with: BookTransactionEntity, - # to_path: ->(inst, _) { "/v1/payment_ledgers/#{inst.id}" } + expose_related :vendor_service_categories, + as: :categories, + with: VendorServiceCategoryEntity, + all: true, + to_path: ->(inst, _) { "/v1/payment_ledgers/#{inst.id}" } end class MemberDetailPaymentAccountEntity < SimplePaymentAccountEntity @@ -111,18 +112,18 @@ class MemberDetailPaymentAccountEntity < SimplePaymentAccountEntity expose :total_balance, with: MoneyEntity expose_related :ledgers, - with: MemberDetailLedgerEntity, - all: true, - to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } + with: MemberDetailLedgerEntity, + all: true, + to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } expose_related :originated_funding_transactions, - with: FundingTransactionEntity, - to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } + with: FundingTransactionEntity, + to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } expose_related :originated_payout_transactions, - with: PayoutTransactionEntity, - to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } + with: PayoutTransactionEntity, + to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } expose_related :all_book_transactions, - with: BookTransactionEntity, - to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } + with: BookTransactionEntity, + to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } end class DetailedMemberEntity < MemberEntity diff --git a/lib/suma/admin_api/payment_accounts.rb b/lib/suma/admin_api/payment_accounts.rb index ba97f3165..12d473a31 100644 --- a/lib/suma/admin_api/payment_accounts.rb +++ b/lib/suma/admin_api/payment_accounts.rb @@ -22,11 +22,13 @@ class LedgerEntity < SimpleLedgerEntity class DetailedPaymentAccountEntity < PaymentAccountEntity include Suma::AdminAPI::Entities include AutoExposeDetail + expose_related :ledgers, with: LedgerEntity expose :total_balance, with: MoneyEntity expose_related :ledgers, with: LedgerEntity expose_related :originated_funding_transactions, with: FundingTransactionEntity expose_related :originated_payout_transactions, with: PayoutTransactionEntity + expose_related :all_book_transactions, with: BookTransactionEntity end resource :payment_accounts do diff --git a/lib/suma/payment/platform_status.rb b/lib/suma/payment/platform_status.rb index acc20ba3f..91c3efb41 100644 --- a/lib/suma/payment/platform_status.rb +++ b/lib/suma/payment/platform_status.rb @@ -80,4 +80,14 @@ def calculate # Unbalanced ledgers. These do not belong to the platform account, # since unbalanced member ledgers always mean unbalanced platform ledgers. def unbalanced_ledgers_dataset = self.find_unbalanced_ledgers_ds + + # Semantics for easier API usage. + class Calculated < self + def self.[](_) = self.new + + def initialize + super + self.calculate + end + end end diff --git a/spec/suma/admin_api/financials_spec.rb b/spec/suma/admin_api/financials_spec.rb index 6bd95bb8a..77ea84c9b 100644 --- a/spec/suma/admin_api/financials_spec.rb +++ b/spec/suma/admin_api/financials_spec.rb @@ -28,4 +28,14 @@ that_includes(:platform_ledgers, :funding) end end + + it "has related children" do + pa = Suma::Payment::Account.lookup_platform_account + cash = Suma::Payment.ensure_cash_ledger(pa) + + get "/v1/financials/platform_status/platform_ledgers" + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes(items: have_same_ids_as(cash)) + end end diff --git a/spec/suma/admin_api/members_spec.rb b/spec/suma/admin_api/members_spec.rb index f8a413d3b..c8a41c4cc 100644 --- a/spec/suma/admin_api/members_spec.rb +++ b/spec/suma/admin_api/members_spec.rb @@ -115,11 +115,13 @@ def make_item(i) expanded_eligibility_assignments: include(url: "/v1/members/#{m.id}/expanded_eligibility_assignments"), ) expect(last_response_json_body[:payment_account]).to include( - originated_funding_transactions: include(url: "/v1/payment_accounts/#{acct.id}/originated_funding_transactions"), + originated_funding_transactions: include( + url: "/v1/payment_accounts/#{acct.id}/originated_funding_transactions", + ), ledgers: include(url: "/v1/payment_accounts/#{acct.id}/ledgers"), ) expect(last_response_json_body[:payment_account][:ledgers][:items].first).to include( - combined_book_transactions: include(url: "/v1/payment_ledgers/#{led.id}/combined_book_transactions"), + categories: include(url: "/v1/payment_ledgers/#{led.id}/vendor_service_categories"), ) end From 15a7184c8021065ca3f511a7a016528d4355947b Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 24 May 2026 13:52:29 -0700 Subject: [PATCH 12/22] fix test --- spec/suma/admin_api_spec.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/spec/suma/admin_api_spec.rb b/spec/suma/admin_api_spec.rb index 66754623f..a7fa53024 100644 --- a/spec/suma/admin_api_spec.rb +++ b/spec/suma/admin_api_spec.rb @@ -51,15 +51,15 @@ class ModelEntity < Suma::AdminAPI::Entities::BaseModelEntity vendor = Suma::Vendor.find!(id: params[:id]) present vendor, with: ModelEntity end - end - Suma::AdminAPI::CommonEndpoints.related( - self, - Suma::Vendor, - Suma::Commerce::Product, - ChildEntity, - :products, - ) + Suma::AdminAPI::CommonEndpoints.related( + self, + Suma::Vendor, + Suma::Commerce::Product, + ChildEntity, + :products, + ) + end end end From c9f7a8f428081b9b6ad483e4e63e961a8486a2d9 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 24 May 2026 13:56:08 -0700 Subject: [PATCH 13/22] Turn combined_notes into an association This was a beast, as these things often go, but we need this for future pagination, and we need eager loading or things break for some reason due to tactical eager loading and large association warning. --- db/migrations/111_combined_notes.rb | 28 +++++++++ lib/suma/member.rb | 21 ++++--- .../organization/membership/verification.rb | 27 ++++++++- lib/suma/support/note.rb | 23 ++++---- spec/suma/member_spec.rb | 2 +- spec/suma/support/note_spec.rb | 57 +++++++++++++++++++ 6 files changed, 136 insertions(+), 22 deletions(-) create mode 100644 db/migrations/111_combined_notes.rb diff --git a/db/migrations/111_combined_notes.rb b/db/migrations/111_combined_notes.rb new file mode 100644 index 000000000..dc2144e65 --- /dev/null +++ b/db/migrations/111_combined_notes.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +Sequel.migration do + up do + create_view :support_notes_combined_view, + from(:support_notes). + left_join(:support_notes_members, {note_id: :id}). + select( + Sequel[:support_notes].*, + :member_id, + Sequel[nil].as(:verification_id), + ). + union( + from(:support_notes). + left_join(:support_notes_organization_membership_verifications, {note_id: :id}). + left_join(:organization_membership_verifications, {id: :verification_id}). + left_join(:organization_memberships, {id: :membership_id}). + select( + Sequel[:support_notes].*, + :member_id, + :verification_id, + ), + ).order(Sequel.desc(Sequel.function(:coalesce, :edited_at, :created_at)), :id) + end + down do + drop_view :support_notes_combined_view + end +end diff --git a/lib/suma/member.rb b/lib/suma/member.rb index 7eab91712..eb9c379e2 100644 --- a/lib/suma/member.rb +++ b/lib/suma/member.rb @@ -125,6 +125,18 @@ def initialize(reason) join_table: :support_notes_members, left_key: :member_id, order: order_desc + many_to_many :combined_notes, + class: "Suma::Support::Note", + eager_loader: (lambda do |eo| + eo[:rows].each { |p| p.associations[:combined_notes] = [] } + ds = self.db[:support_notes_combined_view].where(member_id: eo[:id_map].keys) + ds.all.each do |note| + member = eo[:id_map][note[:member_id]].first + member.associations[:combined_notes] << note + end + end) do |_ds| + Suma::Support::Note.for_member(self) + end one_to_many :eligibility_assignments, class: "Suma::Eligibility::Assignment", order: order_desc one_to_many :expanded_eligibility_assignments, @@ -276,15 +288,6 @@ def default_payment_instrument return self.public_payment_instruments.find { |pi| pi.status == :ok } end - def combined_notes - ds = Suma::Support::Note.combine_datasets( - Sequel[members: self], - Sequel[organization_membership_verifications: Suma::Organization::Membership::Verification. - where(membership: self.organization_memberships_dataset)], - ) - return ds.all - end - # @return [Suma::Member::StripeAttributes] def stripe return @stripe ||= Suma::Member::StripeAttributes.new(self) diff --git a/lib/suma/organization/membership/verification.rb b/lib/suma/organization/membership/verification.rb index 78ef03e13..66cf06dfc 100644 --- a/lib/suma/organization/membership/verification.rb +++ b/lib/suma/organization/membership/verification.rb @@ -44,6 +44,31 @@ class Suma::Organization::Membership::Verification < Suma::Postgres::Model(:orga join_table: :support_notes_organization_membership_verifications, left_key: :verification_id, order: order_desc + many_to_many :combined_notes, + class: "Suma::Support::Note", + eager_loader: (lambda do |eo| + eo[:rows].each { |p| p.associations[:combined_notes] = [] } + verifications_for_member_ids = {} + verifications_by_ids = {} + eo[:rows].each do |v| + verifications_for_member_ids[v.membership.member.id] ||= [] + verifications_for_member_ids[v.membership.member.id] << v + verifications_by_ids[v.id] = v + end + ds = self.db[:support_notes_combined_view].where(Sequel[member_id: verifications_for_member_ids.keys]) + ds.all.each do |note| + if (source_id = note[:verification_id]) + verifications_by_ids[source_id].associations[:combined_notes] << note + else + verifications_for_member_ids[note[:member_id]].each do |v| + v.associations[:combined_notes] << note + end + end + end + end) do |_ds| + Suma::Support::Note.for_verification(self) + end + many_to_one :owner, class: "Suma::Member" many_to_one :front_partner_conversation, @@ -340,8 +365,6 @@ def find_duplicates = DuplicateFinder.lookup_matches(self) # Duplicates are stored sorted so we can use the 0th item. def duplicate_risk = self.find_duplicates.first&.max_risk - def combined_notes = Suma::Support::Note.combine_instances(self.notes, self.membership.member.notes) - def rel_admin_link = "/membership-verification/#{self.id}" def hybrid_search_fields diff --git a/lib/suma/support/note.rb b/lib/suma/support/note.rb index ab390370d..48116ab37 100644 --- a/lib/suma/support/note.rb +++ b/lib/suma/support/note.rb @@ -19,18 +19,21 @@ class Suma::Support::Note < Suma::Postgres::Model(:support_notes) left_key: :note_id, right_key: :member_id - class << self - def combine_datasets(*exprs) - ds = self.reduce_expr(:|, exprs) + dataset_module do + def for_verification(verification) + criteria = Sequel[verification_id: verification.id] | + Sequel[member_id: verification.membership.member_id, verification_id: nil] + ds = self.db[:support_notes_combined_view].where(criteria) + ds = self.where(id: ds.select(:id)) ds = ds.order(Sequel.desc(Sequel.function(:coalesce, :edited_at, :created_at)), :id) return ds end - def combine_instances(*arrays) - notes = [].concat(*arrays) - notes.sort_by! { |n| [n.authored_at, -n.id] } - notes.reverse! - return notes + def for_member(member) + ds = self.db[:support_notes_combined_view].where(member_id: member.id) + ds = self.where(id: ds.select(:id)) + ds = ds.order(Sequel.desc(Sequel.function(:coalesce, :edited_at, :created_at)), :id) + return ds end end @@ -48,13 +51,13 @@ def content_md c = self.content start_of_string_regex = %r{^https?://\S+} c = c.gsub(start_of_string_regex) do |url| - # Since this is the start of the string, we can just format as markdown. + # Since this is the start of the string, we can just format as Markdown. "[#{url}](#{url})" end in_string_regex = %r{\shttps?://\S+} c = c.gsub(in_string_regex) do |match| - # The leading character is a space; remove it from the url and prepend it before the markdown link. + # The leading character is a space; remove it from the url and prepend it before the Markdown link. # We do not have matchdata available so cannot use capture groups. url = match[1..] "#{match[0]}[#{url}](#{url})" diff --git a/spec/suma/member_spec.rb b/spec/suma/member_spec.rb index f1a8a9e2b..0d7fe5b49 100644 --- a/spec/suma/member_spec.rb +++ b/spec/suma/member_spec.rb @@ -373,7 +373,7 @@ def skip_verification?(c, list=nil) end end - describe "#combine_notes" do + describe "#combined_notes" do it "selects and sorts all notes from related resources" do m = Suma::Fixtures.member.create v_fac = Suma::Fixtures.organization_membership_verification.member(m) diff --git a/spec/suma/support/note_spec.rb b/spec/suma/support/note_spec.rb index 47eb75161..fb24dfcd3 100644 --- a/spec/suma/support/note_spec.rb +++ b/spec/suma/support/note_spec.rb @@ -18,6 +18,63 @@ expect(ver.notes).to have_same_ids_as(note) end + describe "combined_notes association" do + it "combines and sorts verification and member notes" do + content = "hi" + v = Suma::Fixtures.organization_membership_verification.create + m = v.membership.member + vn1 = v.add_note(content:, edited_at: 4.hours.ago) + vn2 = v.add_note(content:, created_at: 2.hours.ago) + vn3 = v.add_note(content:, created_at: 3.hours.ago) + mn1 = m.add_note(content:, created_at: 5.hours.ago) + mn2 = m.add_note(content:, edited_at: 1.hours.ago) + + other_vn = Suma::Fixtures.organization_membership_verification.create.add_note(content:) + + expect(v.combined_notes).to have_same_ids_as(mn2, vn2, vn3, vn1, mn1).ordered + eagered_v = refetch_for_eager(v) + expect(eagered_v.combined_notes).to have_same_ids_as(mn2, vn2, vn3, vn1, mn1).ordered + + expect(m.combined_notes).to have_same_ids_as(mn2, vn2, vn3, vn1, mn1).ordered + eagered_m = refetch_for_eager(m) + expect(eagered_m.combined_notes).to have_same_ids_as(mn2, vn2, vn3, vn1, mn1).ordered + end + + it "handles notes on members shared between eager loaded instances" do + m1 = Suma::Fixtures.member.create + m2 = Suma::Fixtures.member.create + m1v1 = Suma::Fixtures.organization_membership_verification.member(m1).create + m1v2 = Suma::Fixtures.organization_membership_verification.member(m1).create + m2v1 = Suma::Fixtures.organization_membership_verification.member(m2).create + m1note = m1.add_note(content: "m1note") + m2note = m2.add_note(content: "m2note") + m1v1note = m1v1.add_note(content: "m1v1note") + m1v2note = m1v2.add_note(content: "m1v2note") + m2v1note = m2v1.add_note(content: "m2v1note") + + expect(m1v1.combined_notes).to have_same_ids_as(m1note, m1v1note) + expect(m1v2.combined_notes).to have_same_ids_as(m1note, m1v2note) + expect(m1.combined_notes).to have_same_ids_as(m1note, m1v1note, m1v2note) + expect(m2v1.combined_notes).to have_same_ids_as(m2note, m2v1note) + expect(m2.combined_notes).to have_same_ids_as(m2note, m2v1note) + + eagered_verifications = Suma::Organization::Membership::Verification.dataset.all + m1v1 = eagered_verifications.find { |v| v === m1v1 } + m1v2 = eagered_verifications.find { |v| v === m1v2 } + m2v1 = eagered_verifications.find { |v| v === m2v1 } + + eagered_members = Suma::Member.all + m1 = eagered_members.find { |m| m === m1 } + m2 = eagered_members.find { |m| m === m2 } + + expect(m1v1.combined_notes).to have_same_ids_as(m1note, m1v1note) + expect(m1v2.combined_notes).to have_same_ids_as(m1note, m1v2note) + expect(m1.combined_notes).to have_same_ids_as(m1note, m1v1note, m1v2note) + expect(m2v1.combined_notes).to have_same_ids_as(m2note, m2v1note) + expect(m2.combined_notes).to have_same_ids_as(m2note, m2v1note) + end + end + describe "rendering" do it "renders markdown to html" do m = Suma::Fixtures.member.create From 1d9cd06e2ae44cd7088f853565ad06976256988d Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 24 May 2026 13:56:34 -0700 Subject: [PATCH 14/22] Card: Add originated_funding_transactions dataset --- lib/suma/payment/card.rb | 12 +++++++++--- spec/suma/payment/card_spec.rb | 10 ++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/suma/payment/card.rb b/lib/suma/payment/card.rb index 26d673fbf..1d16ae8e1 100644 --- a/lib/suma/payment/card.rb +++ b/lib/suma/payment/card.rb @@ -17,6 +17,15 @@ class Suma::Payment::Card < Suma::Postgres::Model(:payment_cards) one_to_many :originated_funding_stripe_card_strategies, key: :originating_card_id, class: "Suma::Payment::FundingTransaction::StripeCardStrategy" + many_through_many :originated_funding_transactions, + [ + [:payment_funding_transaction_stripe_card_strategies, :originating_card_id, :id], + ], + class: "Suma::Payment::FundingTransaction", + left_primary_key: :id, + right_primary_key: :stripe_card_strategy_id, + read_only: true, + order: [:created_at, :id] dataset_module do def usable_for_funding = self.unexpired_as_of(Time.now) @@ -63,9 +72,6 @@ def refetch_remote_data @stripe_data = nil end - # Could move this to an association later - def originated_funding_transactions = self.originated_funding_stripe_card_strategies.map(&:funding_transaction) - def _external_links_self return [ self._external_link( diff --git a/spec/suma/payment/card_spec.rb b/spec/suma/payment/card_spec.rb index 23fa0db3f..ec4c83342 100644 --- a/spec/suma/payment/card_spec.rb +++ b/spec/suma/payment/card_spec.rb @@ -7,6 +7,16 @@ it_behaves_like "a payment instrument" + describe "associations" do + it "knows originated funding transactions" do + card = Suma::Fixtures.card.create + strategy = Suma::Payment::FundingTransaction::StripeCardStrategy.create(originating_card: card) + xaction = Suma::Fixtures.funding_transaction(strategy:).create + + expect(card.originated_funding_transactions).to have_same_ids_as(xaction) + end + end + it "knows when it is usable for funding and payouts" do c = Suma::Fixtures.card.create expect(c).to be_usable_for_funding From 51a3be98640fe7c5329292799ad818406e4aa675 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 24 May 2026 13:57:28 -0700 Subject: [PATCH 15/22] Vendor: Add discounted_rates association --- lib/suma/vendor/service_rate.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/suma/vendor/service_rate.rb b/lib/suma/vendor/service_rate.rb index e724a1108..62a265c23 100644 --- a/lib/suma/vendor/service_rate.rb +++ b/lib/suma/vendor/service_rate.rb @@ -9,6 +9,7 @@ class Suma::Vendor::ServiceRate < Suma::Postgres::Model(:vendor_service_rates) plugin :money_fields, :unit_amount, :surcharge many_to_one :undiscounted_rate, key: :undiscounted_rate_id, class: "Suma::Vendor::ServiceRate" + one_to_many :discounted_rates, key: :undiscounted_rate_id, class: "Suma::Vendor::ServiceRate" one_to_many :program_pricings, class: "Suma::Program::Pricing", From 4a3a4a018c38c98910068fd04d7bf449895e4541 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 24 May 2026 14:00:25 -0700 Subject: [PATCH 16/22] Add association loading performance plugins Add two plugins to help with association loading performance. - large association warning: warn when associations over a certain size are loaded. Prevents unexpectedly blowing out memory. - efficient each: Allows 1) reusing a loaded association array, 2) streaming a not-loaded association from the database, and 3) storing a streamed association into the array if it's just one page. Move each_cursor_page to this plugin. --- lib/sequel/plugins/efficient_each.rb | 85 +++++++++++ .../plugins/large_association_warning.rb | 55 +++++++ lib/suma/postgres/model.rb | 20 +++ lib/suma/postgres/model_utilities.rb | 51 ------- spec/sequel/plugins/efficient_each_spec.rb | 135 ++++++++++++++++++ .../plugins/large_association_warning_spec.rb | 60 ++++++++ spec/suma/postgres/model_spec.rb | 78 ++-------- 7 files changed, 365 insertions(+), 119 deletions(-) create mode 100644 lib/sequel/plugins/efficient_each.rb create mode 100644 lib/sequel/plugins/large_association_warning.rb create mode 100644 spec/sequel/plugins/efficient_each_spec.rb create mode 100644 spec/sequel/plugins/large_association_warning_spec.rb diff --git a/lib/sequel/plugins/efficient_each.rb b/lib/sequel/plugins/efficient_each.rb new file mode 100644 index 000000000..4bca33ab5 --- /dev/null +++ b/lib/sequel/plugins/efficient_each.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Sequel::Plugins::EfficientEach + class UnknownAssociation < ArgumentError; end + + DEFAULT_OPTIONS = { + page_size: 100, + }.freeze + + class << self + def configure(model, opts=DEFAULT_OPTIONS) + opts = DEFAULT_OPTIONS.merge(opts) + model.efficient_each_page_size = opts[:page_size] + end + end + + module ClassMethods + attr_accessor :efficient_each_page_size + + def inherited(subclass) + super + [:efficient_each_page_size].each do |m| + subclass.send("#{m}=", self.send(m)) + end + end + end + + module DatasetMethods + # Call a block for each row in a dataset. + # This is the same as paged_each or use_cursor.each, except that for each page, + # rows are re-fetched using self.where(primary_key => [pks]).all to enable eager loading. + # + # @param page_size [Integer] Size of each page. Smaller uses less memory. + # @param order [Symbol] Column to order by. Default to primary key. + # @param yield_page [true,false] If true, yield the page to the block, rather than individual rows. + # Helpful when bulk processing. + # + # (Note that paged_each does not do eager loading, which makes enumerating model associations very slow) + def each_cursor_page(page_size: nil, order: nil, yield_page: false, &block) + raise LocalJumpError unless block + raise "dataset requires a use_cursor method, class may need `extension(:pagination)`" unless + self.respond_to?(:use_cursor) + model = self.model + page_size ||= model.efficient_each_page_size + pk = model.primary_key + order ||= pk + current_chunk_pks = [] + order = [order] unless order.respond_to?(:to_ary) + self.naked.select(pk).order(*order).use_cursor(rows_per_fetch: page_size, hold: true).each do |row| + current_chunk_pks << row[pk] + next if current_chunk_pks.length < page_size + page = model.where(pk => current_chunk_pks).order(*order).all + current_chunk_pks.clear + yield_page ? yield(page) : page.each(&block) + end + remainder = model.where(pk => current_chunk_pks).order(*order).all + yield_page && !remainder.empty? ? yield(remainder) : remainder.each(&block) + end + end + + module InstanceMethods + def efficient_each(association_name, &) + return enum_for(:efficient_each, association_name) unless block_given? + + assoc = self.class.association_reflection(association_name) + raise UnknownAssociation, "#{self.class.name} has no association :#{association_name}" if + assoc.nil? + loaded = self.associations[association_name] + unless loaded.nil? + loaded.each(&) + return nil + end + dataset = self.send(assoc.fetch(:dataset_method)) + pagecount = 0 + prev_page = [] + dataset.each_cursor_page(yield_page: true) do |page| + pagecount += 1 + prev_page = page + page.each(&) + end + self.associations[association_name] = prev_page if pagecount < 2 + return nil + end + end +end diff --git a/lib/sequel/plugins/large_association_warning.rb b/lib/sequel/plugins/large_association_warning.rb new file mode 100644 index 000000000..5bb092b42 --- /dev/null +++ b/lib/sequel/plugins/large_association_warning.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Sequel::Plugins::LargeAssociationWarning + DEFAULT_CALLBACK = lambda do |m, assoc, array| + Appydays::Loggable[m].warn( + "large_assocation_loaded", + model_pk: m.primary_key, + model_type: m.class.name, + model_association: assoc, + model_association_size: array.size, + ) + end + + DEFAULT_OPTIONS = { + threshold: 100, + callback: DEFAULT_CALLBACK, + }.freeze + + class << self + attr_reader :warned_associations + + def configure(model, opts=DEFAULT_OPTIONS) + opts = DEFAULT_OPTIONS.merge(opts) + model.large_association_warning_threshold = opts[:threshold] + model.large_association_warning_callback = opts[:callback] + @warned_associations = Set.new + end + end + + module ClassMethods + attr_accessor :large_association_warning_threshold, :large_association_warning_callback + + def inherited(subclass) + super + [:large_association_warning_threshold, :large_association_warning_callback].each do |m| + subclass.send("#{m}=", self.send(m)) + end + end + end + + module InstanceMethods + def load_associated_objects(opts, dynamic_opts={}) + results = super + if results.is_a?(Array) && results.size > model.large_association_warning_threshold + assoc = opts.fetch(:name) + warn_key = [self.class, assoc] + unless Sequel::Plugins::LargeAssociationWarning.warned_associations.include?(warn_key) + Sequel::Plugins::LargeAssociationWarning.warned_associations.add(warn_key) + model.large_association_warning_callback[self, assoc, results] + end + end + return results + end + end +end diff --git a/lib/suma/postgres/model.rb b/lib/suma/postgres/model.rb index 5c38735ed..5620e56f2 100644 --- a/lib/suma/postgres/model.rb +++ b/lib/suma/postgres/model.rb @@ -35,6 +35,8 @@ class Suma::Postgres::Model setting :extension_schema, "public" + setting :large_association_warning_threshold, 500 + # The number of (Float) seconds that should be considered "slow" for a # single query; queries that take longer than this amount of time will be logged # at `warn` level. @@ -68,6 +70,24 @@ class Suma::Postgres::Model db.extension(:pg_interval) db.extension(:pretty_table) self.db = db + + plugin :large_association_warning, + threshold: self.large_association_warning_threshold, + callback: lambda { |m, assoc, array| + Sentry.capture_message("Large association loaded") do |scope| + scope.set_extras( + model_pk: m.pk, + model_type: m.class.name, + model_association: assoc, + model_association_size: array.size, + ) + end + Sequel::Plugins::LargeAssociationWarning::DEFAULT_CALLBACK[m, assoc, array] + } + plugin :efficient_each, + # Use a page size of the large warning threshold, + # as this is what we consider a reasonable page size. + page_size: self.large_association_warning_threshold end end diff --git a/lib/suma/postgres/model_utilities.rb b/lib/suma/postgres/model_utilities.rb index ac6d7d897..dd5cda732 100644 --- a/lib/suma/postgres/model_utilities.rb +++ b/lib/suma/postgres/model_utilities.rb @@ -361,57 +361,6 @@ def reduce_expr(op_symbol, operands, method: :where) return self.send(method, full_op) end - # Call a block for each row in a dataset. - # This is the same as paged_each or use_cursor.each, except that for each page, - # rows are re-fetched using self.where(primary_key => [pks]).all to enable eager loading. - # - # @param page_size [Integer] Size of each page. Smaller uses less memory. - # @param order [Symbol] Column to order by. Default to primary key. - # @param yield_page [true,false] If true, yield the page to the block, rather than individual rows. - # Helpful when bulk processing. - # - # (Note that paged_each does not do eager loading, which makes enumerating model associations very slow) - def each_cursor_page(page_size: 500, order: nil, yield_page: false, &block) - raise LocalJumpError unless block - raise "dataset requires a use_cursor method, class may need `extension(:pagination)`" unless - self.respond_to?(:use_cursor) - model = self.model - pk = model.primary_key - order ||= pk - current_chunk_pks = [] - order = [order] unless order.respond_to?(:to_ary) - self.naked.select(pk).order(*order).use_cursor(rows_per_fetch: page_size, hold: true).each do |row| - current_chunk_pks << row[pk] - next if current_chunk_pks.length < page_size - page = model.where(pk => current_chunk_pks).order(*order).all - current_chunk_pks.clear - yield_page ? yield(page) : page.each(&block) - end - remainder = model.where(pk => current_chunk_pks).order(*order).all - yield_page ? yield(remainder) : remainder.each(&block) - end - - # See each_cursor_page, but takes an additional action on each chunk of returned rows. - # The action is called with pages of return values from the block when a page is is reached. - # Each call to action should return nil, a result, or an array of results (nil results are ignored). - # - # The most common case is for ETL: process one dataset, map it in a block to return new row values, - # and multi_insert it into a different table. - def each_cursor_page_action(action:, page_size: 500, order: :id) - raise LocalJumpError unless block_given? - returned_rows_chunk = [] - self.each_cursor_page(page_size:, order:) do |instance| - new_row = yield(instance) - next if action.nil? || new_row.nil? - new_row.respond_to?(:to_ary) ? returned_rows_chunk.concat(new_row) : returned_rows_chunk.push(new_row) - if returned_rows_chunk.length >= page_size - action.call(returned_rows_chunk) - returned_rows_chunk.clear - end - end - action&.call(returned_rows_chunk) - end - # Reselect is shorthandle for "ds.select(Sequel[ds.model.table_name][Sequel.lit("*")])". # This is useful after a join that is used in the query, but we only want to return the original model. def reselect diff --git a/spec/sequel/plugins/efficient_each_spec.rb b/spec/sequel/plugins/efficient_each_spec.rb new file mode 100644 index 000000000..3f597708c --- /dev/null +++ b/spec/sequel/plugins/efficient_each_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "sequel/plugins/efficient_each" + +RSpec.describe Sequel::Plugins::EfficientEach, :db do + before(:all) do + @db = Sequel.connect(ENV.fetch("DATABASE_URL")) + @db.drop_table?(:efeach) + @db.create_table(:efeach) do + primary_key :id + text :name + foreign_key :parent_id, :efeach + end + end + after(:all) do + @db.disconnect + end + + let(:cls) do + Class.new(Sequel::Model(:efeach)) do + plugin :efficient_each, page_size: 2 + many_to_one :parent, class: self + one_to_many :children, class: self, key: :parent_id + end + end + + it "errors for an unknown association" do + n = cls.create + expect { n.efficient_each(:foo).first }.to raise_error(described_class::UnknownAssociation) + end + + it "copies configuration to subclasses" do + sub = Class.new(cls) + + expect(cls.efficient_each_page_size).to eq(2) + expect(sub.efficient_each_page_size).to eq(2) + end + + it "can work with a block or return an enumerator" do + parent = cls.create + ch = cls.create(parent:) + expect(parent.children).to contain_exactly(ch) + + expect(parent.efficient_each(:children).to_a).to contain_exactly(ch) + + calls = [] + parent.efficient_each(:children) { |r| calls << r } + expect(calls).to contain_exactly(ch) + end + + describe "with a loaded association" do + let(:parent) { cls.create } + let!(:children) { Array.new(3) { cls.create(parent:) } } + before(:each) do + parent.associations[:children] = children + end + + it "yields each item in the dataset" do + got = parent.efficient_each(:children).to_a + expect(got).to eq(children) + end + end + + describe "without a loaded association" do + let(:parent) { cls.create } + let!(:children) { Array.new(3) { cls.create(parent:) } } + before(:each) do + parent.refresh + end + + it "streams pages from the dataset" do + got = parent.efficient_each(:children).to_a + expect(got).to contain_exactly(be === children[0], be === children[1], be === children[2]) + end + + it "stores the association if there is only one page" do + children[2].destroy + got = parent.efficient_each(:children).to_a + expect(got).to contain_exactly(be === children[0], be === children[1]) + expect(parent.associations).to include(children: have_length(2)) + end + + it "handles an empty association array" do + children.each(&:destroy) + got = parent.efficient_each(:children).to_a + expect(got).to be_empty + expect(parent.associations).to include(children: []) + end + + it "does not store the association if there is more than one page" do + got = parent.efficient_each(:children).to_a + expect(got).to contain_exactly(be === children[0], be === children[1], be === children[2]) + expect(parent.associations).to be_empty + end + end + + describe "dataset method each_cursor_page" do + names = ["a", "b", "c", "d"] + let(:ds) { cls.dataset } + + before(:each) do + names.each { |n| cls.create(name: n) } + end + + it "yields each item to the block" do + result = [] + cls.dataset.each_cursor_page { |r| result << r.name } + expect(result).to eq(names) + end + + it "can order by a column" do + result = [] + cls.dataset.each_cursor_page(order: Sequel.desc(:name)) { |r| result << r.name } + expect(result).to eq(names.reverse) + end + + it "can order by multiple columns" do + result = [] + cls.dataset.each_cursor_page(order: [Sequel.desc(:name), :id]) { |r| result << r.name } + expect(result).to eq(names.reverse) + end + + it "can yield the full page rather than a row" do + result = [] + cls.dataset.each_cursor_page(yield_page: true) { |page| result << page.map(&:name) } + expect(result).to eq([["a", "b"], ["c", "d"]]) + end + + it "can use an override page size" do + result = [] + cls.dataset.each_cursor_page(yield_page: true, page_size: 4) { |page| result << page.map(&:name) } + expect(result).to eq([["a", "b", "c", "d"]]) + end + end +end diff --git a/spec/sequel/plugins/large_association_warning_spec.rb b/spec/sequel/plugins/large_association_warning_spec.rb new file mode 100644 index 000000000..53117d568 --- /dev/null +++ b/spec/sequel/plugins/large_association_warning_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "sequel/plugins/large_association_warning" + +RSpec.describe Sequel::Plugins::LargeAssociationWarning, :db do + before(:all) do + @db = Sequel.connect(ENV.fetch("DATABASE_URL")) + @db.drop_table?(:largeassocwarn) + @db.create_table(:largeassocwarn) do + primary_key :id + foreign_key :parent_id, :largeassocwarn + end + end + after(:all) do + @db.disconnect + end + + it "can use its default callback" do + cls = Class.new(Sequel::Model(:largeassocwarn)) do + plugin :large_association_warning, threshold: 2 + many_to_one :parent, class: self + one_to_many :children, class: self, key: :parent_id + end + + parent = cls.create + Array.new(3) { cls.create(parent:) } + parent.refresh.children + end + + it "warns about large associations" do + calls = [] + cls = Class.new(Sequel::Model(:largeassocwarn)) do + plugin :large_association_warning, threshold: 5, callback: ->(*args) { calls << args } + many_to_one :parent, class: self + one_to_many :children, class: self, key: :parent_id + end + + parent = cls.create + Array.new(5) { cls.create(parent:) } + parent.refresh.children + expect(calls).to be_empty + cls.create(parent:) + expect(calls).to be_empty + parent.refresh.children + expect(calls).to contain_exactly([parent, :children, have_length(6)]) + # Do not re-warn + parent.refresh.children + expect(calls).to contain_exactly([parent, :children, have_length(6)]) + end + + it "copies configuration to subclasses" do + base = Class.new(Sequel::Model(:largeassocwarn)) do + plugin :large_association_warning, threshold: 5 + end + sub = Class.new(base) + + expect(base.large_association_warning_threshold).to eq(5) + expect(sub.large_association_warning_threshold).to eq(5) + end +end diff --git a/spec/suma/postgres/model_spec.rb b/spec/suma/postgres/model_spec.rb index 67fe613e2..0b5e17ba0 100755 --- a/spec/suma/postgres/model_spec.rb +++ b/spec/suma/postgres/model_spec.rb @@ -510,74 +510,6 @@ def inspect = "MyCls" end end - describe "each_cursor_page" do - names = ["a", "b", "c", "d"] - cls = Suma::Postgres::TestingPixie - let(:ds) { cls.dataset } - - before(:each) do - names.each { |n| cls.create(name: n) } - end - - it "chunks pages and calls each item in the block" do - result = [] - cls.each_cursor_page(page_size: 2) { |r| result << r.name } - expect(result).to eq(names) - end - - it "can order by a column" do - result = [] - cls.each_cursor_page(page_size: 2, order: Sequel.desc(:name)) { |r| result << r.name } - expect(result).to eq(names.reverse) - end - - it "can order by multiple columns" do - result = [] - cls.each_cursor_page(page_size: 2, order: [Sequel.desc(:name), :id]) { |r| result << r.name } - expect(result).to eq(names.reverse) - end - - it "can yield the full page rather than a row" do - result = [] - cls.each_cursor_page(page_size: 3, yield_page: true) { |page| page.map(&:name).each { |n| result << n } } - expect(result).to eq(names) - end - - it "can perform an action on the returned values of each chunk" do - clean_ds = ds.exclude(Sequel.like(:name, "%prime")) # Avoid re-selecting the stuff we just inserted - clean_ds.each_cursor_page_action(page_size: 3, action: ds.method(:multi_insert)) do |tp| - {name: tp.name + "prime"} - end - expect(ds.order(:id).all.map(&:name)).to eq( - ["a", "b", "c", "d", "aprime", "bprime", "cprime", "dprime"], - ) - end - - it "can handle multiple return rows" do - action_calls = 0 - action = lambda { |v| - action_calls += 1 - ds.multi_insert(v) - } - cls.each_cursor_page_action(page_size: 3, action:) do |tp| - tp.name == "a" ? Array.new(10) { |i| {name: "a#{i}"} } : nil - end - expect(ds.order(:id).all.map(&:name)).to eq( - ["a", "b", "c", "d", "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9"], - ) - expect(action_calls).to eq(2) - end - - it "ignores nil results returned from the block" do - cls.each_cursor_page_action(page_size: 1, action: ds.method(:multi_insert)) do |tp| - tp.name >= "c" ? nil : {name: tp.name + "prime"} - end - expect(ds.order(:id).all.map(&:name)).to eq( - ["a", "b", "c", "d", "aprime", "bprime"], - ) - end - end - describe "one_to_many" do Suma::Postgres::Model.descendants.reject(&:anonymous?).each do |host_class| describe host_class.name do @@ -621,4 +553,14 @@ def inspect = "MyCls" end end end + + describe "large association plugin", reset_configuration: described_class do + it "warns to sentry" do + described_class.reset_configuration(large_association_warning_threshold: 3) + expect(Sentry).to receive(:capture_message).with("Large association loaded") + vendor = Suma::Fixtures.vendor.create + Array.new(4) { Suma::Fixtures.vendor_service(vendor:).create } + vendor.refresh.services + end + end end From 12e1aeda8d85d8f9c779a10a9f8eb8def4fb1226 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 24 May 2026 14:00:48 -0700 Subject: [PATCH 17/22] Add useDebugEffect to debug things on load. --- adminapp/src/shared/react/useDebugEffect.jsx | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 adminapp/src/shared/react/useDebugEffect.jsx diff --git a/adminapp/src/shared/react/useDebugEffect.jsx b/adminapp/src/shared/react/useDebugEffect.jsx new file mode 100644 index 000000000..ed37ddbeb --- /dev/null +++ b/adminapp/src/shared/react/useDebugEffect.jsx @@ -0,0 +1,22 @@ +import React from "react"; + +/** + * Just a `React.useEffect(cb, [])` that does not fire unless in development mode. + * @param cb + * @param {Array=} deps Dependency list. If not given, use empty list. + * @param {boolean=} once If true, fire just once, even in strict mode. + */ +export default function useDebugEffect(cb, { deps, once } = {}) { + const calledRef = React.useRef(false); + React.useEffect(() => { + if (process.env.NODE_ENV !== "development") { + return; + } + if (once && calledRef.current) { + return; + } + cb(); + calledRef.current = true; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps || []); +} From a6d1b91d13a4c462de5e3e817be1ddc01c5a4f21 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 24 May 2026 14:01:58 -0700 Subject: [PATCH 18/22] Ledger: Use efficient_each Loading combined_book_transactions could be very big. Use efficient_each to optimizing loading. --- lib/suma/payment/ledger.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/suma/payment/ledger.rb b/lib/suma/payment/ledger.rb index 535a179d2..31abefa41 100644 --- a/lib/suma/payment/ledger.rb +++ b/lib/suma/payment/ledger.rb @@ -230,7 +230,7 @@ def category_used_to_purchase(has_vnd_svc_categories) def find_unbalanced_counterparty_ledgers(include_all: false) platform_account = Suma::Payment::Account.lookup_platform_account totals_by_ledger = {} - self.combined_book_transactions.each do |bx| + self.efficient_each(:combined_book_transactions).each do |bx| if bx.originating_ledger === self counterparty = bx.receiving_ledger amount = bx.amount * -1 From 743f879d86751b872a1cf61f7c56067a108688de Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 24 May 2026 21:29:30 -0700 Subject: [PATCH 19/22] Fix issues with database corruption Reconfiguring the model replaced the DB which caused spec corruption. --- lib/suma/analytics/model.rb | 6 +++-- lib/suma/postgres/model.rb | 22 +++++++++-------- lib/suma/postgres/model_utilities.rb | 11 +++++++++ lib/suma/spec_helpers/postgres.rb | 37 +++++++++++++++++++--------- spec/suma/postgres/model_spec.rb | 28 ++++++++++++++++++--- 5 files changed, 76 insertions(+), 28 deletions(-) diff --git a/lib/suma/analytics/model.rb b/lib/suma/analytics/model.rb index b03969c28..009daaecf 100644 --- a/lib/suma/analytics/model.rb +++ b/lib/suma/analytics/model.rb @@ -29,8 +29,10 @@ class RowMismatch < StandardError; end max_connections: self.max_connections, pool_timeout: self.pool_timeout, } - db = Sequel.connect(self.uri, options) - self.db = db + if self.guard_db_reconnect(self.uri, options) + db = Sequel.connect(self.uri, options) + self.db = db + end end end diff --git a/lib/suma/postgres/model.rb b/lib/suma/postgres/model.rb index 5620e56f2..cbbde2d6e 100644 --- a/lib/suma/postgres/model.rb +++ b/lib/suma/postgres/model.rb @@ -60,16 +60,18 @@ class Suma::Postgres::Model pool_timeout: self.pool_timeout, log_warn_duration: self.slow_query_seconds, } - db = Sequel.connect(self.uri, options) - db.extension(:pagination) - db.extension(:pg_json) - db.extension(:pg_inet) - db.extension(:pg_array) - db.extension(:pg_streaming) - db.extension(:pg_range) - db.extension(:pg_interval) - db.extension(:pretty_table) - self.db = db + if self.guard_db_reconnect(self.uri, options) + db = Sequel.connect(self.uri, options) + db.extension(:pagination) + db.extension(:pg_json) + db.extension(:pg_inet) + db.extension(:pg_array) + db.extension(:pg_streaming) + db.extension(:pg_range) + db.extension(:pg_interval) + db.extension(:pretty_table) + self.db = db + end plugin :large_association_warning, threshold: self.large_association_warning_threshold, diff --git a/lib/suma/postgres/model_utilities.rb b/lib/suma/postgres/model_utilities.rb index dd5cda732..ec8c347ea 100644 --- a/lib/suma/postgres/model_utilities.rb +++ b/lib/suma/postgres/model_utilities.rb @@ -47,6 +47,17 @@ def update_connection_appname end end + def guard_db_reconnect?(uri, options) + if self.instance_variable_get(:@db).nil? + @original_options = [self.uri, options] + return true + end + raise Suma::InvalidPrecondition, "cannot modify DB at runtime" unless Suma.test? + raise Suma::InvalidPrecondition, "cannot modify DB with new parameters" unless + @original_options == [uri, options] + return false + end + # Some model classes just map Sequel models onto readonly connections. # These models should override this method and return true. def read_only? = false diff --git a/lib/suma/spec_helpers/postgres.rb b/lib/suma/spec_helpers/postgres.rb index 9509c15a1..84426debb 100644 --- a/lib/suma/spec_helpers/postgres.rb +++ b/lib/suma/spec_helpers/postgres.rb @@ -23,7 +23,7 @@ module Suma::SpecHelpers::Postgres SPECDIR = BASEDIR + "spec" DATADIR = SPECDIR + "data" - SNIFF_LEAKY_TESTS = false + SNIFF_LEAKY_TESTS = ENV["SNIFF_LEAKY_TESTS"] == "true" Suma::Postgres.register_model("suma/postgres/testing_pixie") SequelHybridSearch.indexing_mode = :off @@ -47,24 +47,15 @@ def self.included(context) Suma::SpecHelpers::Postgres.wrap_example_in_transactions(example) else example.run + truncate_all if example.metadata[:db] == :no_transaction end - has_leaked = SNIFF_LEAKY_TESTS && ( - !Suma::Member.empty? || - !Suma::TranslatedText.empty? - ) - if has_leaked - puts "Database is not cleaned up, failing for diagnosis." - puts "Check this or the spec that ran before: #{example.metadata[:full_description]}" - exit - end + SNIFF_LEAKY_TESTS && Suma::SpecHelpers::Postgres.check_for_leaky_tests end context.after(:each) do |example| Suma::Postgres.do_not_defer_events = false if example.metadata[:do_not_defer_events] Suma::Postgres.unsafe_skip_transaction_check = false if example.metadata[:no_transaction_check] SequelHybridSearch.embedding_generator = nil if example.metadata[:hybrid_search] - - truncate_all if example.metadata[:db] == :no_transaction end super @@ -82,6 +73,28 @@ def self.wrap_example_in_transactions(example) wrapped_proc.call end + def self.check_for_leaky_tests + raise "why is DB still in a transaction?" if Suma::Member.db.in_transaction? + ok_tables = [:schema_info, :uploaded_file_blobs, :roles] + bad_tables = {} + Suma::Postgres::Model.descendants.reject(&:anonymous?).each do |cls| + tbl = cls.table_name + next if ok_tables.include?(tbl) + next unless cls.db.table_exists?(tbl) + rows = cls.dataset.naked.all + next if rows.empty? + bad_tables[tbl] = rows + end + return if bad_tables.empty? + puts "Database is not cleaned up, failing for diagnosis." + puts "Check the last spec that ran" + bad_tables.each do |tbl, rows| + puts tbl + rows.each { |r| puts r } + end + exit + end + singleton_attr_accessor :current_test_model_uid self.current_test_model_uid = 0 diff --git a/spec/suma/postgres/model_spec.rb b/spec/suma/postgres/model_spec.rb index 0b5e17ba0..ac140de67 100755 --- a/spec/suma/postgres/model_spec.rb +++ b/spec/suma/postgres/model_spec.rb @@ -22,6 +22,24 @@ module SumaTestModels; end expect(subclass.db).to eq(described_class.db) end + describe "reconfiguring/replacing the database" do + it "allows modification of non-DB configuration", db: false do + subclass = create_model(:conn_setter) + expect(subclass.db).to equal(described_class.db) + + described_class.reset_configuration(large_association_warning_threshold: 1) + + expect do + described_class.reset_configuration(pool_timeout: 1) + end.to raise_error(Suma::InvalidPrecondition) + end + + it "errors if not in test env" do + stub_const("Suma::RACK_ENV", "development") + expect { described_class.reset_configuration }.to raise_error(Suma::InvalidPrecondition) + end + end + it "registers a topological dependency for associations" do subclass = create_model(:allergies) other_class = create_model(:food_preferences) @@ -554,13 +572,15 @@ def inspect = "MyCls" end end - describe "large association plugin", reset_configuration: described_class do + describe "large association plugin", reset_configuration: described_class, db: false do it "warns to sentry" do described_class.reset_configuration(large_association_warning_threshold: 3) expect(Sentry).to receive(:capture_message).with("Large association loaded") - vendor = Suma::Fixtures.vendor.create - Array.new(4) { Suma::Fixtures.vendor_service(vendor:).create } - vendor.refresh.services + described_class.db.transaction(rollback: :always) do + vendor = Suma::Fixtures.vendor.create + Array.new(4) { Suma::Fixtures.vendor_service(vendor:).create } + vendor.refresh.services + end end end end From 74fd571bf339803e0e5e4114c3f51a85dccda247 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 24 May 2026 21:29:30 -0700 Subject: [PATCH 20/22] Fix issues with database corruption Reconfiguring the model replaced the DB which caused spec corruption. --- lib/suma/analytics/model.rb | 6 +++-- lib/suma/postgres/model.rb | 22 +++++++++-------- lib/suma/postgres/model_utilities.rb | 11 +++++++++ lib/suma/spec_helpers/postgres.rb | 37 +++++++++++++++++++--------- spec/suma/postgres/model_spec.rb | 28 ++++++++++++++++++--- 5 files changed, 76 insertions(+), 28 deletions(-) diff --git a/lib/suma/analytics/model.rb b/lib/suma/analytics/model.rb index b03969c28..57d5dd6c8 100644 --- a/lib/suma/analytics/model.rb +++ b/lib/suma/analytics/model.rb @@ -29,8 +29,10 @@ class RowMismatch < StandardError; end max_connections: self.max_connections, pool_timeout: self.pool_timeout, } - db = Sequel.connect(self.uri, options) - self.db = db + if self.guard_db_reconnect?(self.uri, options) + db = Sequel.connect(self.uri, options) + self.db = db + end end end diff --git a/lib/suma/postgres/model.rb b/lib/suma/postgres/model.rb index 5620e56f2..1eee8ca9f 100644 --- a/lib/suma/postgres/model.rb +++ b/lib/suma/postgres/model.rb @@ -60,16 +60,18 @@ class Suma::Postgres::Model pool_timeout: self.pool_timeout, log_warn_duration: self.slow_query_seconds, } - db = Sequel.connect(self.uri, options) - db.extension(:pagination) - db.extension(:pg_json) - db.extension(:pg_inet) - db.extension(:pg_array) - db.extension(:pg_streaming) - db.extension(:pg_range) - db.extension(:pg_interval) - db.extension(:pretty_table) - self.db = db + if self.guard_db_reconnect?(self.uri, options) + db = Sequel.connect(self.uri, options) + db.extension(:pagination) + db.extension(:pg_json) + db.extension(:pg_inet) + db.extension(:pg_array) + db.extension(:pg_streaming) + db.extension(:pg_range) + db.extension(:pg_interval) + db.extension(:pretty_table) + self.db = db + end plugin :large_association_warning, threshold: self.large_association_warning_threshold, diff --git a/lib/suma/postgres/model_utilities.rb b/lib/suma/postgres/model_utilities.rb index dd5cda732..ec8c347ea 100644 --- a/lib/suma/postgres/model_utilities.rb +++ b/lib/suma/postgres/model_utilities.rb @@ -47,6 +47,17 @@ def update_connection_appname end end + def guard_db_reconnect?(uri, options) + if self.instance_variable_get(:@db).nil? + @original_options = [self.uri, options] + return true + end + raise Suma::InvalidPrecondition, "cannot modify DB at runtime" unless Suma.test? + raise Suma::InvalidPrecondition, "cannot modify DB with new parameters" unless + @original_options == [uri, options] + return false + end + # Some model classes just map Sequel models onto readonly connections. # These models should override this method and return true. def read_only? = false diff --git a/lib/suma/spec_helpers/postgres.rb b/lib/suma/spec_helpers/postgres.rb index 9509c15a1..84426debb 100644 --- a/lib/suma/spec_helpers/postgres.rb +++ b/lib/suma/spec_helpers/postgres.rb @@ -23,7 +23,7 @@ module Suma::SpecHelpers::Postgres SPECDIR = BASEDIR + "spec" DATADIR = SPECDIR + "data" - SNIFF_LEAKY_TESTS = false + SNIFF_LEAKY_TESTS = ENV["SNIFF_LEAKY_TESTS"] == "true" Suma::Postgres.register_model("suma/postgres/testing_pixie") SequelHybridSearch.indexing_mode = :off @@ -47,24 +47,15 @@ def self.included(context) Suma::SpecHelpers::Postgres.wrap_example_in_transactions(example) else example.run + truncate_all if example.metadata[:db] == :no_transaction end - has_leaked = SNIFF_LEAKY_TESTS && ( - !Suma::Member.empty? || - !Suma::TranslatedText.empty? - ) - if has_leaked - puts "Database is not cleaned up, failing for diagnosis." - puts "Check this or the spec that ran before: #{example.metadata[:full_description]}" - exit - end + SNIFF_LEAKY_TESTS && Suma::SpecHelpers::Postgres.check_for_leaky_tests end context.after(:each) do |example| Suma::Postgres.do_not_defer_events = false if example.metadata[:do_not_defer_events] Suma::Postgres.unsafe_skip_transaction_check = false if example.metadata[:no_transaction_check] SequelHybridSearch.embedding_generator = nil if example.metadata[:hybrid_search] - - truncate_all if example.metadata[:db] == :no_transaction end super @@ -82,6 +73,28 @@ def self.wrap_example_in_transactions(example) wrapped_proc.call end + def self.check_for_leaky_tests + raise "why is DB still in a transaction?" if Suma::Member.db.in_transaction? + ok_tables = [:schema_info, :uploaded_file_blobs, :roles] + bad_tables = {} + Suma::Postgres::Model.descendants.reject(&:anonymous?).each do |cls| + tbl = cls.table_name + next if ok_tables.include?(tbl) + next unless cls.db.table_exists?(tbl) + rows = cls.dataset.naked.all + next if rows.empty? + bad_tables[tbl] = rows + end + return if bad_tables.empty? + puts "Database is not cleaned up, failing for diagnosis." + puts "Check the last spec that ran" + bad_tables.each do |tbl, rows| + puts tbl + rows.each { |r| puts r } + end + exit + end + singleton_attr_accessor :current_test_model_uid self.current_test_model_uid = 0 diff --git a/spec/suma/postgres/model_spec.rb b/spec/suma/postgres/model_spec.rb index 0b5e17ba0..ac140de67 100755 --- a/spec/suma/postgres/model_spec.rb +++ b/spec/suma/postgres/model_spec.rb @@ -22,6 +22,24 @@ module SumaTestModels; end expect(subclass.db).to eq(described_class.db) end + describe "reconfiguring/replacing the database" do + it "allows modification of non-DB configuration", db: false do + subclass = create_model(:conn_setter) + expect(subclass.db).to equal(described_class.db) + + described_class.reset_configuration(large_association_warning_threshold: 1) + + expect do + described_class.reset_configuration(pool_timeout: 1) + end.to raise_error(Suma::InvalidPrecondition) + end + + it "errors if not in test env" do + stub_const("Suma::RACK_ENV", "development") + expect { described_class.reset_configuration }.to raise_error(Suma::InvalidPrecondition) + end + end + it "registers a topological dependency for associations" do subclass = create_model(:allergies) other_class = create_model(:food_preferences) @@ -554,13 +572,15 @@ def inspect = "MyCls" end end - describe "large association plugin", reset_configuration: described_class do + describe "large association plugin", reset_configuration: described_class, db: false do it "warns to sentry" do described_class.reset_configuration(large_association_warning_threshold: 3) expect(Sentry).to receive(:capture_message).with("Large association loaded") - vendor = Suma::Fixtures.vendor.create - Array.new(4) { Suma::Fixtures.vendor_service(vendor:).create } - vendor.refresh.services + described_class.db.transaction(rollback: :always) do + vendor = Suma::Fixtures.vendor.create + Array.new(4) { Suma::Fixtures.vendor_service(vendor:).create } + vendor.refresh.services + end end end end From 313c92823bb75f3e63157405b815d25a9d635a27 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Mon, 25 May 2026 08:32:34 -0700 Subject: [PATCH 21/22] Add editing, support expanding api fields, convert collections to aray on POST, improve formHelpers --- adminapp/src/components/AddressInputs.jsx | 4 +- adminapp/src/components/ImageFileInput.jsx | 4 +- adminapp/src/components/OneToManyEditor.jsx | 18 +++++-- adminapp/src/components/ResourceCreate.jsx | 3 +- adminapp/src/components/ResourceEdit.jsx | 12 +++-- adminapp/src/components/ResourceForm.jsx | 34 ++++++++---- adminapp/src/components/RoleEditor.jsx | 17 ++++-- .../VendorServiceCategoryMultiSelect.jsx | 14 +++-- adminapp/src/modules/apicollection.js | 53 +++++++++++++++++++ adminapp/src/modules/formHelpers.js | 14 ++--- .../src/pages/BookTransactionCreatePage.jsx | 4 +- .../EligibilityRequirementCreatePage.jsx | 6 ++- .../pages/EligibilityRequirementEditPage.jsx | 1 + .../src/pages/EligibilityRequirementForm.jsx | 8 +-- adminapp/src/pages/MarketingListEditPage.jsx | 21 ++++---- .../pages/MarketingSmsBroadcastEditPage.jsx | 10 ++-- adminapp/src/pages/OfferingCreatePage.jsx | 14 ++--- adminapp/src/pages/OfferingEditPage.jsx | 1 + adminapp/src/pages/OfferingForm.jsx | 15 ++++-- adminapp/src/pages/OrganizationCreatePage.jsx | 6 +-- adminapp/src/pages/OrganizationEditPage.jsx | 1 + .../src/pages/PaymentTriggerCreatePage.jsx | 11 ++-- adminapp/src/pages/ProductCreatePage.jsx | 10 ++-- adminapp/src/pages/ProductEditPage.jsx | 1 + adminapp/src/pages/ProductForm.jsx | 2 +- adminapp/src/pages/ProgramCreatePage.jsx | 13 +++-- adminapp/src/pages/ProgramEditPage.jsx | 1 + adminapp/src/pages/ProgramForm.jsx | 4 +- .../src/pages/RegistrationLinkCreatePage.jsx | 4 +- adminapp/src/pages/VendorAccountForm.jsx | 4 +- adminapp/src/pages/VendorCreatePage.jsx | 4 +- .../src/pages/VendorServiceCreatePage.jsx | 3 +- adminapp/src/pages/VendorServiceForm.jsx | 11 ++-- lib/suma/admin_api/entities.rb | 5 +- lib/suma/admin_api/organizations.rb | 5 +- spec/suma/admin_api_spec.rb | 16 ++++++ 36 files changed, 237 insertions(+), 117 deletions(-) create mode 100644 adminapp/src/modules/apicollection.js diff --git a/adminapp/src/components/AddressInputs.jsx b/adminapp/src/components/AddressInputs.jsx index 42fdfcfd5..bb6d45c09 100644 --- a/adminapp/src/components/AddressInputs.jsx +++ b/adminapp/src/components/AddressInputs.jsx @@ -1,6 +1,6 @@ import api from "../api"; import { useGlobalApiState } from "../hooks/globalApiState"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import ResponsiveStack from "./ResponsiveStack"; import AddIcon from "@mui/icons-material/Add"; import DeleteIcon from "@mui/icons-material/Delete"; @@ -23,7 +23,7 @@ export default function AddressInputs({ address, onFieldChange }) { onFieldChange({ address: { ...address, ...a } }); } function handleAddressOn() { - onFieldChange({ address: formHelpers.initialAddress }); + onFieldChange({ address: stub.address }); } function handleAddressOff() { onFieldChange({ address: null }); diff --git a/adminapp/src/components/ImageFileInput.jsx b/adminapp/src/components/ImageFileInput.jsx index a1c980850..f26d032c0 100644 --- a/adminapp/src/components/ImageFileInput.jsx +++ b/adminapp/src/components/ImageFileInput.jsx @@ -1,4 +1,4 @@ -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import MultiLingualText from "./MultiLingualText"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import { Button, FormHelperText, FormLabel, Stack } from "@mui/material"; @@ -18,7 +18,7 @@ import React from "react"; * @constructor */ function ImageFileInput({ image, caption, required, onImageChange, onCaptionChange }) { - caption = caption || formHelpers.initialTranslation; + caption = caption || stub.translation; let src = {}; if (image?.url) { src = image.url; diff --git a/adminapp/src/components/OneToManyEditor.jsx b/adminapp/src/components/OneToManyEditor.jsx index 267565800..c057d49e6 100644 --- a/adminapp/src/components/OneToManyEditor.jsx +++ b/adminapp/src/components/OneToManyEditor.jsx @@ -1,3 +1,4 @@ +import { assertFullCollection, setCollectionItems } from "../modules/apicollection"; import mergeAt from "../shared/mergeAt"; import withoutAt from "../shared/withoutAt"; import AutocompleteSearch from "./AutocompleteSearch"; @@ -7,20 +8,27 @@ import { Box, FormLabel, Icon, Stack } from "@mui/material"; import Button from "@mui/material/Button"; import React from "react"; -export default function OneToManyEditor({ title, items, setItems, apiItemSearch }) { +export default function OneToManyEditor({ + title, + collection, + setCollection, + apiItemSearch, +}) { + assertFullCollection(collection); + const handleAdd = () => { - setItems([...items, { id: 0 }]); + setCollectionItems(setCollection, [...collection.items, { id: 0 }]); }; const handleRemove = (index) => { - setItems(withoutAt(items, index)); + setCollectionItems(setCollection, withoutAt(collection.items, index)); }; function handleChange(index, fields) { - setItems(mergeAt(items, index, fields)); + setCollectionItems(setCollection, mergeAt(collection.items, index, fields)); } return ( <> {`${title}s`} - {items?.map((o, i) => ( + {collection.items?.map((o, i) => ( { - return apiCreate({ ...empty, ...changes }); + return apiCreate(simplifyCollections({ ...empty, ...changes })); }, [apiCreate, empty] ); diff --git a/adminapp/src/components/ResourceEdit.jsx b/adminapp/src/components/ResourceEdit.jsx index 3e8961a08..fadb90470 100644 --- a/adminapp/src/components/ResourceEdit.jsx +++ b/adminapp/src/components/ResourceEdit.jsx @@ -1,5 +1,6 @@ import FormLayout from "../components/FormLayout"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; +import { simplifyCollections } from "../modules/apicollection"; import useAsyncFetch from "../shared/react/useAsyncFetch"; import ResourceForm from "./ResourceForm"; import isEmpty from "lodash/isEmpty"; @@ -9,20 +10,21 @@ import { useParams } from "react-router-dom"; /** * @param apiGet API method to get the resource. + * @param expand Array of fields to expand in the API response. * @param apiUpdate API method to update the resource. * @param alwaysApply If false, show 'no changes to save' if there are no changes queued. * If true, always update on submit. Only useful where submitting no parameters * has some other side effect. * @param Form Form component to use. */ -export default function ResourceEdit({ apiGet, apiUpdate, alwaysApply, Form }) { +export default function ResourceEdit({ apiGet, expand, apiUpdate, alwaysApply, Form }) { const { enqueueErrorSnackbar } = useErrorSnackbar(); const { enqueueSnackbar } = useSnackbar(); const { id: idStr } = useParams(); const id = Number(idStr); const apiGetWithErr = React.useCallback(() => { - return apiGet({ id }).catch(enqueueErrorSnackbar); - }, [apiGet, enqueueErrorSnackbar, id]); + return apiGet({ id, expand }).catch(enqueueErrorSnackbar); + }, [apiGet, expand, enqueueErrorSnackbar, id]); const { state, loading, error } = useAsyncFetch(apiGetWithErr, { default: {}, pickData: true, @@ -34,9 +36,9 @@ export default function ResourceEdit({ apiGet, apiUpdate, alwaysApply, Form }) { window.history.back(); return Promise.resolve(); } - return apiUpdate({ id, ...changes }); + return apiUpdate(simplifyCollections({ id, expand, ...changes })); }, - [apiUpdate, enqueueSnackbar, id, alwaysApply] + [apiUpdate, expand, enqueueSnackbar, id, alwaysApply] ); // TODO: Add an error page at some point diff --git a/adminapp/src/components/ResourceForm.jsx b/adminapp/src/components/ResourceForm.jsx index 46b0055b0..c3e0bc39a 100644 --- a/adminapp/src/components/ResourceForm.jsx +++ b/adminapp/src/components/ResourceForm.jsx @@ -1,6 +1,7 @@ import api from "../api"; import useBusy from "../hooks/useBusy"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; +import { isCollection } from "../modules/apicollection"; import HelmetTitle from "./HelmetTitle"; import assign from "lodash/assign"; import isArray from "lodash/isArray"; @@ -61,18 +62,29 @@ export default function ResourceForm({ InnerForm, baseResource, isCreate, applyC [changes] ); - const resource = mergeWith({}, baseResource, changes, (obj, src) => { - // Since `_.merge()` only merges array indexes and does not replace arrays, - // we need to check for empty arrays and return them, also return src when - // it's an image or not an object (like a string). - // This allows nested resources and sub-nested resources to be removed/set - const isEmptyArray = isArray(src) && !isNil(src); - if (!isObject(src) || isEmptyArray || src instanceof File) { - return src; + const resource = mergeWith( + {}, + baseResource, + changes, + (objValue, srcValue, _key, _object, _source, _stack) => { + // Since `_.merge()` only merges array indexes and does not replace arrays, + // we need to check for empty arrays and return them, also return src when + // it's an image or not an object (like a string). + // This allows nested resources and sub-nested resources to be removed/set + const isArrayValue = isArray(srcValue) && !isNil(srcValue); + const isCollectionValue = isCollection(srcValue); + if ( + !isObject(srcValue) || + isArrayValue || + isCollectionValue || + srcValue instanceof File + ) { + return srcValue; + } + // Otherwise, return default object and src merge + return merge({}, objValue, srcValue); } - // Otherwise, return default object and src merge - return merge({}, obj, src); - }); + ); return ( <> r.data.items }); @@ -21,18 +24,22 @@ export default function RoleEditor({ roles, setRoles }) { return null; } + function setRoleItems(items) { + setRoles({ ...roles, items }); + } + function deleteRole(id) { - const newRoles = roles.filter((c) => c.id !== id); - setRoles(newRoles); + const newRoles = roles.items.filter((c) => c.id !== id); + setRoleItems(newRoles); } function handleAdd(newRole) { - const newRoles = [...roles, newRole]; - setRoles(newRoles); + const newRoles = [...roles.items, newRole]; + setRoleItems(newRoles); } const hasRoleIds = new Set(); - roles.forEach((r) => hasRoleIds.add(r.id)); + roles.items.forEach((r) => hasRoleIds.add(r.id)); return ( diff --git a/adminapp/src/components/VendorServiceCategoryMultiSelect.jsx b/adminapp/src/components/VendorServiceCategoryMultiSelect.jsx index b49f40460..c70117f80 100644 --- a/adminapp/src/components/VendorServiceCategoryMultiSelect.jsx +++ b/adminapp/src/components/VendorServiceCategoryMultiSelect.jsx @@ -1,5 +1,6 @@ import api from "../api"; import { useGlobalApiState } from "../hooks/globalApiState"; +import { assertFullCollection, itemsToCollection } from "../modules/apicollection"; import { Box, Chip, @@ -20,9 +21,10 @@ import React from "react"; */ const VendorServiceCategoryMultiSelect = React.forwardRef( function VendorServiceCategoryMultiSelect( - { value, helperText, label, className, style, onChange, ...rest }, + { collection, helperText, label, className, style, onChange, ...rest }, ref ) { + assertFullCollection(collection); const theme = useTheme(); const categories = useGlobalApiState( api.getVendorServiceCategoriesMeta, @@ -43,7 +45,7 @@ const VendorServiceCategoryMultiSelect = React.forwardRef( idCounts[c.id] = (idCounts[c.id] || 0) + 1; }); const nonDupeValues = values.filter((c) => idCounts[c.id] === 1); - onChange(e, nonDupeValues); + onChange(e, itemsToCollection(nonDupeValues)); }, [onChange] ); @@ -55,7 +57,7 @@ const VendorServiceCategoryMultiSelect = React.forwardRef( id="vscategory-select" multiple ref={ref} - value={value} + value={collection.items} label={label} input={} renderValue={(selected) => ( @@ -70,7 +72,11 @@ const VendorServiceCategoryMultiSelect = React.forwardRef( {...rest} > {categories.map((c) => ( - + {c.label} ))} diff --git a/adminapp/src/modules/apicollection.js b/adminapp/src/modules/apicollection.js new file mode 100644 index 000000000..7f88bb240 --- /dev/null +++ b/adminapp/src/modules/apicollection.js @@ -0,0 +1,53 @@ +import isNil from "lodash/isNil"; + +/** + * Raise unless the collection has items, + * and all have been returned from the API (hasMore). + * Check this before collection editing. + * @param collection + */ +export function assertFullCollection(collection) { + if (!collection.items) { + throw new Error("need to pass an API collection"); + } + if (collection.currentPage !== 1) { + throw new Error("collection must be loaded from page 1"); + } + if (collection.hasMore) { + throw new Error("collection must be loaded with all:true in the entity"); + } +} + +export function isCollection(v) { + return typeof v === "object" && !isNil(v) && Array.isArray(v.items); +} +/** + * Call set with a collection object with the given items. + * currentPage and hasMore are set so assertFullCollection passes. + * @param {function} set + * @param {Array} items + */ +export function setCollectionItems(set, items) { + set(itemsToCollection(items)); +} + +export function itemsToCollection(items) { + return { items, currentPage: 1, hasMore: false }; +} + +/** + * Given a POST body, convert any collection hashes into just + * an array of their items. + * @param {object} h + */ +export function simplifyCollections(h) { + const r = {}; + Object.entries(h).forEach(([k, v]) => { + if (isCollection(v)) { + r[k] = v.items; + } else { + r[k] = v; + } + }); + return r; +} diff --git a/adminapp/src/modules/formHelpers.js b/adminapp/src/modules/formHelpers.js index 483c8fb6a..91109762e 100644 --- a/adminapp/src/modules/formHelpers.js +++ b/adminapp/src/modules/formHelpers.js @@ -1,8 +1,6 @@ -const initialTranslation = { en: "", es: "" }; +const translation = { en: "", es: "" }; -const initialFulfillmentOption = { type: "pickup", description: initialTranslation }; - -const initialAddress = { +const address = { address1: "", address2: "", city: "", @@ -10,10 +8,6 @@ const initialAddress = { postalCode: "", }; -const formHelpers = { - initialTranslation, - initialFulfillmentOption, - initialAddress, -}; +const collection = { items: [], hasMore: false, currentPage: 1 }; -export default formHelpers; +export const stub = { translation, address, collection }; diff --git a/adminapp/src/pages/BookTransactionCreatePage.jsx b/adminapp/src/pages/BookTransactionCreatePage.jsx index 01673d9bf..fcb74b010 100644 --- a/adminapp/src/pages/BookTransactionCreatePage.jsx +++ b/adminapp/src/pages/BookTransactionCreatePage.jsx @@ -9,7 +9,7 @@ import VendorServiceCategorySelect from "../components/VendorServiceCategorySele import config from "../config"; import useBusy from "../hooks/useBusy"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import useMountEffect from "../shared/react/useMountEffect"; import SwapHorizontalIcon from "@mui/icons-material/SwapHoriz"; import { FormLabel, Stack } from "@mui/material"; @@ -26,7 +26,7 @@ export default function BookTransactionCreatePage() { const [originatingLedger, setOriginatingLedger] = React.useState(null); const [receivingLedger, setReceivingLedger] = React.useState(null); const [amount, setAmount] = React.useState(config.defaultZeroMoney); - const [memo, setMemo] = React.useState(formHelpers.initialTranslation); + const [memo, setMemo] = React.useState(stub.translation); const [category, setCategory] = React.useState(null); const { isBusy, busy, notBusy } = useBusy(); const { register, handleSubmit } = useForm(); diff --git a/adminapp/src/pages/EligibilityRequirementCreatePage.jsx b/adminapp/src/pages/EligibilityRequirementCreatePage.jsx index 08b090a0c..259cc5d94 100644 --- a/adminapp/src/pages/EligibilityRequirementCreatePage.jsx +++ b/adminapp/src/pages/EligibilityRequirementCreatePage.jsx @@ -1,10 +1,14 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; +import { stub } from "../modules/formHelpers"; import EligibilityRequirementForm from "./EligibilityRequirementForm"; import React from "react"; export default function EligibilityRequirementCreatePage() { - const empty = { programs: [], paymentTriggers: [] }; + const empty = { + programs: stub.collection, + paymentTriggers: stub.collection, + }; return ( ); diff --git a/adminapp/src/pages/EligibilityRequirementForm.jsx b/adminapp/src/pages/EligibilityRequirementForm.jsx index 8a44f172c..97facc5f9 100644 --- a/adminapp/src/pages/EligibilityRequirementForm.jsx +++ b/adminapp/src/pages/EligibilityRequirementForm.jsx @@ -26,14 +26,14 @@ export default function EligibilityRequirementForm({ setField("programs", o)} + collection={resource.programs} + setCollection={(o) => setField("programs", o)} apiItemSearch={api.searchPrograms} /> setField("paymentTriggers", o)} + collection={resource.paymentTriggers} + setCollection={(o) => setField("paymentTriggers", o)} apiItemSearch={api.searchPaymentTriggers} /> diff --git a/adminapp/src/pages/MarketingListEditPage.jsx b/adminapp/src/pages/MarketingListEditPage.jsx index 996cb0506..a14593c75 100644 --- a/adminapp/src/pages/MarketingListEditPage.jsx +++ b/adminapp/src/pages/MarketingListEditPage.jsx @@ -3,6 +3,7 @@ import FormLayout from "../components/FormLayout"; import ResourceEdit from "../components/ResourceEdit"; import useBusy from "../hooks/useBusy"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; +import { assertFullCollection, setCollectionItems } from "../modules/apicollection"; import useMountEffect from "../shared/react/useMountEffect"; import AddIcon from "@mui/icons-material/Add"; import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline"; @@ -35,6 +36,7 @@ export default function MarketingListEditPage() { return ( @@ -71,6 +73,7 @@ function EditForm({ resource, setField, setFieldFromInput, register, isBusy, onS } function Members({ members, setMembers }) { + assertFullCollection(members); const { enqueueErrorSnackbar } = useErrorSnackbar(); const [allMembers, setAllMembers] = React.useState([]); const [search, setSearchInner] = React.useState(""); @@ -101,24 +104,24 @@ function Members({ members, setMembers }) { useMountEffect(() => getMembersDebounced()); - const currentMemberIds = members.map(({ id }) => id); + const currentMemberIds = members.items.map(({ id }) => id); const eligibleMembers = allMembers.filter((m) => !currentMemberIds.includes(m.id)); function handleAdd(m) { - setMembers([...members, m]); + setCollectionItems(setMembers, [...members.items, m]); } function handleAddAll() { - setMembers([...members, ...eligibleMembers]); + setCollectionItems(setMembers, [...members.items, ...eligibleMembers]); } function handleRemoveAll() { - setMembers([]); + setCollectionItems(setMembers, []); } function handleRemove(m) { - const without = members.filter((m2) => m2.id !== m.id); - setMembers(without); + const without = members.items.filter((m2) => m2.id !== m.id); + setCollectionItems(setMembers, without); } return ( @@ -127,17 +130,17 @@ function Members({ members, setMembers }) { - List Members ({members.length}) + List Members ({members.items.length}) } onClick={handleRemoveAll} > Remove all members - {members.map((m) => ( + {members.items.map((m) => ( handleRemove(m)} dense> diff --git a/adminapp/src/pages/MarketingSmsBroadcastEditPage.jsx b/adminapp/src/pages/MarketingSmsBroadcastEditPage.jsx index fa4bd340f..7faab76c6 100644 --- a/adminapp/src/pages/MarketingSmsBroadcastEditPage.jsx +++ b/adminapp/src/pages/MarketingSmsBroadcastEditPage.jsx @@ -4,6 +4,7 @@ import ResourceEdit from "../components/ResourceEdit"; import ResponsiveStack from "../components/ResponsiveStack"; import { useGlobalApiState } from "../hooks/globalApiState"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; +import { setCollectionItems } from "../modules/apicollection"; import withoutAt from "../shared/withoutAt"; import { Card, @@ -33,6 +34,7 @@ export default function MarketingSmsBroadcastEditPage() { ); @@ -186,17 +188,17 @@ function BodyPreview({ register, resource, onBodyChange, language, preview }) { } function MarketingLists({ allLists, lists, setLists }) { - const checkedListIds = lists.map((l) => l.id); + const checkedListIds = lists.items.map((l) => l.id); const handleToggle = (value) => { const existingCheckedIdx = checkedListIds.indexOf(value); let newlyCheckedLists; if (existingCheckedIdx > -1) { - newlyCheckedLists = withoutAt(lists, existingCheckedIdx); + newlyCheckedLists = withoutAt(lists.items, existingCheckedIdx); } else { - newlyCheckedLists = [...lists, allLists.find((l) => l.id === value)]; + newlyCheckedLists = [...lists.items, allLists.find((l) => l.id === value)]; } - setLists(newlyCheckedLists); + setCollectionItems(setLists, newlyCheckedLists); }; return ( diff --git a/adminapp/src/pages/OfferingCreatePage.jsx b/adminapp/src/pages/OfferingCreatePage.jsx index edd60e738..589ab200a 100644 --- a/adminapp/src/pages/OfferingCreatePage.jsx +++ b/adminapp/src/pages/OfferingCreatePage.jsx @@ -1,19 +1,19 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; import { dayjs } from "../modules/dayConfig"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import OfferingForm from "./OfferingForm"; import React from "react"; export default function OfferingCreatePage() { const empty = { image: null, - imageCaption: formHelpers.initialTranslation, - description: formHelpers.initialTranslation, - fulfillmentPrompt: formHelpers.initialTranslation, - fulfillmentConfirmation: formHelpers.initialTranslation, - fulfillmentInstructions: formHelpers.initialTranslation, - fulfillmentOptions: [], + imageCaption: stub.translation, + description: stub.translation, + fulfillmentPrompt: stub.translation, + fulfillmentConfirmation: stub.translation, + fulfillmentInstructions: stub.translation, + fulfillmentOptions: stub.collection, periodBegin: dayjs().format(), periodEnd: dayjs().add(1, "day").format(), beginFulfillmentAt: null, diff --git a/adminapp/src/pages/OfferingEditPage.jsx b/adminapp/src/pages/OfferingEditPage.jsx index 2b5d4c875..91fd3ef87 100644 --- a/adminapp/src/pages/OfferingEditPage.jsx +++ b/adminapp/src/pages/OfferingEditPage.jsx @@ -8,6 +8,7 @@ export default function OfferingEditPage() { ); diff --git a/adminapp/src/pages/OfferingForm.jsx b/adminapp/src/pages/OfferingForm.jsx index 5a0c04dad..42a162c05 100644 --- a/adminapp/src/pages/OfferingForm.jsx +++ b/adminapp/src/pages/OfferingForm.jsx @@ -5,7 +5,7 @@ import MultiLingualText from "../components/MultiLingualText"; import ResponsiveStack from "../components/ResponsiveStack"; import SafeDateTimePicker from "../components/SafeDateTimePicker"; import { formatOrNull } from "../modules/dayConfig"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import mergeAt from "../shared/mergeAt"; import withoutAt from "../shared/withoutAt"; import AddIcon from "@mui/icons-material/Add"; @@ -160,19 +160,24 @@ export default function OfferingForm({ ); } +const stubFulfillmentOption = { type: "pickup", description: stub.translation }; + function FulfillmentOptions({ options, setOptions }) { + function setOptionItems(items) { + setOptions({ ...options, items }); + } const handleAdd = () => { - setOptions([...options, formHelpers.initialFulfillmentOption]); + setOptionItems([...options.items, stubFulfillmentOption]); }; const handleRemove = (index) => { - setOptions(withoutAt(options, index)); + setOptionItems(withoutAt(options.items, index)); }; function handleChange(index, fields) { - setOptions(mergeAt(options, index, fields)); + setOptionItems(mergeAt(options.items, index, fields)); } return ( <> - {options.map((o, i) => ( + {options.items.map((o, i) => ( ); diff --git a/adminapp/src/pages/PaymentTriggerCreatePage.jsx b/adminapp/src/pages/PaymentTriggerCreatePage.jsx index bf7e274dd..d583b60fe 100644 --- a/adminapp/src/pages/PaymentTriggerCreatePage.jsx +++ b/adminapp/src/pages/PaymentTriggerCreatePage.jsx @@ -1,6 +1,6 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import PaymentTriggerForm from "./PaymentTriggerForm"; import dayjs from "dayjs"; import React from "react"; @@ -8,13 +8,10 @@ import React from "react"; export default function PaymentTriggerCreatePage() { const empty = { label: "", - description: formHelpers.initialTranslation, + description: stub.translation, receivingLedgerName: "", - receivingLedgerContributionText: formHelpers.initialTranslation, - memo: formHelpers.initialTranslation, - fulfillmentPrompt: formHelpers.initialTranslation, - fulfillmentConfirmation: formHelpers.initialTranslation, - fulfillmentOptions: [formHelpers.initialFulfillmentOption], + receivingLedgerContributionText: stub.translation, + memo: stub.translation, activeDuringBegin: dayjs().format(), activeDuringEnd: dayjs().add(1, "day").format(), matchMultiplier: 1, diff --git a/adminapp/src/pages/ProductCreatePage.jsx b/adminapp/src/pages/ProductCreatePage.jsx index 4d6740f76..d47276e9e 100644 --- a/adminapp/src/pages/ProductCreatePage.jsx +++ b/adminapp/src/pages/ProductCreatePage.jsx @@ -1,18 +1,18 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import ProductForm from "./ProductForm"; import React from "react"; export default function ProductCreatePage() { const product = { image: null, - imageCaption: formHelpers.initialTranslation, - description: formHelpers.initialTranslation, - name: formHelpers.initialTranslation, + imageCaption: stub.translation, + description: stub.translation, + name: stub.translation, vendor: null, ordinal: 0, - vendorServiceCategories: [], + vendorServiceCategories: stub.collection, inventory: { maxQuantityPerMemberPerOrder: null, limitedQuantity: false, diff --git a/adminapp/src/pages/ProductEditPage.jsx b/adminapp/src/pages/ProductEditPage.jsx index 865af9aad..f4a82f891 100644 --- a/adminapp/src/pages/ProductEditPage.jsx +++ b/adminapp/src/pages/ProductEditPage.jsx @@ -8,6 +8,7 @@ export default function ProductEditPage() { ); diff --git a/adminapp/src/pages/ProductForm.jsx b/adminapp/src/pages/ProductForm.jsx index 4f82d32c1..2b1826076 100644 --- a/adminapp/src/pages/ProductForm.jsx +++ b/adminapp/src/pages/ProductForm.jsx @@ -99,7 +99,7 @@ export default function ProductForm({ {...register("category")} label="Category" helperText="What ledger funds can be used to purchase this product?" - value={resource.vendorServiceCategories} + collection={resource.vendorServiceCategories} style={{ flex: 1 }} onChange={(_, c) => setField("vendorServiceCategories", c)} /> diff --git a/adminapp/src/pages/ProgramCreatePage.jsx b/adminapp/src/pages/ProgramCreatePage.jsx index 91d30803f..77d5613ae 100644 --- a/adminapp/src/pages/ProgramCreatePage.jsx +++ b/adminapp/src/pages/ProgramCreatePage.jsx @@ -1,22 +1,21 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; import { dayjs } from "../modules/dayConfig"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import ProgramForm from "./ProgramForm"; import React from "react"; export default function ProgramCreatePage() { const empty = { image: null, - imageCaption: formHelpers.initialTranslation, - name: formHelpers.initialTranslation, - description: formHelpers.initialTranslation, + imageCaption: stub.translation, + name: stub.translation, + description: stub.translation, appLink: "", - appLinkText: formHelpers.initialTranslation, + appLinkText: stub.translation, periodBegin: dayjs().format(), periodEnd: dayjs().add(1, "day").format(), - vendorServices: [], - commerceOfferings: [], + commerceOfferings: stub.collection, ordinal: 0, }; return ( diff --git a/adminapp/src/pages/ProgramEditPage.jsx b/adminapp/src/pages/ProgramEditPage.jsx index 709e23564..59a2b2c0a 100644 --- a/adminapp/src/pages/ProgramEditPage.jsx +++ b/adminapp/src/pages/ProgramEditPage.jsx @@ -8,6 +8,7 @@ export default function ProgramEditPage() { ); diff --git a/adminapp/src/pages/ProgramForm.jsx b/adminapp/src/pages/ProgramForm.jsx index f41b25aae..f64b431ef 100644 --- a/adminapp/src/pages/ProgramForm.jsx +++ b/adminapp/src/pages/ProgramForm.jsx @@ -117,8 +117,8 @@ export default function ProgramForm({ /> setField("commerceOfferings", o)} + collection={resource.commerceOfferings} + setCollection={(o) => setField("commerceOfferings", o)} apiItemSearch={api.searchCommerceOffering} /> diff --git a/adminapp/src/pages/RegistrationLinkCreatePage.jsx b/adminapp/src/pages/RegistrationLinkCreatePage.jsx index fc8da2af1..23aac3ec3 100644 --- a/adminapp/src/pages/RegistrationLinkCreatePage.jsx +++ b/adminapp/src/pages/RegistrationLinkCreatePage.jsx @@ -1,12 +1,12 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import RegistrationLinkForm from "./RegistrationLinkForm"; import React from "react"; export default function RegistrationLinkCreatePage() { const empty = { - intro: formHelpers.initialTranslation, + intro: stub.translation, icalDtstart: null, icalDtend: null, icalRrule: "", diff --git a/adminapp/src/pages/VendorAccountForm.jsx b/adminapp/src/pages/VendorAccountForm.jsx index 2149c2fb9..8827844f5 100644 --- a/adminapp/src/pages/VendorAccountForm.jsx +++ b/adminapp/src/pages/VendorAccountForm.jsx @@ -29,14 +29,14 @@ export default function VendorAccountForm({ {...register("latestAccessCode")} label="Latest Access Code" name="latestAccessCode" - value={resource.latestAccessCode} + value={resource.latestAccessCode || ""} onChange={setFieldFromInput} /> diff --git a/adminapp/src/pages/VendorCreatePage.jsx b/adminapp/src/pages/VendorCreatePage.jsx index 95c3e02a4..80565577c 100644 --- a/adminapp/src/pages/VendorCreatePage.jsx +++ b/adminapp/src/pages/VendorCreatePage.jsx @@ -1,10 +1,10 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import VendorForm from "./VendorForm"; import React from "react"; export default function VendorCreatePage() { - const empty = { image: null, imageCaption: formHelpers.initialTranslation, name: "" }; + const empty = { image: null, imageCaption: stub.translation, name: "" }; return ; } diff --git a/adminapp/src/pages/VendorServiceCreatePage.jsx b/adminapp/src/pages/VendorServiceCreatePage.jsx index ba830ec5d..ece48dba9 100644 --- a/adminapp/src/pages/VendorServiceCreatePage.jsx +++ b/adminapp/src/pages/VendorServiceCreatePage.jsx @@ -1,6 +1,7 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; import { dayjs } from "../modules/dayConfig"; +import { stub } from "../modules/formHelpers"; import VendorServiceForm from "./VendorServiceForm.jsx"; import React from "react"; @@ -9,7 +10,7 @@ export default function VendorServiceCreatePage() { internalName: "", externalName: "", vendor: { name: "" }, - categories: [], + categories: stub.collection, mobilityAdapterSetting: "no_adapter", periodBegin: dayjs().format(), periodEnd: dayjs().add(1, "day").format(), diff --git a/adminapp/src/pages/VendorServiceForm.jsx b/adminapp/src/pages/VendorServiceForm.jsx index 537cee8d1..caeada92f 100644 --- a/adminapp/src/pages/VendorServiceForm.jsx +++ b/adminapp/src/pages/VendorServiceForm.jsx @@ -95,7 +95,7 @@ export default function VendorServiceForm({ {...register("categories")} label="Categories" helperText="What ledger funds can be used to pay for this service?" - value={resource.categories} + collection={resource.categories} style={{ flex: 1 }} onChange={(_, c) => setField("categories", c)} /> @@ -136,14 +136,17 @@ export default function VendorServiceForm({ ); } -function MobilityAdapterSelect({ ...rest }) { +const MobilityAdapterSelect = React.forwardRef(function MobilityAdapterSelect( + { ...rest }, + ref +) { const data = useGlobalApiState( (data, ...args) => api.getVendorServiceMobilityAdapterOptions({ ...data }, ...args), { items: [] } ); return ( - {data.items.map(({ name, value }) => ( {name} @@ -151,4 +154,4 @@ function MobilityAdapterSelect({ ...rest }) { ))} ); -} +}); diff --git a/lib/suma/admin_api/entities.rb b/lib/suma/admin_api/entities.rb index f02f0e4bd..6e814af79 100644 --- a/lib/suma/admin_api/entities.rb +++ b/lib/suma/admin_api/entities.rb @@ -87,9 +87,12 @@ def expose_related(name, with:, as: nil, all: false, inherit_permissions: false, ds_method = assoc.fetch(:dataset_method) end self.exposed_related << {name:, with:, inherit_permissions:} + exposed_attr = (name || as).to_s self.expose(name, as:, with: collection_entity) do |instance, options| ds = instance.send(ds_method) - if all + # Do NOT overwrite the closure all or we get leakage/corruption + req_all = all || Rack::Request.new(options[:env]).GET.fetch("expand", []).include?(exposed_attr) + if req_all collection = Suma::Service::Collection.from_array(ds.all) else ds = ds.paginate(1, Suma::Service.related_list_size) diff --git a/lib/suma/admin_api/organizations.rb b/lib/suma/admin_api/organizations.rb index 3e1dfe445..dfcfc1e37 100644 --- a/lib/suma/admin_api/organizations.rb +++ b/lib/suma/admin_api/organizations.rb @@ -45,6 +45,7 @@ class DetailedOrganizationEntity < OrganizationEntity optional :membership_verification_email, type: String, allow_blank: true optional :membership_verification_front_template_id, type: String, allow_blank: true optional(:membership_verification_member_outreach_template, type: JSON) { use :translated_text, optional: true } + optional(:roles, type: Array[JSON]) { use :model_with_id } end end @@ -71,9 +72,7 @@ class DetailedOrganizationEntity < OrganizationEntity optional :membership_verification_email, type: String, allow_blank: true optional :membership_verification_front_template_id, type: String, allow_blank: true optional(:membership_verification_member_outreach_template, type: JSON) { use :translated_text, optional: true } - optional :roles, type: Array[JSON] do - use :model_with_id - end + optional(:roles, type: Array[JSON]) { use :model_with_id } end end end diff --git a/spec/suma/admin_api_spec.rb b/spec/suma/admin_api_spec.rb index a7fa53024..a840fb446 100644 --- a/spec/suma/admin_api_spec.rb +++ b/spec/suma/admin_api_spec.rb @@ -170,6 +170,22 @@ def method_missing(*); end ) end + it "can be expanded" do + get "/v1/model_with_related/#{vendor.id}", expand: ["products"] + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body. + that_includes( + products: include( + current_page: 1, + page_count: 1, + total_count: 5, + has_more: false, + items: have_length(5), + ), + ) + end + it "can expose a paginated list endpoint" do get "/v1/model_with_related/#{vendor.id}/products", page: 2, per_page: 2 From adc907bac9a0b3262b23b0aec29417c4e3073542 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Mon, 25 May 2026 09:08:20 -0700 Subject: [PATCH 22/22] coverage improvements --- lib/suma/admin_api/common_endpoints.rb | 2 +- lib/suma/postgres/model_utilities.rb | 9 --------- spec/spec_helper.rb | 2 ++ spec/suma/admin_api_spec.rb | 18 ++++++++++++++++++ spec/suma/anon_proxy/message_handler_spec.rb | 1 - spec/suma/api/anon_proxy_spec.rb | 1 - spec/suma/lime/sync_trips_from_report_spec.rb | 2 -- spec/suma/lyft/pass_spec.rb | 2 -- spec/suma/postgres/model_spec.rb | 4 +++- spec/suma/spec_helpers/sentry_spec.rb | 1 - 10 files changed, 24 insertions(+), 18 deletions(-) diff --git a/lib/suma/admin_api/common_endpoints.rb b/lib/suma/admin_api/common_endpoints.rb index 65560a525..60629496c 100644 --- a/lib/suma/admin_api/common_endpoints.rb +++ b/lib/suma/admin_api/common_endpoints.rb @@ -279,7 +279,7 @@ def self.related( (m = model_type[params[:id]]) or forbidden! if dataset_method.nil? dataset_method = :"#{association_name}_dataset" - unless m.respond_to?(dataset_method) + unless m.class.method_defined?(dataset_method) assoc = m.class.association_reflections.fetch(association_name) dataset_method = assoc.fetch(:dataset_method) end diff --git a/lib/suma/postgres/model_utilities.rb b/lib/suma/postgres/model_utilities.rb index 6391c6850..ec8c347ea 100644 --- a/lib/suma/postgres/model_utilities.rb +++ b/lib/suma/postgres/model_utilities.rb @@ -345,15 +345,6 @@ def set_ambiguous_association(assocs, v) end raise TypeError, "invalid association type: #{v.class}(#{v})" end - - # Yield each row in the association to the block. - # Use this method when iterating associations which may be large; - # if the association is already loaded, it can be used (loaded: :reuse) - # or an error can be raised (loaded: :raise, default). - # If the association is not loaded, paginate with each_cursor_page. - def each_row_efficient(association) - self.class.association_reflections.fetch(association) - end end module DatasetMethods diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 18fa8dde3..6cb51f554 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -79,5 +79,7 @@ config.include(Suma::SpecHelpers::Postgres) require "suma/spec_helpers/service" config.include(Suma::SpecHelpers::Service) + # Not widely needed so include only as needed + require "suma/spec_helpers/sentry" end end diff --git a/spec/suma/admin_api_spec.rb b/spec/suma/admin_api_spec.rb index a840fb446..a69fe6ce9 100644 --- a/spec/suma/admin_api_spec.rb +++ b/spec/suma/admin_api_spec.rb @@ -200,6 +200,24 @@ def method_missing(*); end items: have_length(2), ) end + + it "can sniff the dataset name from an association" do + expect(Suma::Vendor).to receive(:method_defined?). + with(:products_dataset). + and_return(false). + twice + ent = Class.new(Suma::AdminAPI::Entities::BaseModelEntity) do + model Suma::Vendor + expose_related :products, with: Suma::AdminAPI::Entities::BaseModelEntity + end + v = Suma::Fixtures.vendor.create + expect(JSON.parse(ent.represent(v, {env: {}}).to_json)).to include("products" => include("items")) + + get "/v1/model_with_related/#{vendor.id}/products" + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes(:items) + end end end diff --git a/spec/suma/anon_proxy/message_handler_spec.rb b/spec/suma/anon_proxy/message_handler_spec.rb index 0a9255aaa..69a162f67 100644 --- a/spec/suma/anon_proxy/message_handler_spec.rb +++ b/spec/suma/anon_proxy/message_handler_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "url_shortener/spec_helpers" -require "suma/spec_helpers/sentry" RSpec.describe Suma::AnonProxy::MessageHandler, :db do include UrlShortener::SpecHelpers diff --git a/spec/suma/api/anon_proxy_spec.rb b/spec/suma/api/anon_proxy_spec.rb index c08bcf281..da9daeea4 100644 --- a/spec/suma/api/anon_proxy_spec.rb +++ b/spec/suma/api/anon_proxy_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "suma/api/anon_proxy" -require "suma/spec_helpers/sentry" RSpec.describe Suma::API::AnonProxy, :db do include Rack::Test::Methods diff --git a/spec/suma/lime/sync_trips_from_report_spec.rb b/spec/suma/lime/sync_trips_from_report_spec.rb index 63fb73103..aa35a8f7b 100644 --- a/spec/suma/lime/sync_trips_from_report_spec.rb +++ b/spec/suma/lime/sync_trips_from_report_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "suma/spec_helpers/sentry" - require "suma/lime/sync_trips_from_report" RSpec.describe Suma::Lime::SyncTripsFromReport, :db, reset_configuration: Suma::Lime do diff --git a/spec/suma/lyft/pass_spec.rb b/spec/suma/lyft/pass_spec.rb index f71d149e6..f6f937aa9 100644 --- a/spec/suma/lyft/pass_spec.rb +++ b/spec/suma/lyft/pass_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "suma/spec_helpers/sentry" - require "suma/lyft/pass" # rubocop:disable Layout/LineLength diff --git a/spec/suma/postgres/model_spec.rb b/spec/suma/postgres/model_spec.rb index ac140de67..04b434f56 100755 --- a/spec/suma/postgres/model_spec.rb +++ b/spec/suma/postgres/model_spec.rb @@ -573,9 +573,11 @@ def inspect = "MyCls" end describe "large association plugin", reset_configuration: described_class, db: false do + include Suma::SpecHelpers::Sentry + it "warns to sentry" do described_class.reset_configuration(large_association_warning_threshold: 3) - expect(Sentry).to receive(:capture_message).with("Large association loaded") + expect_sentry_capture(type: :message, arg_matcher: eq("Large association loaded")) described_class.db.transaction(rollback: :always) do vendor = Suma::Fixtures.vendor.create Array.new(4) { Suma::Fixtures.vendor_service(vendor:).create } diff --git a/spec/suma/spec_helpers/sentry_spec.rb b/spec/suma/spec_helpers/sentry_spec.rb index 174736c76..9259543dc 100644 --- a/spec/suma/spec_helpers/sentry_spec.rb +++ b/spec/suma/spec_helpers/sentry_spec.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "suma/spec_helpers/sentry" require "suma/spec_helpers/testing_helpers" RSpec.describe Suma::SpecHelpers::Sentry do