@fayz-ai/db 0.13.0 → 0.14.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 (187) 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 +116 -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/package.json +1 -1
  33. package/migrations/0000_legacy_quarantine.sql +0 -86
  34. package/migrations/0000b_straggler_sweep.sql +0 -37
  35. package/migrations/000_core_v1_convert.sql +0 -343
  36. package/migrations/001_core.sql +0 -372
  37. package/migrations/002_rls_user_tenant_ids.sql +0 -64
  38. package/migrations/003_invitations.sql +0 -17
  39. package/migrations/004_archetypes.sql +0 -343
  40. package/migrations/005_locations_archetype.sql +0 -18
  41. package/migrations/006_archetype_rls.sql +0 -74
  42. package/migrations/007_archetype_grants.sql +0 -4
  43. package/migrations/008_grants.sql +0 -5
  44. package/migrations/009_anon_hardening.sql +0 -19
  45. package/migrations/010_migration_ledger.sql +0 -46
  46. package/migrations/011_anon_write_revoke.sql +0 -12
  47. package/migrations/012_rename_migration_ledger.sql +0 -61
  48. package/migrations/013_sequences_rls.sql +0 -22
  49. package/migrations/014_plan_entitlements.sql +0 -28
  50. package/migrations/015_agent_guard.sql +0 -108
  51. package/migrations/016_agent_guard_actor.sql +0 -102
  52. package/migrations/017_core_addresses.sql +0 -115
  53. package/migrations/018_global_search.sql +0 -482
  54. package/migrations/019_tenant_members_person_id.sql +0 -20
  55. package/migrations/020_fayz_projects.sql +0 -63
  56. package/migrations/021_tenant_roles.sql +0 -74
  57. package/migrations/022_invite_acceptance.sql +0 -91
  58. package/migrations/023_team_visibility.sql +0 -75
  59. package/migrations/024_analytics_engine.sql +0 -317
  60. package/migrations/025_created_by.sql +0 -75
  61. package/migrations/026_audit_trail.sql +0 -73
  62. package/migrations/027_domain_events.sql +0 -266
  63. package/migrations/028_tenant_scoped_token.sql +0 -127
  64. package/migrations/029_connections.sql +0 -186
  65. package/migrations/030_effect_idempotency.sql +0 -159
  66. package/migrations/031_sync_run_message.sql +0 -39
  67. package/migrations/032_connection_secrets.sql +0 -227
  68. package/migrations/033_sync_schedule.sql +0 -651
  69. package/migrations/034_custom_fields.sql +0 -55
  70. package/migrations/035_field_registry.sql +0 -148
  71. package/migrations/036_analytics_run_batch.sql +0 -84
  72. package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
  73. package/migrations/038_onboarding_responses.sql +0 -103
  74. package/migrations/039_unit_tree.sql +0 -270
  75. package/migrations/040_resource_grants.sql +0 -474
  76. package/migrations/041_scoped_columns.sql +0 -192
  77. package/migrations/042_unit_scope_policies.sql +0 -145
  78. package/migrations/043_view_invoker.sql +0 -90
  79. package/migrations/044_unit_member_facts.sql +0 -47
  80. package/migrations/045_unit_entry.sql +0 -236
  81. package/migrations/046_membership_visible_to_members.sql +0 -85
  82. package/migrations/047_tasks.sql +0 -266
  83. package/migrations/048_every_login_is_a_person.sql +0 -190
  84. package/migrations/049_bookable_people.sql +0 -126
  85. package/migrations/050_authz_core.sql +0 -230
  86. package/migrations/051_authz_helpers.sql +0 -330
  87. package/migrations/052_authz_fanout.sql +0 -253
  88. package/migrations/053_authz_catalog_templates.sql +0 -402
  89. package/migrations/060_tenancy_seam.sql +0 -132
  90. package/migrations/061_create_tenant.sql +0 -125
  91. package/migrations/062_invitations.sql +0 -329
  92. package/migrations/063_legacy_tenancy_retired.sql +0 -568
  93. package/migrations/064_agent_guard_authz.sql +0 -118
  94. package/migrations/064b_supersede_unit_scope.sql +0 -205
  95. package/migrations/065_scaffold_sweep.sql +0 -139
  96. package/migrations/066_shadow_report.sql +0 -122
  97. package/migrations/070_permission_catalog.sql +0 -205
  98. package/migrations/071_v1_permission_map.sql +0 -266
  99. package/migrations/072_role_rpcs.sql +0 -126
  100. package/migrations/073_legacy_rbac_retire.sql +0 -471
  101. package/migrations/075_company_legal_entities.sql +0 -169
  102. package/migrations/076_tenant_settings.sql +0 -251
  103. package/migrations/080_people_roles.sql +0 -269
  104. package/migrations/081_people_identity.sql +0 -271
  105. package/migrations/082_people_membership_link.sql +0 -142
  106. package/migrations/083_people_data_migration.sql +0 -75
  107. package/migrations/084_people_registrations_tokens.sql +0 -282
  108. package/migrations/085_catalog_products.sql +0 -333
  109. package/migrations/086_catalog_pricing.sql +0 -194
  110. package/migrations/087_catalog_packages.sql +0 -134
  111. package/migrations/090_orders_venda.sql +0 -166
  112. package/migrations/091_orders_scaffold.sql +0 -92
  113. package/migrations/095_config_field_rules.sql +0 -261
  114. package/migrations/096_audit_append_only.sql +0 -256
  115. package/migrations/097_sequences_authorized.sql +0 -111
  116. package/migrations/098_holidays.sql +0 -80
  117. package/migrations/099_access_grants.sql +0 -293
  118. package/migrations/100_migration_schema.sql +0 -217
  119. package/migrations/101_migration_allowlist.sql +0 -202
  120. package/migrations/102_migration_rpcs.sql +0 -933
  121. package/migrations/103_migration_reconcile_ponr.sql +0 -132
  122. package/migrations/105_policies_to_authenticated.sql +0 -71
  123. package/migrations/106_function_search_path.sql +0 -55
  124. package/migrations/107_core_table_prefix.sql +0 -361
  125. package/migrations/108_authz_write_guards.sql +0 -149
  126. package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
  127. package/migrations/110_people_kind_vocabulary.sql +0 -120
  128. package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
  129. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
  130. package/migrations/112_money_precision_standard.sql +0 -132
  131. package/migrations/113_addresses_units_companies.sql +0 -114
  132. package/migrations/114_migrations_ledger_immutable.sql +0 -65
  133. package/migrations/115_registration_honours_field_rules.sql +0 -172
  134. package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
  135. package/migrations/117_authz_reconciliation_schedule.sql +0 -82
  136. package/migrations/118_migration_catalog_writers.sql +0 -450
  137. package/migrations/119_migration_config_writers.sql +0 -193
  138. package/migrations/120_migration_facet_writer.sql +0 -139
  139. package/migrations/121_migration_financial_writers.sql +0 -294
  140. package/migrations/122_migration_inventory_writers.sql +0 -245
  141. package/migrations/123_migration_order_item_writer.sql +0 -144
  142. package/migrations/124_migration_ledger_writers.sql +0 -170
  143. package/migrations/125_migration_authz_people_writers.sql +0 -205
  144. package/migrations/126_owner_scoping_is_configuration.sql +0 -247
  145. package/migrations/127_scaffold_audit_hook.sql +0 -98
  146. package/migrations/128_shadow_tables_enforced.sql +0 -93
  147. package/migrations/129_order_kind_terminal_states.sql +0 -57
  148. package/migrations/130_every_login_is_a_person.sql +0 -196
  149. package/migrations/131_migration_invoice_split.sql +0 -127
  150. package/migrations/132_audit_prune_scheduled.sql +0 -28
  151. package/migrations/133_unit_is_a_session_property.sql +0 -155
  152. package/migrations/134_field_rules_on_read.sql +0 -341
  153. package/migrations/135_distribution_polarity.sql +0 -158
  154. package/migrations/136_unit_id_means_locality.sql +0 -358
  155. package/migrations/137_vendor_named_spine_objects.sql +0 -126
  156. package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
  157. package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
  158. package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
  159. package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
  160. package/migrations/142_a_person_has_addresses.sql +0 -212
  161. package/migrations/143_a_professional_has_a_rate.sql +0 -75
  162. package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
  163. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
  164. package/migrations/146_products_stock_has_one_writer.sql +0 -67
  165. package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
  166. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
  167. package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
  168. package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
  169. package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
  170. package/migrations/152_a_file_is_an_archetype.sql +0 -222
  171. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
  172. package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
  173. package/migrations/155_a_tenant_can_leave.sql +0 -271
  174. package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
  175. package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
  176. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
  177. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
  178. package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
  179. package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
  180. package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
  181. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
  182. package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
  183. package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
  184. package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
  185. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
  186. package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
  187. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
@@ -0,0 +1,139 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 017_the_pin_is_checked_against_the_right_workspace.sql — a unidade escolhida
3
+ -- sobrevive ao F5.
4
+ --
5
+ -- O SINTOMA: "troco de unidade, recarrego, e volta pra 'Todas as unidades'".
6
+ --
7
+ -- O CAMINHO: `setActive` grava o pino chamando `public.set_entry_unit`, que
8
+ -- valida antes de gravar:
9
+ --
10
+ -- IF p_unit_id NOT IN (SELECT public.user_unit_ids())
11
+ -- THEN RAISE EXCEPTION 'unidade fora do alcance';
12
+ --
13
+ -- A gravação é silenciosa de propósito — falhar em guardar uma preferência não
14
+ -- é assunto de quem só queria trocar de loja — então a exceção não aparece em
15
+ -- lugar nenhum. `public.user_unit_prefs` fica VAZIA. Na entrada seguinte não há
16
+ -- pino, `resolveEntry` cai no último degrau e responde "todas". A escolha nunca
17
+ -- chegou a existir; só parecia ter chegado, porque a tela trocou na hora.
18
+ --
19
+ -- ── Por que a validação recusava uma unidade que o seletor tinha acabado de
20
+ -- oferecer ─────────────────────────────────────────────────────────────────
21
+ --
22
+ -- `public.user_unit_ids()` errava DUAS vezes, e as duas na mesma direção.
23
+ --
24
+ -- ① NÃO FILTRAVA PELA CONTA. Lia `app.membership_units` só por `user_id`, e os
25
+ -- vínculos de uma pessoa atravessam todas as contas dela. Standing em Espaço
26
+ -- Facial, a função respondia com uma unidade do Espaco Renova Rio e uma da
27
+ -- Clínica Sol. É a MESMA família de bug que a 015 consertou em
28
+ -- `unit_bootstrap`: invisível em conta única, porque lá toda unidade sua é
29
+ -- da única conta que você tem.
30
+ --
31
+ -- ② LIA A TABELA ERRADA. `app.membership_units` guarda só o vínculo EXPLÍCITO,
32
+ -- unidade por unidade. Quem alcança a conta inteira entra por
33
+ -- `app.user_unit_access` com `via = 'all_units'` — que é a tabela que a
34
+ -- própria RLS consulta (`app.has_unit`) e que
35
+ -- `app.refresh_user_unit_access` mantém. As quatro unidades do Espaço Facial
36
+ -- estavam lá, todas com `via = 'all_units'`, e nenhuma em
37
+ -- `membership_units`.
38
+ --
39
+ -- Somados: a função devolvia exatamente as duas unidades erradas e nenhuma das
40
+ -- quatro certas. O seletor (que lê `unit_bootstrap`, já corrigido pela 015)
41
+ -- oferecia as quatro; a gravação, olhando outra tabela e outra conta, recusava
42
+ -- as quatro. Seletor e gravação discordavam sobre o que "minhas unidades"
43
+ -- significa, e quem perdia era a escolha da pessoa.
44
+ --
45
+ -- ── A correção ─────────────────────────────────────────────────────────────
46
+ -- Uma fonte só, a mesma que a RLS usa, recortada pela conta em que a pessoa
47
+ -- está — e a descida na árvore preservada, porque um vínculo numa região
48
+ -- sempre alcançou as filiais abaixo dela.
49
+ --
50
+ -- E `set_entry_unit` passa a validar contra a conta que ELE recebeu, em vez da
51
+ -- conta ambiente da sessão. As duas coincidem hoje; depender disso seria deixar
52
+ -- de pé, no caminho de gravação, a mesma suposição que causou este bug.
53
+ --
54
+ -- NADA AQUI CONCEDE. Isto decide qual pino pode ser GRAVADO. Quem pode ler o
55
+ -- quê continua sendo `app.has_unit` sobre a mesma `app.user_unit_access` — e o
56
+ -- efeito de errar aqui é uma preferência perdida, nunca uma linha revelada.
57
+ -- ---------------------------------------------------------------------------
58
+
59
+ -- As unidades que esta pessoa alcança DENTRO de uma conta nomeada.
60
+ --
61
+ -- Recebe a conta em vez de deduzi-la: quem chama no caminho de gravação já sabe
62
+ -- em qual está, e passar é o que impede a pergunta de ser respondida sobre
63
+ -- outra.
64
+ CREATE OR REPLACE FUNCTION app.user_unit_ids_in(p_tenant uuid)
65
+ RETURNS SETOF uuid
66
+ LANGUAGE sql STABLE SECURITY DEFINER
67
+ SET search_path TO ''
68
+ AS $$
69
+ WITH RECURSIVE seed AS (
70
+ -- A MESMA tabela que a RLS lê. `membership_units` só conhece o vínculo
71
+ -- explícito; quem alcança a conta inteira entra por aqui com
72
+ -- via = 'all_units' e não tem linha lá.
73
+ SELECT a.unit_id AS id, 0 AS depth
74
+ FROM app.user_unit_access a
75
+ WHERE a.user_id = (SELECT auth.uid())
76
+ AND a.tenant_id = p_tenant
77
+ ),
78
+ tree AS (
79
+ SELECT id, depth FROM seed
80
+ UNION
81
+ -- Um vínculo numa região alcança as filiais abaixo. `user_unit_access` já
82
+ -- costuma chegar expandida; a descida é o que mantém a resposta certa
83
+ -- quando ela não chegou.
84
+ SELECT u.id, t.depth + 1
85
+ FROM app.units u
86
+ JOIN tree t ON u.parent_unit_id = t.id
87
+ WHERE u.tenant_id = p_tenant AND t.depth < 8
88
+ )
89
+ SELECT id FROM tree;
90
+ $$;
91
+
92
+ REVOKE ALL ON FUNCTION app.user_unit_ids_in(uuid) FROM PUBLIC;
93
+ GRANT ALL ON FUNCTION app.user_unit_ids_in(uuid) TO authenticated;
94
+ GRANT ALL ON FUNCTION app.user_unit_ids_in(uuid) TO service_role;
95
+
96
+ COMMENT ON FUNCTION app.user_unit_ids_in(uuid) IS
97
+ 'As unidades que o chamador alcança dentro da conta informada (#017), lidas de app.user_unit_access — a mesma fonte de app.has_unit — e descidas pela árvore. Decide o que pode ser GRAVADO como pino; nunca o que pode ser lido.';
98
+
99
+ -- A versão sem argumento passa a ser um apelido da de cima sobre a conta da
100
+ -- sessão. Mesma assinatura, mesma resposta para quem já a chamava — só que
101
+ -- agora sobre a conta certa e a tabela certa.
102
+ CREATE OR REPLACE FUNCTION public.user_unit_ids()
103
+ RETURNS SETOF uuid
104
+ LANGUAGE sql STABLE SECURITY DEFINER
105
+ SET search_path TO ''
106
+ AS $$
107
+ SELECT app.user_unit_ids_in(app.current_tenant_id());
108
+ $$;
109
+
110
+ COMMENT ON FUNCTION public.user_unit_ids() IS
111
+ 'As unidades que o chamador alcança na conta em que está (#017). Antes lia app.membership_units sem filtro de conta e devolvia unidades de OUTRO workspace — o que fazia set_entry_unit recusar a unidade que o seletor tinha acabado de oferecer, e a escolha se perder no F5.';
112
+
113
+ -- A gravação do pino valida contra a conta que recebeu.
114
+ CREATE OR REPLACE FUNCTION public.set_entry_unit(p_tenant_id uuid, p_unit_id uuid, p_device_id text)
115
+ RETURNS void
116
+ LANGUAGE plpgsql
117
+ SET search_path TO 'public', 'pg_temp'
118
+ AS $$
119
+ BEGIN
120
+ IF p_device_id IS NULL OR length(trim(p_device_id)) = 0 THEN
121
+ RAISE EXCEPTION 'device_id obrigatório';
122
+ END IF;
123
+
124
+ -- "Todas as unidades" (nulo) é sempre válido. Uma unidade só é aceita se a
125
+ -- pessoa a alcança de verdade NESTA conta — a mesma fonte que o RLS usa, e a
126
+ -- conta que o chamador nomeou, não a que a sessão calhar de ter.
127
+ IF p_unit_id IS NOT NULL
128
+ AND p_unit_id NOT IN (SELECT app.user_unit_ids_in(p_tenant_id)) THEN
129
+ RAISE EXCEPTION 'unidade fora do alcance';
130
+ END IF;
131
+
132
+ INSERT INTO public.user_unit_prefs (user_id, tenant_id, device_id, unit_id, updated_at)
133
+ VALUES ((SELECT auth.uid()), p_tenant_id, p_device_id, p_unit_id, now())
134
+ ON CONFLICT (user_id, tenant_id, device_id)
135
+ DO UPDATE SET unit_id = EXCLUDED.unit_id, updated_at = now();
136
+ END $$;
137
+
138
+ COMMENT ON FUNCTION public.set_entry_unit(uuid, uuid, text) IS
139
+ 'Guarda em qual unidade a pessoa está, por aparelho (#017 valida contra a conta recebida). Silenciosa para quem chama: falhar em gravar uma preferência não é assunto de quem só queria trocar de loja.';
@@ -0,0 +1,114 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 018_the_bootstrap_says_what_you_reach.sql — a entrada passa a saber quais
3
+ -- unidades a pessoa alcança, e não só quais foram amarradas na mão.
4
+ --
5
+ -- A 017 consertou a GRAVAÇÃO do pino. Ele passou a ser aceito e a chegar em
6
+ -- `public.user_unit_prefs`. E a unidade continuou não persistindo, porque o
7
+ -- problema tinha uma segunda metade, do lado da LEITURA.
8
+ --
9
+ -- ── O que a entrada faz com o pino ─────────────────────────────────────────
10
+ --
11
+ -- const reachable = branchesOnly(expandDown(units, bindings.map(…)), byId)
12
+ -- const entry = resolveEntry(reachable, { device, account, legacy })
13
+ --
14
+ -- e `resolveEntry` só honra um pino que a pessoa AINDA alcança:
15
+ --
16
+ -- if (canStand(pin.unitId)) return { unitId: pin.unitId, … }
17
+ -- …
18
+ -- return { unitId: undefined, reason: 'all', … } // o último degrau
19
+ --
20
+ -- A verificação é certa — um pino para uma loja à qual alguém perdeu acesso não
21
+ -- pode ressuscitar o acesso. O que estava errado era o `reachable` contra o qual
22
+ -- ela media.
23
+ --
24
+ -- ── De onde vinha o `reachable` ────────────────────────────────────────────
25
+ -- Do campo `bindings` deste bootstrap, que lê `public.v_unit_members` —
26
+ -- portanto `app.membership_units`, que guarda só o vínculo EXPLÍCITO, unidade
27
+ -- por unidade.
28
+ --
29
+ -- Quem alcança a conta inteira NÃO TEM LINHA LÁ. O acesso dessa pessoa mora em
30
+ -- `app.user_unit_access` com `via = 'all_units'` — a mesma tabela que a RLS lê,
31
+ -- e a mesma que a 017 acabou de adotar. É exatamente o caso do dono do Espaço
32
+ -- Facial: quatro unidades em `user_unit_access`, zero em `membership_units`.
33
+ --
34
+ -- Então `bindings` voltava `[]`, `reachable` era vazio, `canStand` era falso
35
+ -- para qualquer pino, e a entrada caía no último degrau: "todas as unidades".
36
+ -- Toda vez. O menu de unidades disfarçava a metade dele — ele tem um recuo
37
+ -- (`myUnitIds.length > 0 ? myUnitIds : todas`) que fazia o seletor mostrar as
38
+ -- quatro mesmo assim. `resolveEntry` não tem recuo nenhum, e é ele que decide
39
+ -- onde a tela abre.
40
+ --
41
+ -- ── A correção ─────────────────────────────────────────────────────────────
42
+ -- O bootstrap passa a dizer as duas coisas, porque são duas coisas:
43
+ --
44
+ -- • `bindings` — o que foi amarrado na mão. Continua sendo isto, sem
45
+ -- mudança: é o que a aba "Unidades" de uma pessoa edita.
46
+ -- • `reachable` — o que a pessoa ALCANÇA de verdade, de
47
+ -- `app.user_unit_ids_in` (#017). É contra isto que um pino
48
+ -- deve ser medido, porque é isto que o Postgres vai obedecer.
49
+ --
50
+ -- Confundir os dois é o bug: um deles é a intenção que alguém digitou, o outro
51
+ -- é o alcance que o banco reconhece, e só o segundo responde "posso ficar aqui?".
52
+ --
53
+ -- Campo NOVO e aditivo de propósito: um cliente que ainda não conhece
54
+ -- `reachable` continua lendo `bindings` como sempre leu.
55
+ --
56
+ -- NADA AQUI CONCEDE. `reachable` é a leitura de uma tabela que a RLS já usa
57
+ -- para decidir, e serve para escolher onde a tela ABRE. Errar aqui abre na
58
+ -- unidade errada — e as linhas que aparecerem lá continuam sendo as que o
59
+ -- Postgres deixar aparecer.
60
+ -- ---------------------------------------------------------------------------
61
+
62
+ CREATE OR REPLACE FUNCTION public.unit_bootstrap(p_tenant_id uuid, p_device_id text DEFAULT NULL::text)
63
+ RETURNS jsonb
64
+ LANGUAGE sql
65
+ STABLE
66
+ SET search_path TO 'public', 'pg_temp'
67
+ AS $function$
68
+ SELECT jsonb_build_object(
69
+ 'mode', COALESCE((
70
+ SELECT t.unit_scoping_enabled FROM public.tenants t WHERE t.id = p_tenant_id
71
+ ), false),
72
+ 'units', COALESCE((
73
+ SELECT jsonb_agg(to_jsonb(l) ORDER BY l.is_headquarters DESC NULLS LAST, l.name)
74
+ FROM public.v_units l WHERE l.tenant_id = p_tenant_id
75
+ ), '[]'::jsonb),
76
+ -- O vínculo EXPLÍCITO, como sempre. A 015 é que lhe deu o filtro de conta.
77
+ 'bindings', COALESCE((
78
+ SELECT jsonb_agg(jsonb_build_object('location_id', lm.location_id, 'role', lm.role))
79
+ FROM public.v_unit_members lm
80
+ WHERE lm.user_id = (SELECT auth.uid())
81
+ AND lm.tenant_id = p_tenant_id
82
+ ), '[]'::jsonb),
83
+ -- O ALCANCE. Inclui quem entra pela conta inteira (`via = 'all_units'`), que
84
+ -- é justamente quem não tem vínculo explícito nenhum.
85
+ 'reachable', COALESCE((
86
+ SELECT jsonb_agg(u) FROM app.user_unit_ids_in(p_tenant_id) u
87
+ ), '[]'::jsonb),
88
+ 'scoped', COALESCE((
89
+ SELECT jsonb_object_agg(sr.resource_table, jsonb_build_object(
90
+ 'unit_column', sr.unit_column, 'shareable', sr.shareable))
91
+ FROM public.scoped_resources sr
92
+ ), '{}'::jsonb),
93
+ 'device_pin', (
94
+ SELECT p.unit_id FROM public.user_unit_prefs p
95
+ WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id AND p.device_id = p_device_id
96
+ ),
97
+ 'device_pin_set', EXISTS (
98
+ SELECT 1 FROM public.user_unit_prefs p
99
+ WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id AND p.device_id = p_device_id
100
+ ),
101
+ 'account_pin', (
102
+ SELECT p.unit_id FROM public.user_unit_prefs p
103
+ WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id
104
+ ORDER BY p.updated_at DESC LIMIT 1
105
+ ),
106
+ 'account_pin_set', EXISTS (
107
+ SELECT 1 FROM public.user_unit_prefs p
108
+ WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id
109
+ )
110
+ );
111
+ $function$;
112
+
113
+ COMMENT ON FUNCTION public.unit_bootstrap(uuid, text) IS
114
+ 'Tudo que a entrada precisa, numa viagem. `bindings` é o vínculo explícito (o que a aba Unidades edita); `reachable` (#018) é o alcance real, de app.user_unit_ids_in — inclui quem entra pela conta inteira e não tem vínculo nenhum. É contra `reachable` que um pino de entrada deve ser medido.';
@@ -0,0 +1,245 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 019_distributing_one_row_stops_costing_the_table.sql — usar a distribuição
3
+ -- para UMA linha deixa de custar o preço dela em TODAS.
4
+ --
5
+ -- A 016 tirou `app.available_in_my_units` de dentro do laço da RLS com uma
6
+ -- guarda de TIPO: "este tenant tem alguma tupla de distribuição para
7
+ -- `people.person`?". Não depende da linha, então vira InitPlan e roda uma vez.
8
+ -- `/clients` foi de 10.036ms para 15ms.
9
+ --
10
+ -- A guarda de tipo tem um degrau, e o Espaço Facial chegou nele no mesmo dia.
11
+ --
12
+ -- ── O degrau ───────────────────────────────────────────────────────────────
13
+ -- 128 profissionais da rede atendem em MAIS DE UMA unidade. Uma coluna
14
+ -- `people.unit_id` guarda uma unidade, não seis — quem atende em várias se
15
+ -- descreve por tuplas `available_in`, que é para isso que elas existem.
16
+ --
17
+ -- Só que a guarda da 016 responde por TIPO, e cliente e profissional são o
18
+ -- mesmo tipo: `people.person`. Gravar os 128 tornaria
19
+ -- `grants_exist('people.person')` verdadeiro para o tenant inteiro, e os 3.545
20
+ -- CLIENTES — todos sem unidade — voltariam a pagar 2,5ms cada um. `/clients`
21
+ -- voltaria aos 10s por causa de um vínculo de equipe.
22
+ --
23
+ -- Uma otimização que se desliga sozinha quando o recurso que ela protege começa
24
+ -- a ser usado não é uma otimização, é uma armadilha com prazo.
25
+ --
26
+ -- ── Três coisas, medidas uma a uma ─────────────────────────────────────────
27
+ -- Depois de gravar os vínculos, `/clients` foi a 10.036ms. As três correções,
28
+ -- na ordem em que o planejador as entregou:
29
+ --
30
+ -- ① A PERGUNTA CERTA É SOBRE A LINHA, não sobre o tipo: "ESTA linha tem
31
+ -- alguma tupla?". Sondagem no prefixo (tenant_id, resource_type,
32
+ -- resource_id) da PK. A derivação da 016 continua valendo palavra por
33
+ -- palavra, só que verificada em vez de deduzida. 10.036ms → 2.110ms
34
+ --
35
+ -- ② `app.current_tenant_id()` SAI DO LAÇO. Ela é plpgsql, lê o JWT, casa um
36
+ -- regex e consulta `memberships` — e a guarda a chamava por linha. Com o
37
+ -- tenant recebido por parâmetro e `(select app.current_tenant_id())` na
38
+ -- POLÍTICA, ela vira InitPlan. 2.110ms → 690ms
39
+ --
40
+ -- ③ A CHAMADA EM SI é o que sobrou: 0,1ms por linha só para o Postgres
41
+ -- trocar o contexto de `SECURITY DEFINER`, e uma função assim nunca é
42
+ -- inlinada. Então a lista de linhas distribuídas é resolvida UMA vez
43
+ -- (`app.released_ids`, um InitPlan materializado) e o teste por linha vira
44
+ -- `id <> ALL (…)` — uma comparação em memória. 690ms → 19ms
45
+ --
46
+ -- (Os 641ms que o EXPLAIN ANALYZE mostrava no fim eram instrumentação: com
47
+ -- SubPlan por linha, medir custa mais do que executar. O tempo de parede da
48
+ -- consulta, cronometrado sem EXPLAIN, é 19ms.)
49
+ --
50
+ -- ── A forma final da política ──────────────────────────────────────────────
51
+ -- Três guardas antes da regra cara, e NENHUMA delas menciona coluna — as três
52
+ -- são InitPlan:
53
+ --
54
+ -- 1. o tenant distribui algo deste tipo?
55
+ -- 2. a linha tem unidade própria? (então é localizada, não distribuída)
56
+ -- 3. a linha está na lista das que têm tupla?
57
+ --
58
+ -- Só quem passa pelas três paga a resolução completa, que é o preço correto de
59
+ -- um recurso que realmente foi distribuído.
60
+ -- ---------------------------------------------------------------------------
61
+
62
+ -- ① As linhas que TÊM regra, resolvidas de uma vez. Sem id de linha no
63
+ -- argumento: é o que permite ao planejador materializá-la e reusá-la.
64
+ CREATE OR REPLACE FUNCTION app.released_ids(p_tenant uuid, p_type text)
65
+ RETURNS SETOF uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
66
+ AS $fn$
67
+ SELECT DISTINCT g.resource_id FROM app.resource_grants g
68
+ WHERE g.tenant_id = p_tenant AND g.resource_type = p_type
69
+ AND g.relation IN ('available_in', 'unavailable_in');
70
+ $fn$;
71
+
72
+ REVOKE ALL ON FUNCTION app.released_ids(uuid, text) FROM PUBLIC;
73
+ GRANT ALL ON FUNCTION app.released_ids(uuid, text) TO authenticated;
74
+ GRANT ALL ON FUNCTION app.released_ids(uuid, text) TO service_role;
75
+
76
+ COMMENT ON FUNCTION app.released_ids(uuid, text) IS
77
+ 'As linhas deste tipo que têm alguma tupla de distribuição (#019). Existe para ser InitPlan: resolvida uma vez, o teste por linha vira `id <> ALL (…)` em memória em vez de uma chamada SECURITY DEFINER, que custa 0,1ms só de trocar o contexto.';
78
+
79
+ -- ② As mesmas perguntas, recebendo o tenant em vez de deduzi-lo. A dedução é
80
+ -- cara e não depende da linha; quem chama de dentro de uma política já a
81
+ -- tem resolvida num InitPlan.
82
+ CREATE OR REPLACE FUNCTION app.grants_exist(p_tenant uuid, p_type text, p_relations text[])
83
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
84
+ AS $fn$
85
+ SELECT EXISTS (SELECT 1 FROM app.resource_grants g
86
+ WHERE g.tenant_id = p_tenant AND g.resource_type = p_type
87
+ AND g.relation = ANY (p_relations));
88
+ $fn$;
89
+
90
+ REVOKE ALL ON FUNCTION app.grants_exist(uuid, text, text[]) FROM PUBLIC;
91
+ GRANT ALL ON FUNCTION app.grants_exist(uuid, text, text[]) TO authenticated;
92
+ GRANT ALL ON FUNCTION app.grants_exist(uuid, text, text[]) TO service_role;
93
+
94
+ CREATE OR REPLACE FUNCTION app.unit_release_verdict(p_tenant uuid, p_type text, p_id uuid, p_unit uuid)
95
+ RETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
96
+ AS $fn$
97
+ SELECT coalesce(
98
+ (SELECT CASE WHEN bool_or(g.relation = 'unavailable_in') THEN 'deny' ELSE 'allow' END
99
+ FROM app.unit_chain(p_unit) c
100
+ JOIN app.resource_grants g
101
+ ON g.tenant_id = p_tenant
102
+ AND g.resource_type = p_type AND g.resource_id = p_id
103
+ AND g.subject_type = 'unit' AND g.subject_id = c.unit_id
104
+ AND g.relation IN ('available_in', 'unavailable_in')
105
+ GROUP BY c.depth ORDER BY c.depth LIMIT 1),
106
+ 'none');
107
+ $fn$;
108
+
109
+ REVOKE ALL ON FUNCTION app.unit_release_verdict(uuid, text, uuid, uuid) FROM PUBLIC;
110
+ GRANT ALL ON FUNCTION app.unit_release_verdict(uuid, text, uuid, uuid) TO authenticated;
111
+ GRANT ALL ON FUNCTION app.unit_release_verdict(uuid, text, uuid, uuid) TO service_role;
112
+
113
+ -- A regra completa, com o tenant recebido e a guarda de LINHA na frente. Corpo
114
+ -- idêntico ao da 000 a partir do `WITH me`.
115
+ CREATE OR REPLACE FUNCTION app.available_in_my_units(p_tenant uuid, p_type text, p_id uuid)
116
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
117
+ AS $fn$
118
+ SELECT CASE
119
+ WHEN NOT EXISTS (
120
+ SELECT 1 FROM app.resource_grants g
121
+ WHERE g.tenant_id = p_tenant AND g.resource_type = p_type AND g.resource_id = p_id
122
+ AND g.relation IN ('available_in', 'unavailable_in')
123
+ ) THEN true
124
+ ELSE (
125
+ WITH me AS (
126
+ SELECT auth.uid() AS uid,
127
+ nullif(app.current_unit_id(), '00000000-0000-0000-0000-000000000000'::uuid) AS focus
128
+ ),
129
+ tup AS (
130
+ SELECT g.relation, g.subject_type, g.subject_id
131
+ FROM app.resource_grants g
132
+ WHERE g.tenant_id = p_tenant AND g.resource_type = p_type AND g.resource_id = p_id
133
+ AND g.relation IN ('available_in', 'unavailable_in')
134
+ ),
135
+ -- me, or a role of mine: outside the tree, so it simply decides
136
+ direct AS (
137
+ SELECT t.relation FROM tup t, me
138
+ WHERE (t.subject_type = 'user' AND t.subject_id = me.uid)
139
+ OR (t.subject_type = 'role' AND t.subject_id IN (
140
+ SELECT m.role_id FROM app.memberships m
141
+ WHERE m.tenant_id = p_tenant AND m.user_id = me.uid AND m.active
142
+ UNION
143
+ SELECT mu.role_override_id FROM app.membership_units mu
144
+ WHERE mu.tenant_id = p_tenant AND mu.user_id = me.uid AND mu.role_override_id IS NOT NULL))
145
+ ),
146
+ mine AS (
147
+ SELECT a.unit_id FROM app.user_unit_access a, me
148
+ WHERE a.tenant_id = p_tenant AND a.user_id = me.uid
149
+ AND (me.focus IS NULL OR a.unit_id = me.focus)
150
+ ),
151
+ verdicts AS (
152
+ SELECT app.unit_release_verdict(p_tenant, p_type, p_id, mine.unit_id) AS v FROM mine
153
+ )
154
+ SELECT NOT EXISTS (SELECT 1 FROM direct WHERE relation = 'unavailable_in')
155
+ AND (
156
+ EXISTS (SELECT 1 FROM direct WHERE relation = 'available_in')
157
+ OR EXISTS (SELECT 1 FROM verdicts WHERE v = 'allow')
158
+ OR (NOT EXISTS (SELECT 1 FROM tup WHERE relation = 'available_in')
159
+ AND (EXISTS (SELECT 1 FROM verdicts WHERE v = 'none') OR NOT EXISTS (SELECT 1 FROM mine)))
160
+ )
161
+ )
162
+ END;
163
+ $fn$;
164
+
165
+ REVOKE ALL ON FUNCTION app.available_in_my_units(uuid, text, uuid) FROM PUBLIC;
166
+ GRANT ALL ON FUNCTION app.available_in_my_units(uuid, text, uuid) TO authenticated;
167
+ GRANT ALL ON FUNCTION app.available_in_my_units(uuid, text, uuid) TO service_role;
168
+
169
+ -- A forma de dois argumentos continua existindo para quem chama de fora de uma
170
+ -- política (RPCs, views) e não tem o tenant à mão.
171
+ CREATE OR REPLACE FUNCTION app.available_in_my_units(p_type text, p_id uuid)
172
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
173
+ AS $fn$
174
+ SELECT app.available_in_my_units(app.current_tenant_id(), p_type, p_id);
175
+ $fn$;
176
+
177
+ COMMENT ON FUNCTION app.available_in_my_units(p_type text, p_id uuid) IS
178
+ 'A regra de distribuição, resolvida ao longo da árvore de unidades (#223 + #227): sem tupla ⇒ em todo lugar; havendo, o nível mais próximo da cadeia de cada unidade no escopo decide, e negar vence permitir naquele nível. Desde #019 delega para a forma que recebe o tenant, para que a dedução dele não aconteça por linha.';
179
+
180
+ -- ③ O gerador da política de distribuição, com as três guardas na frente.
181
+ CREATE OR REPLACE FUNCTION app.scaffold_distributable(p_table regclass, p_resource_type text DEFAULT NULL::text, p_key_column text DEFAULT 'id'::text, p_unit_escape boolean DEFAULT true, p_enabled boolean DEFAULT true) RETURNS void
182
+ LANGUAGE plpgsql SECURITY DEFINER
183
+ SET search_path TO ''
184
+ AS $fn$
185
+ DECLARE
186
+ v_schema text; v_name text; v_q text; v_policy text;
187
+ r app.scaffold_registry%ROWTYPE; v_rt text;
188
+ BEGIN
189
+ SELECT n.nspname, c.relname INTO v_schema, v_name
190
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
191
+ v_q := format('%I.%I', v_schema, v_name);
192
+ v_policy := v_name || '_available_in';
193
+
194
+ SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
195
+ IF NOT FOUND THEN
196
+ RAISE EXCEPTION 'scaffold_distributable: %.% is not scaffolded', v_schema, v_name USING ERRCODE = 'P0002';
197
+ END IF;
198
+ v_rt := coalesce(p_resource_type, r.resource_type);
199
+
200
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_policy, v_q);
201
+
202
+ IF p_enabled THEN
203
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
204
+ WHERE table_schema = v_schema AND table_name = v_name AND column_name = p_key_column) THEN
205
+ RAISE EXCEPTION 'scaffold_distributable: %.% has no column %', v_schema, v_name, p_key_column;
206
+ END IF;
207
+ -- RESTRICTIVE: permissive policies OR together, so a release rule that is
208
+ -- meant to REMOVE rows has to AND with the scaffold's select.
209
+ --
210
+ -- A ordem das guardas é o desenho (ver o cabeçalho): as três primeiras não
211
+ -- mencionam coluna nenhuma e por isso são InitPlan. `(select
212
+ -- app.current_tenant_id())` aparece três vezes e é avaliada uma.
213
+ EXECUTE format(
214
+ 'CREATE POLICY %I ON %s AS RESTRICTIVE FOR SELECT TO authenticated USING ('
215
+ || 'NOT (select app.grants_exist((select app.current_tenant_id()), %L, ARRAY[''available_in'', ''unavailable_in''])) '
216
+ || 'OR %s%I <> ALL (select app.released_ids((select app.current_tenant_id()), %L)) '
217
+ || 'OR (select app.available_in_my_units((select app.current_tenant_id()), %L, %I)))',
218
+ v_policy, v_q, v_rt,
219
+ CASE WHEN p_unit_escape THEN 'unit_id IS NOT NULL OR ' ELSE '' END,
220
+ p_key_column, v_rt, v_rt, p_key_column);
221
+ END IF;
222
+
223
+ UPDATE app.scaffold_registry
224
+ SET distributable = p_enabled,
225
+ dist_resource_type = CASE WHEN p_enabled THEN v_rt END,
226
+ dist_key_column = CASE WHEN p_enabled THEN p_key_column END,
227
+ dist_unit_escape = CASE WHEN p_enabled THEN p_unit_escape END
228
+ WHERE table_schema = v_schema AND table_name = v_name;
229
+ END $fn$;
230
+
231
+ -- Reescrever o que já está no banco: o texto das políticas existentes foi
232
+ -- assado quando elas foram criadas.
233
+ DO $do$
234
+ DECLARE r record; v_n int := 0;
235
+ BEGIN
236
+ FOR r IN SELECT table_schema, table_name, dist_resource_type, dist_key_column, dist_unit_escape
237
+ FROM app.scaffold_registry WHERE distributable ORDER BY 1, 2 LOOP
238
+ CONTINUE WHEN to_regclass(format('%I.%I', r.table_schema, r.table_name)) IS NULL;
239
+ PERFORM app.scaffold_distributable(
240
+ to_regclass(format('%I.%I', r.table_schema, r.table_name)),
241
+ r.dist_resource_type, coalesce(r.dist_key_column, 'id'), coalesce(r.dist_unit_escape, true), true);
242
+ v_n := v_n + 1;
243
+ END LOOP;
244
+ RAISE NOTICE '019: % política(s) de distribuição regerada(s)', v_n;
245
+ END $do$;
@@ -0,0 +1,81 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 020_the_agenda_asks_where_someone_works.sql — a agenda de uma unidade passa a
3
+ -- mostrar quem trabalha NELA.
4
+ --
5
+ -- O SINTOMA, depois de migrar as 61 filiais e carimbar a equipe: standing em
6
+ -- Brisamar, a agenda mostrava 404 profissionais de 739. Filtrou — e não pareceu
7
+ -- ter filtrado, porque 396 dos 404 são pessoas cujo `unit_id` é NULL.
8
+ --
9
+ -- ── Por que NULL passa por todo filtro ─────────────────────────────────────
10
+ -- `people` é `shareable` no registro (`public.scoped_resources`), e para um
11
+ -- recurso compartilhável `unit_id IS NULL` quer dizer "é da rede inteira". A
12
+ -- regra é certa e existe por um bom motivo: o Botox vendido em toda parte
13
+ -- continua aparecendo em Ipanema.
14
+ --
15
+ -- Só que uma PESSOA sem unidade não é "atende em toda parte". É NÃO LOTADA —
16
+ -- 268 delas porque o V1 nunca disse onde trabalham, e as outras 128 porque
17
+ -- atendem em várias e se descrevem por tuplas `available_in`, não por coluna.
18
+ -- Os dois casos caem no mesmo NULL, e a coluna sozinha não sabe distinguir.
19
+ --
20
+ -- Uma agenda que mostra 396 colunas de gente que pode nem pisar naquela loja
21
+ -- não está compartilhando ninguém; está deixando de responder a pergunta que
22
+ -- lhe fizeram.
23
+ --
24
+ -- ── O que esta view acrescenta ─────────────────────────────────────────────
25
+ -- `unit_ids`: ONDE a pessoa efetivamente atende, juntando as duas formas de
26
+ -- dizer isso que hoje vivem em lugares diferentes —
27
+ --
28
+ -- • a coluna `unit_id`, para quem tem uma só;
29
+ -- • as tuplas `available_in` de `app.resource_grants`, para quem tem várias.
30
+ --
31
+ -- E `NULL` quando não há nem uma nem outra, que agora é uma resposta DISTINTA
32
+ -- de "está em todas": é "ninguém disse". A agenda esconde essas de uma unidade
33
+ -- e as mostra em "todas as unidades", que é onde alguém pode notar a falta e
34
+ -- corrigi-la. Antes elas eram invisíveis por excesso.
35
+ --
36
+ -- O filtro vira `unit_ids=cs.{id}` no PostgREST — contenção de array, sem
37
+ -- enumerar unidade nenhuma no navegador, que é a única coisa que este desenho
38
+ -- se recusa a fazer.
39
+ --
40
+ -- security_invoker OBRIGATÓRIO: sem ele a view roda com os direitos do dono e
41
+ -- as políticas de `people` não são avaliadas — que é exatamente como views
42
+ -- vazaram entre tenants antes. Com ele, esta view não pode mostrar uma linha
43
+ -- que a tabela recusaria.
44
+ -- ---------------------------------------------------------------------------
45
+
46
+ DROP VIEW IF EXISTS public.v_bookable_people;
47
+
48
+ CREATE VIEW public.v_bookable_people
49
+ WITH (security_invoker = true)
50
+ AS
51
+ SELECT
52
+ p.id,
53
+ p.tenant_id,
54
+ p.kind,
55
+ p.name,
56
+ p.avatar_url,
57
+ p.is_active,
58
+ p.is_bookable,
59
+ p.unit_id,
60
+ -- Uma unidade própria já é a resposta completa; a lista de tuplas só é
61
+ -- consultada para quem não tem coluna. `NULL` é "ninguém disse", e é
62
+ -- deliberadamente diferente de um array vazio.
63
+ CASE
64
+ WHEN p.unit_id IS NOT NULL THEN ARRAY[p.unit_id]
65
+ ELSE (
66
+ SELECT array_agg(DISTINCT g.subject_id)
67
+ FROM app.resource_grants g
68
+ WHERE g.tenant_id = p.tenant_id
69
+ AND g.resource_type = 'people.person'
70
+ AND g.resource_id = p.id
71
+ AND g.relation = 'available_in'
72
+ AND g.subject_type = 'unit'
73
+ )
74
+ END AS unit_ids
75
+ FROM public.people p;
76
+
77
+ REVOKE ALL ON public.v_bookable_people FROM public, anon;
78
+ GRANT SELECT ON public.v_bookable_people TO authenticated, service_role;
79
+
80
+ COMMENT ON VIEW public.v_bookable_people IS
81
+ 'Pessoas com `unit_ids`: onde cada uma efetivamente atende (#020), juntando a coluna `unit_id` de quem tem uma só com as tuplas `available_in` de quem tem várias. NULL = ninguém disse, que é diferente de "em todas" — a agenda de uma unidade esconde essas, e "todas as unidades" as mostra. security_invoker: as políticas de public.people continuam decidindo o que é visível.';