@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,125 @@
1
+ -- ============================================================================
2
+ -- 061_create_tenant.sql — one transactional RPC creates a tenant (#134, PRD 01 R7).
3
+ --
4
+ -- public.create_tenant(name, slug, kind, vertical, template, settings, hq_name)
5
+ -- creates, in one transaction and as the SIGNED-IN user: the tenant row, its
6
+ -- system roles from the vertical template (app.seed_role_template), its
7
+ -- headquarters unit and the owner membership (all_units) of the caller. The
8
+ -- actor is auth.uid() — there is no p_user_id — and a call without a session
9
+ -- raises 42501. Nobody can create a tenant on behalf of someone else through
10
+ -- this surface; a platform process acts with a signed token whose `sub` is
11
+ -- the future owner.
12
+ --
13
+ -- public.create_tenant_with_owner(name, slug, p_user_id, …) — the legacy
14
+ -- SECURITY DEFINER function of 001 that trusted p_user_id — is REPLACED by a
15
+ -- shim with the same signature (the admin adapter's rpc keeps working for the
16
+ -- self case) that refuses any p_user_id other than auth.uid() (42501) and
17
+ -- delegates to create_tenant. p_plan is ignored: a plan is assigned by billing,
18
+ -- not chosen by the client. Removal of the shim: Phase 1 (admin adapter on
19
+ -- create_tenant).
20
+ --
21
+ -- Idempotent: CREATE OR REPLACE; explicit REVOKE/GRANT allowlist re-asserted
22
+ -- (001's replay re-grants EXECUTE ON ALL FUNCTIONS to anon; 009 and this file
23
+ -- take it back — the chain converges).
24
+ -- ============================================================================
25
+
26
+ CREATE OR REPLACE FUNCTION public.create_tenant(
27
+ p_name text,
28
+ p_slug text,
29
+ p_kind text DEFAULT 'business',
30
+ p_vertical_id text DEFAULT NULL,
31
+ p_template text DEFAULT 'generic',
32
+ p_settings jsonb DEFAULT '{}'::jsonb,
33
+ p_hq_name text DEFAULT NULL
34
+ )
35
+ RETURNS jsonb
36
+ LANGUAGE plpgsql SECURITY DEFINER
37
+ SET search_path = ''
38
+ AS $$
39
+ DECLARE
40
+ v_uid uuid := auth.uid();
41
+ v_tenant uuid;
42
+ v_unit uuid;
43
+ v_membership uuid;
44
+ v_owner_role uuid;
45
+ v_template text;
46
+ v_slug text;
47
+ v_name text;
48
+ BEGIN
49
+ IF v_uid IS NULL THEN
50
+ RAISE EXCEPTION 'create_tenant: the actor is derived from the session and there is no session'
51
+ USING ERRCODE = '42501';
52
+ END IF;
53
+ IF p_kind IS NULL OR p_kind NOT IN ('network', 'business') THEN
54
+ RAISE EXCEPTION 'create_tenant: kind must be network or business (got %)', coalesce(p_kind, '<null>')
55
+ USING ERRCODE = '22023';
56
+ END IF;
57
+ v_name := nullif(btrim(coalesce(p_name, '')), '');
58
+ IF v_name IS NULL THEN
59
+ RAISE EXCEPTION 'create_tenant: name is required' USING ERRCODE = '22023';
60
+ END IF;
61
+ v_slug := lower(btrim(coalesce(p_slug, '')));
62
+ IF v_slug !~ '^[a-z0-9]+([._-][a-z0-9]+)*$' OR length(v_slug) > 100 THEN
63
+ RAISE EXCEPTION 'create_tenant: slug must be lowercase letters and digits separated by single . _ - (got %)', coalesce(nullif(v_slug, ''), '<empty>')
64
+ USING ERRCODE = '22023';
65
+ END IF;
66
+ IF EXISTS (SELECT 1 FROM public.tenants t WHERE t.slug = v_slug) THEN
67
+ RAISE EXCEPTION 'create_tenant: slug % is already taken', v_slug USING ERRCODE = '23505';
68
+ END IF;
69
+
70
+ -- Template: explicit, else the vertical's own template when one exists, else generic.
71
+ v_template := coalesce(
72
+ p_template,
73
+ CASE WHEN p_vertical_id IS NOT NULL AND EXISTS (SELECT 1 FROM app.role_templates rt WHERE rt.template = p_vertical_id)
74
+ THEN p_vertical_id ELSE 'generic' END);
75
+ IF NOT EXISTS (SELECT 1 FROM app.role_templates rt WHERE rt.template = v_template) THEN
76
+ RAISE EXCEPTION 'create_tenant: unknown role template %', v_template USING ERRCODE = '22023';
77
+ END IF;
78
+
79
+ INSERT INTO public.tenants (name, slug, plan, vertical_id, kind, settings, role_template)
80
+ VALUES (v_name, v_slug, 'free', p_vertical_id, p_kind, coalesce(p_settings, '{}'::jsonb), v_template)
81
+ RETURNING id INTO v_tenant;
82
+
83
+ v_owner_role := app.seed_role_template(v_tenant, v_template);
84
+
85
+ INSERT INTO app.units (tenant_id, kind, name, slug, active)
86
+ VALUES (v_tenant, 'hq', coalesce(nullif(btrim(coalesce(p_hq_name, '')), ''), v_name), 'hq', true)
87
+ RETURNING id INTO v_unit;
88
+
89
+ -- The owner: role from the template (holds the whole catalog), every unit.
90
+ INSERT INTO app.memberships (tenant_id, user_id, role_id, all_units, active)
91
+ VALUES (v_tenant, v_uid, v_owner_role, true, true)
92
+ RETURNING id INTO v_membership;
93
+
94
+ RETURN jsonb_build_object(
95
+ 'tenant', (SELECT to_jsonb(t) FROM public.tenants t WHERE t.id = v_tenant),
96
+ 'hq_unit', (SELECT to_jsonb(u) FROM app.units u WHERE u.id = v_unit),
97
+ 'membership', (SELECT to_jsonb(m) FROM app.memberships m WHERE m.id = v_membership)
98
+ );
99
+ END $$;
100
+ REVOKE ALL ON FUNCTION public.create_tenant(text, text, text, text, text, jsonb, text) FROM public, anon;
101
+ GRANT EXECUTE ON FUNCTION public.create_tenant(text, text, text, text, text, jsonb, text) TO authenticated, service_role;
102
+
103
+ -- ── the legacy entry point, now a shim ──────────────────────────────────
104
+ CREATE OR REPLACE FUNCTION public.create_tenant_with_owner(
105
+ p_name text, p_slug text, p_user_id uuid,
106
+ p_vertical_id text DEFAULT NULL, p_plan text DEFAULT 'free', p_settings jsonb DEFAULT '{}'
107
+ )
108
+ RETURNS jsonb
109
+ LANGUAGE plpgsql SECURITY DEFINER
110
+ SET search_path = ''
111
+ AS $$
112
+ DECLARE v_result jsonb;
113
+ BEGIN
114
+ IF auth.uid() IS NULL OR p_user_id IS DISTINCT FROM auth.uid() THEN
115
+ RAISE EXCEPTION 'create_tenant_with_owner: the actor is derived from the session; p_user_id must be the caller'
116
+ USING ERRCODE = '42501';
117
+ END IF;
118
+ -- p_plan is deliberately not honoured: billing assigns plans.
119
+ v_result := public.create_tenant(p_name, p_slug, 'business', p_vertical_id, NULL, p_settings, NULL);
120
+ RETURN v_result -> 'tenant';
121
+ END $$;
122
+ COMMENT ON FUNCTION public.create_tenant_with_owner(text, text, uuid, text, text, jsonb) IS
123
+ 'DEPRECATED (#134): shim over public.create_tenant. Refuses p_user_id <> auth.uid() (42501); p_plan ignored. Removal: Phase 1.';
124
+ REVOKE ALL ON FUNCTION public.create_tenant_with_owner(text, text, uuid, text, text, jsonb) FROM public, anon;
125
+ GRANT EXECUTE ON FUNCTION public.create_tenant_with_owner(text, text, uuid, text, text, jsonb) TO authenticated, service_role;
@@ -0,0 +1,329 @@
1
+ -- ============================================================================
2
+ -- 062_invitations.sql — the transactional invite flow onto app.* (#134, PRD 01 R7).
3
+ --
4
+ -- public.invitations stays the table (the admin adapter reads and writes it by
5
+ -- name) and grows the columns the new flow needs:
6
+ -- role_id → app.roles the invited role (the legacy `role` text key is kept in step)
7
+ -- unit_ids[] / all_units the unit scope of the future membership
8
+ -- token_hash sha256 of the bearer token; the token itself is
9
+ -- returned ONCE by invite_member and never stored
10
+ -- accepted_by who consumed it
11
+ -- The legacy `token` column (a random string readable by every member of the
12
+ -- tenant) is retired: no default, nullable, nulled — nothing ever consumed it.
13
+ --
14
+ -- RPCs (SECURITY DEFINER, search_path '', actor = auth.uid(), tenant =
15
+ -- app.current_tenant_id(), permission = app.has_permission):
16
+ -- invite_member(email, role_key, unit_ids, all_units, ttl) → {invitation_id, token, …}
17
+ -- needs members.manage; the invited role's permission set must be a subset of
18
+ -- the inviter's (no privilege escalation by invitation); the token is what the
19
+ -- e-mail carries.
20
+ -- accept_invitation(token) → {tenant_id, membership_id, role_key, units, all_units}
21
+ -- single-use, expiring, bound to the e-mail it was issued to (auth.users.email
22
+ -- of the caller must match, case-insensitively — a forwarded link is not an
23
+ -- invitation); creates the membership + membership_units in one transaction;
24
+ -- the fan-out triggers of 042 fill user_unit_access.
25
+ -- accept_pending_invitations() → integer (re-authored from 022, same contract)
26
+ -- converts every pending, non-expired invitation addressed to the caller's
27
+ -- e-mail into a membership. Called by the admin adapter right after sign-in.
28
+ --
29
+ -- All denials are ERRCODE 42501 with an English message; malformed input is 22023.
30
+ -- Idempotent and replay-safe.
31
+ -- ============================================================================
32
+
33
+ -- ── columns ─────────────────────────────────────────────────────────────
34
+ ALTER TABLE public.invitations
35
+ ADD COLUMN IF NOT EXISTS role_id uuid,
36
+ ADD COLUMN IF NOT EXISTS unit_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
37
+ ADD COLUMN IF NOT EXISTS all_units boolean NOT NULL DEFAULT false,
38
+ ADD COLUMN IF NOT EXISTS token_hash text,
39
+ ADD COLUMN IF NOT EXISTS accepted_by uuid;
40
+ DO $$
41
+ BEGIN
42
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invitations_role_id_fkey' AND conrelid = 'public.invitations'::regclass) THEN
43
+ ALTER TABLE public.invitations
44
+ ADD CONSTRAINT invitations_role_id_fkey FOREIGN KEY (role_id) REFERENCES app.roles(id) ON DELETE SET NULL;
45
+ END IF;
46
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invitations_status_check' AND conrelid = 'public.invitations'::regclass) THEN
47
+ ALTER TABLE public.invitations
48
+ ADD CONSTRAINT invitations_status_check CHECK (status IN ('pending', 'accepted', 'revoked', 'expired'));
49
+ END IF;
50
+ END $$;
51
+ CREATE UNIQUE INDEX IF NOT EXISTS invitations_token_hash_uidx ON public.invitations (token_hash) WHERE token_hash IS NOT NULL;
52
+ CREATE INDEX IF NOT EXISTS invitations_pending_email_idx ON public.invitations (lower(email)) WHERE status = 'pending';
53
+
54
+ -- The legacy bearer column is retired: it was never consumed by anything in the
55
+ -- repo (accept always matched by e-mail) and a raw random secret readable by
56
+ -- every member of the tenant is not a credential we keep.
57
+ ALTER TABLE public.invitations ALTER COLUMN token DROP NOT NULL;
58
+ ALTER TABLE public.invitations ALTER COLUMN token DROP DEFAULT;
59
+ UPDATE public.invitations SET token = NULL WHERE token IS NOT NULL;
60
+
61
+ -- ── normalisation trigger: both column sets stay coherent ───────────────
62
+ -- Legacy writers (the admin adapter) insert (tenant_id, email, role, location_ids);
63
+ -- the RPC inserts (role_id, unit_ids, token_hash). Whichever side is written,
64
+ -- the other is derived so every reader sees the same invitation.
65
+ CREATE OR REPLACE FUNCTION public.trg_invitations_normalize()
66
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
67
+ DECLARE v_key text;
68
+ BEGIN
69
+ NEW.email := lower(btrim(coalesce(NEW.email, '')));
70
+ IF NEW.email = '' THEN
71
+ RAISE EXCEPTION 'invitations: email is required' USING ERRCODE = '22023';
72
+ END IF;
73
+ NEW.token := NULL;
74
+
75
+ -- role: whichever side was written wins; validated on insert and on change
76
+ IF TG_OP = 'INSERT' OR NEW.role_id IS DISTINCT FROM OLD.role_id OR NEW.role IS DISTINCT FROM OLD.role THEN
77
+ IF NEW.role_id IS NOT NULL AND (TG_OP = 'INSERT' OR NEW.role_id IS DISTINCT FROM OLD.role_id OR NEW.role IS NOT DISTINCT FROM OLD.role) THEN
78
+ SELECT r.key INTO v_key FROM app.roles r WHERE r.id = NEW.role_id AND r.tenant_id = NEW.tenant_id;
79
+ IF v_key IS NULL THEN
80
+ RAISE EXCEPTION 'invitations: role % does not belong to this tenant', NEW.role_id USING ERRCODE = '22023';
81
+ END IF;
82
+ NEW.role := v_key;
83
+ ELSIF NEW.role IS NOT NULL THEN
84
+ SELECT r.id INTO NEW.role_id FROM app.roles r WHERE r.tenant_id = NEW.tenant_id AND r.key = NEW.role;
85
+ IF NEW.role_id IS NULL THEN
86
+ RAISE EXCEPTION 'invitations: unknown role key % in this tenant', NEW.role USING ERRCODE = '22023';
87
+ END IF;
88
+ END IF;
89
+ END IF;
90
+
91
+ -- unit scope: units are the successors of locations (same ids); keep both in step
92
+ IF TG_OP = 'INSERT' THEN
93
+ IF cardinality(coalesce(NEW.unit_ids, '{}'::uuid[])) = 0 AND cardinality(coalesce(NEW.location_ids, '{}'::uuid[])) > 0 THEN
94
+ NEW.unit_ids := NEW.location_ids;
95
+ ELSE
96
+ NEW.location_ids := coalesce(NEW.unit_ids, '{}'::uuid[]);
97
+ END IF;
98
+ ELSIF NEW.unit_ids IS DISTINCT FROM OLD.unit_ids THEN
99
+ NEW.location_ids := coalesce(NEW.unit_ids, '{}'::uuid[]);
100
+ ELSIF NEW.location_ids IS DISTINCT FROM OLD.location_ids THEN
101
+ NEW.unit_ids := coalesce(NEW.location_ids, '{}'::uuid[]);
102
+ END IF;
103
+ IF (TG_OP = 'INSERT' OR NEW.unit_ids IS DISTINCT FROM OLD.unit_ids)
104
+ AND EXISTS (SELECT 1 FROM unnest(NEW.unit_ids) x
105
+ WHERE NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = x AND u.tenant_id = NEW.tenant_id)) THEN
106
+ RAISE EXCEPTION 'invitations: every unit must belong to the tenant' USING ERRCODE = '22023';
107
+ END IF;
108
+ RETURN NEW;
109
+ END $$;
110
+ REVOKE ALL ON FUNCTION public.trg_invitations_normalize() FROM public, anon, authenticated;
111
+ DROP TRIGGER IF EXISTS invitations_normalize ON public.invitations;
112
+ CREATE TRIGGER invitations_normalize BEFORE INSERT OR UPDATE ON public.invitations
113
+ FOR EACH ROW EXECUTE FUNCTION public.trg_invitations_normalize();
114
+
115
+ -- ── policies (re-authored: platform permission keys, no admin literal) ──
116
+ DROP POLICY IF EXISTS "invites_select" ON public.invitations;
117
+ CREATE POLICY "invites_select" ON public.invitations FOR SELECT TO authenticated
118
+ USING (
119
+ (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.read')))
120
+ OR lower(email) = lower(coalesce(auth.jwt() ->> 'email', ''))
121
+ );
122
+ DROP POLICY IF EXISTS "invites_insert" ON public.invitations;
123
+ CREATE POLICY "invites_insert" ON public.invitations FOR INSERT TO authenticated
124
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')));
125
+ DROP POLICY IF EXISTS "invites_update" ON public.invitations;
126
+ CREATE POLICY "invites_update" ON public.invitations FOR UPDATE TO authenticated
127
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')))
128
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')));
129
+ DROP POLICY IF EXISTS "invites_delete" ON public.invitations;
130
+ CREATE POLICY "invites_delete" ON public.invitations FOR DELETE TO authenticated
131
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')));
132
+
133
+ -- ── the shared acceptance step ──────────────────────────────────────────
134
+ -- Applies one invitation to one user: membership (an active member keeps her
135
+ -- role — an invitation never demotes or promotes an existing member, 022's
136
+ -- rule; an inactive membership is reactivated with the invited role), the
137
+ -- invited units, the audit stamp. Internal: not granted to any API role.
138
+ CREATE OR REPLACE FUNCTION app.apply_invitation(p_invitation uuid, p_user uuid)
139
+ RETURNS jsonb
140
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
141
+ DECLARE
142
+ inv record;
143
+ v_role uuid;
144
+ v_membership uuid;
145
+ BEGIN
146
+ SELECT * INTO inv FROM public.invitations i WHERE i.id = p_invitation FOR UPDATE;
147
+ IF NOT FOUND THEN
148
+ RAISE EXCEPTION 'invitation not found' USING ERRCODE = '42501';
149
+ END IF;
150
+ v_role := inv.role_id;
151
+ IF v_role IS NULL AND inv.role IS NOT NULL THEN
152
+ SELECT r.id INTO v_role FROM app.roles r WHERE r.tenant_id = inv.tenant_id AND r.key = inv.role;
153
+ END IF;
154
+ IF v_role IS NULL THEN
155
+ RAISE EXCEPTION 'invitation role no longer exists in the tenant' USING ERRCODE = '22023';
156
+ END IF;
157
+
158
+ INSERT INTO app.memberships (tenant_id, user_id, role_id, all_units, active, invited_by)
159
+ VALUES (inv.tenant_id, p_user, v_role, coalesce(inv.all_units, false), true, inv.invited_by)
160
+ ON CONFLICT (tenant_id, user_id) DO UPDATE
161
+ SET active = true,
162
+ role_id = CASE WHEN app.memberships.active THEN app.memberships.role_id ELSE EXCLUDED.role_id END,
163
+ all_units = app.memberships.all_units OR EXCLUDED.all_units,
164
+ invited_by = coalesce(app.memberships.invited_by, EXCLUDED.invited_by)
165
+ RETURNING id INTO v_membership;
166
+
167
+ INSERT INTO app.membership_units (tenant_id, user_id, unit_id)
168
+ SELECT inv.tenant_id, p_user, u.id
169
+ FROM app.units u
170
+ WHERE u.tenant_id = inv.tenant_id AND u.id = ANY (coalesce(inv.unit_ids, '{}'::uuid[]))
171
+ ON CONFLICT DO NOTHING;
172
+
173
+ UPDATE public.invitations
174
+ SET status = 'accepted', accepted_at = now(), accepted_by = p_user
175
+ WHERE id = inv.id;
176
+
177
+ RETURN jsonb_build_object(
178
+ 'invitation_id', inv.id,
179
+ 'tenant_id', inv.tenant_id,
180
+ 'membership_id', v_membership,
181
+ 'role_key', (SELECT r.key FROM app.roles r WHERE r.id = (SELECT m.role_id FROM app.memberships m WHERE m.id = v_membership)),
182
+ 'all_units', (SELECT m.all_units FROM app.memberships m WHERE m.id = v_membership),
183
+ 'units', coalesce((SELECT jsonb_agg(mu.unit_id ORDER BY mu.unit_id) FROM app.membership_units mu
184
+ WHERE mu.tenant_id = inv.tenant_id AND mu.user_id = p_user), '[]'::jsonb)
185
+ );
186
+ END $$;
187
+ REVOKE ALL ON FUNCTION app.apply_invitation(uuid, uuid) FROM public, anon, authenticated;
188
+
189
+ -- ── invite_member ───────────────────────────────────────────────────────
190
+ CREATE OR REPLACE FUNCTION public.invite_member(
191
+ p_email text,
192
+ p_role_key text,
193
+ p_unit_ids uuid[] DEFAULT '{}'::uuid[],
194
+ p_all_units boolean DEFAULT false,
195
+ p_ttl interval DEFAULT '7 days'
196
+ )
197
+ RETURNS jsonb
198
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
199
+ DECLARE
200
+ v_uid uuid := auth.uid();
201
+ v_tenant uuid := app.current_tenant_id();
202
+ v_role uuid;
203
+ v_email text;
204
+ v_token text;
205
+ v_ttl interval := coalesce(p_ttl, interval '7 days');
206
+ v_id uuid;
207
+ v_expires timestamptz;
208
+ BEGIN
209
+ IF v_uid IS NULL OR v_tenant IS NULL THEN
210
+ RAISE EXCEPTION 'invite_member: no session or no active tenant' USING ERRCODE = '42501';
211
+ END IF;
212
+ IF NOT app.has_permission('members.manage') THEN
213
+ RAISE EXCEPTION 'invite_member: members.manage is required' USING ERRCODE = '42501';
214
+ END IF;
215
+ v_email := lower(btrim(coalesce(p_email, '')));
216
+ IF v_email !~ '^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$' THEN
217
+ RAISE EXCEPTION 'invite_member: invalid e-mail' USING ERRCODE = '22023';
218
+ END IF;
219
+ SELECT r.id INTO v_role FROM app.roles r WHERE r.tenant_id = v_tenant AND r.key = p_role_key;
220
+ IF v_role IS NULL THEN
221
+ RAISE EXCEPTION 'invite_member: unknown role %', coalesce(p_role_key, '<null>') USING ERRCODE = '22023';
222
+ END IF;
223
+ -- No escalation by invitation: every permission of the invited role must be
224
+ -- held by the inviter (base role, tenant-wide).
225
+ IF EXISTS (SELECT 1 FROM app.role_permissions rp
226
+ WHERE rp.tenant_id = v_tenant AND rp.role_id = v_role
227
+ AND NOT app.has_permission(rp.permission)) THEN
228
+ RAISE EXCEPTION 'invite_member: cannot invite to a role holding permissions you do not have' USING ERRCODE = '42501';
229
+ END IF;
230
+ IF EXISTS (SELECT 1 FROM unnest(coalesce(p_unit_ids, '{}'::uuid[])) x
231
+ WHERE NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = x AND u.tenant_id = v_tenant)) THEN
232
+ RAISE EXCEPTION 'invite_member: every unit must belong to the current tenant' USING ERRCODE = '22023';
233
+ END IF;
234
+ IF v_ttl <= interval '0' OR v_ttl > interval '30 days' THEN
235
+ RAISE EXCEPTION 'invite_member: ttl must be between 1 second and 30 days' USING ERRCODE = '22023';
236
+ END IF;
237
+
238
+ -- 256 bits from the CSPRNG behind gen_random_uuid(); only its sha256 is stored.
239
+ v_token := replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', '');
240
+ INSERT INTO public.invitations
241
+ (tenant_id, email, role, role_id, unit_ids, location_ids, all_units, token_hash, status, invited_by, expires_at)
242
+ VALUES (v_tenant, v_email, p_role_key, v_role, coalesce(p_unit_ids, '{}'::uuid[]), coalesce(p_unit_ids, '{}'::uuid[]),
243
+ coalesce(p_all_units, false), encode(sha256(convert_to(v_token, 'UTF8')), 'hex'), 'pending', v_uid, now() + v_ttl)
244
+ RETURNING id, expires_at INTO v_id, v_expires;
245
+
246
+ RETURN jsonb_build_object('invitation_id', v_id, 'token', v_token, 'email', v_email,
247
+ 'role_key', p_role_key, 'expires_at', v_expires);
248
+ END $$;
249
+ REVOKE ALL ON FUNCTION public.invite_member(text, text, uuid[], boolean, interval) FROM public, anon;
250
+ GRANT EXECUTE ON FUNCTION public.invite_member(text, text, uuid[], boolean, interval) TO authenticated, service_role;
251
+
252
+ -- ── accept_invitation ───────────────────────────────────────────────────
253
+ CREATE OR REPLACE FUNCTION public.accept_invitation(p_token text)
254
+ RETURNS jsonb
255
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
256
+ DECLARE
257
+ v_uid uuid := auth.uid();
258
+ v_email text;
259
+ inv record;
260
+ BEGIN
261
+ IF v_uid IS NULL THEN
262
+ RAISE EXCEPTION 'accept_invitation: no session' USING ERRCODE = '42501';
263
+ END IF;
264
+ IF nullif(btrim(coalesce(p_token, '')), '') IS NULL THEN
265
+ RAISE EXCEPTION 'accept_invitation: invitation not found or not usable' USING ERRCODE = '42501';
266
+ END IF;
267
+ SELECT lower(u.email) INTO v_email FROM auth.users u WHERE u.id = v_uid;
268
+
269
+ SELECT i.id, i.email, i.status, i.accepted_at, i.expires_at INTO inv
270
+ FROM public.invitations i
271
+ WHERE i.token_hash = encode(sha256(convert_to(btrim(p_token), 'UTF8')), 'hex')
272
+ FOR UPDATE;
273
+ IF NOT FOUND THEN
274
+ RAISE EXCEPTION 'accept_invitation: invitation not found or not usable' USING ERRCODE = '42501';
275
+ END IF;
276
+ IF inv.status <> 'pending' OR inv.accepted_at IS NOT NULL THEN
277
+ RAISE EXCEPTION 'accept_invitation: invitation already used or revoked' USING ERRCODE = '42501';
278
+ END IF;
279
+ IF inv.expires_at <= now() THEN
280
+ RAISE EXCEPTION 'accept_invitation: invitation expired' USING ERRCODE = '42501';
281
+ END IF;
282
+ IF v_email IS NULL OR v_email <> lower(inv.email) THEN
283
+ RAISE EXCEPTION 'accept_invitation: invitation was issued to a different e-mail' USING ERRCODE = '42501';
284
+ END IF;
285
+
286
+ RETURN app.apply_invitation(inv.id, v_uid);
287
+ END $$;
288
+ REVOKE ALL ON FUNCTION public.accept_invitation(text) FROM public, anon;
289
+ GRANT EXECUTE ON FUNCTION public.accept_invitation(text) TO authenticated, service_role;
290
+
291
+ -- ── accept_pending_invitations (022 re-authored, same contract) ─────────
292
+ CREATE OR REPLACE FUNCTION public.accept_pending_invitations()
293
+ RETURNS integer
294
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
295
+ DECLARE
296
+ v_uid uuid := auth.uid();
297
+ v_email text;
298
+ v_count integer := 0;
299
+ r record;
300
+ BEGIN
301
+ IF v_uid IS NULL THEN
302
+ RETURN 0;
303
+ END IF;
304
+ -- From auth.users, not the JWT: survives a token without an e-mail claim.
305
+ SELECT lower(u.email) INTO v_email FROM auth.users u WHERE u.id = v_uid;
306
+ IF v_email IS NULL OR v_email = '' THEN
307
+ RETURN 0;
308
+ END IF;
309
+ FOR r IN
310
+ SELECT i.id
311
+ FROM public.invitations i
312
+ WHERE lower(i.email) = v_email
313
+ AND i.status = 'pending'
314
+ AND i.accepted_at IS NULL
315
+ AND i.expires_at > now()
316
+ ORDER BY i.created_at, i.id
317
+ LOOP
318
+ BEGIN
319
+ PERFORM app.apply_invitation(r.id, v_uid);
320
+ v_count := v_count + 1;
321
+ EXCEPTION WHEN SQLSTATE '22023' THEN
322
+ -- a role that no longer exists: leave the invitation pending for an admin to fix
323
+ RAISE NOTICE 'accept_pending_invitations: skipped invitation % (%)', r.id, SQLERRM;
324
+ END;
325
+ END LOOP;
326
+ RETURN v_count;
327
+ END $$;
328
+ REVOKE ALL ON FUNCTION public.accept_pending_invitations() FROM public, anon;
329
+ GRANT EXECUTE ON FUNCTION public.accept_pending_invitations() TO authenticated, service_role;