@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.
- package/canonical-schema.json +106 -23
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/schema/spine.d.ts +42 -37
- package/dist/schema/spine.d.ts.map +1 -1
- package/migrations/000_baseline.sql +29667 -0
- package/migrations/001_the_tenant_has_apps.sql +314 -0
- package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
- package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
- package/migrations/004_the_projections_get_a_successor.sql +312 -0
- package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
- package/migrations/006_the_helpers_read_the_source.sql +153 -0
- package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
- package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
- package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
- package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
- package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
- package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
- package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
- package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
- package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
- package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
- package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
- package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
- package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
- package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
- package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
- package/package.json +1 -1
- package/migrations/0000_legacy_quarantine.sql +0 -86
- package/migrations/0000b_straggler_sweep.sql +0 -37
- package/migrations/000_core_v1_convert.sql +0 -343
- package/migrations/001_core.sql +0 -372
- package/migrations/002_rls_user_tenant_ids.sql +0 -64
- package/migrations/003_invitations.sql +0 -17
- package/migrations/004_archetypes.sql +0 -343
- package/migrations/005_locations_archetype.sql +0 -18
- package/migrations/006_archetype_rls.sql +0 -74
- package/migrations/007_archetype_grants.sql +0 -4
- package/migrations/008_grants.sql +0 -5
- package/migrations/009_anon_hardening.sql +0 -19
- package/migrations/010_migration_ledger.sql +0 -46
- package/migrations/011_anon_write_revoke.sql +0 -12
- package/migrations/012_rename_migration_ledger.sql +0 -61
- package/migrations/013_sequences_rls.sql +0 -22
- package/migrations/014_plan_entitlements.sql +0 -28
- package/migrations/015_agent_guard.sql +0 -108
- package/migrations/016_agent_guard_actor.sql +0 -102
- package/migrations/017_core_addresses.sql +0 -115
- package/migrations/018_global_search.sql +0 -482
- package/migrations/019_tenant_members_person_id.sql +0 -20
- package/migrations/020_fayz_projects.sql +0 -63
- package/migrations/021_tenant_roles.sql +0 -74
- package/migrations/022_invite_acceptance.sql +0 -91
- package/migrations/023_team_visibility.sql +0 -75
- package/migrations/024_analytics_engine.sql +0 -317
- package/migrations/025_created_by.sql +0 -75
- package/migrations/026_audit_trail.sql +0 -73
- package/migrations/027_domain_events.sql +0 -266
- package/migrations/028_tenant_scoped_token.sql +0 -127
- package/migrations/029_connections.sql +0 -186
- package/migrations/030_effect_idempotency.sql +0 -159
- package/migrations/031_sync_run_message.sql +0 -39
- package/migrations/032_connection_secrets.sql +0 -227
- package/migrations/033_sync_schedule.sql +0 -651
- package/migrations/034_custom_fields.sql +0 -55
- package/migrations/035_field_registry.sql +0 -148
- package/migrations/036_analytics_run_batch.sql +0 -84
- package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
- package/migrations/038_onboarding_responses.sql +0 -103
- package/migrations/039_unit_tree.sql +0 -270
- package/migrations/040_resource_grants.sql +0 -474
- package/migrations/041_scoped_columns.sql +0 -192
- package/migrations/042_unit_scope_policies.sql +0 -145
- package/migrations/043_view_invoker.sql +0 -90
- package/migrations/044_unit_member_facts.sql +0 -47
- package/migrations/045_unit_entry.sql +0 -236
- package/migrations/046_membership_visible_to_members.sql +0 -85
- package/migrations/047_tasks.sql +0 -266
- package/migrations/048_every_login_is_a_person.sql +0 -190
- package/migrations/049_bookable_people.sql +0 -126
- package/migrations/050_authz_core.sql +0 -230
- package/migrations/051_authz_helpers.sql +0 -330
- package/migrations/052_authz_fanout.sql +0 -253
- package/migrations/053_authz_catalog_templates.sql +0 -402
- package/migrations/060_tenancy_seam.sql +0 -132
- package/migrations/061_create_tenant.sql +0 -125
- package/migrations/062_invitations.sql +0 -329
- package/migrations/063_legacy_tenancy_retired.sql +0 -568
- package/migrations/064_agent_guard_authz.sql +0 -118
- package/migrations/064b_supersede_unit_scope.sql +0 -205
- package/migrations/065_scaffold_sweep.sql +0 -139
- package/migrations/066_shadow_report.sql +0 -122
- package/migrations/070_permission_catalog.sql +0 -205
- package/migrations/071_v1_permission_map.sql +0 -266
- package/migrations/072_role_rpcs.sql +0 -126
- package/migrations/073_legacy_rbac_retire.sql +0 -471
- package/migrations/075_company_legal_entities.sql +0 -169
- package/migrations/076_tenant_settings.sql +0 -251
- package/migrations/080_people_roles.sql +0 -269
- package/migrations/081_people_identity.sql +0 -271
- package/migrations/082_people_membership_link.sql +0 -142
- package/migrations/083_people_data_migration.sql +0 -75
- package/migrations/084_people_registrations_tokens.sql +0 -282
- package/migrations/085_catalog_products.sql +0 -333
- package/migrations/086_catalog_pricing.sql +0 -194
- package/migrations/087_catalog_packages.sql +0 -134
- package/migrations/090_orders_venda.sql +0 -166
- package/migrations/091_orders_scaffold.sql +0 -92
- package/migrations/095_config_field_rules.sql +0 -261
- package/migrations/096_audit_append_only.sql +0 -256
- package/migrations/097_sequences_authorized.sql +0 -111
- package/migrations/098_holidays.sql +0 -80
- package/migrations/099_access_grants.sql +0 -293
- package/migrations/100_migration_schema.sql +0 -217
- package/migrations/101_migration_allowlist.sql +0 -202
- package/migrations/102_migration_rpcs.sql +0 -933
- package/migrations/103_migration_reconcile_ponr.sql +0 -132
- package/migrations/105_policies_to_authenticated.sql +0 -71
- package/migrations/106_function_search_path.sql +0 -55
- package/migrations/107_core_table_prefix.sql +0 -361
- package/migrations/108_authz_write_guards.sql +0 -149
- package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
- package/migrations/110_people_kind_vocabulary.sql +0 -120
- package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
- package/migrations/112_money_precision_standard.sql +0 -132
- package/migrations/113_addresses_units_companies.sql +0 -114
- package/migrations/114_migrations_ledger_immutable.sql +0 -65
- package/migrations/115_registration_honours_field_rules.sql +0 -172
- package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
- package/migrations/117_authz_reconciliation_schedule.sql +0 -82
- package/migrations/118_migration_catalog_writers.sql +0 -450
- package/migrations/119_migration_config_writers.sql +0 -193
- package/migrations/120_migration_facet_writer.sql +0 -139
- package/migrations/121_migration_financial_writers.sql +0 -294
- package/migrations/122_migration_inventory_writers.sql +0 -245
- package/migrations/123_migration_order_item_writer.sql +0 -144
- package/migrations/124_migration_ledger_writers.sql +0 -170
- package/migrations/125_migration_authz_people_writers.sql +0 -205
- package/migrations/126_owner_scoping_is_configuration.sql +0 -247
- package/migrations/127_scaffold_audit_hook.sql +0 -98
- package/migrations/128_shadow_tables_enforced.sql +0 -93
- package/migrations/129_order_kind_terminal_states.sql +0 -57
- package/migrations/130_every_login_is_a_person.sql +0 -196
- package/migrations/131_migration_invoice_split.sql +0 -127
- package/migrations/132_audit_prune_scheduled.sql +0 -28
- package/migrations/133_unit_is_a_session_property.sql +0 -155
- package/migrations/134_field_rules_on_read.sql +0 -341
- package/migrations/135_distribution_polarity.sql +0 -158
- package/migrations/136_unit_id_means_locality.sql +0 -358
- package/migrations/137_vendor_named_spine_objects.sql +0 -126
- package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
- package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
- package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
- package/migrations/142_a_person_has_addresses.sql +0 -212
- package/migrations/143_a_professional_has_a_rate.sql +0 -75
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
- package/migrations/146_products_stock_has_one_writer.sql +0 -67
- package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
- package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
- package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
- package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
- package/migrations/152_a_file_is_an_archetype.sql +0 -222
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
- package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
- package/migrations/155_a_tenant_can_leave.sql +0 -271
- package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
- package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
- package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
- package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
- package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
- package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
- package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
- package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
|
@@ -1,57 +0,0 @@
|
|
|
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)');
|
|
@@ -1,196 +0,0 @@
|
|
|
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 $$;
|
|
@@ -1,127 +0,0 @@
|
|
|
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 $$;
|
|
@@ -1,28 +0,0 @@
|
|
|
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$;
|
|
@@ -1,155 +0,0 @@
|
|
|
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;
|