@fayz-ai/db 0.12.0 → 0.13.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 (116) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/040_resource_grants.sql +2 -2
  4. package/migrations/041_scoped_columns.sql +2 -2
  5. package/migrations/042_unit_scope_policies.sql +1 -1
  6. package/migrations/043_view_invoker.sql +12 -3
  7. package/migrations/044_unit_member_facts.sql +1 -1
  8. package/migrations/045_unit_entry.sql +1 -1
  9. package/migrations/046_membership_visible_to_members.sql +1 -1
  10. package/migrations/047_tasks.sql +4 -4
  11. package/migrations/048_every_login_is_a_person.sql +1 -1
  12. package/migrations/049_bookable_people.sql +1 -1
  13. package/migrations/050_authz_core.sql +230 -0
  14. package/migrations/051_authz_helpers.sql +330 -0
  15. package/migrations/052_authz_fanout.sql +253 -0
  16. package/migrations/053_authz_catalog_templates.sql +402 -0
  17. package/migrations/060_tenancy_seam.sql +132 -0
  18. package/migrations/061_create_tenant.sql +125 -0
  19. package/migrations/062_invitations.sql +329 -0
  20. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  21. package/migrations/064_agent_guard_authz.sql +118 -0
  22. package/migrations/064b_supersede_unit_scope.sql +205 -0
  23. package/migrations/065_scaffold_sweep.sql +139 -0
  24. package/migrations/066_shadow_report.sql +122 -0
  25. package/migrations/070_permission_catalog.sql +205 -0
  26. package/migrations/071_v1_permission_map.sql +266 -0
  27. package/migrations/072_role_rpcs.sql +126 -0
  28. package/migrations/073_legacy_rbac_retire.sql +471 -0
  29. package/migrations/075_company_legal_entities.sql +169 -0
  30. package/migrations/076_tenant_settings.sql +251 -0
  31. package/migrations/080_people_roles.sql +269 -0
  32. package/migrations/081_people_identity.sql +271 -0
  33. package/migrations/082_people_membership_link.sql +142 -0
  34. package/migrations/083_people_data_migration.sql +75 -0
  35. package/migrations/084_people_registrations_tokens.sql +282 -0
  36. package/migrations/085_catalog_products.sql +333 -0
  37. package/migrations/086_catalog_pricing.sql +194 -0
  38. package/migrations/087_catalog_packages.sql +134 -0
  39. package/migrations/090_orders_venda.sql +166 -0
  40. package/migrations/091_orders_scaffold.sql +92 -0
  41. package/migrations/095_config_field_rules.sql +261 -0
  42. package/migrations/096_audit_append_only.sql +256 -0
  43. package/migrations/097_sequences_authorized.sql +111 -0
  44. package/migrations/098_holidays.sql +80 -0
  45. package/migrations/099_access_grants.sql +293 -0
  46. package/migrations/100_migration_schema.sql +217 -0
  47. package/migrations/101_migration_allowlist.sql +202 -0
  48. package/migrations/102_migration_rpcs.sql +933 -0
  49. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  50. package/migrations/105_policies_to_authenticated.sql +71 -0
  51. package/migrations/106_function_search_path.sql +55 -0
  52. package/migrations/107_core_table_prefix.sql +361 -0
  53. package/migrations/108_authz_write_guards.sql +149 -0
  54. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  55. package/migrations/110_people_kind_vocabulary.sql +120 -0
  56. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  57. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  58. package/migrations/112_money_precision_standard.sql +132 -0
  59. package/migrations/113_addresses_units_companies.sql +114 -0
  60. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  61. package/migrations/115_registration_honours_field_rules.sql +172 -0
  62. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  63. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  64. package/migrations/118_migration_catalog_writers.sql +450 -0
  65. package/migrations/119_migration_config_writers.sql +193 -0
  66. package/migrations/120_migration_facet_writer.sql +139 -0
  67. package/migrations/121_migration_financial_writers.sql +294 -0
  68. package/migrations/122_migration_inventory_writers.sql +245 -0
  69. package/migrations/123_migration_order_item_writer.sql +144 -0
  70. package/migrations/124_migration_ledger_writers.sql +170 -0
  71. package/migrations/125_migration_authz_people_writers.sql +205 -0
  72. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  73. package/migrations/127_scaffold_audit_hook.sql +98 -0
  74. package/migrations/128_shadow_tables_enforced.sql +93 -0
  75. package/migrations/129_order_kind_terminal_states.sql +57 -0
  76. package/migrations/130_every_login_is_a_person.sql +196 -0
  77. package/migrations/131_migration_invoice_split.sql +127 -0
  78. package/migrations/132_audit_prune_scheduled.sql +28 -0
  79. package/migrations/133_unit_is_a_session_property.sql +155 -0
  80. package/migrations/134_field_rules_on_read.sql +341 -0
  81. package/migrations/135_distribution_polarity.sql +158 -0
  82. package/migrations/136_unit_id_means_locality.sql +358 -0
  83. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  84. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  85. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  86. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  87. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  88. package/migrations/142_a_person_has_addresses.sql +212 -0
  89. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  90. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  91. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  92. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  93. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  94. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  95. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  96. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  97. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  98. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  99. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  100. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  101. package/migrations/155_a_tenant_can_leave.sql +271 -0
  102. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  103. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  104. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  105. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  106. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  107. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  108. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  109. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  110. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  111. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  112. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  113. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  114. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  115. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  116. package/package.json +3 -2
@@ -0,0 +1,568 @@
1
+ -- ============================================================================
2
+ -- 063_legacy_tenancy_retired.sql — tenant_members / locations / location_members
3
+ -- stop being sources of truth (#134). They become COMPAT PROJECTIONS of
4
+ -- app.memberships / app.units / app.membership_units.
5
+ --
6
+ -- WHY PROJECTION TABLES AND NOT VIEWS. The retirement was designed as views;
7
+ -- the bench proved that shape impossible in this repo: frozen migrations
8
+ -- (001, 019, 023 …) re-run on every force replay and execute
9
+ -- `ALTER TABLE public.tenant_members ENABLE ROW LEVEL SECURITY`,
10
+ -- `ADD COLUMN IF NOT EXISTS`, `CREATE POLICY … ON public.tenant_members` —
11
+ -- every one of which ERRORS on a view ("This operation is not supported for
12
+ -- views"), and plugin-blog 002 declares a FOREIGN KEY to tenant_members(id)
13
+ -- and 004 declares FKs to locations(id), which a view cannot satisfy. Applied
14
+ -- migrations are never edited, so the names must stay physical tables. What
15
+ -- changes is who writes them:
16
+ --
17
+ -- * app.* is the ONLY source of truth. Triggers on app.memberships,
18
+ -- app.membership_units, app.units and app.roles rebuild the projection rows
19
+ -- (same ids: tenant_members.id = memberships.id, locations.id = units.id).
20
+ -- * A write to a projection table by an API role is FORWARDED to app.* by a
21
+ -- BEFORE trigger (as the invoker, so the app.* RLS policies decide), and
22
+ -- the projection row is materialised by the same statement only when the
23
+ -- forwarded write happened — the admin adapter's UPDATE role / DELETE
24
+ -- member / INSERT location keep working, and RETURNING keeps returning.
25
+ -- tenant_members INSERT → app.memberships INSERT (members.manage; never self)
26
+ -- tenant_members UPDATE → memberships.role_id / person_id (never your own role)
27
+ -- tenant_members DELETE → memberships.active = false (history is kept)
28
+ -- locations INSERT/UPDATE/DELETE → app.units (units.manage); legacy scalar
29
+ -- columns travel in units.metadata
30
+ -- location_members INSERT/DELETE → app.membership_units (members.manage)
31
+ -- * The projection tables' policies are re-authored onto app.* helpers; the
32
+ -- legacy self-insert policy (members_insert_own — the old model's P0) is
33
+ -- dropped. On a force replay 001 re-creates it and this file drops it again.
34
+ --
35
+ -- DATA MIGRATION (idempotent, replay-safe): every legacy row that has no app.*
36
+ -- counterpart is migrated once — tenant_members → memberships (role key mapped
37
+ -- through the 'generic' template: owner/admin/manager/staff/viewer keep their
38
+ -- key, anything else → staff, falling back to viewer, then to the least
39
+ -- privileged non-owner system role; the tenant's roles are seeded from
40
+ -- 'generic' first when it has none), locations → units (the first
41
+ -- is_headquarters row → hq, the rest → branch; a tenant left without an hq
42
+ -- gets its earliest unit promoted or, having none, an hq named after the
43
+ -- tenant), location_members → membership_units (only for users who hold a
44
+ -- membership; owners get all_units). Then app.compat_rebuild() re-derives the
45
+ -- three projections completely.
46
+ --
47
+ -- Custom role grants of the legacy tenant_role_overrides / tenant_roles are
48
+ -- NOT translated here — that is #136's mapping. public.permissions,
49
+ -- role_permissions, tenant_role_overrides and tenant_roles are left in place.
50
+ -- ============================================================================
51
+
52
+ -- ─────────────────────────── helpers ────────────────────────────────────
53
+ -- Two transaction-local switches:
54
+ -- fayz.compat_sync = 'on' while the projection is being (re)written from
55
+ -- app.* — the forwarding triggers let the row through.
56
+ -- fayz.compat_forward = 'on' while a forwarding trigger writes app.* — the
57
+ -- sync triggers stay quiet; the outer statement
58
+ -- materialises the projection row itself.
59
+ CREATE OR REPLACE FUNCTION app.compat_flag(p_name text)
60
+ RETURNS boolean LANGUAGE sql STABLE SET search_path = '' AS $$
61
+ SELECT coalesce(current_setting('fayz.compat_' || p_name, true), '') = 'on';
62
+ $$;
63
+ REVOKE ALL ON FUNCTION app.compat_flag(text) FROM public, anon;
64
+ GRANT EXECUTE ON FUNCTION app.compat_flag(text) TO authenticated, service_role;
65
+
66
+ CREATE OR REPLACE FUNCTION app.compat_role_id(p_tenant uuid, p_key text)
67
+ RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
68
+ SELECT r.id FROM app.roles r WHERE r.tenant_id = p_tenant AND r.key = p_key;
69
+ $$;
70
+ REVOKE ALL ON FUNCTION app.compat_role_id(uuid, text) FROM public, anon;
71
+ GRANT EXECUTE ON FUNCTION app.compat_role_id(uuid, text) TO authenticated, service_role;
72
+
73
+ CREATE OR REPLACE FUNCTION app.compat_uuid(p_text text)
74
+ RETURNS uuid LANGUAGE sql IMMUTABLE SET search_path = '' AS $$
75
+ SELECT CASE WHEN p_text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN p_text::uuid END;
76
+ $$;
77
+ REVOKE ALL ON FUNCTION app.compat_uuid(text) FROM public, anon;
78
+ GRANT EXECUTE ON FUNCTION app.compat_uuid(text) TO authenticated, service_role;
79
+
80
+ -- The legacy role-key mapping used by the data migration only.
81
+ CREATE OR REPLACE FUNCTION app.compat_legacy_role_id(p_tenant uuid, p_key text)
82
+ RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
83
+ SELECT coalesce(
84
+ app.compat_role_id(p_tenant, p_key),
85
+ app.compat_role_id(p_tenant, CASE p_key WHEN 'owner' THEN 'owner' WHEN 'admin' THEN 'admin' WHEN 'manager' THEN 'manager'
86
+ WHEN 'staff' THEN 'staff' WHEN 'viewer' THEN 'viewer' ELSE 'staff' END),
87
+ app.compat_role_id(p_tenant, 'viewer'),
88
+ (SELECT r.id FROM app.roles r
89
+ LEFT JOIN app.role_permissions rp ON rp.tenant_id = r.tenant_id AND rp.role_id = r.id
90
+ WHERE r.tenant_id = p_tenant AND r.is_system AND r.key <> 'owner'
91
+ GROUP BY r.id, r.key ORDER BY count(rp.permission), r.key LIMIT 1)
92
+ );
93
+ $$;
94
+ REVOKE ALL ON FUNCTION app.compat_legacy_role_id(uuid, text) FROM public, anon, authenticated;
95
+
96
+ -- ── projection writers (SECURITY DEFINER: they write the legacy tables as the
97
+ -- migration role; the forwarding triggers see fayz.compat_sync = on) ─────
98
+ CREATE OR REPLACE FUNCTION app.compat_sync_membership(p_tenant uuid, p_user uuid)
99
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
100
+ DECLARE m record;
101
+ BEGIN
102
+ PERFORM set_config('fayz.compat_sync', 'on', true);
103
+ SELECT ms.id, ms.tenant_id, ms.user_id, ms.role_id, ms.person_id, ms.created_at, ms.active,
104
+ r.key AS role_key
105
+ INTO m
106
+ FROM app.memberships ms
107
+ JOIN app.roles r ON r.tenant_id = ms.tenant_id AND r.id = ms.role_id
108
+ WHERE ms.tenant_id = p_tenant AND ms.user_id = p_user;
109
+
110
+ IF m.id IS NULL OR NOT m.active THEN
111
+ DELETE FROM public.location_members lm
112
+ WHERE lm.user_id = p_user
113
+ AND lm.location_id IN (SELECT u.id FROM app.units u WHERE u.tenant_id = p_tenant);
114
+ DELETE FROM public.tenant_members tm WHERE tm.tenant_id = p_tenant AND tm.user_id = p_user;
115
+ ELSE
116
+ -- a stale row for the same (tenant, user) under another id would collide
117
+ DELETE FROM public.tenant_members tm
118
+ WHERE tm.tenant_id = p_tenant AND tm.user_id = p_user AND tm.id <> m.id;
119
+ INSERT INTO public.tenant_members (id, tenant_id, user_id, role, person_id, created_at)
120
+ VALUES (m.id, m.tenant_id, m.user_id, m.role_key,
121
+ -- the legacy unique (tenant_id, person_id) must hold
122
+ CASE WHEN EXISTS (SELECT 1 FROM public.tenant_members x
123
+ WHERE x.tenant_id = m.tenant_id AND x.person_id = m.person_id AND x.id <> m.id)
124
+ THEN NULL ELSE m.person_id END,
125
+ m.created_at)
126
+ ON CONFLICT (id) DO UPDATE
127
+ SET tenant_id = EXCLUDED.tenant_id, user_id = EXCLUDED.user_id, role = EXCLUDED.role,
128
+ person_id = EXCLUDED.person_id, created_at = EXCLUDED.created_at;
129
+
130
+ DELETE FROM public.location_members lm
131
+ WHERE lm.user_id = p_user
132
+ AND lm.location_id IN (SELECT u.id FROM app.units u WHERE u.tenant_id = p_tenant)
133
+ AND NOT EXISTS (SELECT 1 FROM app.membership_units mu
134
+ WHERE mu.tenant_id = p_tenant AND mu.user_id = p_user AND mu.unit_id = lm.location_id);
135
+ INSERT INTO public.location_members (location_id, user_id, role)
136
+ SELECT mu.unit_id, mu.user_id, coalesce(ro.key, m.role_key)
137
+ FROM app.membership_units mu
138
+ LEFT JOIN app.roles ro ON ro.tenant_id = mu.tenant_id AND ro.id = mu.role_override_id
139
+ WHERE mu.tenant_id = p_tenant AND mu.user_id = p_user
140
+ AND EXISTS (SELECT 1 FROM public.locations l WHERE l.id = mu.unit_id)
141
+ ON CONFLICT (location_id, user_id) DO UPDATE SET role = EXCLUDED.role;
142
+ END IF;
143
+ PERFORM set_config('fayz.compat_sync', 'off', true);
144
+ END $$;
145
+ REVOKE ALL ON FUNCTION app.compat_sync_membership(uuid, uuid) FROM public, anon, authenticated;
146
+
147
+ CREATE OR REPLACE FUNCTION app.compat_sync_unit(p_unit uuid)
148
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
149
+ DECLARE u record;
150
+ BEGIN
151
+ PERFORM set_config('fayz.compat_sync', 'on', true);
152
+ SELECT * INTO u FROM app.units x WHERE x.id = p_unit;
153
+ IF u.id IS NULL THEN
154
+ DELETE FROM public.locations l WHERE l.id = p_unit;
155
+ ELSE
156
+ INSERT INTO public.locations
157
+ (id, tenant_id, kind, name, email, phone, address, city, state, country, postal_code,
158
+ is_headquarters, is_active, tags, notes, metadata, custom_fields, created_by, created_at, updated_at,
159
+ parent_id, code)
160
+ VALUES (u.id, u.tenant_id, coalesce(u.metadata ->> 'legacy_kind', u.kind), u.name,
161
+ u.metadata ->> 'email', u.metadata ->> 'phone', u.metadata ->> 'address',
162
+ u.metadata ->> 'city', u.metadata ->> 'state', coalesce(u.metadata ->> 'country', 'BR'),
163
+ u.metadata ->> 'postal_code',
164
+ u.kind = 'hq', u.active,
165
+ coalesce((SELECT array_agg(t) FROM jsonb_array_elements_text(CASE WHEN jsonb_typeof(u.metadata -> 'tags') = 'array' THEN u.metadata -> 'tags' ELSE '[]'::jsonb END) t), '{}'::text[]),
166
+ u.metadata ->> 'notes',
167
+ u.metadata,
168
+ CASE WHEN jsonb_typeof(u.metadata -> 'custom_fields') = 'object' THEN u.metadata -> 'custom_fields' ELSE '{}'::jsonb END,
169
+ app.compat_uuid(u.metadata ->> 'created_by'),
170
+ u.created_at, u.updated_at,
171
+ -- main's 039 gave locations a tree; the projection carries it, or the
172
+ -- units UI reads a flat list and 064b nulls the real tree on replay
173
+ u.parent_unit_id, u.metadata ->> 'code')
174
+ ON CONFLICT (id) DO UPDATE
175
+ SET tenant_id = EXCLUDED.tenant_id, kind = EXCLUDED.kind, name = EXCLUDED.name, email = EXCLUDED.email,
176
+ phone = EXCLUDED.phone, address = EXCLUDED.address, city = EXCLUDED.city, state = EXCLUDED.state,
177
+ country = EXCLUDED.country, postal_code = EXCLUDED.postal_code, is_headquarters = EXCLUDED.is_headquarters,
178
+ is_active = EXCLUDED.is_active, tags = EXCLUDED.tags, notes = EXCLUDED.notes, metadata = EXCLUDED.metadata,
179
+ custom_fields = EXCLUDED.custom_fields, created_by = EXCLUDED.created_by, updated_at = EXCLUDED.updated_at,
180
+ parent_id = EXCLUDED.parent_id, code = EXCLUDED.code;
181
+ -- created_by is stamped by the legacy trigger from auth.uid() when null; keep it null-safe.
182
+ END IF;
183
+ PERFORM set_config('fayz.compat_sync', 'off', true);
184
+ END $$;
185
+ REVOKE ALL ON FUNCTION app.compat_sync_unit(uuid) FROM public, anon, authenticated;
186
+
187
+ -- Full re-derivation of the three projections (used by this migration and
188
+ -- available to reconciliation). Deletes what app.* no longer has, upserts the rest.
189
+ CREATE OR REPLACE FUNCTION app.compat_rebuild()
190
+ RETURNS TABLE (n_tenant_members integer, n_locations integer, n_location_members integer)
191
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
192
+ DECLARE r record;
193
+ BEGIN
194
+ PERFORM set_config('fayz.compat_sync', 'on', true);
195
+ DELETE FROM public.location_members lm
196
+ WHERE NOT EXISTS (SELECT 1 FROM app.membership_units mu
197
+ JOIN app.memberships m ON m.tenant_id = mu.tenant_id AND m.user_id = mu.user_id AND m.active
198
+ WHERE mu.unit_id = lm.location_id AND mu.user_id = lm.user_id);
199
+ DELETE FROM public.tenant_members tm
200
+ WHERE NOT EXISTS (SELECT 1 FROM app.memberships m WHERE m.id = tm.id AND m.active);
201
+ DELETE FROM public.locations l
202
+ WHERE NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = l.id);
203
+ PERFORM set_config('fayz.compat_sync', 'off', true);
204
+ FOR r IN SELECT u.id FROM app.units u LOOP
205
+ PERFORM app.compat_sync_unit(r.id);
206
+ END LOOP;
207
+ FOR r IN SELECT m.tenant_id, m.user_id FROM app.memberships m LOOP
208
+ PERFORM app.compat_sync_membership(r.tenant_id, r.user_id);
209
+ END LOOP;
210
+ SELECT count(*)::integer INTO n_tenant_members FROM public.tenant_members;
211
+ SELECT count(*)::integer INTO n_locations FROM public.locations;
212
+ SELECT count(*)::integer INTO n_location_members FROM public.location_members;
213
+ RETURN NEXT;
214
+ END $$;
215
+ REVOKE ALL ON FUNCTION app.compat_rebuild() FROM public, anon, authenticated;
216
+ GRANT EXECUTE ON FUNCTION app.compat_rebuild() TO service_role;
217
+
218
+ -- ── sync triggers on app.* ──────────────────────────────────────────────
219
+ CREATE OR REPLACE FUNCTION app.trg_memberships_compat()
220
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
221
+ BEGIN
222
+ IF app.compat_flag('forward') THEN RETURN NULL; END IF;
223
+ IF TG_OP = 'DELETE' THEN
224
+ PERFORM app.compat_sync_membership(OLD.tenant_id, OLD.user_id);
225
+ ELSE
226
+ IF TG_OP = 'UPDATE' AND (OLD.tenant_id <> NEW.tenant_id OR OLD.user_id <> NEW.user_id) THEN
227
+ PERFORM app.compat_sync_membership(OLD.tenant_id, OLD.user_id);
228
+ END IF;
229
+ PERFORM app.compat_sync_membership(NEW.tenant_id, NEW.user_id);
230
+ END IF;
231
+ RETURN NULL;
232
+ END $$;
233
+ DROP TRIGGER IF EXISTS memberships_compat ON app.memberships;
234
+ CREATE TRIGGER memberships_compat
235
+ AFTER INSERT OR UPDATE OR DELETE ON app.memberships
236
+ FOR EACH ROW EXECUTE FUNCTION app.trg_memberships_compat();
237
+
238
+ CREATE OR REPLACE FUNCTION app.trg_membership_units_compat()
239
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
240
+ BEGIN
241
+ IF app.compat_flag('forward') THEN RETURN NULL; END IF;
242
+ IF TG_OP IN ('DELETE', 'UPDATE') THEN
243
+ PERFORM app.compat_sync_membership(OLD.tenant_id, OLD.user_id);
244
+ END IF;
245
+ IF TG_OP IN ('INSERT', 'UPDATE') AND (TG_OP = 'INSERT' OR OLD.tenant_id <> NEW.tenant_id OR OLD.user_id <> NEW.user_id) THEN
246
+ PERFORM app.compat_sync_membership(NEW.tenant_id, NEW.user_id);
247
+ END IF;
248
+ RETURN NULL;
249
+ END $$;
250
+ DROP TRIGGER IF EXISTS membership_units_compat ON app.membership_units;
251
+ CREATE TRIGGER membership_units_compat
252
+ AFTER INSERT OR UPDATE OR DELETE ON app.membership_units
253
+ FOR EACH ROW EXECUTE FUNCTION app.trg_membership_units_compat();
254
+
255
+ CREATE OR REPLACE FUNCTION app.trg_units_compat()
256
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
257
+ BEGIN
258
+ IF app.compat_flag('forward') THEN RETURN NULL; END IF;
259
+ IF TG_OP = 'DELETE' THEN
260
+ PERFORM app.compat_sync_unit(OLD.id);
261
+ ELSE
262
+ PERFORM app.compat_sync_unit(NEW.id);
263
+ END IF;
264
+ RETURN NULL;
265
+ END $$;
266
+ DROP TRIGGER IF EXISTS units_compat ON app.units;
267
+ CREATE TRIGGER units_compat
268
+ AFTER INSERT OR UPDATE OR DELETE ON app.units
269
+ FOR EACH ROW EXECUTE FUNCTION app.trg_units_compat();
270
+
271
+ -- a role key rename must show in tenant_members.role / location_members.role
272
+ CREATE OR REPLACE FUNCTION app.trg_roles_compat()
273
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
274
+ DECLARE r record;
275
+ BEGIN
276
+ IF OLD.key IS DISTINCT FROM NEW.key THEN
277
+ FOR r IN SELECT m.tenant_id, m.user_id FROM app.memberships m WHERE m.tenant_id = NEW.tenant_id AND m.role_id = NEW.id
278
+ UNION SELECT mu.tenant_id, mu.user_id FROM app.membership_units mu WHERE mu.tenant_id = NEW.tenant_id AND mu.role_override_id = NEW.id
279
+ LOOP
280
+ PERFORM app.compat_sync_membership(r.tenant_id, r.user_id);
281
+ END LOOP;
282
+ END IF;
283
+ RETURN NULL;
284
+ END $$;
285
+ DROP TRIGGER IF EXISTS roles_compat ON app.roles;
286
+ CREATE TRIGGER roles_compat
287
+ AFTER UPDATE ON app.roles
288
+ FOR EACH ROW EXECUTE FUNCTION app.trg_roles_compat();
289
+
290
+ -- ── forwarding triggers on the projection tables (SECURITY INVOKER: app.* RLS
291
+ -- decides; a write that app.* filters to zero rows changes nothing) ──────
292
+ CREATE OR REPLACE FUNCTION public.trg_tenant_members_forward()
293
+ RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$
294
+ DECLARE v_role uuid; v_n integer;
295
+ BEGIN
296
+ IF app.compat_flag('sync') THEN
297
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
298
+ END IF;
299
+ PERFORM set_config('fayz.compat_forward', 'on', true);
300
+ IF TG_OP = 'INSERT' THEN
301
+ v_role := app.compat_role_id(NEW.tenant_id, NEW.role);
302
+ IF v_role IS NULL THEN
303
+ RAISE EXCEPTION 'tenant_members: unknown role key % in this tenant', NEW.role USING ERRCODE = '22023';
304
+ END IF;
305
+ NEW.id := coalesce(NEW.id, gen_random_uuid());
306
+ INSERT INTO app.memberships (tenant_id, user_id, id, role_id, all_units, active, person_id, created_at)
307
+ VALUES (NEW.tenant_id, NEW.user_id, NEW.id, v_role, false, true, NEW.person_id, coalesce(NEW.created_at, now()));
308
+ PERFORM set_config('fayz.compat_forward', 'off', true);
309
+ RETURN NEW;
310
+ ELSIF TG_OP = 'UPDATE' THEN
311
+ IF NEW.role IS DISTINCT FROM OLD.role AND OLD.user_id = auth.uid() THEN
312
+ RAISE EXCEPTION 'tenant_members: you cannot change your own role' USING ERRCODE = '42501';
313
+ END IF;
314
+ IF NEW.tenant_id <> OLD.tenant_id OR NEW.user_id <> OLD.user_id OR NEW.id <> OLD.id THEN
315
+ RAISE EXCEPTION 'tenant_members: tenant, user and id are immutable' USING ERRCODE = '22023';
316
+ END IF;
317
+ v_role := app.compat_role_id(NEW.tenant_id, NEW.role);
318
+ IF v_role IS NULL THEN
319
+ RAISE EXCEPTION 'tenant_members: unknown role key % in this tenant', NEW.role USING ERRCODE = '22023';
320
+ END IF;
321
+ UPDATE app.memberships m SET role_id = v_role, person_id = NEW.person_id WHERE m.id = OLD.id;
322
+ GET DIAGNOSTICS v_n = ROW_COUNT;
323
+ PERFORM set_config('fayz.compat_forward', 'off', true);
324
+ IF v_n = 0 THEN RETURN NULL; END IF;
325
+ RETURN NEW;
326
+ ELSE
327
+ -- removing a member = deactivating the membership (history and FKs survive)
328
+ UPDATE app.memberships m SET active = false WHERE m.id = OLD.id AND m.active;
329
+ GET DIAGNOSTICS v_n = ROW_COUNT;
330
+ PERFORM set_config('fayz.compat_forward', 'off', true);
331
+ IF v_n = 0 THEN RETURN NULL; END IF;
332
+ RETURN OLD;
333
+ END IF;
334
+ END $$;
335
+ REVOKE ALL ON FUNCTION public.trg_tenant_members_forward() FROM public, anon;
336
+ DROP TRIGGER IF EXISTS tenant_members_forward ON public.tenant_members;
337
+ CREATE TRIGGER tenant_members_forward BEFORE INSERT OR UPDATE OR DELETE ON public.tenant_members
338
+ FOR EACH ROW EXECUTE FUNCTION public.trg_tenant_members_forward();
339
+
340
+ CREATE OR REPLACE FUNCTION public.trg_locations_forward()
341
+ RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$
342
+ DECLARE v_kind text; v_meta jsonb; v_n integer;
343
+ BEGIN
344
+ IF app.compat_flag('sync') THEN
345
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
346
+ END IF;
347
+ PERFORM set_config('fayz.compat_forward', 'on', true);
348
+ IF TG_OP = 'DELETE' THEN
349
+ DELETE FROM app.units u WHERE u.id = OLD.id;
350
+ GET DIAGNOSTICS v_n = ROW_COUNT;
351
+ PERFORM set_config('fayz.compat_forward', 'off', true);
352
+ IF v_n = 0 THEN RETURN NULL; END IF;
353
+ RETURN OLD;
354
+ END IF;
355
+
356
+ -- the legacy scalar columns live in units.metadata
357
+ v_meta := (coalesce(NEW.metadata, '{}'::jsonb)
358
+ - ARRAY['email', 'phone', 'address', 'city', 'state', 'country', 'postal_code', 'tags', 'notes', 'custom_fields', 'legacy_kind'])
359
+ || jsonb_strip_nulls(jsonb_build_object(
360
+ 'email', NEW.email, 'phone', NEW.phone, 'address', NEW.address, 'city', NEW.city,
361
+ 'state', NEW.state, 'country', NEW.country, 'postal_code', NEW.postal_code,
362
+ 'tags', CASE WHEN NEW.tags IS NULL OR cardinality(NEW.tags) = 0 THEN NULL ELSE to_jsonb(NEW.tags) END,
363
+ 'notes', NEW.notes,
364
+ 'custom_fields', CASE WHEN NEW.custom_fields IS NULL OR NEW.custom_fields = '{}'::jsonb THEN NULL ELSE NEW.custom_fields END,
365
+ 'created_by', coalesce(NEW.created_by, auth.uid(), app.compat_uuid(NEW.metadata ->> 'created_by')),
366
+ 'legacy_kind', CASE WHEN NEW.kind IN ('hq', 'branch') THEN NULL ELSE NEW.kind END));
367
+ IF TG_OP = 'INSERT' THEN
368
+ NEW.id := coalesce(NEW.id, gen_random_uuid());
369
+ v_kind := CASE WHEN coalesce(NEW.is_headquarters, false) OR NEW.kind = 'hq' THEN 'hq' ELSE 'branch' END;
370
+ IF v_kind = 'hq' AND EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = NEW.tenant_id AND u.kind = 'hq') THEN
371
+ RAISE EXCEPTION 'locations: the tenant already has a headquarters unit' USING ERRCODE = '23505';
372
+ END IF;
373
+ INSERT INTO app.units (tenant_id, id, kind, name, active, metadata, created_at, updated_at)
374
+ VALUES (NEW.tenant_id, NEW.id, v_kind, NEW.name, coalesce(NEW.is_active, true), v_meta,
375
+ coalesce(NEW.created_at, now()), coalesce(NEW.updated_at, now()));
376
+ PERFORM set_config('fayz.compat_forward', 'off', true);
377
+ NEW.kind := v_kind; NEW.is_headquarters := (v_kind = 'hq'); NEW.is_active := coalesce(NEW.is_active, true);
378
+ NEW.metadata := v_meta; NEW.country := coalesce(NEW.country, 'BR');
379
+ RETURN NEW;
380
+ END IF;
381
+
382
+ IF NEW.tenant_id <> OLD.tenant_id OR NEW.id <> OLD.id THEN
383
+ RAISE EXCEPTION 'locations: tenant and id are immutable' USING ERRCODE = '22023';
384
+ END IF;
385
+ v_kind := CASE WHEN coalesce(NEW.is_headquarters, false) OR NEW.kind = 'hq' THEN 'hq' ELSE 'branch' END;
386
+ UPDATE app.units u
387
+ SET name = NEW.name, kind = v_kind, active = coalesce(NEW.is_active, true), metadata = v_meta
388
+ WHERE u.id = OLD.id;
389
+ GET DIAGNOSTICS v_n = ROW_COUNT;
390
+ PERFORM set_config('fayz.compat_forward', 'off', true);
391
+ IF v_n = 0 THEN RETURN NULL; END IF;
392
+ NEW.kind := v_kind; NEW.is_headquarters := (v_kind = 'hq'); NEW.metadata := v_meta;
393
+ RETURN NEW;
394
+ END $$;
395
+ REVOKE ALL ON FUNCTION public.trg_locations_forward() FROM public, anon;
396
+ DROP TRIGGER IF EXISTS locations_forward ON public.locations;
397
+ CREATE TRIGGER locations_forward BEFORE INSERT OR UPDATE OR DELETE ON public.locations
398
+ FOR EACH ROW EXECUTE FUNCTION public.trg_locations_forward();
399
+
400
+ CREATE OR REPLACE FUNCTION public.trg_location_members_forward()
401
+ RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$
402
+ DECLARE v_tenant uuid; v_n integer;
403
+ BEGIN
404
+ IF app.compat_flag('sync') THEN
405
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
406
+ END IF;
407
+ PERFORM set_config('fayz.compat_forward', 'on', true);
408
+ IF TG_OP = 'INSERT' THEN
409
+ SELECT u.tenant_id INTO v_tenant FROM app.units u WHERE u.id = NEW.location_id;
410
+ IF v_tenant IS NULL THEN
411
+ RAISE EXCEPTION 'location_members: unknown location %', NEW.location_id USING ERRCODE = '22023';
412
+ END IF;
413
+ INSERT INTO app.membership_units (tenant_id, user_id, unit_id) VALUES (v_tenant, NEW.user_id, NEW.location_id);
414
+ PERFORM set_config('fayz.compat_forward', 'off', true);
415
+ NEW.id := coalesce(NEW.id, gen_random_uuid());
416
+ RETURN NEW;
417
+ ELSIF TG_OP = 'UPDATE' THEN
418
+ PERFORM set_config('fayz.compat_forward', 'off', true);
419
+ RAISE EXCEPTION 'location_members is a compat projection: rows are added and removed, not edited' USING ERRCODE = '0A000';
420
+ ELSE
421
+ DELETE FROM app.membership_units mu WHERE mu.unit_id = OLD.location_id AND mu.user_id = OLD.user_id;
422
+ GET DIAGNOSTICS v_n = ROW_COUNT;
423
+ PERFORM set_config('fayz.compat_forward', 'off', true);
424
+ IF v_n = 0 THEN RETURN NULL; END IF;
425
+ RETURN OLD;
426
+ END IF;
427
+ END $$;
428
+ REVOKE ALL ON FUNCTION public.trg_location_members_forward() FROM public, anon;
429
+ DROP TRIGGER IF EXISTS location_members_forward ON public.location_members;
430
+ CREATE TRIGGER location_members_forward BEFORE INSERT OR UPDATE OR DELETE ON public.location_members
431
+ FOR EACH ROW EXECUTE FUNCTION public.trg_location_members_forward();
432
+
433
+ -- ─────────────────────── data migration (idempotent) ─────────────────────
434
+ DO $migrate$
435
+ DECLARE
436
+ t record;
437
+ l record;
438
+ v_has_hq boolean;
439
+ v_kind text;
440
+ v_first uuid;
441
+ BEGIN
442
+ PERFORM set_config('fayz.compat_sync', 'off', true);
443
+ PERFORM set_config('fayz.compat_forward', 'off', true);
444
+
445
+ -- 1. roles for tenants that hold legacy memberships but no roles yet
446
+ FOR t IN
447
+ SELECT DISTINCT tm.tenant_id AS id
448
+ FROM public.tenant_members tm
449
+ WHERE NOT EXISTS (SELECT 1 FROM app.roles r WHERE r.tenant_id = tm.tenant_id)
450
+ LOOP
451
+ PERFORM app.seed_role_template(t.id, 'generic');
452
+ UPDATE public.tenants SET role_template = coalesce(role_template, 'generic') WHERE id = t.id;
453
+ END LOOP;
454
+
455
+ -- 2. tenant_members → memberships (ids preserved; owner ⇒ all_units)
456
+ FOR t IN
457
+ SELECT tm.*
458
+ FROM public.tenant_members tm
459
+ WHERE NOT EXISTS (SELECT 1 FROM app.memberships m WHERE m.tenant_id = tm.tenant_id AND m.user_id = tm.user_id)
460
+ AND EXISTS (SELECT 1 FROM auth.users u WHERE u.id = tm.user_id)
461
+ ORDER BY tm.created_at, tm.id
462
+ LOOP
463
+ DECLARE v_role uuid := app.compat_legacy_role_id(t.tenant_id, t.role);
464
+ BEGIN
465
+ IF v_role IS NULL THEN
466
+ RAISE EXCEPTION 'legacy tenancy: tenant % has no role to receive tenant_members.role = %', t.tenant_id, t.role;
467
+ END IF;
468
+ INSERT INTO app.memberships (tenant_id, user_id, id, role_id, all_units, active, person_id, created_at)
469
+ VALUES (t.tenant_id, t.user_id,
470
+ CASE WHEN EXISTS (SELECT 1 FROM app.memberships m WHERE m.id = t.id) THEN gen_random_uuid() ELSE t.id END,
471
+ v_role, t.role = 'owner', true, t.person_id, t.created_at)
472
+ ON CONFLICT (tenant_id, user_id) DO NOTHING;
473
+ END;
474
+ END LOOP;
475
+
476
+ -- 3. locations → units (same ids); one hq per tenant
477
+ FOR t IN SELECT DISTINCT loc.tenant_id AS id FROM public.locations loc
478
+ WHERE NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = loc.id)
479
+ LOOP
480
+ v_has_hq := EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = t.id AND u.kind = 'hq');
481
+ FOR l IN SELECT * FROM public.locations x
482
+ WHERE x.tenant_id = t.id AND NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = x.id)
483
+ ORDER BY coalesce(x.is_headquarters, false) DESC, x.created_at, x.id
484
+ LOOP
485
+ v_kind := CASE WHEN NOT v_has_hq AND coalesce(l.is_headquarters, false) THEN 'hq' ELSE 'branch' END;
486
+ IF v_kind = 'hq' THEN v_has_hq := true; END IF;
487
+ INSERT INTO app.units (tenant_id, id, kind, name, active, metadata, created_at, updated_at)
488
+ VALUES (t.id, l.id, v_kind, l.name, coalesce(l.is_active, true),
489
+ coalesce(l.metadata, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object(
490
+ 'email', l.email, 'phone', l.phone, 'address', l.address, 'city', l.city, 'state', l.state,
491
+ 'country', l.country, 'postal_code', l.postal_code,
492
+ 'tags', CASE WHEN l.tags IS NULL OR cardinality(l.tags) = 0 THEN NULL ELSE to_jsonb(l.tags) END,
493
+ 'notes', l.notes,
494
+ 'custom_fields', CASE WHEN l.custom_fields = '{}'::jsonb THEN NULL ELSE l.custom_fields END,
495
+ 'created_by', l.created_by,
496
+ 'legacy_kind', CASE WHEN l.kind IN ('hq', 'branch') THEN NULL ELSE l.kind END)),
497
+ coalesce(l.created_at, now()), coalesce(l.updated_at, now()))
498
+ ON CONFLICT (id) DO NOTHING;
499
+ END LOOP;
500
+ END LOOP;
501
+
502
+ -- every tenant has a headquarters: promote the earliest unit, or create one
503
+ FOR t IN SELECT x.id, x.name FROM public.tenants x
504
+ WHERE NOT EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = x.id AND u.kind = 'hq')
505
+ LOOP
506
+ SELECT u.id INTO v_first FROM app.units u WHERE u.tenant_id = t.id ORDER BY u.created_at, u.id LIMIT 1;
507
+ IF v_first IS NOT NULL THEN
508
+ UPDATE app.units SET kind = 'hq' WHERE id = v_first;
509
+ ELSE
510
+ INSERT INTO app.units (tenant_id, kind, name, slug, active)
511
+ VALUES (t.id, 'hq', coalesce(nullif(btrim(t.name), ''), 'Sede'), 'hq', true);
512
+ END IF;
513
+ END LOOP;
514
+
515
+ -- 4. location_members → membership_units (only members of the tenant)
516
+ INSERT INTO app.membership_units (tenant_id, user_id, unit_id)
517
+ SELECT u.tenant_id, lm.user_id, lm.location_id
518
+ FROM public.location_members lm
519
+ JOIN app.units u ON u.id = lm.location_id
520
+ JOIN app.memberships m ON m.tenant_id = u.tenant_id AND m.user_id = lm.user_id
521
+ ON CONFLICT DO NOTHING;
522
+
523
+ -- 5. re-derive the projections completely and re-check the share table
524
+ PERFORM app.compat_rebuild();
525
+ PERFORM app.recompute_all_user_unit_access('migration');
526
+ END $migrate$;
527
+
528
+ -- ─────────────────── policies on the projection tables ───────────────────
529
+ -- Reads through the seam; writes need the platform permission key AND are then
530
+ -- forwarded to app.* whose own policies decide again. The legacy self-insert
531
+ -- (members_insert_own) is gone.
532
+ DROP POLICY IF EXISTS "members_insert_own" ON public.tenant_members;
533
+ DROP POLICY IF EXISTS "members_select" ON public.tenant_members;
534
+ CREATE POLICY "members_select" ON public.tenant_members FOR SELECT TO authenticated
535
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
536
+ DROP POLICY IF EXISTS "members_insert" ON public.tenant_members;
537
+ CREATE POLICY "members_insert" ON public.tenant_members FOR INSERT TO authenticated
538
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')) AND user_id <> (select auth.uid()));
539
+ DROP POLICY IF EXISTS "members_update" ON public.tenant_members;
540
+ CREATE POLICY "members_update" ON public.tenant_members FOR UPDATE TO authenticated
541
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')))
542
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')));
543
+ DROP POLICY IF EXISTS "members_delete" ON public.tenant_members;
544
+ CREATE POLICY "members_delete" ON public.tenant_members FOR DELETE TO authenticated
545
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')) AND user_id <> (select auth.uid()));
546
+
547
+ DROP POLICY IF EXISTS "locations_select" ON public.locations;
548
+ CREATE POLICY "locations_select" ON public.locations FOR SELECT TO authenticated
549
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
550
+ DROP POLICY IF EXISTS "locations_manage" ON public.locations;
551
+ CREATE POLICY "locations_manage" ON public.locations FOR ALL TO authenticated
552
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('units.manage')))
553
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('units.manage')));
554
+
555
+ DROP POLICY IF EXISTS "loc_members_select" ON public.location_members;
556
+ CREATE POLICY "loc_members_select" ON public.location_members FOR SELECT TO authenticated
557
+ USING (location_id IN (SELECT l.id FROM public.locations l WHERE l.tenant_id IN (SELECT public.user_tenant_ids())));
558
+ DROP POLICY IF EXISTS "loc_members_manage" ON public.location_members;
559
+ CREATE POLICY "loc_members_manage" ON public.location_members FOR ALL TO authenticated
560
+ USING (location_id IN (SELECT l.id FROM public.locations l WHERE l.tenant_id = (select app.current_tenant_id())) AND (select app.has_permission('members.manage')))
561
+ WITH CHECK (location_id IN (SELECT l.id FROM public.locations l WHERE l.tenant_id = (select app.current_tenant_id())) AND (select app.has_permission('members.manage')));
562
+
563
+ COMMENT ON TABLE public.tenant_members IS
564
+ 'COMPAT PROJECTION of app.memberships (#134, 2026-08-18): same ids, role = app.roles.key, active memberships only. Derived by triggers; direct writes are forwarded to app.memberships under its RLS. Read it if you must; write app.* or use the RPCs. Removal: Phase 1.';
565
+ COMMENT ON TABLE public.locations IS
566
+ 'COMPAT PROJECTION of app.units (#134, 2026-08-18): same ids; is_headquarters = (kind = hq); contact/address columns come from units.metadata. Direct writes are forwarded to app.units under its RLS. Removal: Phase 1.';
567
+ COMMENT ON TABLE public.location_members IS
568
+ 'COMPAT PROJECTION of app.membership_units (#134, 2026-08-18): (location_id, user_id) = (unit_id, user_id) of active members; role = effective role key. Direct inserts/deletes are forwarded. Removal: Phase 1.';