@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,57 @@
1
+ -- 129_order_kind_terminal_states.sql
2
+ --
3
+ -- The four deferred kinds get the one rule nobody disputes. #140.
4
+ --
5
+ -- #140 asks for "an explicit sale state machine per kind". `sale` and `dine_in`
6
+ -- carry the default machine; `appointment`, `service_order`, `quote` and `deal`
7
+ -- declare their STATUSES and leave `transitions` NULL, with "lifecycle owned by
8
+ -- plugin-agenda / plugin-crm" written beside each. `shop` and `purchase` are
9
+ -- `{"*": ["*"]}` — deliberately free, because their documents are mirrored from
10
+ -- systems that own the lifecycle outright.
11
+ --
12
+ -- That deferral is right: what a quote may do after it is `sent` is product
13
+ -- design, and the slice that owns the quote should decide it. Inventing a
14
+ -- workflow here would put agenda's and CRM's rules in the orders spine, which is
15
+ -- exactly the coupling #146's convention exists to prevent.
16
+ --
17
+ -- But there is one rule that needs nobody's product opinion: A TERMINAL STATE IS
18
+ -- TERMINAL. A cancelled appointment does not become confirmed again — you make a
19
+ -- new one. A paid order does not go back to draft. An archived deal is archived.
20
+ -- Encoding that costs no design decisions and closes the hole the deferral left:
21
+ -- today any of these kinds can go from any status to any other, including
22
+ -- backwards out of a terminal state, and nothing notices.
23
+ --
24
+ -- The guard reads `transitions -> OLD.status`, falling back to `transitions ->
25
+ -- '*'` and then to `[]`. So `{"*": ["*"], "cancelled": []}` means "anything may
26
+ -- happen, except that nothing may happen after cancelled" — free where the
27
+ -- owning slice has not decided, closed where the answer is not in doubt.
28
+ --
29
+ -- Which statuses are terminal, and why each is beyond argument:
30
+ -- appointment cancelled, no_show, paid
31
+ -- service_order cancelled, paid
32
+ -- quote converted, archived (rejected/expired stay open: a
33
+ -- rejected quote can be revised)
34
+ -- deal archived (won/lost stay open: a deal can be
35
+ -- reopened, and CRMs do)
36
+ --
37
+ -- Replay-safe: register_order_kind upserts by kind.
38
+
39
+ SELECT public.register_order_kind('appointment', 'agenda',
40
+ ARRAY['scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show', 'invoiced', 'paid'],
41
+ '{"*": ["*"], "cancelled": [], "no_show": [], "paid": []}'::jsonb,
42
+ 'An appointment''s commercial document (agenda); lifecycle owned by plugin-agenda, except that a terminal state is terminal (#119)');
43
+
44
+ SELECT public.register_order_kind('service_order', 'agenda',
45
+ ARRAY['draft', 'scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'invoiced', 'paid'],
46
+ '{"*": ["*"], "cancelled": [], "paid": []}'::jsonb,
47
+ 'A service order (agenda); lifecycle owned by plugin-agenda, except that a terminal state is terminal (#119)');
48
+
49
+ SELECT public.register_order_kind('quote', 'crm',
50
+ ARRAY['draft', 'sent', 'approved', 'rejected', 'expired', 'converted', 'archived'],
51
+ '{"*": ["*"], "converted": [], "archived": []}'::jsonb,
52
+ 'A quote (CRM); lifecycle owned by plugin-crm. rejected and expired stay open — a rejected quote can be revised (#119)');
53
+
54
+ SELECT public.register_order_kind('deal', 'crm',
55
+ ARRAY['open', 'won', 'lost', 'archived'],
56
+ '{"*": ["*"], "archived": []}'::jsonb,
57
+ 'A deal (CRM); lifecycle owned by plugin-crm. won and lost stay open — a deal can be reopened, and CRMs do (#119)');
@@ -0,0 +1,196 @@
1
+ -- 130_every_login_is_a_person.sql
2
+ --
3
+ -- `memberships.person_id` becomes required, because a login is a person. #137.
4
+ --
5
+ -- #137 asks for "memberships.person_id required for employee/professional roles
6
+ -- with login; one user = one person per tenant". The FK, the one-person-per-user
7
+ -- unique index and the merge-tombstone guard all shipped; the REQUIREMENT did
8
+ -- not, and 7 of 8 salon memberships carry NULL.
9
+ --
10
+ -- `082`'s header argues it cannot be a constraint — "a professional without a
11
+ -- login is legitimate" — and that is true, but it is the other direction. A
12
+ -- Person may have no membership; the question here is whether a MEMBERSHIP may
13
+ -- have no Person, and a membership IS a login. Somebody is using it.
14
+ --
15
+ -- THE DECISION TAKEN, since #137 left it open: every login gets a Person, not
16
+ -- only the staff roles. Two reasons. Splitting by role means asking "is this
17
+ -- role staff?" at every write, and role sets are per tenant and per vertical —
18
+ -- the question has no stable answer. And the whole point of #137 is that a
19
+ -- Person is the one identity a human has in a tenant: an owner who is not a
20
+ -- Person cannot be scheduled, paid a commission, or given a document, and would
21
+ -- have to be invented later anyway.
22
+ --
23
+ -- HOW, so that no writer has to remember: a BEFORE INSERT trigger fills
24
+ -- `person_id` when it is absent, finding the Person already linked to that login
25
+ -- or creating one from the account's own name and e-mail. The legacy projection
26
+ -- (053), the invitation flow, `create_tenant` and the seed all go through it
27
+ -- without knowing it exists — which is the only way a NOT NULL on this column
28
+ -- could be added without breaking one of them.
29
+ --
30
+ -- The Person it creates is `kind = 'employee'`: a login is a member of the team
31
+ -- until somebody says otherwise, and `people_link_membership` already used that
32
+ -- rule when linking an existing Person with no staff role.
33
+ --
34
+ -- Replay-safe: the backfill is `WHERE person_id IS NULL`, the trigger is dropped
35
+ -- and recreated, and the NOT NULL is applied only once every row satisfies it.
36
+
37
+ CREATE OR REPLACE FUNCTION app.person_for_login(p_tenant uuid, p_user uuid)
38
+ RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
39
+ DECLARE
40
+ v_person uuid;
41
+ v_email text;
42
+ v_name text;
43
+ BEGIN
44
+ IF p_tenant IS NULL OR p_user IS NULL THEN RETURN NULL; END IF;
45
+
46
+ -- already linked somewhere in this tenant? one user = one person per tenant
47
+ SELECT m.person_id INTO v_person FROM app.memberships m
48
+ WHERE m.tenant_id = p_tenant AND m.user_id = p_user AND m.person_id IS NOT NULL
49
+ LIMIT 1;
50
+ IF v_person IS NOT NULL THEN RETURN v_person; END IF;
51
+
52
+ SELECT u.email, coalesce(nullif(btrim(u.raw_user_meta_data ->> 'full_name'), ''),
53
+ nullif(btrim(u.raw_user_meta_data ->> 'name'), ''))
54
+ INTO v_email, v_name
55
+ FROM auth.users u WHERE u.id = p_user;
56
+
57
+ -- an existing Person of this tenant with the same e-mail is the same human:
58
+ -- creating a second one would be the duplicate #137 exists to prevent
59
+ IF v_email IS NOT NULL THEN
60
+ SELECT p.id INTO v_person FROM public.people p
61
+ WHERE p.tenant_id = p_tenant AND p.merged_into_id IS NULL
62
+ AND lower(p.email) = lower(v_email)
63
+ AND NOT EXISTS (SELECT 1 FROM app.memberships m2 WHERE m2.tenant_id = p_tenant AND m2.person_id = p.id)
64
+ ORDER BY p.created_at LIMIT 1;
65
+ IF v_person IS NOT NULL THEN RETURN v_person; END IF;
66
+ END IF;
67
+
68
+ INSERT INTO public.people (tenant_id, kind, name, email, metadata)
69
+ VALUES (p_tenant, 'employee',
70
+ coalesce(v_name, split_part(coalesce(v_email, 'membro'), '@', 1)),
71
+ v_email,
72
+ jsonb_build_object('source', 'migration:120', 'reason', 'a login is a person'))
73
+ RETURNING id INTO v_person;
74
+ RETURN v_person;
75
+ END $$;
76
+ REVOKE ALL ON FUNCTION app.person_for_login(uuid, uuid) FROM public, anon, authenticated;
77
+
78
+ CREATE OR REPLACE FUNCTION app.trg_memberships_person_default()
79
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
80
+ BEGIN
81
+ IF NEW.person_id IS NULL THEN
82
+ NEW.person_id := app.person_for_login(NEW.tenant_id, NEW.user_id);
83
+ END IF;
84
+ RETURN NEW;
85
+ END $$;
86
+ REVOKE ALL ON FUNCTION app.trg_memberships_person_default() FROM public, anon, authenticated;
87
+
88
+ -- BEFORE the link check of 072 (alphabetical order decides, and
89
+ -- `memberships_person_default` sorts before `memberships_person_link_check`), so
90
+ -- the value this fills is the value that gets validated.
91
+ DROP TRIGGER IF EXISTS memberships_person_default ON app.memberships;
92
+ CREATE TRIGGER memberships_person_default
93
+ BEFORE INSERT ON app.memberships
94
+ FOR EACH ROW EXECUTE FUNCTION app.trg_memberships_person_default();
95
+
96
+ DO $$
97
+ DECLARE r record; v_n integer := 0;
98
+ BEGIN
99
+ FOR r IN SELECT tenant_id, user_id FROM app.memberships WHERE person_id IS NULL LOOP
100
+ UPDATE app.memberships SET person_id = app.person_for_login(r.tenant_id, r.user_id)
101
+ WHERE tenant_id = r.tenant_id AND user_id = r.user_id AND person_id IS NULL;
102
+ v_n := v_n + 1;
103
+ END LOOP;
104
+ RAISE NOTICE '120: gave a Person to % membership(s) that had none', v_n;
105
+
106
+ IF NOT EXISTS (SELECT 1 FROM app.memberships WHERE person_id IS NULL) THEN
107
+ ALTER TABLE app.memberships ALTER COLUMN person_id SET NOT NULL;
108
+ ELSE
109
+ -- A membership whose login has no auth.users row cannot get a Person, and
110
+ -- that is a broken membership, not a reason to relax the rule. Say which.
111
+ RAISE WARNING '120: % membership(s) still have no Person (no auth.users row for the login?) — person_id stays nullable until they are resolved',
112
+ (SELECT count(*) FROM app.memberships WHERE person_id IS NULL);
113
+ END IF;
114
+ END $$;
115
+
116
+ COMMENT ON COLUMN app.memberships.person_id IS
117
+ 'The Person this login IS, in this tenant (#137). Required: a membership is a login and somebody is using it. Filled automatically on insert when a writer does not supply it, so create_tenant, the invitation flow, the legacy projection and the seed all satisfy it without knowing (#120).';
118
+
119
+
120
+ -- ── naming the real human is an answer, not a conflict ───────────────────
121
+ -- people_link_membership refused when the login already pointed at a different
122
+ -- Person — the rule that stops one login being two humans. Every login now
123
+ -- points at one from the moment it exists, so without this the RPC could never
124
+ -- be called: the placeholder would always be in the way.
125
+ CREATE OR REPLACE FUNCTION public.people_link_membership(p_person uuid, p_user uuid)
126
+ RETURNS uuid
127
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
128
+ AS $$
129
+ DECLARE
130
+ v_tenant uuid := public.people_assert_scope('members.manage');
131
+ p public.people%ROWTYPE;
132
+ m app.memberships%ROWTYPE;
133
+ v_other uuid;
134
+ BEGIN
135
+ SELECT * INTO p FROM public.people WHERE tenant_id = v_tenant AND id = p_person FOR UPDATE;
136
+ IF NOT FOUND THEN RAISE EXCEPTION 'people_link_membership: person not found in this tenant' USING ERRCODE = 'P0002'; END IF;
137
+ IF p.merged_into_id IS NOT NULL THEN
138
+ RAISE EXCEPTION 'people_link_membership: person is a merge tombstone (use %)', p.merged_into_id USING ERRCODE = 'P0001';
139
+ END IF;
140
+ IF p.unit_id IS NOT NULL AND NOT app.has_unit(p.unit_id) THEN
141
+ RAISE EXCEPTION 'people_link_membership: person is in a unit out of your scope' USING ERRCODE = '42501';
142
+ END IF;
143
+ SELECT * INTO m FROM app.memberships WHERE tenant_id = v_tenant AND user_id = p_user FOR UPDATE;
144
+ IF NOT FOUND THEN RAISE EXCEPTION 'people_link_membership: no membership for this user in this tenant' USING ERRCODE = 'P0002'; END IF;
145
+ -- Since #120 every login already HAS a Person: the membership trigger mints a
146
+ -- placeholder when nobody says who the human is. Naming the real one is not a
147
+ -- conflict, it is the answer — so a placeholder is replaced and removed rather
148
+ -- than standing in the way. Anything else (a Person somebody actually chose)
149
+ -- still refuses, which is the rule this guard was written for.
150
+ IF m.person_id IS NOT NULL AND m.person_id <> p_person
151
+ AND EXISTS (SELECT 1 FROM public.people ph
152
+ WHERE ph.id = m.person_id AND ph.tenant_id = v_tenant
153
+ AND ph.metadata ->> 'source' = 'migration:120') THEN
154
+ -- Move the reference straight to the real Person rather than clearing it
155
+ -- first: person_id is NOT NULL now, so there is no moment in between. Then
156
+ -- the placeholder has nothing pointing at it and can go.
157
+ UPDATE app.memberships SET person_id = p_person WHERE tenant_id = v_tenant AND user_id = p_user;
158
+ DELETE FROM public.person_roles WHERE tenant_id = v_tenant AND person_id = m.person_id;
159
+ DELETE FROM public.people WHERE tenant_id = v_tenant AND id = m.person_id;
160
+ m.person_id := p_person;
161
+ END IF;
162
+ IF m.person_id IS NOT NULL AND m.person_id <> p_person THEN
163
+ RAISE EXCEPTION 'people_link_membership: this login is already linked to another person (%)', m.person_id USING ERRCODE = '23505';
164
+ END IF;
165
+ SELECT x.user_id INTO v_other FROM app.memberships x WHERE x.tenant_id = v_tenant AND x.person_id = p_person AND x.user_id <> p_user;
166
+ IF v_other IS NOT NULL THEN
167
+ RAISE EXCEPTION 'people_link_membership: this person is already linked to another login (%)', v_other USING ERRCODE = '23505';
168
+ END IF;
169
+
170
+ IF m.person_id IS DISTINCT FROM p_person THEN
171
+ UPDATE app.memberships SET person_id = p_person WHERE tenant_id = v_tenant AND user_id = p_user;
172
+ END IF;
173
+
174
+ -- a login is a member of the team: at least one staff role
175
+ IF NOT EXISTS (SELECT 1 FROM public.person_roles r
176
+ WHERE r.tenant_id = v_tenant AND r.person_id = p_person
177
+ AND r.role IN ('professional', 'employee')
178
+ AND public.person_role_active(r.valid_from, r.valid_to)) THEN
179
+ INSERT INTO public.person_roles (tenant_id, person_id, role, unit_id, is_primary, metadata)
180
+ VALUES (v_tenant, p_person, 'employee', p.unit_id,
181
+ NOT EXISTS (SELECT 1 FROM public.person_roles r WHERE r.tenant_id = v_tenant AND r.person_id = p_person AND r.is_primary),
182
+ jsonb_build_object('source', 'people_link_membership'))
183
+ ON CONFLICT DO NOTHING;
184
+ -- an expired employee row for the same unit blocks the insert above: reopen it
185
+ UPDATE public.person_roles r
186
+ SET valid_to = NULL, valid_from = least(r.valid_from, current_date)
187
+ WHERE r.tenant_id = v_tenant AND r.person_id = p_person AND r.role = 'employee'
188
+ AND NOT public.person_role_active(r.valid_from, r.valid_to)
189
+ AND NOT EXISTS (SELECT 1 FROM public.person_roles x WHERE x.tenant_id = v_tenant AND x.person_id = p_person
190
+ AND x.role IN ('professional', 'employee') AND public.person_role_active(x.valid_from, x.valid_to));
191
+ END IF;
192
+
193
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
194
+ VALUES (v_tenant, auth.uid(), 'people.link_membership', 'people', p_person::text, jsonb_build_object('user_id', p_user, 'membership_id', m.id));
195
+ RETURN m.id;
196
+ END $$;
@@ -0,0 +1,127 @@
1
+ -- 131_migration_invoice_split.sql
2
+ --
3
+ -- The invoice split completes: an invoice arrives with its installments. #141 / #148.
4
+ --
5
+ -- `migration.upsert_invoice` existed and wrote a header with no installments, so
6
+ -- the split V1 invoices produced a `plg_financial_invoices` row whose balance
7
+ -- views had nothing to compute from — `v_financial_invoice_balances` derives
8
+ -- everything from `plg_financial_invoice_installments`, so every migrated invoice
9
+ -- read as zero owed and zero paid regardless of what V1 said.
10
+ --
11
+ -- IT ALSO DROPPED COLUMNS IN SILENCE, which is the same defect as #198 wearing a
12
+ -- different hat. The writer built a CANDIDATE object with several speculative
13
+ -- names for each value — `party_id` and `counterparty_id`, `total` and
14
+ -- `total_amount` and `amount`, `kind` and `direction` — and kept whichever
15
+ -- `_has_column` recognised. That reads as robustness and behaves as a silent
16
+ -- drop: the real column is `counterparty_person_id`, which was in none of the
17
+ -- guesses, so **every migrated invoice lost its counterparty** and nothing said
18
+ -- so. A destination column is not something to guess at; it is something to
19
+ -- name.
20
+ --
21
+ -- The rewrite names them, and quarantines rather than guessing:
22
+ -- · no total, or a negative one -> value quarantine
23
+ -- · a direction outside receivable|payable -> value quarantine
24
+ -- · a counterparty that is not a Person of this tenant -> fk quarantine
25
+ -- (rather than dropping the reference, which is what it did before)
26
+ --
27
+ -- INSTALLMENTS. V1 offers them under `installments` when the extractor has them;
28
+ -- when it does not, the invoice still needs one, because an invoice with no
29
+ -- installment is a document nobody can pay. That single installment carries the
30
+ -- whole total and the invoice's own due date — the same shape
31
+ -- `financial_create_invoice` builds for a one-off. Installments must sum to the
32
+ -- total to the cent: a mismatch quarantines instead of being absorbed, because
33
+ -- deciding which installment is wrong is not a migration's call.
34
+ --
35
+ -- The number is minted from the tenant's own sequence, so a migrated invoice is
36
+ -- numbered in the same series as one created in V2 — two numbering schemes in
37
+ -- one tenant is the kind of thing nobody notices until an auditor does.
38
+ --
39
+ -- Replay-safe: it still refuses to rewrite an invoice that exists ("financial
40
+ -- documents are never rewritten by the migration"), so a second offer is inert.
41
+
42
+ CREATE OR REPLACE FUNCTION migration.upsert_invoice(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
43
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
44
+ DECLARE
45
+ v_table text := 'public.plg_financial_invoices';
46
+ v_dir text := coalesce(p_args ->> 'direction', p_row ->> 'direction', 'receivable');
47
+ v_total numeric := round(coalesce((p_row ->> 'total')::numeric, -1), 2);
48
+ v_party uuid;
49
+ v_number text;
50
+ v_insts jsonb;
51
+ v_sum numeric := 0;
52
+ v_i jsonb;
53
+ v_n integer := 0;
54
+ v_due date;
55
+ v_exists boolean;
56
+ v_standalone boolean := migration._standalone();
57
+ BEGIN
58
+ IF to_regclass(v_table) IS NULL THEN
59
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'destination table absent: ' || v_table);
60
+ END IF;
61
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
62
+ EXECUTE format('SELECT EXISTS (SELECT 1 FROM %s WHERE id = $1)', v_table) INTO v_exists USING p_id;
63
+ IF v_exists THEN
64
+ RETURN jsonb_build_object('status', 'skipped', 'target_id', p_id, 'reason', 'invoice already exists: financial documents are never rewritten by the migration');
65
+ END IF;
66
+
67
+ IF v_dir NOT IN ('receivable', 'payable') THEN
68
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown invoice direction %L', v_dir));
69
+ END IF;
70
+ IF v_total < 0 THEN
71
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'an invoice needs a total >= 0');
72
+ END IF;
73
+
74
+ -- the counterparty is a Person. A reference that does not resolve is a broken
75
+ -- link, not a column to drop — which is what the old candidate-name loop did.
76
+ BEGIN
77
+ v_party := coalesce((p_row ->> 'counterparty_person_id')::uuid, (p_row ->> 'party_id')::uuid);
78
+ EXCEPTION WHEN OTHERS THEN v_party := NULL; END;
79
+ IF v_party IS NOT NULL AND NOT EXISTS (SELECT 1 FROM public.people p WHERE p.id = v_party AND p.tenant_id = p_tenant) THEN
80
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
81
+ 'reason', format('counterparty %s is not a Person of this tenant', v_party));
82
+ END IF;
83
+
84
+ v_due := coalesce((p_row ->> 'due_date')::date, (p_row ->> 'competence_date')::date, current_date);
85
+
86
+ -- installments: as offered, or one for the whole total. An invoice with no
87
+ -- installment is a document nobody can pay, and the balance views derive
88
+ -- everything from them.
89
+ v_insts := CASE WHEN jsonb_typeof(p_row -> 'installments') = 'array' AND jsonb_array_length(p_row -> 'installments') > 0
90
+ THEN p_row -> 'installments'
91
+ ELSE jsonb_build_array(jsonb_build_object('number', 1, 'due_date', v_due, 'amount', v_total)) END;
92
+ FOR v_i IN SELECT * FROM jsonb_array_elements(v_insts) LOOP
93
+ v_sum := v_sum + round(coalesce((v_i ->> 'amount')::numeric, 0), 2);
94
+ END LOOP;
95
+ IF v_sum <> v_total THEN
96
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
97
+ 'reason', format('installments sum to %s but the invoice total is %s — which one is wrong is not a migration''s call', v_sum, v_total));
98
+ END IF;
99
+
100
+ -- same series as an invoice created in V2: two numbering schemes in one tenant
101
+ -- is the kind of thing nobody notices until an auditor does
102
+ BEGIN
103
+ v_number := public.plg_financial_next_invoice_number(p_tenant, v_dir);
104
+ EXCEPTION WHEN OTHERS THEN v_number := p_row ->> 'number'; END;
105
+
106
+ INSERT INTO public.plg_financial_invoices
107
+ (id, tenant_id, unit_id, direction, counterparty_person_id, order_id, number,
108
+ competence_date, issue_date, total, description, notes, metadata)
109
+ VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_dir, v_party, (p_row ->> 'order_id')::uuid, v_number,
110
+ coalesce((p_row ->> 'competence_date')::date, v_due), coalesce((p_row ->> 'issue_date')::date, v_due),
111
+ v_total, p_row ->> 'description', p_row ->> 'notes',
112
+ coalesce(p_row -> 'metadata', '{}'::jsonb));
113
+
114
+ FOR v_i IN SELECT * FROM jsonb_array_elements(v_insts) LOOP
115
+ v_n := v_n + 1;
116
+ INSERT INTO public.plg_financial_invoice_installments
117
+ (tenant_id, unit_id, invoice_id, number, due_date, amount, metadata)
118
+ VALUES (p_tenant, (p_row ->> 'unit_id')::uuid, p_id,
119
+ coalesce((v_i ->> 'number')::int, v_n),
120
+ coalesce((v_i ->> 'due_date')::date, v_due),
121
+ round(coalesce((v_i ->> 'amount')::numeric, 0), 2),
122
+ CASE WHEN jsonb_typeof(v_i -> 'metadata') = 'object' THEN v_i -> 'metadata' ELSE '{}'::jsonb END);
123
+ END LOOP;
124
+
125
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, v_table, p_id, jsonb_build_object('reason', 'created', 'installments', v_n)); END IF;
126
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created', 'installments', v_n, 'number', v_number);
127
+ END $$;
@@ -0,0 +1,28 @@
1
+ -- 132_audit_prune_scheduled.sql
2
+ --
3
+ -- The retention policy starts being a policy. #144 / #205.
4
+ --
5
+ -- `096` shipped `audit_logs_prune` and `audit_retention_policies` — service-only,
6
+ -- per-tenant `keep_days`, tested — and nothing ever called it. The seed commits a
7
+ -- tenant to 365 days; `pg_cron` runs three jobs and this was not among them,
8
+ -- while the sibling `plg_prune_sync_runs` (spine 033) is scheduled at 04:17. A
9
+ -- retention policy nothing enforces is a promise, and the thing it promises —
10
+ -- that an audit trail does not grow forever — is exactly the kind that is only
11
+ -- noticed when a table is already too big to prune comfortably.
12
+ --
13
+ -- 04:37: after the sync pruner and after the authz reconciliation (03:41), and
14
+ -- off the hour for the same reason those are. Daily, because retention is
15
+ -- measured in days and a finer cadence buys nothing.
16
+ --
17
+ -- Replay-safe: `cron.schedule` upserts by job name; guarded on pg_cron being
18
+ -- present, like `033` and `117`.
19
+
20
+ DO $do$
21
+ BEGIN
22
+ IF to_regprocedure('cron.schedule(text, text, text)') IS NULL THEN
23
+ RAISE WARNING 'pg_cron is not available: audit retention is NOT scheduled on this pool and audit_logs will grow without bound';
24
+ RETURN;
25
+ END IF;
26
+ PERFORM cron.schedule('fayz_audit_prune', '37 4 * * *',
27
+ $cmd$SELECT public.audit_logs_prune()$cmd$);
28
+ END $do$;
@@ -0,0 +1,155 @@
1
+ -- ============================================================================
2
+ -- 133_unit_is_a_session_property.sql — the operating unit joins the session,
3
+ -- and like the tenant claim it only ever narrows (#220).
4
+ --
5
+ -- `has_unit(u)` answers "do I have access to this unit", so a user with fifteen
6
+ -- branches receives all fifteen branches' rows in one query. Nothing in the
7
+ -- database could express "I am operating in branch X" — which is what corporate
8
+ -- operation and compliance both ask for, and what 051's own comment already
9
+ -- anticipated ("Only tenant_id (and, later, the active unit) live in the JWT").
10
+ --
11
+ -- Not a new shape: `access_sessions` (089) has minted (tenant_id, unit_id,
12
+ -- role_key) for the QR/link path since #144. Normal logins get the same thing.
13
+ --
14
+ -- ONE function changes and every policy follows. The scaffold template calls
15
+ -- `app.has_unit(unit_id)` in all four policies of all 45 registered tables, and
16
+ -- the RPC guards of 070/071/072/089/120 plus plugin-financial 015 and
17
+ -- plugin-inventory 008 call it too. Narrowing the function narrows every one of
18
+ -- them, with no policy regenerated and no table touched.
19
+ --
20
+ -- WHAT DOES *NOT* NARROW, on purpose:
21
+ -- app.units — its SELECT policy is `is_member(tenant_id)`, so the unit
22
+ -- switcher keeps listing every unit of the tenant. A session
23
+ -- focused on one branch must still be able to see the others in
24
+ -- order to leave.
25
+ -- unit_id IS NULL rows — the template reads `unit_id IS NULL OR has_unit(…)`,
26
+ -- so tenant-wide rows (the catalog) stay visible while operating
27
+ -- in a branch. That is the point of tenant-wide.
28
+ --
29
+ -- Replay-safe: CREATE OR REPLACE only. No DDL, no data touched. Every existing
30
+ -- session has no `unit_id` claim, so `current_unit_id()` returns NULL and the
31
+ -- behaviour is byte-for-byte the one before this file.
32
+ -- ============================================================================
33
+
34
+ -- ── current_unit_id() ───────────────────────────────────────────────────
35
+ -- Three states, and the third is why this deliberately does NOT mirror
36
+ -- current_tenant_id():
37
+ --
38
+ -- no claim → NULL the session spans every unit the caller holds
39
+ -- claim, parseable → that uuid the session is focused there
40
+ -- claim, unparseable → nil uuid focused on nothing
41
+ --
42
+ -- current_tenant_id() returns NULL for a tenant you are not a member of, and
43
+ -- NULL there means "no tenant" — fail closed. NULL *here* would mean "no focus",
44
+ -- which is fail OPEN: a garbage claim would silently widen the session back to
45
+ -- every unit you hold. So a claim that is present but unusable focuses the
46
+ -- session on the nil uuid, which no unit can ever be.
47
+ --
48
+ -- And it does NOT check membership. It does not need to: has_unit ANDs the focus
49
+ -- with user_unit_access, so a claim naming someone else's unit focuses you on a
50
+ -- unit whose rows you cannot see, and you see nothing. Keeping the membership
51
+ -- test out of here also keeps the helper honest about what it is — the session's
52
+ -- FOCUS, never a grant of access.
53
+ CREATE OR REPLACE FUNCTION app.current_unit_id()
54
+ RETURNS uuid
55
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
56
+ SET search_path = ''
57
+ AS $$
58
+ DECLARE
59
+ v_jwt jsonb := coalesce(auth.jwt(), '{}'::jsonb);
60
+ v_raw text;
61
+ BEGIN
62
+ IF NOT jsonb_exists(v_jwt, 'unit_id') THEN
63
+ RETURN NULL;
64
+ END IF;
65
+ v_raw := v_jwt ->> 'unit_id';
66
+ IF v_raw IS NULL OR v_raw !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
67
+ RETURN '00000000-0000-0000-0000-000000000000'::uuid;
68
+ END IF;
69
+ RETURN v_raw::uuid;
70
+ END $$;
71
+ REVOKE ALL ON FUNCTION app.current_unit_id() FROM public, anon;
72
+ GRANT EXECUTE ON FUNCTION app.current_unit_id() TO authenticated, service_role;
73
+
74
+ COMMENT ON FUNCTION app.current_unit_id() IS
75
+ 'The unit the session is operating in, from the `unit_id` JWT claim (#220). NULL = no focus (every unit the caller holds). A present-but-unparseable claim focuses on the nil uuid — fail closed, because NULL here would widen. Never checks membership: this is the session''s focus, and app.has_unit ANDs it with user_unit_access.';
76
+
77
+ -- ── has_unit(unit) ──────────────────────────────────────────────────────
78
+ -- 051's function with the focus clause ANDed on. THE ORDER OF THE TWO CLAUSES
79
+ -- IS THE SECURITY: `access AND focus`. It must never become `access OR focus`,
80
+ -- because then a claim would grant instead of narrow — 080_people/005 carries
81
+ -- the negative test that proves it (a session claiming a unit the user does not
82
+ -- hold returns empty, not that unit's rows).
83
+ --
84
+ -- The focus check goes first because it is a JWT read and the access check is a
85
+ -- table lookup: a focused session skips the lookup for every unit but one.
86
+ CREATE OR REPLACE FUNCTION app.has_unit(p_unit uuid)
87
+ RETURNS boolean
88
+ LANGUAGE sql STABLE SECURITY DEFINER
89
+ SET search_path = ''
90
+ AS $$
91
+ SELECT p_unit IS NOT NULL
92
+ AND (app.current_unit_id() IS NULL OR p_unit = app.current_unit_id())
93
+ AND CASE WHEN app.unit_scoping_on()
94
+ THEN EXISTS (SELECT 1 FROM app.user_unit_access a
95
+ WHERE a.tenant_id = app.current_tenant_id()
96
+ AND a.user_id = auth.uid()
97
+ AND a.unit_id = p_unit)
98
+ ELSE EXISTS (SELECT 1 FROM app.units u
99
+ WHERE u.id = p_unit AND u.tenant_id = app.current_tenant_id())
100
+ END;
101
+ $$;
102
+
103
+ COMMENT ON FUNCTION app.has_unit(uuid) IS
104
+ 'Access to a unit, narrowed by the session''s operating unit (#220): user_unit_access AND the focus. AND, never OR — the focus can only ever remove units, so a forged claim restricts the caller and cannot widen anything.';
105
+
106
+ -- ── the acting unit is derived, exactly like the tenant ─────────────────
107
+ -- 096's audit_log() re-emitted with one expression changed: `p_unit` becomes
108
+ -- `coalesce(p_unit, app.current_unit_id())`. A focused session stamps the unit
109
+ -- it was acting in without every caller having to remember to pass it, and the
110
+ -- audit trail can answer "acting as whom, in which branch" without taking the
111
+ -- client's word for either. An explicit p_unit still wins — a service job
112
+ -- naming the unit it acts for has no JWT to read.
113
+ CREATE OR REPLACE FUNCTION public.audit_log(
114
+ p_action text,
115
+ p_entity_type text,
116
+ p_entity_id uuid,
117
+ p_reason text DEFAULT NULL,
118
+ p_before jsonb DEFAULT NULL,
119
+ p_after jsonb DEFAULT NULL,
120
+ p_unit uuid DEFAULT NULL,
121
+ p_metadata jsonb DEFAULT NULL,
122
+ p_tenant uuid DEFAULT NULL
123
+ )
124
+ RETURNS uuid
125
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
126
+ AS $$
127
+ DECLARE
128
+ v_uid uuid := auth.uid();
129
+ v_tenant uuid;
130
+ v_unit uuid;
131
+ v_id uuid;
132
+ BEGIN
133
+ IF nullif(btrim(coalesce(p_action, '')), '') IS NULL THEN
134
+ RAISE EXCEPTION 'audit_log: action is required' USING ERRCODE = '22023';
135
+ END IF;
136
+ IF v_uid IS NOT NULL THEN
137
+ v_tenant := app.current_tenant_id();
138
+ IF p_tenant IS NOT NULL AND p_tenant <> v_tenant THEN
139
+ RAISE EXCEPTION 'audit_log: tenant is derived server-side' USING ERRCODE = '42501';
140
+ END IF;
141
+ ELSE
142
+ v_tenant := coalesce(p_tenant, app.current_tenant_id());
143
+ END IF;
144
+ IF v_tenant IS NULL THEN
145
+ RAISE EXCEPTION 'audit_log: no tenant in scope' USING ERRCODE = '42501';
146
+ END IF;
147
+ -- the nil uuid is "focused on nothing", not a unit: it must never be stamped
148
+ v_unit := coalesce(p_unit, nullif(app.current_unit_id(), '00000000-0000-0000-0000-000000000000'::uuid));
149
+ INSERT INTO public.audit_logs (tenant_id, unit_id, user_id, action, entity_type, entity_id, reason, before, after, metadata)
150
+ VALUES (v_tenant, v_unit, v_uid, p_action, p_entity_type, p_entity_id::text, p_reason, p_before, p_after, coalesce(p_metadata, '{}'::jsonb))
151
+ RETURNING id INTO v_id;
152
+ RETURN v_id;
153
+ END $$;
154
+ REVOKE ALL ON FUNCTION public.audit_log(text, text, uuid, text, jsonb, jsonb, uuid, jsonb, uuid) FROM public, anon;
155
+ GRANT EXECUTE ON FUNCTION public.audit_log(text, text, uuid, text, jsonb, jsonb, uuid, jsonb, uuid) TO authenticated, service_role;