@fayz-ai/db 0.13.0 → 0.15.0

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 (198) hide show
  1. package/canonical-schema.json +106 -23
  2. package/dist/index.cjs +4 -4
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/schema/spine.d.ts +42 -37
  9. package/dist/schema/spine.d.ts.map +1 -1
  10. package/migrations/000_baseline.sql +29667 -0
  11. package/migrations/001_the_tenant_has_apps.sql +314 -0
  12. package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
  13. package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
  14. package/migrations/004_the_projections_get_a_successor.sql +312 -0
  15. package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
  16. package/migrations/006_the_helpers_read_the_source.sql +153 -0
  17. package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
  18. package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
  19. package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
  20. package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
  21. package/migrations/011_leadcontrol_joins_the_family.sql +120 -0
  22. package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
  23. package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
  24. package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
  25. package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
  26. package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
  27. package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
  28. package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
  29. package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
  30. package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
  31. package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
  32. package/migrations/029_o_administrador_enxerga_o_que_existe.sql +167 -0
  33. package/migrations/031_ticketcontrol_joins_the_family.sql +109 -0
  34. package/migrations/032_a_marca_e_um_conjunto_de_tokens.sql +291 -0
  35. package/migrations/041_a_entrega_tem_onde_guardar_a_politica.sql +53 -0
  36. package/migrations/042_o_token_de_marca_fecha_a_porta_anon.sql +17 -0
  37. package/migrations/043_fiscalcontrol_joins_the_family.sql +75 -0
  38. package/migrations/044_o_teste_gratis_dura_trinta_dias.sql +15 -0
  39. package/migrations/046_the_tick_advances_the_flows.sql +77 -0
  40. package/migrations/047_the_tick_sweeps_the_offers.sql +74 -0
  41. package/migrations/052_fullcontrol_joins_the_family.sql +213 -0
  42. package/migrations/053_o_erp_para_de_exigir_o_que_nao_desenha.sql +37 -0
  43. package/package.json +1 -1
  44. package/migrations/0000_legacy_quarantine.sql +0 -86
  45. package/migrations/0000b_straggler_sweep.sql +0 -37
  46. package/migrations/000_core_v1_convert.sql +0 -343
  47. package/migrations/001_core.sql +0 -372
  48. package/migrations/002_rls_user_tenant_ids.sql +0 -64
  49. package/migrations/003_invitations.sql +0 -17
  50. package/migrations/004_archetypes.sql +0 -343
  51. package/migrations/005_locations_archetype.sql +0 -18
  52. package/migrations/006_archetype_rls.sql +0 -74
  53. package/migrations/007_archetype_grants.sql +0 -4
  54. package/migrations/008_grants.sql +0 -5
  55. package/migrations/009_anon_hardening.sql +0 -19
  56. package/migrations/010_migration_ledger.sql +0 -46
  57. package/migrations/011_anon_write_revoke.sql +0 -12
  58. package/migrations/012_rename_migration_ledger.sql +0 -61
  59. package/migrations/013_sequences_rls.sql +0 -22
  60. package/migrations/014_plan_entitlements.sql +0 -28
  61. package/migrations/015_agent_guard.sql +0 -108
  62. package/migrations/016_agent_guard_actor.sql +0 -102
  63. package/migrations/017_core_addresses.sql +0 -115
  64. package/migrations/018_global_search.sql +0 -482
  65. package/migrations/019_tenant_members_person_id.sql +0 -20
  66. package/migrations/020_fayz_projects.sql +0 -63
  67. package/migrations/021_tenant_roles.sql +0 -74
  68. package/migrations/022_invite_acceptance.sql +0 -91
  69. package/migrations/023_team_visibility.sql +0 -75
  70. package/migrations/024_analytics_engine.sql +0 -317
  71. package/migrations/025_created_by.sql +0 -75
  72. package/migrations/026_audit_trail.sql +0 -73
  73. package/migrations/027_domain_events.sql +0 -266
  74. package/migrations/028_tenant_scoped_token.sql +0 -127
  75. package/migrations/029_connections.sql +0 -186
  76. package/migrations/030_effect_idempotency.sql +0 -159
  77. package/migrations/031_sync_run_message.sql +0 -39
  78. package/migrations/032_connection_secrets.sql +0 -227
  79. package/migrations/033_sync_schedule.sql +0 -651
  80. package/migrations/034_custom_fields.sql +0 -55
  81. package/migrations/035_field_registry.sql +0 -148
  82. package/migrations/036_analytics_run_batch.sql +0 -84
  83. package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
  84. package/migrations/038_onboarding_responses.sql +0 -103
  85. package/migrations/039_unit_tree.sql +0 -270
  86. package/migrations/040_resource_grants.sql +0 -474
  87. package/migrations/041_scoped_columns.sql +0 -192
  88. package/migrations/042_unit_scope_policies.sql +0 -145
  89. package/migrations/043_view_invoker.sql +0 -90
  90. package/migrations/044_unit_member_facts.sql +0 -47
  91. package/migrations/045_unit_entry.sql +0 -236
  92. package/migrations/046_membership_visible_to_members.sql +0 -85
  93. package/migrations/047_tasks.sql +0 -266
  94. package/migrations/048_every_login_is_a_person.sql +0 -190
  95. package/migrations/049_bookable_people.sql +0 -126
  96. package/migrations/050_authz_core.sql +0 -230
  97. package/migrations/051_authz_helpers.sql +0 -330
  98. package/migrations/052_authz_fanout.sql +0 -253
  99. package/migrations/053_authz_catalog_templates.sql +0 -402
  100. package/migrations/060_tenancy_seam.sql +0 -132
  101. package/migrations/061_create_tenant.sql +0 -125
  102. package/migrations/062_invitations.sql +0 -329
  103. package/migrations/063_legacy_tenancy_retired.sql +0 -568
  104. package/migrations/064_agent_guard_authz.sql +0 -118
  105. package/migrations/064b_supersede_unit_scope.sql +0 -205
  106. package/migrations/065_scaffold_sweep.sql +0 -139
  107. package/migrations/066_shadow_report.sql +0 -122
  108. package/migrations/070_permission_catalog.sql +0 -205
  109. package/migrations/071_v1_permission_map.sql +0 -266
  110. package/migrations/072_role_rpcs.sql +0 -126
  111. package/migrations/073_legacy_rbac_retire.sql +0 -471
  112. package/migrations/075_company_legal_entities.sql +0 -169
  113. package/migrations/076_tenant_settings.sql +0 -251
  114. package/migrations/080_people_roles.sql +0 -269
  115. package/migrations/081_people_identity.sql +0 -271
  116. package/migrations/082_people_membership_link.sql +0 -142
  117. package/migrations/083_people_data_migration.sql +0 -75
  118. package/migrations/084_people_registrations_tokens.sql +0 -282
  119. package/migrations/085_catalog_products.sql +0 -333
  120. package/migrations/086_catalog_pricing.sql +0 -194
  121. package/migrations/087_catalog_packages.sql +0 -134
  122. package/migrations/090_orders_venda.sql +0 -166
  123. package/migrations/091_orders_scaffold.sql +0 -92
  124. package/migrations/095_config_field_rules.sql +0 -261
  125. package/migrations/096_audit_append_only.sql +0 -256
  126. package/migrations/097_sequences_authorized.sql +0 -111
  127. package/migrations/098_holidays.sql +0 -80
  128. package/migrations/099_access_grants.sql +0 -293
  129. package/migrations/100_migration_schema.sql +0 -217
  130. package/migrations/101_migration_allowlist.sql +0 -202
  131. package/migrations/102_migration_rpcs.sql +0 -933
  132. package/migrations/103_migration_reconcile_ponr.sql +0 -132
  133. package/migrations/105_policies_to_authenticated.sql +0 -71
  134. package/migrations/106_function_search_path.sql +0 -55
  135. package/migrations/107_core_table_prefix.sql +0 -361
  136. package/migrations/108_authz_write_guards.sql +0 -149
  137. package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
  138. package/migrations/110_people_kind_vocabulary.sql +0 -120
  139. package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
  140. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
  141. package/migrations/112_money_precision_standard.sql +0 -132
  142. package/migrations/113_addresses_units_companies.sql +0 -114
  143. package/migrations/114_migrations_ledger_immutable.sql +0 -65
  144. package/migrations/115_registration_honours_field_rules.sql +0 -172
  145. package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
  146. package/migrations/117_authz_reconciliation_schedule.sql +0 -82
  147. package/migrations/118_migration_catalog_writers.sql +0 -450
  148. package/migrations/119_migration_config_writers.sql +0 -193
  149. package/migrations/120_migration_facet_writer.sql +0 -139
  150. package/migrations/121_migration_financial_writers.sql +0 -294
  151. package/migrations/122_migration_inventory_writers.sql +0 -245
  152. package/migrations/123_migration_order_item_writer.sql +0 -144
  153. package/migrations/124_migration_ledger_writers.sql +0 -170
  154. package/migrations/125_migration_authz_people_writers.sql +0 -205
  155. package/migrations/126_owner_scoping_is_configuration.sql +0 -247
  156. package/migrations/127_scaffold_audit_hook.sql +0 -98
  157. package/migrations/128_shadow_tables_enforced.sql +0 -93
  158. package/migrations/129_order_kind_terminal_states.sql +0 -57
  159. package/migrations/130_every_login_is_a_person.sql +0 -196
  160. package/migrations/131_migration_invoice_split.sql +0 -127
  161. package/migrations/132_audit_prune_scheduled.sql +0 -28
  162. package/migrations/133_unit_is_a_session_property.sql +0 -155
  163. package/migrations/134_field_rules_on_read.sql +0 -341
  164. package/migrations/135_distribution_polarity.sql +0 -158
  165. package/migrations/136_unit_id_means_locality.sql +0 -358
  166. package/migrations/137_vendor_named_spine_objects.sql +0 -126
  167. package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
  168. package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
  169. package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
  170. package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
  171. package/migrations/142_a_person_has_addresses.sql +0 -212
  172. package/migrations/143_a_professional_has_a_rate.sql +0 -75
  173. package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
  174. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
  175. package/migrations/146_products_stock_has_one_writer.sql +0 -67
  176. package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
  177. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
  178. package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
  179. package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
  180. package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
  181. package/migrations/152_a_file_is_an_archetype.sql +0 -222
  182. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
  183. package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
  184. package/migrations/155_a_tenant_can_leave.sql +0 -271
  185. package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
  186. package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
  187. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
  188. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
  189. package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
  190. package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
  191. package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
  192. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
  193. package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
  194. package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
  195. package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
  196. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
  197. package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
  198. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
@@ -0,0 +1,291 @@
1
+ -- 032_a_marca_e_um_conjunto_de_tokens.sql
2
+ --
3
+ -- A marca de um tenant tinha DUAS fontes de verdade e TRÊS grafias, e nenhuma
4
+ -- das três conversava com as outras.
5
+ --
6
+ -- `ConnectedBrandingSettings` GRAVA settings.branding.primaryColor (camelo)
7
+ -- `admin-app.tsx` LÊ settings.branding.primaryColor (camelo)
8
+ -- `app.branding_of()` LÊ settings.branding.primary_color (cobra)
9
+ --
10
+ -- Medido antes desta migration: **nenhum** tenant tinha a chave em camelo, e um
11
+ -- tinha a em cobra (posta por script). Ou seja: a cor que o lojista escolhe em
12
+ -- Configurações nunca chegou ao switcher nem à página pública, e a que existia
13
+ -- nunca chegou ao tema. A tela funcionava; o dado ia para uma gaveta que
14
+ -- ninguém abria.
15
+ --
16
+ -- E, por cima disso, o porte de CRM criou uma SEGUNDA marca — `plg_marketing_brand`
17
+ -- — com nome, tom de voz e palavras proibidas, dentro de um plugin. Duas marcas
18
+ -- para uma casa é pior que nenhuma: mudar a cor num lugar não muda no outro, e
19
+ -- ninguém sabe qual das duas o site está usando.
20
+ --
21
+ -- ── O QUE ESTA MIGRATION ESTABELECE ───────────────────────────────────────
22
+ --
23
+ -- Uma marca é UM CONJUNTO DE TOKENS com três camadas, e a terceira é a que
24
+ -- costuma ficar de fora:
25
+ --
26
+ -- identity quem a marca é nome, assinatura, posicionamento, logo
27
+ -- tokens como ela se parece cor e tipografia
28
+ -- voice como ela FALA tom, léxico, chamadas, palavras proibidas
29
+ --
30
+ -- Voz não é "uma funcionalidade de marketing" — é a dimensão VERBAL do mesmo
31
+ -- conjunto. Um sistema de design que descreve a cor do botão e não descreve o
32
+ -- que o produto pode dizer está descrevendo metade da marca. É por isso que ela
33
+ -- sai do plugin e entra aqui, ao lado da cor.
34
+ --
35
+ -- RETROCOMPATIBILIDADE É OBRIGATÓRIA, e não por educação: `app.branding_of` é
36
+ -- lida por `tenant_branding_public(slug, domain)`, que serve loja pública e
37
+ -- domínio próprio a visitante ANÔNIMO. Uma troca de forma que derrube isso tira
38
+ -- o site do ar de quem já vendeu hoje. Por isso o leitor passa a aceitar as
39
+ -- TRÊS grafias e a saída antiga continua idêntica — o documento em camadas vem
40
+ -- ao lado, não no lugar.
41
+
42
+ -- ── o leitor tolerante ─────────────────────────────────────────────────────
43
+ --
44
+ -- Uma chave, três lugares onde ela pode estar. A ordem é deliberada: o formato
45
+ -- NOVO primeiro, porque é para onde tudo caminha; depois cobra, que é o que o
46
+ -- resolvedor sempre leu; por último camelo, que é o que a tela sempre gravou.
47
+ CREATE OR REPLACE FUNCTION app.brand_token(p_branding jsonb, p_path text[], p_snake text, p_camel text)
48
+ RETURNS text
49
+ LANGUAGE sql
50
+ IMMUTABLE
51
+ SET search_path TO ''
52
+ AS $$
53
+ SELECT coalesce(
54
+ nullif(p_branding #>> p_path, ''),
55
+ nullif(p_branding ->> p_snake, ''),
56
+ nullif(p_branding ->> p_camel, '')
57
+ )
58
+ $$;
59
+
60
+ COMMENT ON FUNCTION app.brand_token(jsonb, text[], text, text) IS
61
+ 'Lê um token de marca aceitando as três grafias que existiram: o caminho em camadas, cobra e camelo. A tolerância é o que permite mudar a forma sem derrubar a loja pública (032).';
62
+
63
+ -- ── a marca inteira, normalizada ───────────────────────────────────────────
64
+ CREATE OR REPLACE FUNCTION app.brand_of(p_tenant uuid)
65
+ RETURNS jsonb
66
+ LANGUAGE plpgsql
67
+ STABLE
68
+ SECURITY DEFINER
69
+ SET search_path TO ''
70
+ AS $$
71
+ DECLARE t record; b jsonb;
72
+ BEGIN
73
+ SELECT x.id, x.name, x.slug, x.logo_url, x.settings INTO t
74
+ FROM public.tenants x WHERE x.id = p_tenant;
75
+ IF t.id IS NULL THEN RETURN NULL; END IF;
76
+
77
+ b := coalesce(t.settings -> 'branding', '{}'::jsonb);
78
+
79
+ RETURN jsonb_strip_nulls(jsonb_build_object(
80
+ 'identity', jsonb_build_object(
81
+ 'name', coalesce(app.brand_token(b, '{identity,name}', 'app_name', 'appName'), t.name),
82
+ 'tagline', app.brand_token(b, '{identity,tagline}', 'tagline', 'tagline'),
83
+ 'mission', app.brand_token(b, '{identity,mission}', 'mission', 'mission'),
84
+ 'logoUrl', coalesce(app.brand_token(b, '{identity,logoUrl}', 'logo_url', 'logoUrl'), t.logo_url),
85
+ 'faviconUrl',app.brand_token(b, '{identity,faviconUrl}', 'favicon_url', 'faviconUrl'),
86
+ 'customDomain', app.brand_token(b, '{identity,customDomain}', 'custom_domain', 'customDomain')
87
+ ),
88
+ 'tokens', jsonb_build_object(
89
+ 'color', jsonb_strip_nulls(jsonb_build_object(
90
+ 'primary', app.brand_token(b, '{tokens,color,primary}', 'primary_color', 'primaryColor'),
91
+ 'accent', app.brand_token(b, '{tokens,color,accent}', 'accent_color', 'accentColor')
92
+ )),
93
+ 'font', jsonb_strip_nulls(jsonb_build_object(
94
+ 'heading', app.brand_token(b, '{tokens,font,heading}', 'heading_font', 'headingFont'),
95
+ 'body', app.brand_token(b, '{tokens,font,body}', 'body_font', 'bodyFont')
96
+ ))
97
+ ),
98
+ -- A camada verbal. Arrays e não texto livre: o agente compara termo a
99
+ -- termo, e "evite gírias" não é comparável.
100
+ 'voice', jsonb_strip_nulls(jsonb_build_object(
101
+ 'tone', app.brand_token(b, '{voice,tone}', 'tone_of_voice', 'toneOfVoice'),
102
+ 'lexicon', b #> '{voice,lexicon}',
103
+ 'avoid', b #> '{voice,avoid}',
104
+ 'ctas', b #> '{voice,ctas}',
105
+ 'hashtags', b #> '{voice,hashtags}'
106
+ ))
107
+ ));
108
+ END $$;
109
+
110
+ COMMENT ON FUNCTION app.brand_of(uuid) IS
111
+ 'A marca como conjunto de tokens em três camadas: identity, tokens (cor/tipografia) e voice. Voz é a dimensão VERBAL do mesmo conjunto, não uma funcionalidade de marketing (032).';
112
+
113
+ REVOKE ALL ON FUNCTION app.brand_of(uuid) FROM PUBLIC, anon;
114
+
115
+ -- ── o resolvedor antigo continua idêntico por fora ─────────────────────────
116
+ --
117
+ -- Mesmas chaves, mesma forma. O que muda é que ele passa a ENXERGAR o que a
118
+ -- tela sempre gravou: antes lia só `primary_color`, e a tela gravava
119
+ -- `primaryColor`. É esta linha que faz a cor escolhida em Configurações chegar
120
+ -- ao switcher e à loja pública pela primeira vez.
121
+ CREATE OR REPLACE FUNCTION app.branding_of(p_tenant uuid) RETURNS jsonb
122
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
123
+ SET search_path TO ''
124
+ AS $$
125
+ DECLARE t record; b jsonb;
126
+ BEGIN
127
+ SELECT x.id, x.name, x.slug, x.logo_url, x.settings INTO t FROM public.tenants x WHERE x.id = p_tenant;
128
+ IF t.id IS NULL THEN
129
+ RETURN jsonb_build_object('tenant_id', NULL, 'name', NULL, 'slug', NULL, 'logo_url', NULL, 'primary_color', NULL, 'custom_domain', NULL);
130
+ END IF;
131
+ b := coalesce(t.settings -> 'branding', '{}'::jsonb);
132
+ RETURN jsonb_build_object(
133
+ 'tenant_id', t.id,
134
+ 'name', coalesce(app.brand_token(b, '{identity,name}', 'app_name', 'appName'), t.name),
135
+ 'slug', t.slug,
136
+ 'logo_url', coalesce(app.brand_token(b, '{identity,logoUrl}', 'logo_url', 'logoUrl'), t.logo_url),
137
+ 'primary_color', app.brand_token(b, '{tokens,color,primary}', 'primary_color', 'primaryColor'),
138
+ 'custom_domain', app.brand_token(b, '{identity,customDomain}', 'custom_domain', 'customDomain')
139
+ );
140
+ EXCEPTION WHEN OTHERS THEN
141
+ RETURN jsonb_build_object('tenant_id', p_tenant, 'name', NULL, 'slug', NULL, 'logo_url', NULL, 'primary_color', NULL, 'custom_domain', NULL,
142
+ 'error', 'branding unavailable');
143
+ END $$;
144
+
145
+ -- ── a marca, para quem está dentro ─────────────────────────────────────────
146
+ CREATE OR REPLACE FUNCTION public.tenant_brand() RETURNS jsonb
147
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
148
+ SET search_path TO ''
149
+ AS $$
150
+ BEGIN
151
+ RETURN coalesce(app.brand_of(app.current_tenant_id()), '{}'::jsonb);
152
+ EXCEPTION WHEN OTHERS THEN
153
+ RETURN '{}'::jsonb;
154
+ END $$;
155
+
156
+ COMMENT ON FUNCTION public.tenant_brand() IS
157
+ 'A marca em camadas do tenant da sessão. Consumidores: tema da shell (tokens.color), agente (voice), páginas públicas (tudo) (032).';
158
+
159
+ REVOKE ALL ON FUNCTION public.tenant_brand() FROM PUBLIC, anon;
160
+ GRANT EXECUTE ON FUNCTION public.tenant_brand() TO authenticated;
161
+ GRANT EXECUTE ON FUNCTION public.tenant_brand() TO service_role;
162
+
163
+ -- ── escrever uma camada sem apagar as outras ───────────────────────────────
164
+ --
165
+ -- A tela salva por SEÇÃO — cores, ou voz, ou identidade. Um `update` que troca
166
+ -- `settings.branding` inteiro faria salvar a aba de cores apagar o tom de voz
167
+ -- que outra pessoa acabou de escrever. Fundir por caminho é o que impede isso.
168
+ CREATE OR REPLACE FUNCTION public.tenant_brand_patch(p_patch jsonb)
169
+ RETURNS jsonb
170
+ LANGUAGE plpgsql
171
+ SECURITY DEFINER
172
+ SET search_path TO ''
173
+ AS $$
174
+ DECLARE v_tenant uuid := app.current_tenant_id();
175
+ BEGIN
176
+ IF v_tenant IS NULL THEN
177
+ RAISE EXCEPTION 'tenant_brand_patch: sem tenant na sessão' USING ERRCODE = '42501';
178
+ END IF;
179
+ -- `settings.edit` e não `tenant.edit`: a marca é configuração da casa, e quem
180
+ -- mexe nela é quem mexe nas configurações.
181
+ IF NOT app.has_permission('settings.edit', NULL::uuid) THEN
182
+ RAISE EXCEPTION 'tenant_brand_patch: exige settings.edit' USING ERRCODE = '42501';
183
+ END IF;
184
+
185
+ UPDATE public.tenants t
186
+ SET settings = jsonb_set(
187
+ coalesce(t.settings, '{}'::jsonb),
188
+ '{branding}',
189
+ -- `||` funde raso, e é o que se quer POR CAMADA: mandar
190
+ -- {"voice":{...}} troca a voz inteira e não toca em tokens. Mandar
191
+ -- {"tokens":{"color":{...}}} trocaria `tokens` inteiro, então a tela
192
+ -- manda a camada `tokens` completa quando mexe em qualquer cor.
193
+ coalesce(t.settings -> 'branding', '{}'::jsonb) || coalesce(p_patch, '{}'::jsonb)
194
+ ),
195
+ updated_at = now()
196
+ WHERE t.id = v_tenant;
197
+
198
+ RETURN coalesce(app.brand_of(v_tenant), '{}'::jsonb);
199
+ END $$;
200
+
201
+ COMMENT ON FUNCTION public.tenant_brand_patch(jsonb) IS
202
+ 'Funde uma camada da marca sem apagar as outras — salvar a aba de cores não pode apagar o tom de voz de quem escreveu antes (032).';
203
+
204
+ REVOKE ALL ON FUNCTION public.tenant_brand_patch(jsonb) FROM PUBLIC, anon;
205
+ GRANT EXECUTE ON FUNCTION public.tenant_brand_patch(jsonb) TO authenticated;
206
+ GRANT EXECUTE ON FUNCTION public.tenant_brand_patch(jsonb) TO service_role;
207
+
208
+ -- ── a segunda marca vem para casa ──────────────────────────────────────────
209
+ --
210
+ -- `plg_marketing_brand` durou uma onda. Ela nasceu com o kit de marca do porte
211
+ -- do V1, num plugin, sem saber que o núcleo já tinha uma marca com resolvedor e
212
+ -- três consumidores. As linhas dela entram na camada certa e a tabela sai — não
213
+ -- fica como view: uma segunda porta para a mesma coisa é justamente o que esta
214
+ -- migration existe para desfazer.
215
+ --
216
+ -- A fusão preserva o que já estivesse em `settings.branding`: o kit do plugin
217
+ -- perde para a configuração do núcleo em caso de conflito, porque é a do núcleo
218
+ -- que o tema e a loja pública leem hoje.
219
+ DO $$
220
+ DECLARE r record; v_patch jsonb; v_n integer := 0;
221
+ BEGIN
222
+ IF to_regclass('public.plg_marketing_brand') IS NULL THEN RETURN; END IF;
223
+
224
+ FOR r IN SELECT * FROM public.plg_marketing_brand LOOP
225
+ v_patch := jsonb_strip_nulls(jsonb_build_object(
226
+ 'identity', jsonb_strip_nulls(jsonb_build_object(
227
+ 'name', nullif(btrim(coalesce(r.name, '')), ''),
228
+ 'tagline', nullif(btrim(coalesce(r.tagline, '')), ''),
229
+ 'mission', nullif(btrim(coalesce(r.mission, '')), ''),
230
+ 'logoUrl', nullif(btrim(coalesce(r.logo_url, '')), '')
231
+ )),
232
+ -- A COR DO PLUGIN SÓ ENTRA SE O NÚCLEO NÃO TIVER UMA.
233
+ --
234
+ -- `||` funde raso e não sobrescreve caminho diferente: com a cor do
235
+ -- núcleo em `branding.primaryColor` (plana) e a do plugin em
236
+ -- `branding.tokens.color.primary` (em camadas), AS DUAS sobrevivem — e o
237
+ -- leitor tolerante prefere a forma nova, então a do plugin venceria.
238
+ -- Isto foi pego no container: `#FF0000` do plugin passou na frente do
239
+ -- `#2563EB` do núcleo. Quem manda é o núcleo, porque é o que o tema e a
240
+ -- loja pública leem hoje.
241
+ 'tokens', jsonb_strip_nulls(jsonb_build_object(
242
+ 'color', CASE
243
+ WHEN app.brand_token((SELECT x.settings -> 'branding' FROM public.tenants x WHERE x.id = r.tenant_id),
244
+ '{tokens,color,primary}', 'primary_color', 'primaryColor') IS NULL
245
+ THEN nullif(coalesce(r.colors, '{}'::jsonb), '{}'::jsonb)
246
+ END,
247
+ 'font', nullif(coalesce(r.fonts, '{}'::jsonb), '{}'::jsonb)
248
+ )),
249
+ 'voice', jsonb_strip_nulls(jsonb_build_object(
250
+ 'tone', nullif(btrim(coalesce(r.tone_of_voice, '')), ''),
251
+ 'lexicon', to_jsonb(coalesce(r.keywords, ARRAY[]::text[])),
252
+ 'avoid', to_jsonb(coalesce(r.avoid_words, ARRAY[]::text[])),
253
+ 'ctas', to_jsonb(coalesce(r.ctas, ARRAY[]::text[])),
254
+ 'hashtags', to_jsonb(coalesce(r.hashtags, ARRAY[]::text[]))
255
+ ))
256
+ ));
257
+
258
+ UPDATE public.tenants t
259
+ SET settings = jsonb_set(
260
+ coalesce(t.settings, '{}'::jsonb), '{branding}',
261
+ -- `patch || existente` deixa o que já estava vencer NO MESMO
262
+ -- caminho. Para caminhos diferentes (plana × camadas) a defesa é
263
+ -- o CASE acima, na montagem do patch.
264
+ v_patch || coalesce(t.settings -> 'branding', '{}'::jsonb))
265
+ WHERE t.id = r.tenant_id;
266
+ v_n := v_n + 1;
267
+ END LOOP;
268
+
269
+ RAISE NOTICE '032: % kit(s) de marca trazidos do plugin para o núcleo', v_n;
270
+ END $$;
271
+
272
+ DROP TABLE IF EXISTS public.plg_marketing_brand;
273
+ DELETE FROM app.scaffold_registry WHERE table_name = 'plg_marketing_brand';
274
+
275
+ -- ── a grafia velha, normalizada de uma vez ─────────────────────────────────
276
+ --
277
+ -- O leitor tolera as três grafias para sempre — é o que protege quem tiver dado
278
+ -- antigo. Mas os tenants que existem HOJE ganham a forma nova agora, para que a
279
+ -- tela edite e leia o mesmo lugar desde o primeiro clique.
280
+ UPDATE public.tenants t
281
+ SET settings = jsonb_set(
282
+ coalesce(t.settings, '{}'::jsonb), '{branding}',
283
+ jsonb_strip_nulls(jsonb_build_object(
284
+ 'tokens', jsonb_build_object('color', jsonb_strip_nulls(jsonb_build_object(
285
+ 'primary', app.brand_token(t.settings -> 'branding', '{tokens,color,primary}', 'primary_color', 'primaryColor'),
286
+ 'accent', app.brand_token(t.settings -> 'branding', '{tokens,color,accent}', 'accent_color', 'accentColor')
287
+ )))
288
+ )) || coalesce(t.settings -> 'branding', '{}'::jsonb))
289
+ WHERE t.settings -> 'branding' IS NOT NULL
290
+ AND app.brand_token(t.settings -> 'branding', '{tokens,color,primary}', 'primary_color', 'primaryColor') IS NOT NULL
291
+ AND t.settings #>> '{branding,tokens,color,primary}' IS NULL;
@@ -0,0 +1,53 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 041_a_entrega_tem_onde_guardar_a_politica.sql — o quadro de Delivery existe
3
+ -- desde sempre e a política dele não tinha onde morar.
4
+ --
5
+ -- ── O defeito ──────────────────────────────────────────────────────────────
6
+ -- Taxa de entrega, pedido mínimo, tempo estimado e raio são decisões da CASA:
7
+ -- mudam com a estação, com o combustível e com o bairro. Não estavam em lugar
8
+ -- nenhum — o app desenhava um interruptor de Delivery em `useState`, que volta
9
+ -- ao padrão a cada F5, e o resto era combinado por telefone.
10
+ --
11
+ -- ── A escolha ──────────────────────────────────────────────────────────────
12
+ -- `tenants.settings` (150/ADR 0023) já é onde a conta guarda o que muda sem
13
+ -- deploy, com `tenant_setting_set` cobrando `settings.manage` e conferindo o
14
+ -- tipo. Só faltava a chave existir: a função recusa chave não registrada, de
15
+ -- propósito, para que um erro de digitação no cliente não vire uma
16
+ -- configuração fantasma que ninguém lê.
17
+ --
18
+ -- Registradas com `reader = 'plugin:orders'`: quem as lê é o plugin de
19
+ -- pedidos, e é ele quem some junto se um dia o app não montar delivery.
20
+ -- ---------------------------------------------------------------------------
21
+
22
+ SELECT app.seed_setting_keys($$[
23
+ {
24
+ "key": "delivery.enabled", "type": "boolean", "default": true,
25
+ "reader": "plugin:orders",
26
+ "description": "Se a casa entrega. Desligado, o quadro de despacho segue existindo para o histórico e nenhum canal novo nasce como entrega."
27
+ },
28
+ {
29
+ "key": "delivery.fee", "type": "number", "default": 0,
30
+ "reader": "plugin:orders",
31
+ "description": "Taxa de entrega padrão, na moeda da casa. Zero = entrega sem taxa."
32
+ },
33
+ {
34
+ "key": "delivery.free_over", "type": "number", "default": null,
35
+ "reader": "plugin:orders",
36
+ "description": "Acima deste valor de pedido a taxa é zerada. Nulo = a taxa vale sempre."
37
+ },
38
+ {
39
+ "key": "delivery.min_order", "type": "number", "default": 0,
40
+ "reader": "plugin:orders",
41
+ "description": "Pedido mínimo para entregar. Zero = sem mínimo."
42
+ },
43
+ {
44
+ "key": "delivery.eta_minutes", "type": "integer", "default": 45,
45
+ "reader": "plugin:orders",
46
+ "description": "Tempo estimado de entrega, em minutos — o que o cliente ouve quando pergunta."
47
+ },
48
+ {
49
+ "key": "delivery.radius_km", "type": "number", "default": 0,
50
+ "reader": "plugin:orders",
51
+ "description": "Raio de entrega em quilômetros. Zero = sem limite declarado."
52
+ }
53
+ ]$$::jsonb);
@@ -0,0 +1,17 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 042_o_token_de_marca_fecha_a_porta_anon.sql
3
+ --
4
+ -- A 032 criou app.brand_token como helper interno, mas funções novas do
5
+ -- PostgreSQL nascem executáveis por PUBLIC. Isso deixou `anon` chamar uma
6
+ -- função do schema privado `app` e fez a postura N15c ficar vermelha.
7
+ --
8
+ -- A correção não pode morar retroativamente na 032: os clusters ChefControl
9
+ -- já registraram o checksum original dela em public._migrations. Editar um
10
+ -- arquivo aplicado produz drift e faz `fayz db apply` parar antes de alcançar
11
+ -- qualquer correção posterior. Por isso a 032 mantém seus bytes históricos e
12
+ -- esta migration nova fecha a permissão nos bancos existentes.
13
+ -- ---------------------------------------------------------------------------
14
+
15
+ REVOKE ALL
16
+ ON FUNCTION app.brand_token(jsonb, text[], text, text)
17
+ FROM PUBLIC, anon;
@@ -0,0 +1,75 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 043_fiscalcontrol_joins_the_family.sql — a contabilidade ganha uma linha.
3
+ --
4
+ -- O oitavo produto do registro, e o segundo que é FUNÇÃO e não trade (como o
5
+ -- LeadControl, 011): FiscalControl é o escritório de contabilidade da família.
6
+ -- Todo tenant que opera um Control já produz o material contábil sem saber —
7
+ -- partida dobrada em plg_financial_movements, balancete em
8
+ -- v_financial_trial_balance, imposto por linha em plg_financial_invoice_item_taxes.
9
+ -- O que falta é o app que olha esse material como um contador olha: plano de
10
+ -- contas, lançamento, DRE, apuração. Com a reforma tributária tornando o
11
+ -- destaque de IBS/CBS obrigatório em agosto/2026, esse olhar deixa de ser
12
+ -- opcional para qualquer tenant fora do Simples.
13
+ --
14
+ -- ── `serves_verticals` fica VAZIO, de propósito ────────────────────────────
15
+ -- Contabilidade não é um ramo, é uma função que atravessa todos. Semear por
16
+ -- vertical (010) daria um app de contador para todo restaurante novo — uma tela
17
+ -- de balancete que ninguém pediu. Este é opt-in: quem liga é `tenant_app_set`
18
+ -- ou a mão da casa.
19
+ --
20
+ -- ── `requires` nomeia um plugin só ─────────────────────────────────────────
21
+ -- `financial` é o razão inteiro: invoices, movimentos, plano de contas, caixa.
22
+ -- Sem ele o rail é Painel e placeholders. `reports` e `fiscal-br` entram como
23
+ -- opt-in quando os marcos M3/M4 fecharem — a migration que criar as telas é a
24
+ -- que atualiza este array (mesma regra da 031).
25
+ -- ---------------------------------------------------------------------------
26
+
27
+ -- ── §1 a linha do registro ────────────────────────────────────────────────
28
+ --
29
+ -- #334155 (slate): o único setor sóbrio ainda livre na roda — os oito vizinhos
30
+ -- já tomaram vermelho, ouro, verde, dois roxos, azul e teal. Combina com o que
31
+ -- o app é: o produto sério da família. Branco sobre ele dá 9,6:1.
32
+ --
33
+ -- `url` é a origem de dev enquanto o app não é publicado; a coluna é a
34
+ -- allow-list do handoff, e o coalesce impede que uma reaplicação devolva uma
35
+ -- frota publicada para localhost (regra da 028/031).
36
+ INSERT INTO app.apps (id, name, icon, accent_color, url, requires, serves_verticals, active) VALUES
37
+ ('fiscal', 'FiscalControl', 'Calculator', '#334155', 'http://localhost:5313',
38
+ ARRAY['financial'],
39
+ ARRAY[]::text[], true)
40
+ ON CONFLICT (id) DO UPDATE
41
+ SET name = EXCLUDED.name, icon = EXCLUDED.icon, accent_color = EXCLUDED.accent_color,
42
+ requires = EXCLUDED.requires, active = true,
43
+ url = coalesce(app.apps.url, EXCLUDED.url);
44
+
45
+ -- ── §2 quem recebe agora ──────────────────────────────────────────────────
46
+ --
47
+ -- Só as contas do dogfood que JÁ têm o financeiro ativo — a evidência honesta
48
+ -- de que existe um razão para o contador ler. O app está em M0; distribuir para
49
+ -- o cluster inteiro seria entregar um produto pela metade (regra da 031). O
50
+ -- tenant ControlGroup, criado fora desta migration por platform_tenant_create,
51
+ -- entra pelo p_apps da própria chamada.
52
+ INSERT INTO app.tenant_apps (tenant_id, app_id, status, source)
53
+ SELECT DISTINCT m.tenant_id, 'fiscal', 'active', 'manual'
54
+ FROM app.memberships m
55
+ JOIN auth.users u ON u.id = m.user_id
56
+ WHERE u.email = 'creators@fayalabs.com'
57
+ AND m.active
58
+ AND EXISTS (
59
+ SELECT 1 FROM app.tenant_plugins tp
60
+ WHERE tp.tenant_id = m.tenant_id AND tp.plugin_id = 'financial'
61
+ AND tp.facet = '' AND tp.status = 'active'
62
+ )
63
+ ON CONFLICT (tenant_id, app_id) DO NOTHING;
64
+
65
+ -- E o plugin que ele precisa, para exatamente essas contas. O teto do plano
66
+ -- ganha do padrão, sempre.
67
+ INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
68
+ SELECT DISTINCT ta.tenant_id, r.plugin_id, '', 'active', 'default'
69
+ FROM app.tenant_apps ta
70
+ JOIN app.apps a ON a.id = ta.app_id
71
+ CROSS JOIN LATERAL unnest(a.requires) AS r(plugin_id)
72
+ WHERE ta.app_id = 'fiscal'
73
+ AND ta.status = 'active'
74
+ AND public.plan_permits(r.plugin_id, '', ta.tenant_id)
75
+ ON CONFLICT (tenant_id, plugin_id, facet) DO NOTHING;
@@ -0,0 +1,15 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 044_o_teste_gratis_dura_trinta_dias.sql
3
+ --
4
+ -- A família Control abre para signup público e a promessa comercial passa a
5
+ -- ser "30 dias grátis" — 7 dias não dão para um restaurante montar cardápio,
6
+ -- treinar a equipe e rodar um mês de pedidos.
7
+ --
8
+ -- O prazo vive num único lugar: o DEFAULT da coluna. Só tenants NOVOS são
9
+ -- afetados; quem já tem data estampada mantém a sua, e quem nasceu antes do
10
+ -- trial (NULL) continua sem trial — nunca "expirado". O trigger
11
+ -- tenants_trial_is_readonly segue impedindo escrita client-side na data.
12
+ -- ---------------------------------------------------------------------------
13
+
14
+ ALTER TABLE public.tenants
15
+ ALTER COLUMN trial_ends_at SET DEFAULT (now() + interval '30 days');
@@ -0,0 +1,77 @@
1
+ -- 046_the_tick_advances_the_flows.sql
2
+ --
3
+ -- A cadence needs a clock, and the database already has one: `fayz_event_runner`
4
+ -- is a pg_cron job calling `event_runner_tick(200)` every minute. What it does
5
+ -- not have is a call to the thing that advances a multi-step flow.
6
+ --
7
+ -- Fifth call, same shape as the other four: guarded by `to_regprocedure`, so a
8
+ -- pool without plugin-automations ticks exactly as it does today. The guard is
9
+ -- the whole reason this belongs in the spine rather than in the plugin — the
10
+ -- tick is one function, and a plugin cannot append to it.
11
+ --
12
+ -- ORDER MATTERS, AND IT IS DELIBERATE. The flows run AFTER `notifications_drain`
13
+ -- and not before. A step enqueues with `scheduled_for = now()`, so draining
14
+ -- first means a step enqueued on this tick waits for the next one — sixty
15
+ -- seconds, once, at the start of a cadence that spans days. Running the flows
16
+ -- first would instead let a step be enqueued and drained in the same tick,
17
+ -- which sounds better and is not: the drain would be doing work the advisory
18
+ -- lock is already holding the whole database's tick for, and a slow provider
19
+ -- would then delay every other plugin's due work behind it. Cheap latency,
20
+ -- bought with predictable tick length.
21
+ --
22
+ -- The limit is 50 rather than the caller's `p_limit`. A cadence step is a write
23
+ -- plus an enqueue; two hundred of them in one tick is a long transaction under
24
+ -- an advisory lock that stops everything else. `marketing_run_due(20)` made the
25
+ -- same trade for the same reason.
26
+
27
+ CREATE OR REPLACE FUNCTION public.event_runner_tick(p_limit integer DEFAULT 200) RETURNS jsonb
28
+ LANGUAGE plpgsql SECURITY DEFINER
29
+ SET search_path TO ''
30
+ AS $_$
31
+ DECLARE
32
+ v_lock boolean;
33
+ v_events record;
34
+ v_notif record;
35
+ v_out jsonb := '{}'::jsonb;
36
+ v_extra jsonb;
37
+ v_start timestamptz := clock_timestamp();
38
+ BEGIN
39
+ -- 1 tick at a time per database. A second caller returns immediately rather
40
+ -- than waiting, so a cron that fires while the previous one is still going
41
+ -- does not queue up behind it.
42
+ SELECT pg_try_advisory_xact_lock(hashtextextended('fayz.event_runner', 0)) INTO v_lock;
43
+ IF NOT v_lock THEN
44
+ RETURN jsonb_build_object('skipped', 'another tick is running');
45
+ END IF;
46
+
47
+ SELECT * INTO v_events FROM public.consume_event_log(p_limit);
48
+ v_out := v_out || jsonb_build_object('events', to_jsonb(v_events));
49
+
50
+ -- The notification queue drains in its OWN pass. A provider timing out costs
51
+ -- a retry there and must never hold up the event log — which is why these are
52
+ -- two functions and not one loop.
53
+ IF to_regprocedure('public.notifications_drain(integer)') IS NOT NULL THEN
54
+ EXECUTE 'SELECT * FROM public.notifications_drain($1)' INTO v_notif USING p_limit;
55
+ v_out := v_out || jsonb_build_object('notifications', to_jsonb(v_notif));
56
+ END IF;
57
+
58
+ -- Campaigns, scheduled reports and cadence flows ENQUEUE; they never send. So
59
+ -- they run on the same tick as the log and drain through the same
60
+ -- notification queue.
61
+ IF to_regprocedure('public.marketing_run_due(integer)') IS NOT NULL THEN
62
+ EXECUTE 'SELECT public.marketing_run_due(20)' INTO v_extra;
63
+ v_out := v_out || jsonb_build_object('marketing', v_extra);
64
+ END IF;
65
+
66
+ IF to_regprocedure('public.reports_deliver_due(integer)') IS NOT NULL THEN
67
+ EXECUTE 'SELECT public.reports_deliver_due(50)' INTO v_extra;
68
+ v_out := v_out || jsonb_build_object('reports', v_extra);
69
+ END IF;
70
+
71
+ IF to_regprocedure('public.automations_flows_run_due(integer)') IS NOT NULL THEN
72
+ EXECUTE 'SELECT public.automations_flows_run_due(50)' INTO v_extra;
73
+ v_out := v_out || jsonb_build_object('flows', v_extra);
74
+ END IF;
75
+
76
+ RETURN v_out || jsonb_build_object('took_ms', (extract(epoch FROM clock_timestamp() - v_start) * 1000)::integer);
77
+ END $_$;
@@ -0,0 +1,74 @@
1
+ -- 047_the_tick_sweeps_the_offers.sql
2
+ --
3
+ -- Sixth call on the minute tick: expiring a lead offer that nobody took.
4
+ --
5
+ -- A five-minute window means the sweep's resolution has to be finer than the
6
+ -- window, and one minute is what the runner already gives. A rep therefore gets
7
+ -- their full five minutes and loses the lead within sixty seconds of the window
8
+ -- closing — which is the behaviour the rota is sold on.
9
+ --
10
+ -- It runs LAST, after the flows. Both write, and the offer sweep can cascade —
11
+ -- one expiry creating the next offer — so it is the call most likely to be slow
12
+ -- on a busy tenant. Last means a slow sweep delays nothing but itself.
13
+ --
14
+ -- The whole function is restated because a plugin cannot append to it, and
15
+ -- `CREATE OR REPLACE` needs the entire body. Migration 046 added the flows call
16
+ -- immediately before this one; if this file is ever reordered ahead of it, the
17
+ -- flows call disappears.
18
+
19
+ CREATE OR REPLACE FUNCTION public.event_runner_tick(p_limit integer DEFAULT 200) RETURNS jsonb
20
+ LANGUAGE plpgsql SECURITY DEFINER
21
+ SET search_path TO ''
22
+ AS $_$
23
+ DECLARE
24
+ v_lock boolean;
25
+ v_events record;
26
+ v_notif record;
27
+ v_out jsonb := '{}'::jsonb;
28
+ v_extra jsonb;
29
+ v_start timestamptz := clock_timestamp();
30
+ BEGIN
31
+ -- 1 tick at a time per database. A second caller returns immediately rather
32
+ -- than waiting, so a cron that fires while the previous one is still going
33
+ -- does not queue up behind it.
34
+ SELECT pg_try_advisory_xact_lock(hashtextextended('fayz.event_runner', 0)) INTO v_lock;
35
+ IF NOT v_lock THEN
36
+ RETURN jsonb_build_object('skipped', 'another tick is running');
37
+ END IF;
38
+
39
+ SELECT * INTO v_events FROM public.consume_event_log(p_limit);
40
+ v_out := v_out || jsonb_build_object('events', to_jsonb(v_events));
41
+
42
+ -- The notification queue drains in its OWN pass. A provider timing out costs
43
+ -- a retry there and must never hold up the event log — which is why these are
44
+ -- two functions and not one loop.
45
+ IF to_regprocedure('public.notifications_drain(integer)') IS NOT NULL THEN
46
+ EXECUTE 'SELECT * FROM public.notifications_drain($1)' INTO v_notif USING p_limit;
47
+ v_out := v_out || jsonb_build_object('notifications', to_jsonb(v_notif));
48
+ END IF;
49
+
50
+ -- Campaigns, scheduled reports and cadence flows ENQUEUE; they never send. So
51
+ -- they run on the same tick as the log and drain through the same
52
+ -- notification queue.
53
+ IF to_regprocedure('public.marketing_run_due(integer)') IS NOT NULL THEN
54
+ EXECUTE 'SELECT public.marketing_run_due(20)' INTO v_extra;
55
+ v_out := v_out || jsonb_build_object('marketing', v_extra);
56
+ END IF;
57
+
58
+ IF to_regprocedure('public.reports_deliver_due(integer)') IS NOT NULL THEN
59
+ EXECUTE 'SELECT public.reports_deliver_due(50)' INTO v_extra;
60
+ v_out := v_out || jsonb_build_object('reports', v_extra);
61
+ END IF;
62
+
63
+ IF to_regprocedure('public.automations_flows_run_due(integer)') IS NOT NULL THEN
64
+ EXECUTE 'SELECT public.automations_flows_run_due(50)' INTO v_extra;
65
+ v_out := v_out || jsonb_build_object('flows', v_extra);
66
+ END IF;
67
+
68
+ IF to_regprocedure('public.crm_offers_sweep(integer)') IS NOT NULL THEN
69
+ EXECUTE 'SELECT public.crm_offers_sweep(100)' INTO v_extra;
70
+ v_out := v_out || jsonb_build_object('offers', v_extra);
71
+ END IF;
72
+
73
+ RETURN v_out || jsonb_build_object('took_ms', (extract(epoch FROM clock_timestamp() - v_start) * 1000)::integer);
74
+ END $_$;