has_helpers 5.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (748) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +38 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +10 -0
  5. data/.ruby-gemset +1 -0
  6. data/.ruby-version +1 -0
  7. data/.tekton/Chart.yaml +14 -0
  8. data/.tekton/templates/pipelinerun.yaml +44 -0
  9. data/.tekton/values-main.yaml +53 -0
  10. data/.tekton/values-pr.yaml +40 -0
  11. data/.tekton/values.yaml +17 -0
  12. data/.tool-versions +1 -0
  13. data/CHANGELOG.md +204 -0
  14. data/Gemfile +25 -0
  15. data/README.md +76 -0
  16. data/Rakefile +46 -0
  17. data/app/api/has_helpers/rack_api.rb +116 -0
  18. data/app/api/has_helpers/search_api.rb +173 -0
  19. data/app/assets/stylesheets/has_helpers/base.scss +5 -0
  20. data/app/assets/stylesheets/has_helpers/color_palette.scss +33 -0
  21. data/app/assets/stylesheets/has_helpers/color_palettes/default.scss +11 -0
  22. data/app/assets/stylesheets/has_helpers/components/dataset.scss +43 -0
  23. data/app/assets/stylesheets/has_helpers/mixins.scss +149 -0
  24. data/app/commands/has_helpers/cards/available_cards_command.rb +98 -0
  25. data/app/commands/has_helpers/cards/card_data_command.rb +1062 -0
  26. data/app/commands/reports/base_options.rb +79 -0
  27. data/app/commands/reports/belongs_to_options.rb +178 -0
  28. data/app/commands/reports/constant_options.rb +66 -0
  29. data/app/commands/reports/external_graphql_client.rb +116 -0
  30. data/app/commands/reports/fill_dates.rb +65 -0
  31. data/app/commands/reports/filter_option_generator.rb +186 -0
  32. data/app/commands/reports/generator.rb +310 -0
  33. data/app/commands/reports/has_many_options.rb +105 -0
  34. data/app/commands/reports/importer.rb +132 -0
  35. data/app/commands/reports/nested_attribute_options.rb +134 -0
  36. data/app/constants/color.rb +9 -0
  37. data/app/constants/file_format.rb +12 -0
  38. data/app/constants/has_helpers/cards/card_custom_field.rb +12 -0
  39. data/app/constants/has_helpers/cards/card_data_type.rb +11 -0
  40. data/app/constants/has_helpers/cards/card_identifier.rb +10 -0
  41. data/app/constants/has_helpers/cards/card_layout_type.rb +11 -0
  42. data/app/constants/has_helpers/cards/card_variant_type.rb +12 -0
  43. data/app/constants/has_helpers/link_type.rb +10 -0
  44. data/app/constants/has_helpers/notification_alert_status.rb +14 -0
  45. data/app/constants/has_helpers/notification_alert_type.rb +10 -0
  46. data/app/constants/has_helpers/operation.rb +11 -0
  47. data/app/constants/has_helpers/resource_type.rb +25 -0
  48. data/app/constants/icon.rb +22 -0
  49. data/app/constants/logical_operator.rb +7 -0
  50. data/app/constants/option.rb +10 -0
  51. data/app/constants/time_zone.rb +12 -0
  52. data/app/constructors/has_helpers/base_constructor.rb +62 -0
  53. data/app/controllers/concerns/has_helpers/service_trust/authenticated_as_service.rb +51 -0
  54. data/app/controllers/concerns/has_helpers/service_trust/graphql_authentication.rb +57 -0
  55. data/app/controllers/has_helpers/controller.rb +63 -0
  56. data/app/controllers/has_helpers/factory_controller.rb +261 -0
  57. data/app/controllers/has_helpers/graphql_extensions.rb +83 -0
  58. data/app/controllers/has_helpers/health_check_controller.rb +46 -0
  59. data/app/controllers/has_helpers/search_extensions.rb +92 -0
  60. data/app/controllers/has_helpers/service_trust/jwks_controller.rb +20 -0
  61. data/app/controllers/has_helpers/xlsx_export.rb +115 -0
  62. data/app/controllers/search_extensions.rb +5 -0
  63. data/app/graphql/has_helpers/base_type.rb +13 -0
  64. data/app/graphql/has_helpers/base_union.rb +9 -0
  65. data/app/graphql/has_helpers/enums/resource_enum.rb +24 -0
  66. data/app/graphql/has_helpers/middleware/application_access.rb +26 -0
  67. data/app/graphql/has_helpers/mutations/base.rb +27 -0
  68. data/app/graphql/has_helpers/mutations/external_data_sync.rb +72 -0
  69. data/app/graphql/has_helpers/mutations/mark_individual_alert_read.rb +31 -0
  70. data/app/graphql/has_helpers/mutations/mark_notifications_read.rb +50 -0
  71. data/app/graphql/has_helpers/mutations/toggle_favorite.rb +39 -0
  72. data/app/graphql/has_helpers/resolvers/app_nav.rb +107 -0
  73. data/app/graphql/has_helpers/resolvers/base.rb +27 -0
  74. data/app/graphql/has_helpers/resolvers/cards/available_cards.rb +31 -0
  75. data/app/graphql/has_helpers/resolvers/cards/card_data.rb +74 -0
  76. data/app/graphql/has_helpers/resolvers/cards/hover_card_data.rb +153 -0
  77. data/app/graphql/has_helpers/resolvers/favorite.rb +62 -0
  78. data/app/graphql/has_helpers/resolvers/internal/roles.rb +54 -0
  79. data/app/graphql/has_helpers/resolvers/is_favorite.rb +24 -0
  80. data/app/graphql/has_helpers/resolvers/list_notification_alerts.rb +47 -0
  81. data/app/graphql/has_helpers/resolvers/lookup.rb +36 -0
  82. data/app/graphql/has_helpers/resolvers/recently_viewed.rb +54 -0
  83. data/app/graphql/has_helpers/resolvers/resource_tab.rb +22 -0
  84. data/app/graphql/has_helpers/resolvers/search.rb +18 -0
  85. data/app/graphql/has_helpers/resolvers/search_query.rb +38 -0
  86. data/app/graphql/has_helpers/resolvers/user_application_order.rb +35 -0
  87. data/app/graphql/has_helpers/resources/organization_option_resource.rb +28 -0
  88. data/app/graphql/has_helpers/resources/organization_resource_resource.rb +18 -0
  89. data/app/graphql/has_helpers/resources/resource_group_resource.rb +10 -0
  90. data/app/graphql/has_helpers/resources/resource_resource.rb +21 -0
  91. data/app/graphql/has_helpers/resources/user_role_resource.rb +9 -0
  92. data/app/graphql/has_helpers/types/application_order_type.rb +18 -0
  93. data/app/graphql/has_helpers/types/cards/available_card_type.rb +20 -0
  94. data/app/graphql/has_helpers/types/cards/card_badge_type.rb +12 -0
  95. data/app/graphql/has_helpers/types/cards/card_config_type.rb +18 -0
  96. data/app/graphql/has_helpers/types/cards/card_data_type.rb +13 -0
  97. data/app/graphql/has_helpers/types/cards/card_entry_field_type.rb +21 -0
  98. data/app/graphql/has_helpers/types/cards/card_entry_type.rb +12 -0
  99. data/app/graphql/has_helpers/types/cards/card_field_config_type.rb +17 -0
  100. data/app/graphql/has_helpers/types/layouts/app_nav/link_section_type.rb +17 -0
  101. data/app/graphql/has_helpers/types/layouts/app_nav/link_type.rb +20 -0
  102. data/app/graphql/has_helpers/types/layouts/app_nav/sub_link_type.rb +17 -0
  103. data/app/graphql/has_helpers/types/layouts/app_nav/type.rb +16 -0
  104. data/app/graphql/has_helpers/types/lookup.rb +16 -0
  105. data/app/graphql/has_helpers/types/resource_tab/type.rb +15 -0
  106. data/app/graphql/has_helpers/types/role_type.rb +13 -0
  107. data/app/graphql/has_helpers/types/search/detail_badge_type.rb +15 -0
  108. data/app/graphql/has_helpers/types/search/detail_item_type.rb +14 -0
  109. data/app/graphql/has_helpers/types/search/detail_type.rb +16 -0
  110. data/app/graphql/has_helpers/types/search/filter_operation_enum.rb +19 -0
  111. data/app/graphql/has_helpers/types/search/input_type.rb +16 -0
  112. data/app/graphql/has_helpers/types/search/result_type.rb +72 -0
  113. data/app/graphql/has_helpers/types/search/search_filter_input_type.rb +17 -0
  114. data/app/graphql/has_helpers/types/search/type.rb +17 -0
  115. data/app/importers/has_helpers/cards/importer.rb +1223 -0
  116. data/app/lib/has_helpers/application_helper.rb +21 -0
  117. data/app/lib/has_helpers/dn/processor.rb +55 -0
  118. data/app/lib/has_helpers/dn/refresh.rb +13 -0
  119. data/app/lib/has_helpers/dn/utils/backfill_utils.rb +80 -0
  120. data/app/lib/has_helpers/dn/utils/query_utils.rb +216 -0
  121. data/app/lib/has_helpers/feature.rb +88 -0
  122. data/app/lib/has_helpers/hq_exception.rb +18 -0
  123. data/app/lib/has_helpers/index.rb +890 -0
  124. data/app/lib/has_helpers/organization_merge.rb +128 -0
  125. data/app/lib/has_helpers/query/elasticsearch.rb +20 -0
  126. data/app/lib/has_helpers/query/provider.rb +105 -0
  127. data/app/lib/has_helpers/query/sql.rb +133 -0
  128. data/app/lib/has_helpers/query.rb +14 -0
  129. data/app/lib/has_helpers/search/elasticsearch.rb +41 -0
  130. data/app/lib/has_helpers/search/elasticsearch_shared.rb +70 -0
  131. data/app/lib/has_helpers/search/provider.rb +149 -0
  132. data/app/lib/has_helpers/search/search_utils.rb +74 -0
  133. data/app/lib/has_helpers/search/sql_search.rb +219 -0
  134. data/app/lib/has_helpers/search.rb +46 -0
  135. data/app/models/ability.rb +233 -0
  136. data/app/models/has_helpers/application.rb +47 -0
  137. data/app/models/has_helpers/application_record.rb +7 -0
  138. data/app/models/has_helpers/cards/card_builder/base.rb +74 -0
  139. data/app/models/has_helpers/cards/card_builder/card_definition_builder.rb +486 -0
  140. data/app/models/has_helpers/cards/card_builder/definition_context.rb +82 -0
  141. data/app/models/has_helpers/cards/card_builder/field_builder.rb +142 -0
  142. data/app/models/has_helpers/cards/card_builder/resource_builder.rb +34 -0
  143. data/app/models/has_helpers/cards/card_definition.rb +41 -0
  144. data/app/models/has_helpers/cards/card_definition_resource.rb +19 -0
  145. data/app/models/has_helpers/cards/card_field.rb +39 -0
  146. data/app/models/has_helpers/cards/registry.rb +115 -0
  147. data/app/models/has_helpers/cards/role_card_field.rb +28 -0
  148. data/app/models/has_helpers/cards/role_resource_card.rb +41 -0
  149. data/app/models/has_helpers/dn_utils.rb +61 -0
  150. data/app/models/has_helpers/favorite.rb +20 -0
  151. data/app/models/has_helpers/master_record.rb +18 -0
  152. data/app/models/has_helpers/notification_alert.rb +52 -0
  153. data/app/models/has_helpers/organization.rb +16 -0
  154. data/app/models/has_helpers/organization_link.rb +92 -0
  155. data/app/models/has_helpers/organization_methods.rb +62 -0
  156. data/app/models/has_helpers/organization_option.rb +31 -0
  157. data/app/models/has_helpers/organization_resource.rb +45 -0
  158. data/app/models/has_helpers/pending_db_operation.rb +31 -0
  159. data/app/models/has_helpers/recently_viewed.rb +34 -0
  160. data/app/models/has_helpers/reindex_queue.rb +43 -0
  161. data/app/models/has_helpers/resource.rb +38 -0
  162. data/app/models/has_helpers/resource_group.rb +15 -0
  163. data/app/models/has_helpers/restriction.rb +15 -0
  164. data/app/models/has_helpers/restriction_operation.rb +13 -0
  165. data/app/models/has_helpers/role.rb +20 -0
  166. data/app/models/has_helpers/service_trust/audit_log.rb +20 -0
  167. data/app/models/has_helpers/service_trust/key.rb +56 -0
  168. data/app/models/has_helpers/stat.rb +13 -0
  169. data/app/models/has_helpers/user.rb +18 -0
  170. data/app/models/has_helpers/user_methods.rb +184 -0
  171. data/app/models/has_helpers/user_organization.rb +33 -0
  172. data/app/models/has_helpers/user_role.rb +27 -0
  173. data/app/models/has_helpers/user_session.rb +19 -0
  174. data/app/presenters/has_helpers/barcode_base_presenter.rb +113 -0
  175. data/app/presenters/has_helpers/base_presenter.rb +100 -0
  176. data/app/presenters/has_helpers/cacheable_item.rb +28 -0
  177. data/app/presenters/has_helpers/item_presenter.rb +52 -0
  178. data/app/presenters/has_helpers/items_presenter.rb +33 -0
  179. data/app/presenters/has_helpers/resource/action_presenter.rb +8 -0
  180. data/app/presenters/has_helpers/resource/cell_presenter.rb +52 -0
  181. data/app/presenters/has_helpers/resource/email_presenter.rb +34 -0
  182. data/app/presenters/has_helpers/resource/form_presenter.rb +76 -0
  183. data/app/presenters/has_helpers/resource/info_collection_presenter.rb +33 -0
  184. data/app/presenters/has_helpers/resource/info_presenter.rb +34 -0
  185. data/app/presenters/has_helpers/resource/list_presenter.rb +120 -0
  186. data/app/presenters/has_helpers/resource/note_presenter.rb +14 -0
  187. data/app/presenters/has_helpers/resource/row_presenter.rb +25 -0
  188. data/app/presenters/has_helpers/resource/tag_presenter.rb +31 -0
  189. data/app/presenters/has_helpers/search_presenter.rb +230 -0
  190. data/app/triggers/has_helpers/erase_denormalized_belongs_to_column_trigger.rb +85 -0
  191. data/app/triggers/has_helpers/limit_favorites.rb +97 -0
  192. data/app/utils/has_helpers/redis_configurable.rb +79 -0
  193. data/app/utils/has_helpers/sync_utils.rb +234 -0
  194. data/app/views/has_helpers/actions/_action.html.erb +31 -0
  195. data/app/views/has_helpers/alerts/_alert.html.erb +3 -0
  196. data/app/views/has_helpers/barcodes.html.erb +36 -0
  197. data/app/views/has_helpers/containers/_container.html.erb +3 -0
  198. data/app/views/has_helpers/email_notifier/system.html.erb +8 -0
  199. data/app/views/has_helpers/email_notifier/system.text.erb +7 -0
  200. data/app/views/has_helpers/enumerables/_enumerable.html.erb +1 -0
  201. data/app/views/has_helpers/icons/_icon.html.erb +1 -0
  202. data/app/views/has_helpers/render_objects/_render_object.html.erb +1 -0
  203. data/app/views/has_helpers/services/_service.html.erb +1 -0
  204. data/app/views/has_helpers/text_nodes/_text_node.html.erb +4 -0
  205. data/app/views/has_helpers/ui_attributes/_html_attrs.html.erb +1 -0
  206. data/app/workers/elasticsearch_process_queue.rb +11 -0
  207. data/app/workers/elasticsearch_reindex.rb +11 -0
  208. data/app/workers/elasticsearch_reindex_scheduler.rb +35 -0
  209. data/app/workers/good_job/configurable_backfill.rb +64 -0
  210. data/app/workers/good_job/cron_denormalization_stats_manager.rb +39 -0
  211. data/app/workers/good_job/denormalization_stats_worker.rb +57 -0
  212. data/app/workers/good_job/dn_update_worker.rb +30 -0
  213. data/app/workers/good_job/dn_worker.rb +47 -0
  214. data/app/workers/good_job/elasticsearch_old_index_cleaner_worker.rb +113 -0
  215. data/app/workers/good_job/elasticsearch_on_finish_reindex.rb +26 -0
  216. data/app/workers/good_job/elasticsearch_process_batch.rb +78 -0
  217. data/app/workers/good_job/elasticsearch_process_change_logs_master_worker.rb +27 -0
  218. data/app/workers/good_job/elasticsearch_process_change_logs_worker.rb +155 -0
  219. data/app/workers/good_job/elasticsearch_process_queue.rb +50 -0
  220. data/app/workers/good_job/elasticsearch_reindex.rb +31 -0
  221. data/app/workers/has_helpers/cards/orphan_card_sweeper.rb +28 -0
  222. data/app/workers/has_helpers/db_operations/clean_stats_worker.rb +42 -0
  223. data/app/workers/has_helpers/db_operations/helper_worker.rb +131 -0
  224. data/app/workers/has_helpers/db_operations/main_worker.rb +164 -0
  225. data/app/workers/has_helpers/db_operations/operations.rb +134 -0
  226. data/app/workers/has_helpers/db_operations/update_operations_stats_resume_worker.rb +35 -0
  227. data/app/workers/has_helpers/default_worker.rb +17 -0
  228. data/app/workers/has_helpers/good_job/notification_alert_cleanup_worker.rb +43 -0
  229. data/app/workers/has_helpers/service_trust/audit_log_cleanup_worker.rb +19 -0
  230. data/app/workers/sidekiq/elasticsearch_old_index_cleaner_worker.rb +112 -0
  231. data/app/workers/sidekiq/elasticsearch_process_batch.rb +87 -0
  232. data/app/workers/sidekiq/elasticsearch_process_queue.rb +40 -0
  233. data/app/workers/sidekiq/elasticsearch_reindex.rb +53 -0
  234. data/config/initializers/action_view.rb +7 -0
  235. data/config/initializers/inflections.rb +24 -0
  236. data/config/initializers/marginalia.rb +8 -0
  237. data/config/initializers/mime_types.rb +6 -0
  238. data/config/initializers/opentelemetry.rb +12 -0
  239. data/config/initializers/redis_conf.rb +40 -0
  240. data/config/initializers/searchkick.rb +9 -0
  241. data/config/initializers/will_paginate.rb +39 -0
  242. data/config/redis.yml +12 -0
  243. data/config/routes.rb +27 -0
  244. data/config/service_trust_routes.rb +5 -0
  245. data/config.ru +9 -0
  246. data/db/migrate/20230127194725_create_organization_table.rb +25 -0
  247. data/db/migrate/20230127194726_validate_organization_foreign_keys.rb +7 -0
  248. data/db/migrate/20230206141413_create_security_models.rb +42 -0
  249. data/db/migrate/20230206141513_create_restriction_model.rb +20 -0
  250. data/db/migrate/20230206142613_add_security_indexes.rb +14 -0
  251. data/db/migrate/20230206143513_security_model_constants_seed.rb +8 -0
  252. data/db/migrate/20230206161513_add_organization_to_security_models.rb +13 -0
  253. data/db/migrate/20230209121019_create_search_group_models.rb +36 -0
  254. data/db/migrate/20230209122919_add_search_group_indexes.rb +21 -0
  255. data/db/migrate/20230209181832_add_search_group_resources_type_constraint_trigger.rb +61 -0
  256. data/db/migrate/20230313220337_create_table_user_sessions.rb +31 -0
  257. data/db/migrate/20230517141106_update_resource_types.rb +12 -0
  258. data/db/migrate/20230518194611_add_resources_contraints.rb +17 -0
  259. data/db/migrate/20230518194642_create_nested_resource_model.rb +21 -0
  260. data/db/migrate/20230518194645_validate_security_models_constraints.rb +23 -0
  261. data/db/migrate/20230608125037_add_inactive_field_to_user_sessions.rb +7 -0
  262. data/db/migrate/20230919191525_add_parent_id_to_resources.rb +8 -0
  263. data/db/migrate/20230919191535_validate_parent_id_fk_resources.rb +7 -0
  264. data/db/migrate/20230919210456_drop_nested_resources.rb +7 -0
  265. data/db/migrate/20231222135620_add_user_role.rb +19 -0
  266. data/db/migrate/20240108172021_create_favorites_table.rb +20 -0
  267. data/db/migrate/20240108190209_create_organization_link_table_if_doesnt_exists.rb +31 -0
  268. data/db/migrate/20240109211445_validate_fks_org_links.rb +9 -0
  269. data/db/migrate/20240110135440_create_recently_vieweds.rb +20 -0
  270. data/db/migrate/20240112185312_link_type_options_seed.rb +7 -0
  271. data/db/migrate/20240116173201_organization_link_resource_tab_constraints.rb +19 -0
  272. data/db/migrate/20240131160949_add_user_organizations_table.rb +32 -0
  273. data/db/migrate/20240212132519_add_organization_in_user_sessions.rb +13 -0
  274. data/db/migrate/20240227135052_add_user_organization_composite_fks.rb +32 -0
  275. data/db/migrate/20240312193501_update_organization_links.rb +11 -0
  276. data/db/migrate/20240426185902_add_user_organizations_current_organization_column.rb +12 -0
  277. data/db/migrate/20240429214601_add_primary_trigger.rb +10 -0
  278. data/db/migrate/20241112131819_add_json_file_format_seed.rb +7 -0
  279. data/db/migrate/20250520133417_create_restriction_operations.rb +18 -0
  280. data/db/migrate/20250520133420_validate_create_restriction_operations.rb +8 -0
  281. data/db/migrate/20250520140642_add_indexes_to_restrictions.rb +9 -0
  282. data/db/migrate/20250529130741_create_notification_alerts_table.rb +28 -0
  283. data/db/migrate/20250530223332_create_data_migrations_table.rb +19 -0
  284. data/db/migrate/20250605121622_seed_notification_alert_status.rb +7 -0
  285. data/db/migrate/20250605131439_seed_notification_alert_types.rb +7 -0
  286. data/db/migrate/20250606173128_update_notification_alert_length_constraint.rb +18 -0
  287. data/db/migrate/20250606174043_validate_notification_alert_length_constraints.rb +8 -0
  288. data/db/migrate/20250624183000_add_data_import_alert_type.rb +11 -0
  289. data/db/migrate/20250722183935_create_organization_options_table.rb +38 -0
  290. data/db/migrate/20250722192724_validate_organization_options_fks_and_constraints.rb +32 -0
  291. data/db/migrate/20250722192936_add_indexes_to_organization_options_table.rb +15 -0
  292. data/db/migrate/20250915132448_add_column_restrictions_hash.rb +7 -0
  293. data/db/migrate/20250917143211_add_column_permissions_hash.rb +7 -0
  294. data/db/migrate/20251001145950_add_trust_this_device.rb +7 -0
  295. data/db/migrate/20251021130835_add_backend_urls_to_applications_table.rb +13 -0
  296. data/db/migrate/20251021172715_validate_backend_urls_constraints_in_applications.rb +9 -0
  297. data/db/migrate/20251030200020_create_card_definitions.rb +23 -0
  298. data/db/migrate/20251030201012_create_card_definition_resources.rb +24 -0
  299. data/db/migrate/20251030201236_create_card_fields.rb +34 -0
  300. data/db/migrate/20251103194317_create_role_resource_cards.rb +37 -0
  301. data/db/migrate/20251103194426_create_role_card_fields.rb +34 -0
  302. data/db/migrate/20251107173218_validate_columns_from_cards.rb +17 -0
  303. data/db/migrate/20251111173212_add_column_application_array.rb +7 -0
  304. data/db/migrate/20251112203326_add_card_types.rb +10 -0
  305. data/db/migrate/20251115120000_add_virtual_cards_support.rb +28 -0
  306. data/db/migrate/20251202231256_add_org_scoped_fk_role_resource_cards_to_roles.rb +14 -0
  307. data/db/migrate/20260105134504_add_accessor_field_resource_type.rb +7 -0
  308. data/db/migrate/20260115123000_add_default_visibility_to_card_definitions.rb +9 -0
  309. data/db/migrate/20260120175037_is_new_admin.rb +9 -0
  310. data/db/migrate/20260122392008_add_stats_table.rb +17 -0
  311. data/db/migrate/20260414204718_add_index_to_stats_table.rb +10 -0
  312. data/db/migrate/20260509120000_create_service_trust_keys.rb +25 -0
  313. data/db/migrate/20260509120001_create_service_trust_audit_logs.rb +26 -0
  314. data/db/migrate/20260511120000_add_direction_to_service_trust_audit_logs.rb +17 -0
  315. data/db/migrate/20260511121000_remove_role_fk_from_role_resource_cards.rb +19 -0
  316. data/db/migrate/20260511121001_add_disabled_at_to_role_resource_cards.rb +10 -0
  317. data/db/migrate/20260605120100_seed_hover_card_options.rb +8 -0
  318. data/db/migrate/20260608120000_seed_badge_list_card_data_type.rb +7 -0
  319. data/db/migrate/20260727000000_add_two_factor_trust_id_to_user_sessions.rb +11 -0
  320. data/db/seeds.rb +14 -0
  321. data/docs/Elasticsearch.md +23 -0
  322. data/docs/Search_API.md +126 -0
  323. data/docs/cards_role_proxy.md +107 -0
  324. data/docs/factory_api.md +135 -0
  325. data/has-helpers-pr-289-pipeline.yaml +16 -0
  326. data/has-helpers-pr-298-pipeline.yaml +15 -0
  327. data/has_helpers.gemspec +68 -0
  328. data/lib/active_record/migration/active_migration_patch.rb +123 -0
  329. data/lib/generators/has_helpers/install/USAGE +8 -0
  330. data/lib/generators/has_helpers/install/install_generator.rb +22 -0
  331. data/lib/generators/has_helpers/install/templates/migrate/has_helpers_initial_setup.rb +26 -0
  332. data/lib/has_helpers/active_record/base.rb +50 -0
  333. data/lib/has_helpers/active_record/date_range.rb +228 -0
  334. data/lib/has_helpers/active_record/nested_resource.rb +58 -0
  335. data/lib/has_helpers/active_record/validation.rb +63 -0
  336. data/lib/has_helpers/active_record.rb +520 -0
  337. data/lib/has_helpers/app/command.rb +107 -0
  338. data/lib/has_helpers/app.rb +10 -0
  339. data/lib/has_helpers/assets_precompile_helper.rb +20 -0
  340. data/lib/has_helpers/attributes.rb +196 -0
  341. data/lib/has_helpers/aws.rb +67 -0
  342. data/lib/has_helpers/cards/cache.rb +49 -0
  343. data/lib/has_helpers/cards/config.rb +42 -0
  344. data/lib/has_helpers/cards/remote_role.rb +27 -0
  345. data/lib/has_helpers/cards/remote_roles.rb +75 -0
  346. data/lib/has_helpers/cards/roles.rb +55 -0
  347. data/lib/has_helpers/cards.rb +34 -0
  348. data/lib/has_helpers/concerns/hydrate_concern.rb +76 -0
  349. data/lib/has_helpers/concerns/importable_accessors.rb +25 -0
  350. data/lib/has_helpers/concerns/restrictions_parseable.rb +51 -0
  351. data/lib/has_helpers/config.rb +11 -0
  352. data/lib/has_helpers/constant.rb +578 -0
  353. data/lib/has_helpers/constraints.rb +10 -0
  354. data/lib/has_helpers/core_ext/array.rb +28 -0
  355. data/lib/has_helpers/core_ext/date/infinity.rb +25 -0
  356. data/lib/has_helpers/core_ext/date.rb +39 -0
  357. data/lib/has_helpers/core_ext/enumerable.rb +20 -0
  358. data/lib/has_helpers/core_ext/false_class.rb +14 -0
  359. data/lib/has_helpers/core_ext/float.rb +18 -0
  360. data/lib/has_helpers/core_ext/hash.rb +71 -0
  361. data/lib/has_helpers/core_ext/integer.rb +18 -0
  362. data/lib/has_helpers/core_ext/nil_class.rb +30 -0
  363. data/lib/has_helpers/core_ext/object.rb +60 -0
  364. data/lib/has_helpers/core_ext/proc.rb +16 -0
  365. data/lib/has_helpers/core_ext/string.rb +81 -0
  366. data/lib/has_helpers/core_ext/symbol.rb +17 -0
  367. data/lib/has_helpers/core_ext/true_class.rb +14 -0
  368. data/lib/has_helpers/cursor.rb +46 -0
  369. data/lib/has_helpers/data_migration/base.rb +293 -0
  370. data/lib/has_helpers/data_migration/large_worker.rb +20 -0
  371. data/lib/has_helpers/data_migration/master_worker.rb +33 -0
  372. data/lib/has_helpers/data_migration/migrator.rb +83 -0
  373. data/lib/has_helpers/data_migration/model.rb +18 -0
  374. data/lib/has_helpers/data_migration/small_worker.rb +20 -0
  375. data/lib/has_helpers/data_migration.rb +8 -0
  376. data/lib/has_helpers/engine.rb +47 -0
  377. data/lib/has_helpers/extensions.rb +17 -0
  378. data/lib/has_helpers/factory_api.rb +47 -0
  379. data/lib/has_helpers/factory_scenario/definition.rb +33 -0
  380. data/lib/has_helpers/factory_scenario/dsl.rb +80 -0
  381. data/lib/has_helpers/factory_scenario/factory_bot_override.rb +53 -0
  382. data/lib/has_helpers/factory_scenario/registry.rb +31 -0
  383. data/lib/has_helpers/factory_scenario/runner.rb +285 -0
  384. data/lib/has_helpers/factory_scenario.rb +27 -0
  385. data/lib/has_helpers/fixed_expiration_time_cache_session_store.rb +20 -0
  386. data/lib/has_helpers/flattener/association.rb +140 -0
  387. data/lib/has_helpers/flattener/attribute.rb +42 -0
  388. data/lib/has_helpers/flattener/base.rb +428 -0
  389. data/lib/has_helpers/flattener/column/types.rb +71 -0
  390. data/lib/has_helpers/flattener/column.rb +97 -0
  391. data/lib/has_helpers/flattener/pivot.rb +49 -0
  392. data/lib/has_helpers/flattener/reset.rb +33 -0
  393. data/lib/has_helpers/flattener/resource.rb +114 -0
  394. data/lib/has_helpers/flattener/resource_generator.rb +21 -0
  395. data/lib/has_helpers/flattener/utils.rb +14 -0
  396. data/lib/has_helpers/flattener.rb +53 -0
  397. data/lib/has_helpers/hash_accessors.rb +62 -0
  398. data/lib/has_helpers/helpers/application.rb +291 -0
  399. data/lib/has_helpers/index/config.rb +15 -0
  400. data/lib/has_helpers/lazy_config.rb +61 -0
  401. data/lib/has_helpers/resource_importer.rb +260 -0
  402. data/lib/has_helpers/resources.rb +127 -0
  403. data/lib/has_helpers/rspec/constants.rb +61 -0
  404. data/lib/has_helpers/rspec/factories.rb +11 -0
  405. data/lib/has_helpers/rspec.rb +22 -0
  406. data/lib/has_helpers/sentry/graphql_trace.rb +37 -0
  407. data/lib/has_helpers/serializer.rb +41 -0
  408. data/lib/has_helpers/service_trust/audit.rb +45 -0
  409. data/lib/has_helpers/service_trust/authorization.rb +34 -0
  410. data/lib/has_helpers/service_trust/bearer_token.rb +23 -0
  411. data/lib/has_helpers/service_trust/capability.rb +32 -0
  412. data/lib/has_helpers/service_trust/client/response.rb +13 -0
  413. data/lib/has_helpers/service_trust/client/retry.rb +35 -0
  414. data/lib/has_helpers/service_trust/client.rb +128 -0
  415. data/lib/has_helpers/service_trust/configuration.rb +76 -0
  416. data/lib/has_helpers/service_trust/engine.rb +10 -0
  417. data/lib/has_helpers/service_trust/errors.rb +32 -0
  418. data/lib/has_helpers/service_trust/graphql/authorizable.rb +72 -0
  419. data/lib/has_helpers/service_trust/graphql_client.rb +54 -0
  420. data/lib/has_helpers/service_trust/jwks_client.rb +133 -0
  421. data/lib/has_helpers/service_trust/jwt_codec.rb +45 -0
  422. data/lib/has_helpers/service_trust/key_manager.rb +160 -0
  423. data/lib/has_helpers/service_trust/rate_limiter.rb +27 -0
  424. data/lib/has_helpers/service_trust/replay_cache.rb +26 -0
  425. data/lib/has_helpers/service_trust/signer.rb +47 -0
  426. data/lib/has_helpers/service_trust/test.rb +60 -0
  427. data/lib/has_helpers/service_trust/verifier.rb +85 -0
  428. data/lib/has_helpers/service_trust/verify_middleware.rb +55 -0
  429. data/lib/has_helpers/service_trust.rb +51 -0
  430. data/lib/has_helpers/test_helpers/factory_data/unit_addresses.rb +42 -0
  431. data/lib/has_helpers/test_helpers/factory_data/unit_advisors.rb +272 -0
  432. data/lib/has_helpers/test_helpers/factory_data/unit_emails.rb +37 -0
  433. data/lib/has_helpers/test_helpers/factory_data/unit_organizations.rb +48 -0
  434. data/lib/has_helpers/test_helpers/factory_data/unit_people.rb +34 -0
  435. data/lib/has_helpers/test_helpers/factory_data/unit_phones.rb +41 -0
  436. data/lib/has_helpers/test_helpers/factory_data/unit_states.rb +26 -0
  437. data/lib/has_helpers/test_helpers/factory_data/unit_webs.rb +31 -0
  438. data/lib/has_helpers/test_helpers/shoulda_helpers.rb +249 -0
  439. data/lib/has_helpers/ui_attributes.rb +39 -0
  440. data/lib/has_helpers/utils/action.rb +87 -0
  441. data/lib/has_helpers/utils/active_record_utils.rb +40 -0
  442. data/lib/has_helpers/utils/alert.rb +42 -0
  443. data/lib/has_helpers/utils/container.rb +46 -0
  444. data/lib/has_helpers/utils/dataset.rb +34 -0
  445. data/lib/has_helpers/utils/email_notifier.rb +15 -0
  446. data/lib/has_helpers/utils/icon.rb +66 -0
  447. data/lib/has_helpers/utils/input.rb +45 -0
  448. data/lib/has_helpers/utils/log_notifier.rb +79 -0
  449. data/lib/has_helpers/utils/render_object.rb +32 -0
  450. data/lib/has_helpers/utils/s3_utils.rb +37 -0
  451. data/lib/has_helpers/utils/service.rb +87 -0
  452. data/lib/has_helpers/utils/slack_conf.rb +31 -0
  453. data/lib/has_helpers/utils/slack_notifier.rb +42 -0
  454. data/lib/has_helpers/utils/text_node.rb +34 -0
  455. data/lib/has_helpers/utils/user_restrictions.rb +266 -0
  456. data/lib/has_helpers/utils.rb +19 -0
  457. data/lib/has_helpers/version.rb +6 -0
  458. data/lib/has_helpers/web_socket_publisher.rb +20 -0
  459. data/lib/has_helpers/webhook.rb +17 -0
  460. data/lib/has_helpers.rb +63 -0
  461. data/lib/hq/resource.rb +55 -0
  462. data/lib/hq/structure/inserts.rb +176 -0
  463. data/lib/hq/structure.rb +115 -0
  464. data/lib/tasks/has_helpers/service_trust.rake +39 -0
  465. data/script/rails +10 -0
  466. data/sorbet/config +5 -0
  467. data/sorbet/rbi/gems/actionpack.rbi +1361 -0
  468. data/sorbet/rbi/gems/activemodel.rbi +755 -0
  469. data/sorbet/rbi/gems/activerecord.rbi +4713 -0
  470. data/sorbet/rbi/gems/activesupport.rbi +1970 -0
  471. data/sorbet/rbi/gems/barby.rbi +129 -0
  472. data/sorbet/rbi/gems/better_errors.rbi +155 -0
  473. data/sorbet/rbi/gems/binding_of_caller.rbi +22 -0
  474. data/sorbet/rbi/gems/byebug.rbi +19 -0
  475. data/sorbet/rbi/gems/chronic.rbi +480 -0
  476. data/sorbet/rbi/gems/chunky_png.rbi +510 -0
  477. data/sorbet/rbi/gems/climate_control.rbi +45 -0
  478. data/sorbet/rbi/gems/coderay.rbi +91 -0
  479. data/sorbet/rbi/gems/concurrent-ruby.rbi +1587 -0
  480. data/sorbet/rbi/gems/debug_inspector.rbi +18 -0
  481. data/sorbet/rbi/gems/erubi.rbi +26 -0
  482. data/sorbet/rbi/gems/holidays.rbi +362 -0
  483. data/sorbet/rbi/gems/i18n.rbi +132 -0
  484. data/sorbet/rbi/gems/method_source.rbi +63 -0
  485. data/sorbet/rbi/gems/mime-types-data.rbi +16 -0
  486. data/sorbet/rbi/gems/mime-types.rbi +217 -0
  487. data/sorbet/rbi/gems/mimemagic.rbi +36 -0
  488. data/sorbet/rbi/gems/minitest.rbi +282 -0
  489. data/sorbet/rbi/gems/mustache.rbi +151 -0
  490. data/sorbet/rbi/gems/paperclip.rbi +644 -0
  491. data/sorbet/rbi/gems/parallel.rbi +81 -0
  492. data/sorbet/rbi/gems/parallel_tests.rbi +27 -0
  493. data/sorbet/rbi/gems/pry-rails.rbi +71 -0
  494. data/sorbet/rbi/gems/pry.rbi +1964 -0
  495. data/sorbet/rbi/gems/rack.rbi +228 -0
  496. data/sorbet/rbi/gems/railties.rbi +364 -0
  497. data/sorbet/rbi/gems/rspec-core.rbi +1704 -0
  498. data/sorbet/rbi/gems/rspec-expectations.rbi +389 -0
  499. data/sorbet/rbi/gems/rspec-support.rbi +206 -0
  500. data/sorbet/rbi/gems/rspec.rbi +14 -0
  501. data/sorbet/rbi/gems/spreadsheet_architect.rbi +10 -0
  502. data/sorbet/rbi/gems/sprockets.rbi +659 -0
  503. data/sorbet/rbi/gems/terrapin.rbi +115 -0
  504. data/sorbet/rbi/gems/testhq.rbi +14 -0
  505. data/sorbet/rbi/gems/thor.rbi +482 -0
  506. data/sorbet/rbi/gems/thread_safe.rbi +81 -0
  507. data/sorbet/rbi/gems/tzinfo.rbi +407 -0
  508. data/sorbet/rbi/gems/zeitwerk.rbi +131 -0
  509. data/sorbet/rbi/hidden-definitions/errors.txt +35111 -0
  510. data/sorbet/rbi/hidden-definitions/hidden.rbi +59974 -0
  511. data/sorbet/rbi/sorbet-typed/lib/actionmailer/all/actionmailer.rbi +13 -0
  512. data/sorbet/rbi/sorbet-typed/lib/actionpack/all/actionpack.rbi +855 -0
  513. data/sorbet/rbi/sorbet-typed/lib/actionview/all/actionview.rbi +314 -0
  514. data/sorbet/rbi/sorbet-typed/lib/activemodel/all/activemodel.rbi +423 -0
  515. data/sorbet/rbi/sorbet-typed/lib/activerecord/>=5.2/activerecord.rbi +17 -0
  516. data/sorbet/rbi/sorbet-typed/lib/activerecord/all/activerecord.rbi +1415 -0
  517. data/sorbet/rbi/sorbet-typed/lib/activerecord/~>6.0.0/activerecord.rbi +475 -0
  518. data/sorbet/rbi/sorbet-typed/lib/activestorage/>=6.0.0/activestorage.rbi +19 -0
  519. data/sorbet/rbi/sorbet-typed/lib/activestorage/all/activestorage.rbi +173 -0
  520. data/sorbet/rbi/sorbet-typed/lib/activesupport/>=6.0.0.rc1/activesupport.rbi +23 -0
  521. data/sorbet/rbi/sorbet-typed/lib/activesupport/all/activesupport.rbi +625 -0
  522. data/sorbet/rbi/sorbet-typed/lib/bundler/all/bundler.rbi +8684 -0
  523. data/sorbet/rbi/sorbet-typed/lib/minitest/all/minitest.rbi +99 -0
  524. data/sorbet/rbi/sorbet-typed/lib/railties/all/railties.rbi +25 -0
  525. data/sorbet/rbi/sorbet-typed/lib/rainbow/all/rainbow.rbi +254 -0
  526. data/sorbet/rbi/sorbet-typed/lib/ruby/all/gem.rbi +4222 -0
  527. data/sorbet/rbi/sorbet-typed/lib/ruby/all/open3.rbi +111 -0
  528. data/sorbet/rbi/sorbet-typed/lib/ruby/all/resolv.rbi +543 -0
  529. data/sorbet/rbi/todo.rbi +244 -0
  530. data/spec/commands/cards/available_cards_command_spec.rb +90 -0
  531. data/spec/commands/cards/card_data_command_spec.rb +1048 -0
  532. data/spec/constructors/has_helpers/base_constructor_spec.rb +49 -0
  533. data/spec/controllers/has_helpers/controller_spec.rb +71 -0
  534. data/spec/controllers/has_helpers/xlsx_export_spec.rb +96 -0
  535. data/spec/factories/advisors.rb +20 -0
  536. data/spec/factories/anonymous_cowards.rb +5 -0
  537. data/spec/factories/applications.rb +5 -0
  538. data/spec/factories/appointments.rb +6 -0
  539. data/spec/factories/attachment.rb +15 -0
  540. data/spec/factories/audits.rb +5 -0
  541. data/spec/factories/basic_schedules.rb +12 -0
  542. data/spec/factories/cards/card_definition_resources.rb +7 -0
  543. data/spec/factories/cards/card_definitions.rb +11 -0
  544. data/spec/factories/cards/card_fields.rb +22 -0
  545. data/spec/factories/cards/role_card_fields.rb +17 -0
  546. data/spec/factories/cards/role_resource_cards.rb +12 -0
  547. data/spec/factories/constant_definition.rb +7 -0
  548. data/spec/factories/contracts.rb +10 -0
  549. data/spec/factories/data_migration_models.rb +9 -0
  550. data/spec/factories/demographics.rb +22 -0
  551. data/spec/factories/events.rb +21 -0
  552. data/spec/factories/firms.rb +21 -0
  553. data/spec/factories/imports.rb +9 -0
  554. data/spec/factories/meetups.rb +13 -0
  555. data/spec/factories/organizations.rb +13 -0
  556. data/spec/factories/policies.rb +7 -0
  557. data/spec/factories/policy_notes.rb +9 -0
  558. data/spec/factories/read_only_values.rb +7 -0
  559. data/spec/factories/resources.rb +7 -0
  560. data/spec/factories/roles.rb +11 -0
  561. data/spec/factories/schedules.rb +13 -0
  562. data/spec/factories/shared/flattener/attributes.rb +10 -0
  563. data/spec/factories/shared/flattener/columns.rb +6 -0
  564. data/spec/factories/shared/flattener/resources.rb +5 -0
  565. data/spec/factories/templates.rb +12 -0
  566. data/spec/factories/user_organizations.rb +23 -0
  567. data/spec/factories/user_session.rb +11 -0
  568. data/spec/factories/users.rb +55 -0
  569. data/spec/fixtures/sample_config.yml +3 -0
  570. data/spec/graphql/has_helpers/mutations/external_data_sync_spec.rb +101 -0
  571. data/spec/graphql/has_helpers/resolvers/cards/available_cards_spec.rb +36 -0
  572. data/spec/graphql/has_helpers/resolvers/cards/card_data_spec.rb +42 -0
  573. data/spec/graphql/has_helpers/resolvers/cards/hover_card_data_spec.rb +59 -0
  574. data/spec/has_helpers/cards/cache_spec.rb +58 -0
  575. data/spec/has_helpers/cards/config_spec.rb +68 -0
  576. data/spec/has_helpers/cards/orphan_card_sweeper_spec.rb +97 -0
  577. data/spec/has_helpers/cards/remote_role_spec.rb +17 -0
  578. data/spec/has_helpers/cards/remote_roles_spec.rb +97 -0
  579. data/spec/has_helpers/cards/roles_spec.rb +52 -0
  580. data/spec/has_helpers/cards/smoke_spec.rb +168 -0
  581. data/spec/has_helpers/cards/support.rb +52 -0
  582. data/spec/has_helpers/resolvers/internal/roles_spec.rb +73 -0
  583. data/spec/has_helpers/service_trust/audit_log_cleanup_worker_spec.rb +33 -0
  584. data/spec/has_helpers/service_trust/audit_spec.rb +70 -0
  585. data/spec/has_helpers/service_trust/authenticated_as_service_spec.rb +65 -0
  586. data/spec/has_helpers/service_trust/authorization_spec.rb +37 -0
  587. data/spec/has_helpers/service_trust/capability_spec.rb +35 -0
  588. data/spec/has_helpers/service_trust/client_spec.rb +129 -0
  589. data/spec/has_helpers/service_trust/configuration_spec.rb +35 -0
  590. data/spec/has_helpers/service_trust/engine_spec.rb +13 -0
  591. data/spec/has_helpers/service_trust/errors_spec.rb +23 -0
  592. data/spec/has_helpers/service_trust/graphql_authentication_spec.rb +59 -0
  593. data/spec/has_helpers/service_trust/graphql_authorizable_spec.rb +91 -0
  594. data/spec/has_helpers/service_trust/graphql_client_spec.rb +47 -0
  595. data/spec/has_helpers/service_trust/jwks_client_spec.rb +85 -0
  596. data/spec/has_helpers/service_trust/jwks_controller_spec.rb +47 -0
  597. data/spec/has_helpers/service_trust/jwt_codec_spec.rb +30 -0
  598. data/spec/has_helpers/service_trust/key_manager_spec.rb +80 -0
  599. data/spec/has_helpers/service_trust/key_spec.rb +52 -0
  600. data/spec/has_helpers/service_trust/rake_tasks_spec.rb +58 -0
  601. data/spec/has_helpers/service_trust/rate_limiter_spec.rb +31 -0
  602. data/spec/has_helpers/service_trust/replay_cache_spec.rb +29 -0
  603. data/spec/has_helpers/service_trust/signer_verifier_spec.rb +159 -0
  604. data/spec/has_helpers/service_trust/smoke_spec.rb +216 -0
  605. data/spec/has_helpers/service_trust/support.rb +121 -0
  606. data/spec/has_helpers/service_trust/test_module_spec.rb +39 -0
  607. data/spec/has_helpers/service_trust/verify_middleware_spec.rb +82 -0
  608. data/spec/helpers/application_spec.rb +155 -0
  609. data/spec/importers/cards/importer_spec.rb +861 -0
  610. data/spec/internal/app/assets/config/manifest.js +0 -0
  611. data/spec/internal/app/constants/cat_egory.rb +33 -0
  612. data/spec/internal/app/constants/color.rb +19 -0
  613. data/spec/internal/app/constants/exclude_group_nil_constant.rb +5 -0
  614. data/spec/internal/app/constants/foo.rb +6 -0
  615. data/spec/internal/app/constants/fruit.rb +3 -0
  616. data/spec/internal/app/constants/generic_group_example.rb +12 -0
  617. data/spec/internal/app/constants/my_constant.rb +4 -0
  618. data/spec/internal/app/constants/product.rb +3 -0
  619. data/spec/internal/app/constants/shirt_size.rb +18 -0
  620. data/spec/internal/app/constants/simple_option.rb +14 -0
  621. data/spec/internal/app/constants/status.rb +5 -0
  622. data/spec/internal/app/constructors/organization_constructor.rb +4 -0
  623. data/spec/internal/app/controllers/application_controller.rb +27 -0
  624. data/spec/internal/app/controllers/search_extensions.rb +3 -0
  625. data/spec/internal/app/indices/advisor_index.rb +35 -0
  626. data/spec/internal/app/indices/contract_index.rb +52 -0
  627. data/spec/internal/app/indices/firm_index.rb +16 -0
  628. data/spec/internal/app/indices/policy_index.rb +20 -0
  629. data/spec/internal/app/models/advisor.rb +24 -0
  630. data/spec/internal/app/models/agency.rb +3 -0
  631. data/spec/internal/app/models/anonymous_coward.rb +5 -0
  632. data/spec/internal/app/models/another_event.rb +2 -0
  633. data/spec/internal/app/models/appointment.rb +3 -0
  634. data/spec/internal/app/models/attachment.rb +10 -0
  635. data/spec/internal/app/models/basic_schedule.rb +6 -0
  636. data/spec/internal/app/models/beneficiary.rb +6 -0
  637. data/spec/internal/app/models/client.rb +5 -0
  638. data/spec/internal/app/models/contract.rb +9 -0
  639. data/spec/internal/app/models/demographic.rb +4 -0
  640. data/spec/internal/app/models/division.rb +3 -0
  641. data/spec/internal/app/models/event.rb +10 -0
  642. data/spec/internal/app/models/event_template.rb +1 -0
  643. data/spec/internal/app/models/firm.rb +18 -0
  644. data/spec/internal/app/models/has_audits/audit.rb +15 -0
  645. data/spec/internal/app/models/import.rb +7 -0
  646. data/spec/internal/app/models/meetup.rb +8 -0
  647. data/spec/internal/app/models/organization.rb +17 -0
  648. data/spec/internal/app/models/organization_event.rb +5 -0
  649. data/spec/internal/app/models/policy.rb +16 -0
  650. data/spec/internal/app/models/policy_holder.rb +7 -0
  651. data/spec/internal/app/models/policy_note.rb +8 -0
  652. data/spec/internal/app/models/project.rb +3 -0
  653. data/spec/internal/app/models/read_only_value.rb +8 -0
  654. data/spec/internal/app/models/schedule.rb +12 -0
  655. data/spec/internal/app/models/template.rb +9 -0
  656. data/spec/internal/app/models/user.rb +31 -0
  657. data/spec/internal/app/models/user_organization.rb +17 -0
  658. data/spec/internal/app/views/has_lists/lists/_table.html.erb +1 -0
  659. data/spec/internal/config/database-standard.yml +24 -0
  660. data/spec/internal/config/initializers/resources.rb +19 -0
  661. data/spec/internal/config/routes.rb +19 -0
  662. data/spec/internal/config/storage.yml +3 -0
  663. data/spec/internal/db/good_job_schema.rb +141 -0
  664. data/spec/internal/db/schema.rb +334 -0
  665. data/spec/internal/log/.gitignore +1 -0
  666. data/spec/internal/public/favicon.ico +0 -0
  667. data/spec/lib/flattener/base_spec.rb +93 -0
  668. data/spec/lib/flattener/column/types_spec.rb +65 -0
  669. data/spec/lib/has_helpers/active_record/base_spec.rb +53 -0
  670. data/spec/lib/has_helpers/active_record/date_range_spec.rb +498 -0
  671. data/spec/lib/has_helpers/active_record/lookup_model_spec.rb +408 -0
  672. data/spec/lib/has_helpers/active_record/nested_resource_spec.rb +43 -0
  673. data/spec/lib/has_helpers/app/command/reports/generator_specs.rb +887 -0
  674. data/spec/lib/has_helpers/app/command_spec.rb +117 -0
  675. data/spec/lib/has_helpers/app/support/mocks/graphql_reports_helpers.rb +96 -0
  676. data/spec/lib/has_helpers/app/support/mocks/reports_api_mock.rb +47 -0
  677. data/spec/lib/has_helpers/attributes_spec.rb +220 -0
  678. data/spec/lib/has_helpers/aws_spec.rb +126 -0
  679. data/spec/lib/has_helpers/concerns/hydrate_concern_spec.rb +473 -0
  680. data/spec/lib/has_helpers/constant/definition_spec.rb +128 -0
  681. data/spec/lib/has_helpers/constant_spec.rb +432 -0
  682. data/spec/lib/has_helpers/core_ext/array_spec.rb +22 -0
  683. data/spec/lib/has_helpers/core_ext/date/infinity_spec.rb +67 -0
  684. data/spec/lib/has_helpers/core_ext/date_spec.rb +27 -0
  685. data/spec/lib/has_helpers/core_ext/false_class_spec.rb +7 -0
  686. data/spec/lib/has_helpers/core_ext/float_spec.rb +9 -0
  687. data/spec/lib/has_helpers/core_ext/hash_spec.rb +48 -0
  688. data/spec/lib/has_helpers/core_ext/integer_spec.rb +9 -0
  689. data/spec/lib/has_helpers/core_ext/nil_class_spec.rb +17 -0
  690. data/spec/lib/has_helpers/core_ext/object_spec.rb +54 -0
  691. data/spec/lib/has_helpers/core_ext/proc_spec.rb +24 -0
  692. data/spec/lib/has_helpers/core_ext/string_spec.rb +154 -0
  693. data/spec/lib/has_helpers/core_ext/true_class_spec.rb +7 -0
  694. data/spec/lib/has_helpers/data_migration/base_spec.rb +470 -0
  695. data/spec/lib/has_helpers/data_migration/large_worker_spec.rb +34 -0
  696. data/spec/lib/has_helpers/data_migration/master_worker_spec.rb +51 -0
  697. data/spec/lib/has_helpers/data_migration/migrator_spec.rb +113 -0
  698. data/spec/lib/has_helpers/data_migration/model_spec.rb +52 -0
  699. data/spec/lib/has_helpers/data_migration/small_worker_spec.rb +34 -0
  700. data/spec/lib/has_helpers/good_job/notification_alert_cleanup_worker_spec.rb +89 -0
  701. data/spec/lib/has_helpers/hash_accessors_spec.rb +66 -0
  702. data/spec/lib/has_helpers/index_spec.rb +442 -0
  703. data/spec/lib/has_helpers/lazy_config_spec.rb +31 -0
  704. data/spec/lib/has_helpers/organization_merge_spec.rb +88 -0
  705. data/spec/lib/has_helpers/resource_importer_spec.rb +119 -0
  706. data/spec/lib/has_helpers/rspec_spec.rb +16 -0
  707. data/spec/lib/has_helpers/search/provider_spec.rb +26 -0
  708. data/spec/lib/has_helpers/search/search_utils_spec.rb +134 -0
  709. data/spec/lib/has_helpers/sentry/graphql_trace_spec.rb +140 -0
  710. data/spec/lib/has_helpers/ui_attributes_spec.rb +42 -0
  711. data/spec/lib/has_helpers/utils/action_spec.rb +49 -0
  712. data/spec/lib/has_helpers/utils/alert_spec.rb +33 -0
  713. data/spec/lib/has_helpers/utils/dataset_spec.rb +10 -0
  714. data/spec/lib/has_helpers/utils/icon_spec.rb +23 -0
  715. data/spec/lib/has_helpers/utils/input_spec.rb +40 -0
  716. data/spec/lib/has_helpers/utils/log_notifier_spec.rb +25 -0
  717. data/spec/lib/has_helpers/utils/render_object_spec.rb +17 -0
  718. data/spec/lib/has_helpers/utils/s3_utils_spec.rb +78 -0
  719. data/spec/lib/has_helpers/utils/service_spec.rb +79 -0
  720. data/spec/lib/has_helpers/utils/slack_notifier_spec.rb +70 -0
  721. data/spec/lib/has_helpers/utils/user_restrictions_spec.rb +343 -0
  722. data/spec/lib/hq/structure/inserts_spec.rb +152 -0
  723. data/spec/lib/hq/structure_spec.rb +12 -0
  724. data/spec/models/cards/base_spec.rb +149 -0
  725. data/spec/models/cards/card_definition_resource_spec.rb +103 -0
  726. data/spec/models/cards/card_definition_spec.rb +50 -0
  727. data/spec/models/cards/card_field_spec.rb +46 -0
  728. data/spec/models/cards/field_builder_spec.rb +318 -0
  729. data/spec/models/cards/registry_spec.rb +147 -0
  730. data/spec/models/cards/role_card_field_spec.rb +111 -0
  731. data/spec/models/cards/role_resource_card_spec.rb +112 -0
  732. data/spec/models/has_helpers/master_record_spec.rb +20 -0
  733. data/spec/models/has_helpers/user_organization_spec.rb +28 -0
  734. data/spec/models/has_helpers/user_session_spec.rb +16 -0
  735. data/spec/presenters/base_presenter_spec.rb +166 -0
  736. data/spec/rails_helper.rb +119 -0
  737. data/spec/requests/has_helpers/factory_controller_spec.rb +159 -0
  738. data/spec/requests/has_helpers/health_check_controller_spec.rb +37 -0
  739. data/spec/scenarios/empty_setup.rb +6 -0
  740. data/spec/spec_helper.rb +22 -0
  741. data/spec/support/has_helpers/database_helpers.rb +12 -0
  742. data/spec/utils/sync_utils_spec.rb +233 -0
  743. data/spec/workers/goodjob/elasticsearch_process_batch_spec.rb +94 -0
  744. data/spec/workers/goodjob/elasticsearch_process_change_logs_master_worker_spec.rb +40 -0
  745. data/spec/workers/goodjob/elasticsearch_process_change_logs_worker_spec.rb +82 -0
  746. data/spec/workers/goodjob/elasticsearch_process_queue_spec.rb +89 -0
  747. data/spec/workers/goodjob/elasticsearch_reindex_spec.rb +49 -0
  748. metadata +1593 -0
@@ -0,0 +1,1223 @@
1
+ # frozen_string_literal: true
2
+
3
+ # HasHelpers::Cards::Importer
4
+ #
5
+ # Description:
6
+ #
7
+ # Implemented service responsible for persisting all card
8
+ # definitions declared via the DSL (HasHelpers::Cards::CardBuilder::Base subclasses)
9
+ # into the database tables:
10
+ #
11
+ # - card_definitions
12
+ # - card_fields
13
+ # - card_definition_resources
14
+ #
15
+ # It must:
16
+ # * Upsert records deterministically (idempotent)
17
+ # * Support selective import by resource or all
18
+ # * Perform auditing (checksum + diffing) to detect drift
19
+ # between code and DB definitions
20
+ # * Fail safely when structural changes occur
21
+ # (e.g. field removals / relationship removals / data_type changes)
22
+ # * Persist a definition_json and definition_checksum
23
+ # for each record for future audits
24
+ #
25
+ # This class implements that contract.
26
+ #
27
+ # Important notes:
28
+ # - The importer operates at "card" level (one transaction per card).
29
+ # - It uses the normalized DSL definitions (from HasHelpers::Cards::CardBuilder::Base subclasses).
30
+ # - It computes checksums based on a normalized JSON representation.
31
+ # - It supports "dry_run" mode (audit + diff only, no DB writes).
32
+ # - It supports resource filtering (import only cards that involve specific resources).
33
+
34
+ require "digest"
35
+ require "json"
36
+
37
+ module HasHelpers
38
+ module Cards
39
+ class Importer
40
+ # Raised when auditing detects a breaking drift between DSL (code) and DB.
41
+ # We use this error to stop the import and force devs to write a data migration.
42
+ class AuditError < StandardError; end
43
+
44
+ # Struct used to return a summary of the import operation.
45
+ Result = Struct.new(
46
+ :created_cards,
47
+ :updated_cards,
48
+ :created_fields,
49
+ :updated_fields,
50
+ :created_resources,
51
+ :updated_resources,
52
+ :skipped_cards,
53
+ keyword_init: true
54
+ )
55
+
56
+ # Struct representing the diff between DSL and DB for a single card.
57
+ # Auditing / drift:
58
+ # - card_changes: :new, :same, :changed
59
+ # - fields_added / fields_removed / fields_changed
60
+ # - resources_added / resources_removed
61
+ Diff = Struct.new(
62
+ :card_changes,
63
+ :fields_added,
64
+ :fields_removed,
65
+ :fields_changed,
66
+ :resources_added,
67
+ :resources_removed,
68
+ keyword_init: true
69
+ )
70
+
71
+ class << self
72
+ # Public API entry point.
73
+ # # Import all resources declared in the DSL
74
+ # HasHelpers::Cards::Importer.run
75
+ #
76
+ # # Import only selected resource types
77
+ # HasHelpers::Cards::Importer.run([Advisor, Carrier]) # or symbols: [:advisor, :carrier]
78
+ #
79
+ # # Options (keyword args)
80
+ # HasHelpers::Cards::Importer.run(resources: [:advisor], mode: :apply, audit: true)
81
+ #
82
+ # Supported modes (ClickUp):
83
+ # - mode: :dry_run → compute and print diffs, no DB writes,
84
+ # raise error if audit fails.
85
+ # - mode: :apply → default. Always audit first. If drift is breaking,
86
+ # raise and do not persist.
87
+ def run(*args, **opts)
88
+ ::HasHelpers::Cards::Registry.cards
89
+ resources, mode, audit = normalize_run_arguments(args, opts)
90
+
91
+ application = opts[:application] || opts[:application_name]
92
+ raise ArgumentError, "application is required (e.g. application: 'AgenciesHQ')" if application.blank?
93
+
94
+ new(application: application, resources: resources, mode: mode, audit: audit).run
95
+ end
96
+
97
+ private
98
+
99
+ # Normalizes positional + keyword arguments into:
100
+ # - resources (filter) → Array<Class|Symbol> or nil
101
+ # - mode → :apply or :dry_run
102
+ # - audit → boolean
103
+ #
104
+ # This is compatible with:
105
+ # HasHelpers::Cards::Importer.run
106
+ # HasHelpers::Cards::Importer.run([Advisor, Carrier])
107
+ # HasHelpers::Cards::Importer.run(resources: [:advisor], mode: :apply, audit: true)
108
+ #
109
+ def normalize_run_arguments(args, opts)
110
+ # default mode is :apply
111
+ mode = (opts[:mode] || :apply).to_sym
112
+ # default audit is true (we always audit before writing)
113
+ audit = opts.key?(:audit) ? opts[:audit] : true
114
+
115
+ resources =
116
+ if opts.key?(:resources)
117
+ # explicit keyword arg takes precedence
118
+ opts[:resources]
119
+ else
120
+ # otherwise, optional first positional arg may be the resources filter
121
+ args.first
122
+ end
123
+
124
+ [resources, mode, audit]
125
+ end
126
+ end
127
+
128
+ # Constructor is internal; external callers use .run.
129
+ #
130
+ # resources: Class|Symbol|Array or nil (no filter)
131
+ # mode: :apply or :dry_run
132
+ # audit: true/false (whether to run drift detection)
133
+ #
134
+ def initialize(application:, resources:, mode:, audit:)
135
+ # Normalize resource filter into a compact set of symbols representing resource names (e.g. [:advisor, :carrier]).
136
+ @application_name = application.to_s
137
+ @application = ::HasHelpers::Application.find_by!("LOWER(name) = ?", @application_name.downcase)
138
+ @resource_filter = normalize_resource_filter(resources)
139
+ @mode = (mode || :apply).to_sym
140
+ @audit = audit
141
+
142
+ # This struct accumulates counts while importing.
143
+ @result = Result.new(
144
+ created_cards: 0,
145
+ updated_cards: 0,
146
+ created_fields: 0,
147
+ updated_fields: 0,
148
+ created_resources: 0,
149
+ updated_resources: 0,
150
+ skipped_cards: 0
151
+ )
152
+ end
153
+
154
+ # Main execution entry.
155
+ #
156
+ # "Importer Flow"
157
+ #
158
+ # Steps:
159
+ # 1. (Optionally) run migrations for cards (hook only for now).
160
+ # 2. Discover card definitions from the DSL (CardBuilder::Base descendants).
161
+ # 3. For each card:
162
+ # - Normalize + compute checksum (code view).
163
+ # - Build DB view (if card exists).
164
+ # - Diff + classify changes.
165
+ # - Audit: if breaking drift, raise AuditError.
166
+ # - If mode = :apply, upsert card_definitions + card_fields +
167
+ # card_definition_resources inside a transaction.
168
+ # - If mode = :dry_run, just log diffs and don't write.
169
+ # 4. Optional global audit: detect cards existing only in DB but
170
+ # not in code, when there is no resource filter (breaking).
171
+ # 5. Log summary of created/updated/skipped records.
172
+ #
173
+ def run
174
+ # "We may add a migrations section just like we have for reports and webhooks"
175
+ run_migrations_if_any!
176
+
177
+ # Discover all card definitions exposed by the DSL (HasHelpers::Cards::CardBuilder::Base subclasses).
178
+ card_pairs = discover_card_definitions
179
+ # Each element is: [CardClass, raw_hash_definition]
180
+ validate_hover_cards!(card_pairs)
181
+
182
+ # Hover-only cards live entirely in-memory (DSL registry). They are NOT persisted.
183
+ # Any pre-existing rows for hover-only identifiers (whether currently flagged as
184
+ # hover_only in DB or not) are dropped here, then we skip the import for them below.
185
+ purge_persisted_hover_only_cards!(card_pairs)
186
+
187
+ # Import each card definition (or audit, depending on mode), skipping hover-only definitions.
188
+ card_pairs.each do |klass, raw_def|
189
+ next if hover_only_definition?(raw_def)
190
+ import_card!(klass, raw_def)
191
+ end
192
+
193
+ # Global audit for "cards present in DB but missing in code".
194
+ # This is only meaningful when we are importing "all resources" (i.e. no resource filter is applied).
195
+ audit_removed_cards!(card_pairs) if audit && resource_filter.nil?
196
+
197
+ # Log structured summary for debugging / future dashboards.
198
+ log_summary
199
+
200
+ # Return Result struct (created/updated counts, etc.)
201
+ result
202
+ end
203
+
204
+ # Definitions where `hover_only: true` are skipped at import — they live in
205
+ # memory only and are assembled from the registry at request time.
206
+ def hover_only_definition?(raw_def)
207
+ defn = raw_def.is_a?(Hash) ? raw_def.deep_symbolize_keys : raw_def.to_h.deep_symbolize_keys
208
+ !!defn[:hover_only]
209
+ end
210
+
211
+ # Removes any persisted CardDefinition rows that match a hover-only DSL definition
212
+ # (by identifier). Hover-only cards are in-memory only; this purges any rows left
213
+ # over from prior runs before we made them in-memory. Safe to call repeatedly.
214
+ # Dependent rows cascade via :dependent => :destroy associations.
215
+ def purge_persisted_hover_only_cards!(card_pairs)
216
+ return if mode == :dry_run
217
+
218
+ hover_only_ids =
219
+ card_pairs.
220
+ select { |_klass, defn| hover_only_definition?(defn) }.
221
+ map { |_klass, defn| normalize_identifier((defn.deep_symbolize_keys)[:identifier]) }.
222
+ compact.
223
+ uniq
224
+ return if hover_only_ids.empty?
225
+
226
+ identifier_ids = ::HasHelpers::Cards::CardIdentifier.where(name: hover_only_ids).pluck(:id)
227
+ return if identifier_ids.empty?
228
+
229
+ ::HasHelpers::Cards::CardDefinition.where(identifier_id: identifier_ids).find_each(&:destroy)
230
+ end
231
+
232
+ private
233
+
234
+ attr_reader :resource_filter, :mode, :audit, :result, :application_name, :application
235
+
236
+ # Placeholder to run idempotent migrations before importing.
237
+ def run_migrations_if_any!
238
+ # Example: HasHelpers::Cards::Migrations.run_all if defined?(HasHelpers::Cards::Migrations)
239
+ end
240
+
241
+ # 1) Discover Cards
242
+ # Enumerate all classes inheriting from HasHelpers::Cards::CardBuilder::Base.
243
+ # Each subclass has already evaluated the DSL and stored its normalized definitions in .definitions.
244
+ # We also apply resource filtering here ( "Support selective import by resource or all").
245
+ def discover_card_definitions
246
+ ::HasHelpers::Cards::CardBuilder::Base.
247
+ descendants.
248
+ flat_map do |klass|
249
+ # Each card builder class can define one or many card definitions.
250
+ Array(klass.definitions).
251
+ flat_map do |defn|
252
+ defn = defn.deep_symbolize_keys
253
+ defn[:application] ||= application_name if application_name.present?
254
+ expand_per_resource(defn)
255
+ end.
256
+ map { |defn| [klass, defn] }
257
+ end.
258
+ select { |_klass, defn| passes_resource_filter?(defn) }
259
+ end
260
+
261
+ # Splits a normalized card definition into one per resource.
262
+ # Original DSL allows multiple resources per card; we materialize a separate
263
+ # definition for each resource to keep a 1:1 relationship.
264
+ def expand_per_resource(definition)
265
+ resources = Array(definition[:resources])
266
+ return [definition] if resources.empty?
267
+
268
+ resources.map do |res|
269
+ dup = definition.deep_dup
270
+ dup[:resources] = [res]
271
+ dup
272
+ end
273
+ end
274
+
275
+ # Returns true if this card definition should be imported for the current resource_filter.
276
+ # Resource filter semantics:
277
+ # - Accept classes (Advisor) or symbols (:advisor).
278
+ # - If filter is nil/empty, we import all cards.
279
+ # We map each DSL resource (class_name) to a symbol (advisor, client, etc.), then intersect with the filter.
280
+ def passes_resource_filter?(definition)
281
+ return true if resource_filter.blank?
282
+
283
+ dsl_resources =
284
+ Array(definition[:resources]).
285
+ map { |r| r[:class_name] || r["class_name"] }.
286
+ compact.
287
+ map(&:to_s)
288
+
289
+ (dsl_resources.map { |cn| normalize_resource_name(cn) } & resource_filter).any?
290
+ end
291
+
292
+ def validate_hover_cards!(card_pairs)
293
+ definitions =
294
+ card_pairs.map do |_klass, defn|
295
+ defn.deep_symbolize_keys
296
+ end
297
+
298
+ definitions_by_identifier =
299
+ definitions.index_by { |defn| normalize_identifier(defn[:identifier]) }
300
+
301
+ definitions.each do |definition|
302
+ parent_identifier = normalize_identifier(definition[:identifier])
303
+ parent_name = definition[:name].to_s
304
+
305
+ Array(definition[:fields]).each do |field|
306
+ field = field.deep_symbolize_keys
307
+ next unless normalize_data_type_name(field[:data_type]) == :hover_card
308
+
309
+ field_key = hover_field_key(field)
310
+ raw_hover_card = field[:hover_card]
311
+ if raw_hover_card.is_a?(Hash)
312
+ raise AuditError,
313
+ "Card '#{parent_name}' (identifier=#{parent_identifier}) field '#{field_key}' passed a Hash to `hover_card:` (#{raw_hover_card.inspect}). " \
314
+ "Use `hover_card_source:` for dynamic identifiers resolved from a data_source."
315
+ end
316
+ hover_card_identifier = raw_hover_card.is_a?(String) ? raw_hover_card.presence : nil
317
+ dynamic_hover_card = field[:hover_card_source].present?
318
+
319
+ unless hover_card_identifier || dynamic_hover_card
320
+ raise AuditError,
321
+ "Card '#{parent_name}' (identifier=#{parent_identifier}) field '#{field_key}' declares data_type HoverCard but is missing hover_card (or hover_card_source)."
322
+ end
323
+
324
+ # hover_record_source is optional for :resource-kind fields: the field's own
325
+ # resolved value (e.g. `policy.carrier_id`) is used as the hover target id.
326
+ if field[:hover_record_source].blank? && field[:kind].to_s.to_sym != :resource
327
+ raise AuditError,
328
+ "Card '#{parent_name}' (identifier=#{parent_identifier}) field '#{field_key}' declares hover_card '#{hover_card_identifier || '(dynamic)'}' but is missing hover_record_source."
329
+ end
330
+
331
+ # Dynamic hover_card targets are resolved at request time; we can't audit
332
+ # their target shape statically.
333
+ next if dynamic_hover_card && hover_card_identifier.nil?
334
+
335
+ target = definitions_by_identifier[hover_card_identifier]
336
+ unless target
337
+ raise AuditError,
338
+ "Card '#{parent_name}' (identifier=#{parent_identifier}) field '#{field_key}' references hover_card '#{hover_card_identifier}', but no card with that identifier exists in this import."
339
+ end
340
+
341
+ unless target[:hover_only]
342
+ raise AuditError,
343
+ "Card '#{parent_name}' (identifier=#{parent_identifier}) field '#{field_key}' references hover_card '#{hover_card_identifier}', but that card is not hover_only: true."
344
+ end
345
+
346
+ nested_field = Array(target[:fields]).find do |target_field|
347
+ normalize_data_type_name(target_field.deep_symbolize_keys[:data_type]) == :hover_card
348
+ end
349
+
350
+ next unless nested_field
351
+
352
+ nested_key = hover_field_key(nested_field.deep_symbolize_keys)
353
+ raise AuditError,
354
+ "Card '#{parent_name}' (identifier=#{parent_identifier}) field '#{field_key}' references hover_card '#{hover_card_identifier}', but that hover-only card contains nested HoverCard field '#{nested_key}'."
355
+ end
356
+ end
357
+ end
358
+
359
+ def hover_field_key(field)
360
+ (field[:key] || field[:ref] || field[:name]).to_s
361
+ end
362
+
363
+ # Converts "Advisor" → :advisor, "HasContactInfo::Contact" → :contact, etc.
364
+ def normalize_resource_name(class_name)
365
+ class_name.to_s.demodulize.underscore.to_sym
366
+ end
367
+
368
+ # Normalizes the resource filter argument into an array of symbols.
369
+ # Examples:
370
+ # normalize_resource_filter(nil) → nil (no filter)
371
+ # normalize_resource_filter([Advisor, Carrier]) → [:advisor, :carrier]
372
+ # normalize_resource_filter(:advisor) → [:advisor]
373
+ def normalize_resource_filter(resources)
374
+ return nil if resources.nil?
375
+
376
+ list = resources.is_a?(Array) ? resources : [resources]
377
+ list.map { |r| r.is_a?(Class) ? normalize_resource_name(r.name) : r.to_sym }.uniq
378
+ end
379
+
380
+ # 2) Normalize + checksum
381
+ # Normalizes a raw DSL hash definition into a deterministic structure.
382
+ # This is critical because:
383
+ # - We will compute a checksum = SHA256(JSON(definition_json)).
384
+ # - We want the JSON to be stable across runs (idempotency).
385
+ # - We want to exclude transient / DB-specific attributes (timestamps, ids).
386
+ # It also aligns with the "definition_json" we persist in card_definitions.
387
+ def normalize_definition(raw)
388
+ card = raw.deep_dup
389
+
390
+ # icon_name / icon_color are non-persisted, card-level presentation hints.
391
+ # They live in the DSL registry only, so we drop them before building the
392
+ # definition_json / checksum to keep persistence (and drift audits) stable.
393
+ card.delete(:icon_name)
394
+ card.delete(:icon_color)
395
+
396
+ card[:name] = card[:name].to_s
397
+ card[:identifier] = normalize_identifier(card[:identifier])
398
+ # relationship is currently not persisted in DB, but we keep it in JSON
399
+ card[:relationship] = card[:relationship].to_s if card[:relationship]
400
+ card[:layout_type] = normalize_enum_name(card[:layout_type])
401
+ card[:variant] = normalize_enum_name(card[:variant])
402
+ card[:description] = card[:description].to_s if card.key?(:description)
403
+ card[:is_customizable] = !!card[:is_customizable]
404
+ card[:default_visibility] = card[:default_visibility] # rubocop:disable Lint/SelfAssignment
405
+ card[:virtual] = !!card[:virtual]
406
+ card[:default_position] = card[:default_position] # rubocop:disable Lint/SelfAssignment
407
+ card[:expansion_scope] = nil # runtime-only
408
+ card[:data_sources] =
409
+ Array(card[:data_sources]).
410
+ map { |ds| normalize_data_source(ds) }.
411
+ compact
412
+ # Normalize resources: array of hashes { class_name, is_mandatory }
413
+ card[:resources] =
414
+ Array(card[:resources]).
415
+ map do |r|
416
+ { class_name: r[:class_name].to_s, is_mandatory: !!r[:is_mandatory] }
417
+ end.
418
+ sort_by { |r| r[:class_name] } # deterministic ordering
419
+ # Normalize fields: we standardize keys + values and then sort.
420
+ card[:fields] =
421
+ Array(card[:fields]).
422
+ map { |f| normalize_field_hash(f) }.
423
+ sort_by { |f| [f[:default_position] || 0, field_identity(f)] }
424
+
425
+ card
426
+ end
427
+
428
+ # Normalizes identifier (option) into a string name.
429
+ # e.g. HasHelpers::Cards::CardIdentifier::DEMOGRAPHICS -> "Demographics"
430
+ def normalize_identifier(value)
431
+ return nil if value.nil?
432
+ value.respond_to?(:name) && !value.is_a?(String) ? value.name.to_s : value.to_s
433
+ end
434
+
435
+ # Normalizes enums (layout_type, variant) to their name strings.
436
+ # e.g. HasHelpers::Cards::CardLayoutType::SUMMARY → "Summary".
437
+ def normalize_enum_name(value)
438
+ return nil if value.nil?
439
+
440
+ if value.respond_to?(:name) && !value.is_a?(String)
441
+ value.name.to_s
442
+ else
443
+ value.to_s
444
+ end
445
+ end
446
+
447
+ # Normalizes a field hash (resource or custom) for checksum + diff.
448
+ # Fields (with kind, data_type, etc.)
449
+ # We unify:
450
+ # - kind: :resource vs :custom
451
+ # - data_type: normalized symbol (e.g., :numeric, :string)
452
+ # - default_visibility, is_required: booleans
453
+ # - default_position: numeric (used for ordering but not breaking if changed)
454
+ def normalize_field_hash(f)
455
+ kind = (f[:kind] || f["kind"]).to_sym
456
+
457
+ base = {
458
+ kind: kind,
459
+ data_type: normalize_data_type_name(f[:data_type] || f["data_type"]),
460
+ default_visibility: !!(f[:default_visibility] || f["default_visibility"]),
461
+ default_position: f[:default_position] || f["default_position"],
462
+ is_required: !!(f[:is_required] || f["is_required"])
463
+ }
464
+
465
+ case kind
466
+ when :resource
467
+ base.merge(
468
+ ref: (f[:ref] || f["ref"]).to_s,
469
+ name: (f.key?(:name) ? f[:name] : f["name"])
470
+ )
471
+ when :custom
472
+ base.merge(
473
+ key: (f[:key] || f["key"]).to_s,
474
+ name: f[:name] || f["name"]
475
+ # note: we intentionally exclude mapper from checksum, it's considered implementation detail, not structure.
476
+ )
477
+ when :virtual
478
+ base.merge(
479
+ ref: (f[:ref] || f["ref"]).to_s,
480
+ name: f[:name] || f["name"],
481
+ custom_label: !!(f[:custom_label] || f["custom_label"]),
482
+ custom_label_key: (f[:custom_label_key] || f["custom_label_key"])&.to_s
483
+ )
484
+ else
485
+ base
486
+ end
487
+ end
488
+
489
+ # Normalizes data_type for checksum purposes.
490
+ # Example:
491
+ # HasHelpers::Cards::CardDataType::Numeric → :numeric
492
+ # "numeric" → :numeric
493
+ # :numeric → :numeric
494
+ def normalize_data_type_name(value)
495
+ return nil if value.nil?
496
+
497
+ if value.respond_to?(:name) && !value.is_a?(String)
498
+ value.name.to_s.underscore.to_sym
499
+ elsif value.is_a?(Symbol)
500
+ value
501
+ else
502
+ value.to_s.underscore.to_sym
503
+ end
504
+ end
505
+
506
+ def normalize_data_source(ds)
507
+ return nil if ds.nil?
508
+
509
+ { name: ds[:name].to_s }
510
+ end
511
+
512
+ # Identity of a field, used for:
513
+ # - indexing
514
+ # - diff / drift detection
515
+ # For resource fields: [:resource, "age"]
516
+ # For custom fields: [:custom, "DEMOGRAPHICS_FULL_NAME"]
517
+ def field_identity(field_hash)
518
+ case field_hash[:kind]
519
+ when :custom
520
+ [:custom, field_hash[:key].to_s]
521
+ when :virtual
522
+ [:virtual, field_hash[:ref].to_s]
523
+ else
524
+ [:resource, field_hash[:ref].to_s]
525
+ end
526
+ end
527
+
528
+ # "Compute a checksum"
529
+ # Uses SHA256 over the JSON-encoded normalized definition.
530
+ # This is the value we store in definition_checksum.
531
+ def checksum_for(normalized_definition)
532
+ Digest::SHA256.hexdigest(JSON.generate(normalized_definition))
533
+ end
534
+
535
+ # 3) Import one card (audit + diff + upsert)
536
+ # Imports (or audits) a single card.
537
+ def import_card!(klass, raw_definition)
538
+ # Normalize DSL definition (this will be stored as definition_json).
539
+ normalized = normalize_definition(raw_definition)
540
+ checksum = checksum_for(normalized)
541
+
542
+ identifier_name = normalized[:identifier]
543
+ identifier_opt = identifier_option_for(identifier_name)
544
+ resource_hash = Array(normalized[:resources]).first
545
+ application_name = self.application_name
546
+ resource_opt = resource_hash && find_resource_class(resource_hash[:class_name], application_name)
547
+
548
+ # Concurrency: lock card rows by identifier/resource before writing to avoid race conditions
549
+ # We lock by identifier_id using SELECT ... FOR UPDATE.
550
+ db_card =
551
+ identifier_opt &&
552
+ resource_opt &&
553
+ ::HasHelpers::Cards::CardDefinition.
554
+ joins(:card_definition_resources).
555
+ lock("FOR UPDATE").
556
+ where(identifier_id: identifier_opt.id, card_definition_resources: { resource_id: resource_opt.id }).
557
+ first
558
+ # If we already have a definition_checksum in DB and it matches the computed checksum, we consider there is no drift for this card.
559
+ if db_card &&
560
+ db_card.respond_to?(:definition_checksum) &&
561
+ db_card.definition_checksum.present? &&
562
+ db_card.definition_checksum == checksum
563
+ diffs = Diff.new(
564
+ card_changes: :same,
565
+ fields_added: [],
566
+ fields_removed: [],
567
+ fields_changed: [],
568
+ resources_added: [],
569
+ resources_removed: []
570
+ )
571
+ else
572
+ # Build "DB view" (normalized structure) from existing DB records, if any.
573
+ db_view = db_card && build_db_view_for(db_card)
574
+ # Compute structural diff using normalized DSL view vs DB view.
575
+ diffs = compute_diff(normalized, db_view)
576
+ end
577
+
578
+ # Audit / drift classification.
579
+ # Fail safely when structural changes occur.
580
+ classify_and_raise_if_breaking!(klass, normalized, diffs) if audit
581
+
582
+ # If we are in dry_run mode:
583
+ # - we only log diffs
584
+ # - we do not write anything to DB
585
+ if mode == :dry_run
586
+ log_diffs(normalized[:identifier], diffs)
587
+ result.skipped_cards += 1
588
+ return
589
+ end
590
+
591
+ # Transactions: one transaction per card to ensure consistency.
592
+ ::ActiveRecord::Base.transaction do
593
+ # Upsert card_definitions (by identifier) + definition_json + definition_checksum
594
+ card_def = upsert_card_definition!(normalized, checksum, db_card)
595
+ # Upsert card_definition_resources (links between card_definition and resource)
596
+ upsert_card_resources!(card_def, normalized[:resources], application_name)
597
+ # Upsert card_fields (one per resource/custom fiexld)
598
+ upsert_card_fields!(card_def, normalized[:fields])
599
+ end
600
+ end
601
+
602
+ # Build a "normalized DB view" for an existing card
603
+ # Builds a normalized hash representing how this card currently looks in the DB (card_definitions + card_fields + card_definition_resources).
604
+ # We reuse normalize_definition to align it with the DSL view so that:
605
+ # - compute_diff(code_view, db_view) becomes simple.
606
+ def build_db_view_for(card_def)
607
+ db_app = card_def.card_definition_resources.includes(resource: :application).first&.resource&.application&.name
608
+ {
609
+ application: db_app,
610
+ name: card_def.name,
611
+ identifier: card_def.identifier&.name,
612
+ relationship: nil,
613
+ layout_type: card_def.layout_type&.name,
614
+ variant: card_def.variant&.name,
615
+ description: card_def.description,
616
+ is_customizable: card_def.is_customizable,
617
+ virtual: card_def.respond_to?(:is_virtual) ? card_def.is_virtual : card_def.try(:virtual),
618
+ default_position: card_def.position,
619
+ resources: build_db_resources(card_def),
620
+ fields: build_db_fields(card_def)
621
+ }.then { |h| normalize_definition(h) }
622
+ end
623
+
624
+ # Returns normalized array of resources for the DB view.
625
+ # For each CardDefinitionResource we extract:
626
+ # - resource.name → class_name
627
+ # - is_mandatory → boolean
628
+ def build_db_resources(card_def)
629
+ card_def.card_definition_resources.includes(:resource).map do |link|
630
+ {
631
+ class_name: link.resource.name, # HasHelpers::Resource.name
632
+ is_mandatory: !!link.is_mandatory
633
+ }
634
+ end.sort_by { |r| r[:class_name] }
635
+ end
636
+
637
+ # Returns normalized array of fields for the DB view.
638
+ # We map them back to the same shape used in normalize_definition.
639
+ def build_db_fields(card_def)
640
+ card_def.card_fields.
641
+ includes(:field_resource, :data_type).
642
+ map do |field|
643
+ if field.virtual_ref.present?
644
+ {
645
+ kind: :virtual,
646
+ ref: field.virtual_ref.to_s,
647
+ name: field.field_custom_name,
648
+ data_type: normalize_data_type_name(field.data_type),
649
+ default_visibility: !!field.default_visibility,
650
+ default_position: field.default_position,
651
+ is_required: !!field.is_required,
652
+ custom_label: !!field.try(:custom_label),
653
+ custom_label_key: field.try(:custom_label_key).presence
654
+ }
655
+ elsif field.field_resource.present?
656
+ {
657
+ kind: :resource,
658
+ ref: field.field_resource&.name.to_s,
659
+ name: field.field_custom_name,
660
+ data_type: normalize_data_type_name(field.data_type),
661
+ default_visibility: !!field.default_visibility,
662
+ default_position: field.default_position,
663
+ is_required: !!field.is_required
664
+ }
665
+ else
666
+ {
667
+ kind: :custom,
668
+ key: field.field_custom_name.to_s,
669
+ name: field.field_custom_name,
670
+ data_type: normalize_data_type_name(field.data_type),
671
+ default_visibility: !!field.default_visibility,
672
+ default_position: field.default_position,
673
+ is_required: !!field.is_required
674
+ }
675
+ end
676
+ end.sort_by { |f| [f[:default_position] || 0, field_identity(f)] }
677
+ end
678
+
679
+ # Diff logic (code vs DB)
680
+ # Computes a Diff object given the DSL (code_view) and DB (db_view).
681
+ # "How to detect drift" + "Auditing".
682
+ # Cases:
683
+ # - db_view is nil → this is a brand new card (additive).
684
+ # - otherwise → we compute card attribute changes + fields/resources changes.
685
+ def compute_diff(code_view, db_view)
686
+ if db_view.nil?
687
+ return Diff.new(
688
+ card_changes: :new,
689
+ fields_added: code_view[:fields],
690
+ fields_removed: [],
691
+ fields_changed: [],
692
+ resources_added: code_view[:resources],
693
+ resources_removed: []
694
+ )
695
+ end
696
+
697
+ Diff.new(
698
+ card_changes: card_changes(code_view, db_view),
699
+ fields_added: diff_fields_added(code_view, db_view),
700
+ fields_removed: diff_fields_removed(code_view, db_view),
701
+ fields_changed: diff_fields_changed(code_view, db_view),
702
+ resources_added: diff_resources_added(code_view, db_view),
703
+ resources_removed: diff_resources_removed(code_view, db_view)
704
+ )
705
+ end
706
+
707
+ # Compares high-level card attributes (excluding fields/resources).
708
+ # Card attribute changes (e.g., layout_type, name, is_customizable) → breaking
709
+ # We mark card_attributes as :changed or :same.
710
+ def card_changes(code, db)
711
+ important_keys = %i[
712
+ name
713
+ layout_type
714
+ variant
715
+ virtual
716
+ is_customizable
717
+ description
718
+ ]
719
+
720
+ changed = important_keys.any? { |key| code[key] != db[key] }
721
+ changed ? :changed : :same
722
+ end
723
+
724
+ # Helper: builds an index of fields by their identity (kind + ref/key).
725
+ def index_fields_by_identity(fields)
726
+ fields.index_by { |f| field_identity(f) }
727
+ end
728
+
729
+ # Code fields that are missing in DB → additive.
730
+ def diff_fields_added(code, db)
731
+ code_index = index_fields_by_identity(code[:fields])
732
+ db_index = index_fields_by_identity(db[:fields])
733
+
734
+ (code_index.keys - db_index.keys).map { |k| code_index[k] }
735
+ end
736
+
737
+ # DB fields that are missing in code → breaking (removal).
738
+ def diff_fields_removed(code, db)
739
+ code_index = index_fields_by_identity(code[:fields])
740
+ db_index = index_fields_by_identity(db[:fields])
741
+
742
+ (db_index.keys - code_index.keys).map { |k| db_index[k] }
743
+ end
744
+
745
+ # Fields present in both code and DB but with changed structural attributes.
746
+ # Example: Changed field attributes (e.g., data_type, required...) → breaking.
747
+ # Reordering is non-breaking, so position differences are ignored here.
748
+ def diff_fields_changed(code, db)
749
+ code_index = index_fields_by_identity(code[:fields])
750
+ db_index = index_fields_by_identity(db[:fields])
751
+ common = code_index.keys & db_index.keys
752
+
753
+ common.map do |key|
754
+ c = code_index[key]
755
+ d = db_index[key]
756
+
757
+ # We consider changes in these attributes as potentially breaking.
758
+ attrs = %i[
759
+ data_type
760
+ is_required
761
+ kind
762
+ ]
763
+
764
+ if attrs.any? { |a| c[a] != d[a] }
765
+ { identity: key, code: c, db: d }
766
+ end
767
+ end.compact
768
+ end
769
+
770
+ # Resources present in code but missing in DB → additive.
771
+ def diff_resources_added(code, db)
772
+ code_set = Set.new(code[:resources].map { |r| r[:class_name].to_s })
773
+ db_set = Set.new(db[:resources].map { |r| r[:class_name].to_s })
774
+
775
+ (code_set - db_set).map do |cn|
776
+ code[:resources].find { |r| r[:class_name].to_s == cn }
777
+ end
778
+ end
779
+
780
+ # Resources present in DB but missing in code → breaking (relationship removal).
781
+ # Exmaple: Removed relationship → breaking
782
+ def diff_resources_removed(code, db)
783
+ code_set = Set.new(code[:resources].map { |r| r[:class_name].to_s })
784
+ db_set = Set.new(db[:resources].map { |r| r[:class_name].to_s })
785
+
786
+ (db_set - code_set).map do |cn|
787
+ db[:resources].find { |r| r[:class_name].to_s == cn }
788
+ end
789
+ end
790
+
791
+ # Audit classification: raise if breaking drift.
792
+ # Given the diff, determine if there is any breaking drift.
793
+ # If yes, raise HasHelpers::Cards::Importer::AuditError with detailed messages,in line with the examples (Removed field, Changed data_type, Removed relationship).
794
+ def classify_and_raise_if_breaking!(_klass, code_view, diff)
795
+ card_id = code_view[:identifier]
796
+ name = code_view[:name]
797
+
798
+ msgs = []
799
+
800
+ # Card-level attribute changes
801
+ if diff.card_changes == :changed
802
+ msgs << "Card '#{name}' (identifier=#{card_id}) attributes changed (name/layout/variant/is_customizable/position/description). "\
803
+ "This is a breaking change. Create a data migration to update/remove it and then try again."
804
+ end
805
+
806
+ # Fields removed from code but still in DB
807
+ diff.fields_removed.each do |field|
808
+ label = field_identity(field).join(":")
809
+ msgs << "Card '#{name}' (identifier=#{card_id}) drift: field '#{label}' is persisted but not in code. "\
810
+ "This is a breaking change. Create a data migration to remove it and then try again."
811
+ end
812
+
813
+ # Fields with changed attributes (e.g. data_type, required...)
814
+ diff.fields_changed.each do |change|
815
+ label = change[:identity].join(":")
816
+ msgs << "Card '#{name}' (identifier=#{card_id}) field '#{label}' changed from #{change[:db].inspect} to #{change[:code].inspect}. "\
817
+ "This is a breaking change. Create a data migration and then try again."
818
+ end
819
+
820
+ # Relationships removed (card_definition_resources removed in code)
821
+ diff.resources_removed.each do |res|
822
+ msgs << "Card '#{name}' (identifier=#{card_id}) drift: resource '#{res[:class_name]}' is persisted but not present in code. "\
823
+ "This is a breaking change. Create a data migration to remove it and then try again."
824
+ end
825
+
826
+ raise AuditError, msgs.join("\n") if msgs.any?
827
+ end
828
+
829
+ # Logs diffs for dry_run mode.
830
+ # Example: mode: :dry_run → compute and print diffs, no DB writes.
831
+ def log_diffs(identifier, diff)
832
+ Rails.logger.info(
833
+ event: "cards_importer.dry_run",
834
+ identifier: identifier,
835
+ card_changes: diff.card_changes,
836
+ fields_added_count: diff.fields_added.size,
837
+ fields_removed_count: diff.fields_removed.size,
838
+ fields_changed_count: diff.fields_changed.size,
839
+ resources_added_count: diff.resources_added.size,
840
+ resources_removed_count: diff.resources_removed.size
841
+ )
842
+ end
843
+
844
+ # Global audit: cards present only in DB
845
+ # When there is no resource filter, we also check for cards that:
846
+ # - exist in card_definitions (DB)
847
+ # - but do not appear in any DSL definition.
848
+ #
849
+ def audit_removed_cards!(card_pairs)
850
+ # Track definitions by [identifier, resource_name] so we detect per-resource removals
851
+ defs_in_code =
852
+ card_pairs.map do |_klass, defn|
853
+ norm = normalize_definition(defn.deep_symbolize_keys)
854
+ res = Array(norm[:resources]).first
855
+ [norm[:identifier], res&.[](:class_name)]
856
+ end.compact.to_set
857
+
858
+ app_id = application.id
859
+
860
+ ::HasHelpers::Cards::CardDefinition.
861
+ joins(card_definition_resources: { resource: :application }).
862
+ where(applications: { id: app_id }).
863
+ includes(:identifier, card_definition_resources: :resource).
864
+ find_each do |card|
865
+ identifier_name = card.identifier&.name
866
+ resource_name = card.card_definition_resources.first&.resource&.name
867
+ next if identifier_name.blank?
868
+ next if defs_in_code.include?([identifier_name, resource_name])
869
+
870
+ raise AuditError,
871
+ "Card '#{card.name}' (identifier=#{identifier_name}, resource=#{resource_name}) exists in DB but not in the DSL. "\
872
+ "This is a breaking removal. Create a data migration to remove it and then try again."
873
+ end
874
+ end
875
+
876
+ # 4) Persist (Upsert)
877
+ # Upserts into card_definitions using identifier as the logical key.
878
+ # Example attributes:
879
+ # - "Upsert records deterministically (idempotent)"
880
+ # - "Persist a definition_json and definition_checksum for each record"
881
+ # If db_card is nil → we create a new CardDefinition.
882
+ # Otherwise → we update the existing one.
883
+ def upsert_card_definition!(code, checksum, db_card)
884
+ card =
885
+ if db_card
886
+ result.updated_cards += 1
887
+ db_card
888
+ else
889
+ result.created_cards += 1
890
+ ::HasHelpers::Cards::CardDefinition.new
891
+ end
892
+
893
+ card.name = code[:name]
894
+ card.identifier = identifier_option_for(code[:identifier])
895
+ card.layout_type = layout_type_option_for(code[:layout_type])
896
+ card.variant = variant_option_for(code[:variant])
897
+ card.description = code[:description]
898
+ card.is_customizable = code[:is_customizable]
899
+ card.default_visibility = code[:default_visibility] if card.respond_to?(:default_visibility=)
900
+ card.position = code[:default_position]
901
+ card.relationship = code[:relationship]
902
+ card.relationship_klass = code[:relationship_klass]
903
+ if card.has_attribute?(:is_virtual)
904
+ card.is_virtual = code[:virtual] ? true : nil
905
+ elsif card.respond_to?(:virtual=)
906
+ card.virtual = code[:virtual] ? true : nil
907
+ end
908
+
909
+ # These 2 attributes must exist in card_definitions table:
910
+ # - definition_json (jsonb or text)
911
+ # - definition_checksum (string)
912
+ # They are used for auditing future runs.
913
+ if card.has_attribute?(:definition_json)
914
+ card.definition_json = code
915
+ end
916
+
917
+ if card.has_attribute?(:definition_checksum)
918
+ card.definition_checksum = checksum
919
+ end
920
+
921
+ card.save!
922
+ card
923
+ end
924
+
925
+ # Upserts card_definition_resources for a given card.
926
+ # Matching key: (card_definition_id, resource_id).
927
+ # Note: we do not delete DB relationships that are not present in code, because that would be a breaking removal and is handled at audit time.
928
+ def upsert_card_resources!(card_def, resources_from_code, application_name = nil)
929
+ # indexing the existing links by resource name ("Advisor", "Client", etc.)
930
+ existing_links_by_name =
931
+ card_def.card_definition_resources.includes(:resource).index_by { |link| link.resource.name }
932
+
933
+ resources_from_code.each_with_index do |res, index|
934
+ class_name = res[:class_name].to_s
935
+
936
+ # 1) if a link already exists for that resource name, we reuse it
937
+ link = existing_links_by_name[class_name]
938
+ resource = link&.resource
939
+
940
+ # 2) if not existing, we lookup the resource by name (may fail if DB is dirty)
941
+ resource ||= find_resource_class(class_name, application_name)
942
+
943
+ # 3) we ensure there is a link for (card_definition, resource)
944
+ link ||= ::HasHelpers::Cards::CardDefinitionResource.find_or_initialize_by(
945
+ card_definition_id: card_def.id,
946
+ resource_id: resource.id
947
+ )
948
+
949
+ if link.new_record?
950
+ result.created_resources += 1
951
+ else
952
+ result.updated_resources += 1
953
+ end
954
+
955
+ link.is_mandatory = res[:is_mandatory]
956
+ # If this is the first time we see the link, we set a default position.
957
+ link.default_position ||= (index + 1) * 10
958
+ link.save!
959
+ end
960
+ end
961
+
962
+ # Upserts card_fields for a given card.
963
+ # Matching keys:
964
+ # - (card_definition_id, field_id) for resource fields
965
+ # - (card_definition_id, field_custom_key) for custom fields
966
+ # We use find_or_initialize_field to encapsulate that logic.
967
+ def upsert_card_fields!(card_def, fields_from_code)
968
+ # All resources (HasHelpers::Resource) associated with this card
969
+ parent_ids = card_def.card_definition_resources.pluck(:resource_id)
970
+
971
+ fields_from_code.each do |f|
972
+ field = find_or_initialize_field(card_def, f, parent_ids)
973
+
974
+ if field.new_record?
975
+ result.created_fields += 1
976
+ else
977
+ result.updated_fields += 1
978
+ end
979
+
980
+ apply_field_attributes!(field, f, parent_ids, card_def)
981
+ field.save!
982
+ end
983
+ # As with resources, we do not delete existing DB fields
984
+ # that are missing from code; that drift is handled by the audit.
985
+ end
986
+
987
+ # Finds or initializes a CardField given the card_def and field hash.
988
+ # Custom fields:
989
+ # - identity: (card_definition_id, field_custom_name)
990
+ # Resource fields:
991
+ # - identity: (card_definition_id, field_resource)
992
+ def find_or_initialize_field(card_def, field_hash, parent_ids)
993
+ identity = field_identity(field_hash)
994
+
995
+ case identity.first
996
+ when :custom
997
+ ::HasHelpers::Cards::CardField.find_or_initialize_by(
998
+ card_definition_id: card_def.id,
999
+ field_custom_name: field_hash[:name]
1000
+ )
1001
+ when :virtual
1002
+ scope = ::HasHelpers::Cards::CardField.where(card_definition_id: card_def.id, virtual_ref: identity.last)
1003
+ scope.first || ::HasHelpers::Cards::CardField.new(card_definition_id: card_def.id, virtual_ref: identity.last)
1004
+ else
1005
+ resource = find_field_resource(identity.last, parent_ids, card_def.relationship_klass)
1006
+ ::HasHelpers::Cards::CardField.find_or_initialize_by(
1007
+ card_definition_id: card_def.id,
1008
+ field_resource: resource
1009
+ )
1010
+ end
1011
+ end
1012
+
1013
+ # Applies attributes from the normalized field hash onto a CardField model.
1014
+ # - Sets field_custom_name for custom fields.
1015
+ # - Sets field_resource for resource fields.
1016
+ # - Sets data_type, default_position, default_visibility, is_required.
1017
+ def apply_field_attributes!(field, f, parent_ids, card_def)
1018
+ case f[:kind]
1019
+ when :custom
1020
+ field.field_custom_name = f[:name]
1021
+ field.field_id = nil
1022
+ field.virtual_ref = nil if field.respond_to?(:virtual_ref=)
1023
+ when :virtual
1024
+ field.field_custom_name = f[:name]
1025
+ field.field_id = nil
1026
+ field.virtual_ref = f[:ref] if field.respond_to?(:virtual_ref=)
1027
+ field.field_resource = nil if field.respond_to?(:field_resource=)
1028
+ field.custom_label = !!f[:custom_label] if field.respond_to?(:custom_label=)
1029
+ field.custom_label_key = f[:custom_label_key].presence if field.respond_to?(:custom_label_key=)
1030
+ else
1031
+ resource = find_field_resource(f[:ref], parent_ids, card_def.relationship_klass)
1032
+ field.field_resource = resource
1033
+
1034
+ # Persist static label override (DSL name:)
1035
+ name_override = (f[:name] || f["name"]).presence
1036
+ field.field_custom_name = name_override if name_override
1037
+
1038
+ field.virtual_ref = nil if field.respond_to?(:virtual_ref=)
1039
+
1040
+ field.custom_label = false if field.respond_to?(:custom_label=)
1041
+ field.custom_label_key = nil if field.respond_to?(:custom_label_key=)
1042
+ end
1043
+
1044
+ field.data_type = lookup_card_data_type(f[:data_type])
1045
+ field.default_position = f[:default_position]
1046
+ field.default_visibility = f[:default_visibility]
1047
+ field.is_required = f[:is_required]
1048
+ end
1049
+
1050
+ # Lookups (Options + Resources)
1051
+ # Finds the HasHelpers::Cards::CardIdentifier option by its name.
1052
+ def identifier_option_for(identifier_name)
1053
+ return nil if identifier_name.blank?
1054
+
1055
+ if identifier_name.is_a?(::HasHelpers::Cards::CardIdentifier)
1056
+ identifier_name
1057
+ else
1058
+ ::HasHelpers::Cards::CardIdentifier.find_by!(name: identifier_name.to_s)
1059
+ end
1060
+ end
1061
+
1062
+ # Finds the HasHelpers::Cards::CardLayoutType option by its name.
1063
+ def layout_type_option_for(layout_name)
1064
+ return nil if layout_name.blank?
1065
+
1066
+ if layout_name.is_a?(::HasHelpers::Cards::CardLayoutType)
1067
+ layout_name
1068
+ else
1069
+ ::HasHelpers::Cards::CardLayoutType.find_by!(name: layout_name.to_s)
1070
+ end
1071
+ end
1072
+
1073
+ # Finds the HasHelpers::Cards::CardVariantType option by its name.
1074
+ def variant_option_for(variant_name)
1075
+ return nil if variant_name.blank?
1076
+
1077
+ if variant_name.is_a?(::HasHelpers::Cards::CardVariantType)
1078
+ variant_name
1079
+ else
1080
+ ::HasHelpers::Cards::CardVariantType.find_by!(name: variant_name.to_s)
1081
+ end
1082
+ end
1083
+
1084
+ # Resolves data_type (symbol or option) to HasHelpers::Cards::CardDataType.
1085
+ # Example:
1086
+ # :numeric → HasHelpers::Cards::CardDataType.find_by!(name: "Numeric")
1087
+ def lookup_card_data_type(data_type_ident)
1088
+ return nil if data_type_ident.nil?
1089
+
1090
+ if data_type_ident.is_a?(::HasHelpers::Cards::CardDataType)
1091
+ data_type_ident
1092
+ else
1093
+ ::HasHelpers::Cards::CardDataType.find_by!(name: data_type_ident.to_s.camelize)
1094
+ end
1095
+ end
1096
+
1097
+ # Search for the correct field (HasHelpers::Resource) restricting by:
1098
+ # - name (field_name)
1099
+ # - parent_id in the card's resources (when present)
1100
+ # - relationship_klass is the relationship class for the relationship from parent resource this card is using
1101
+ # - application sanity between this row and its field_resource (if any)
1102
+ #
1103
+ # If after filtering there is more than one match, we treat it as a real
1104
+ # data duplication problem and raise AuditError (so devs clean it up).
1105
+ def find_field_resource(field_name, parent_ids, relationship_klass = nil)
1106
+ base_resource = ::HasHelpers::Resource.find(parent_ids.first)
1107
+ application = base_resource.application
1108
+ parent_resource = (relationship_klass && ::HasHelpers::Resource.find_by(name: relationship_klass, parent_id: nil, application: application)) || base_resource
1109
+
1110
+ parent_klass =
1111
+ parent_resource.name.safe_constantize ||
1112
+ ::HasHelpers::ApplicationHelper.translate_klass(parent_resource.name)
1113
+ association_info = inspect_field_and_association(parent_klass, field_name)
1114
+
1115
+ raise AuditError, "The field #{field_name} is not a valid column from the class #{parent_resource.name}" if association_info[0].nil?
1116
+
1117
+ field_class_name = association_info[1]
1118
+
1119
+ scope = ::HasHelpers::Resource.where(name: field_name.to_s, parent: parent_resource, field_class_name: field_class_name)
1120
+ # We eager load field_resource to avoid N+1 queries when we filter in Ruby.
1121
+ matches =
1122
+ scope.
1123
+ includes(:field_resource). # assumes belongs_to :field_resource, class_name: "HasHelpers::Resource", optional: true
1124
+ order(:created_at, :id).
1125
+ to_a
1126
+
1127
+ # Keep only rows where:
1128
+ # - there is no field_resource (nil), or
1129
+ # - the field_resource belongs to the same application as the resource itself.
1130
+ filtered = matches.select do |resource|
1131
+ fr = resource.field_resource
1132
+ fr.nil? || fr.application_id == resource.application_id
1133
+ end
1134
+
1135
+ case filtered.size
1136
+ when 1
1137
+ # Ideal case: a single candidate after sanity filtering
1138
+ filtered.first
1139
+ when 0
1140
+ # Nothing found with these constraints: real error in DSL/DB alignment
1141
+ raise ::ActiveRecord::RecordNotFound,
1142
+ "No field found with name=#{field_name} for parent_ids=#{parent_ids.inspect}"
1143
+ else
1144
+ # With all filters applied we still have multiple rows → duplicated data
1145
+ ids = filtered.map(&:id).join(",")
1146
+ raise AuditError,
1147
+ "Ambiguous field '#{field_name}' for relationship_klass=#{relationship_klass} parent_ids=#{parent_ids.inspect}. " \
1148
+ "Found #{filtered.size} candidates (ids=#{ids}) even after filtering by application. " \
1149
+ "Please clean up duplicated HasHelpers::Resource rows so (name + parent_id + application_id) is unique."
1150
+ end
1151
+ end
1152
+
1153
+ # Resolves a resource class name to HasHelpers::Resource.
1154
+ # We assume there is a HasHelpers::Resource record with this name.
1155
+ def find_resource_class(class_name, application_name = nil)
1156
+ scope = ::HasHelpers::Resource.where(name: class_name.to_s, parent_id: nil)
1157
+
1158
+ if application_name.present?
1159
+ app = ::HasHelpers::Application.where("LOWER(name) = ?", application_name.to_s.downcase).first!
1160
+ scope = scope.where(application_id: app.id)
1161
+ end
1162
+
1163
+ scope.first!
1164
+ end
1165
+
1166
+ # Returns [field, association_class_name]
1167
+ # - If `field` maps to a belongs_to association (e.g. created_by_id -> :created_by),
1168
+ # returns the association's class name.
1169
+ # - If it's a normal column, returns [field, nil]
1170
+ # - If it's neither a column nor a belongs_to foreign key, returns [nil, nil]
1171
+ #
1172
+ # Examples:
1173
+ # inspect_field_and_association(Policy, "created_by_id") #=> ["created_by_id", "HasHelpers::User"]
1174
+ # inspect_field_and_association(Policy, "carrier_id") #=> ["carrier_id", "Carrier"]
1175
+ # inspect_field_and_association(Policy, "effective_date")#=> ["effective_date", nil]
1176
+ # inspect_field_and_association(Policy, "nope") #=> [nil, nil]
1177
+ def inspect_field_and_association(klass, field)
1178
+ return [nil, nil] unless klass.is_a?(Class) && field.present?
1179
+
1180
+ field = field.to_s
1181
+
1182
+ # 1) Must be a real column, otherwise it's invalid.
1183
+ return [nil, nil] unless klass.column_names.include?(field)
1184
+
1185
+ # 2) If it's the foreign_key of a belongs_to association, return that association's klass.
1186
+ refl =
1187
+ klass.reflect_on_all_associations(:belongs_to).find do |r|
1188
+ r.foreign_key.to_s == field
1189
+ end
1190
+
1191
+ assoc_class_name =
1192
+ if refl
1193
+ # Prefer ActiveRecord's resolved class if possible, fallback to metadata.
1194
+ begin
1195
+ refl.klass.name
1196
+ rescue NameError
1197
+ refl.class_name
1198
+ end
1199
+ end
1200
+
1201
+ [field, assoc_class_name]
1202
+ end
1203
+
1204
+ # Summary logging
1205
+ # Logs a concise summary of the import operation.
1206
+ # "Logging: use structured logs for audit diff outputs."
1207
+ def log_summary
1208
+ Rails.logger.info(
1209
+ event: "cards_importer.summary",
1210
+ created_cards: result.created_cards,
1211
+ updated_cards: result.updated_cards,
1212
+ created_fields: result.created_fields,
1213
+ updated_fields: result.updated_fields,
1214
+ created_resources: result.created_resources,
1215
+ updated_resources: result.updated_resources,
1216
+ skipped_cards: result.skipped_cards,
1217
+ mode: mode,
1218
+ resource_filter: resource_filter
1219
+ )
1220
+ end
1221
+ end
1222
+ end
1223
+ end