@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
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 001_the_tenant_has_apps.sql — an account can run more than one application.
|
|
3
|
+
--
|
|
4
|
+
-- Until now the only thing a tenant could HAVE was a (plugin, facet) pair in
|
|
5
|
+
-- app.tenant_plugins. Which PRODUCT a tenant was living in — the restaurant
|
|
6
|
+
-- admin, the shop admin — was never written down: it was inferred from
|
|
7
|
+
-- tenants.vertical_id, a free-text column with no FK and no check, stamped once
|
|
8
|
+
-- by an AFTER INSERT trigger and never revisited. That was survivable while each
|
|
9
|
+
-- product had its own Supabase pool and a merchant was literally two different
|
|
10
|
+
-- auth.users. On one cluster it is not: the same person, the same account and
|
|
11
|
+
-- the same public.products now sit behind two front doors, and nothing in the
|
|
12
|
+
-- database says so.
|
|
13
|
+
--
|
|
14
|
+
-- So the relation gets written down.
|
|
15
|
+
--
|
|
16
|
+
-- app.apps the registry — which applications exist at all
|
|
17
|
+
-- app.tenant_apps the relation — which of them THIS account runs
|
|
18
|
+
--
|
|
19
|
+
-- The switcher in the sidebar reads the second through app_config(). Nothing
|
|
20
|
+
-- here gates data: an app is a lens over the tenant's rows, not a boundary
|
|
21
|
+
-- around them. Two apps on one tenant read the same public.products by design —
|
|
22
|
+
-- that is the whole point, and it is why this is a relation and not a second
|
|
23
|
+
-- tenant.
|
|
24
|
+
--
|
|
25
|
+
-- ── Why app_id has a real FK, when plugin_id does not ──────────────────────
|
|
26
|
+
--
|
|
27
|
+
-- tenant_plugins.plugin_id is text with only a regex, and vertical_id has not
|
|
28
|
+
-- even that. The bill arrived: four vocabularies of "vertical" now disagree
|
|
29
|
+
-- (the column, app.vertical_defaults, the TS union, the onboarding packs), and
|
|
30
|
+
-- four of eight live tenants matched no defaults at all and were seeded with
|
|
31
|
+
-- nothing. ADR 0022 — a registry with a real FOREIGN KEY from day one — is the
|
|
32
|
+
-- rule this file follows so the same drift cannot start again.
|
|
33
|
+
-- ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
-- ── §1 the registry ───────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS app.apps (
|
|
38
|
+
id text PRIMARY KEY CHECK (id ~ '^[a-z][a-z0-9-]*$'),
|
|
39
|
+
name text NOT NULL,
|
|
40
|
+
-- A lucide icon NAME, resolved client-side through getLucideIcon — a curated
|
|
41
|
+
-- registry, not all of lucide. A name outside it falls back to initials
|
|
42
|
+
-- rather than dragging ~500KB of icons into every app that shows a launcher.
|
|
43
|
+
icon text,
|
|
44
|
+
accent_color text CHECK (accent_color IS NULL OR accent_color ~ '^#[0-9a-fA-F]{6}$'),
|
|
45
|
+
-- Canonical origin. Also the allow-list the session handoff checks against:
|
|
46
|
+
-- the switcher will not hand a session to an origin that is not named here.
|
|
47
|
+
url text,
|
|
48
|
+
-- The plugins this app cannot work without. Activating the app activates
|
|
49
|
+
-- these; deactivating it does NOT deactivate them, because another app on the
|
|
50
|
+
-- same tenant may be standing on them.
|
|
51
|
+
requires text[] NOT NULL DEFAULT '{}',
|
|
52
|
+
active boolean NOT NULL DEFAULT true,
|
|
53
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
54
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
COMMENT ON TABLE app.apps IS
|
|
58
|
+
'Which applications exist on this cluster (001). The registry the app switcher reads. An app is a LENS over a tenant''s rows — it never scopes them; two apps on one tenant share public.products by design. `requires` names the plugins the app cannot work without; `url` doubles as the allow-list for the session handoff.';
|
|
59
|
+
COMMENT ON COLUMN app.apps.icon IS 'Lucide icon name (e.g. ChefHat). Resolved through getLucideIcon, a curated registry — an unknown name falls back to initials.';
|
|
60
|
+
COMMENT ON COLUMN app.apps.requires IS 'plugin_ids activated when the app is added. Removing the app does NOT remove them: another app may share them.';
|
|
61
|
+
|
|
62
|
+
-- ── §2 the relation ───────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS app.tenant_apps (
|
|
65
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
66
|
+
app_id text NOT NULL REFERENCES app.apps(id) ON DELETE RESTRICT,
|
|
67
|
+
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
|
|
68
|
+
source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'onboarding', 'plan', 'default')),
|
|
69
|
+
added_by uuid,
|
|
70
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
71
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
72
|
+
PRIMARY KEY (tenant_id, app_id)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE INDEX IF NOT EXISTS tenant_apps_app_idx ON app.tenant_apps (app_id);
|
|
76
|
+
|
|
77
|
+
COMMENT ON TABLE app.tenant_apps IS
|
|
78
|
+
'Which applications THIS account runs (001). The app switcher lists the active rows. Disabling a row hides the app; it never touches the tenant''s data, which belongs to the tenant and not to the app that happened to write it.';
|
|
79
|
+
|
|
80
|
+
-- Both tables carry updated_at, so reuse the spine's existing stamp.
|
|
81
|
+
DROP TRIGGER IF EXISTS apps_updated_at ON app.apps;
|
|
82
|
+
CREATE TRIGGER apps_updated_at BEFORE UPDATE ON app.apps
|
|
83
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
84
|
+
|
|
85
|
+
DROP TRIGGER IF EXISTS tenant_apps_updated_at ON app.tenant_apps;
|
|
86
|
+
CREATE TRIGGER tenant_apps_updated_at BEFORE UPDATE ON app.tenant_apps
|
|
87
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
88
|
+
|
|
89
|
+
-- ── §3 RLS ────────────────────────────────────────────────────────────────
|
|
90
|
+
--
|
|
91
|
+
-- Same posture as app.tenant_plugins and app.vertical_defaults: the registry is
|
|
92
|
+
-- world-readable to a signed-in user (it is a catalogue of products, not a
|
|
93
|
+
-- secret), the relation is readable only for the tenants you belong to, and
|
|
94
|
+
-- NEITHER has a write policy — writes go through the SECURITY DEFINER function
|
|
95
|
+
-- in §4, which is where the permission and the plan ceiling are checked.
|
|
96
|
+
|
|
97
|
+
ALTER TABLE app.apps ENABLE ROW LEVEL SECURITY;
|
|
98
|
+
ALTER TABLE app.tenant_apps ENABLE ROW LEVEL SECURITY;
|
|
99
|
+
|
|
100
|
+
DROP POLICY IF EXISTS apps_read ON app.apps;
|
|
101
|
+
CREATE POLICY apps_read ON app.apps FOR SELECT TO authenticated USING (true);
|
|
102
|
+
|
|
103
|
+
DROP POLICY IF EXISTS tenant_apps_read ON app.tenant_apps;
|
|
104
|
+
CREATE POLICY tenant_apps_read ON app.tenant_apps FOR SELECT TO authenticated
|
|
105
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
106
|
+
|
|
107
|
+
GRANT SELECT ON TABLE app.apps TO authenticated;
|
|
108
|
+
GRANT ALL ON TABLE app.apps TO service_role;
|
|
109
|
+
GRANT SELECT ON TABLE app.tenant_apps TO authenticated;
|
|
110
|
+
GRANT ALL ON TABLE app.tenant_apps TO service_role;
|
|
111
|
+
|
|
112
|
+
-- ── §4 the one writer ─────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
CREATE OR REPLACE FUNCTION public.tenant_app_set(
|
|
115
|
+
p_app text,
|
|
116
|
+
p_status text DEFAULT 'active',
|
|
117
|
+
p_source text DEFAULT 'manual'
|
|
118
|
+
) RETURNS jsonb
|
|
119
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
120
|
+
SET search_path TO ''
|
|
121
|
+
AS $$
|
|
122
|
+
DECLARE
|
|
123
|
+
v_tenant uuid := app.current_tenant_id();
|
|
124
|
+
v_app app.apps%ROWTYPE;
|
|
125
|
+
v_plugin text;
|
|
126
|
+
v_refused text[] := '{}';
|
|
127
|
+
BEGIN
|
|
128
|
+
IF v_tenant IS NULL THEN
|
|
129
|
+
RAISE EXCEPTION 'no tenant in scope' USING ERRCODE = '42501';
|
|
130
|
+
END IF;
|
|
131
|
+
IF NOT app.has_permission('settings.manage') THEN
|
|
132
|
+
RAISE EXCEPTION 'settings.manage is required to change which applications this account runs'
|
|
133
|
+
USING ERRCODE = '42501';
|
|
134
|
+
END IF;
|
|
135
|
+
IF p_status NOT IN ('active', 'disabled') THEN
|
|
136
|
+
RAISE EXCEPTION 'status must be active or disabled' USING ERRCODE = '22023';
|
|
137
|
+
END IF;
|
|
138
|
+
|
|
139
|
+
SELECT * INTO v_app FROM app.apps WHERE id = p_app AND active;
|
|
140
|
+
IF NOT FOUND THEN
|
|
141
|
+
RAISE EXCEPTION 'no such application: %', p_app USING ERRCODE = '22023';
|
|
142
|
+
END IF;
|
|
143
|
+
|
|
144
|
+
INSERT INTO app.tenant_apps (tenant_id, app_id, status, source, added_by)
|
|
145
|
+
VALUES (v_tenant, p_app, p_status, p_source, auth.uid())
|
|
146
|
+
ON CONFLICT (tenant_id, app_id) DO UPDATE
|
|
147
|
+
SET status = EXCLUDED.status,
|
|
148
|
+
source = EXCLUDED.source,
|
|
149
|
+
added_by = EXCLUDED.added_by;
|
|
150
|
+
|
|
151
|
+
-- Adding an app switches on what it cannot work without. Each plugin still
|
|
152
|
+
-- passes the plan ceiling on its own: an app the plan allows may still name a
|
|
153
|
+
-- plugin it does not, and the honest answer is to add the app with that
|
|
154
|
+
-- plugin missing and SAY so, rather than to refuse the app or to smuggle the
|
|
155
|
+
-- plugin past the ceiling.
|
|
156
|
+
--
|
|
157
|
+
-- Removing an app switches OFF nothing. Another app on this tenant may be
|
|
158
|
+
-- standing on the same plugin, and the tenant's rows outlive both.
|
|
159
|
+
IF p_status = 'active' THEN
|
|
160
|
+
FOREACH v_plugin IN ARRAY v_app.requires LOOP
|
|
161
|
+
IF public.plan_permits(v_plugin, '', v_tenant) THEN
|
|
162
|
+
INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source, activated_by)
|
|
163
|
+
VALUES (v_tenant, v_plugin, '', 'active', 'default', auth.uid())
|
|
164
|
+
ON CONFLICT (tenant_id, plugin_id, facet) DO UPDATE
|
|
165
|
+
SET status = 'active';
|
|
166
|
+
ELSE
|
|
167
|
+
v_refused := v_refused || v_plugin;
|
|
168
|
+
END IF;
|
|
169
|
+
END LOOP;
|
|
170
|
+
END IF;
|
|
171
|
+
|
|
172
|
+
RETURN jsonb_build_object(
|
|
173
|
+
'app', p_app,
|
|
174
|
+
'status', p_status,
|
|
175
|
+
'plugins_activated', to_jsonb(v_app.requires),
|
|
176
|
+
'not_permitted_by_plan', to_jsonb(v_refused)
|
|
177
|
+
);
|
|
178
|
+
END $$;
|
|
179
|
+
|
|
180
|
+
REVOKE ALL ON FUNCTION public.tenant_app_set(text, text, text) FROM PUBLIC;
|
|
181
|
+
GRANT EXECUTE ON FUNCTION public.tenant_app_set(text, text, text) TO authenticated;
|
|
182
|
+
|
|
183
|
+
COMMENT ON FUNCTION public.tenant_app_set(text, text, text) IS
|
|
184
|
+
'Add or remove an application for the tenant in scope (001). Mirrors tenant_plugin_set: settings.manage required, the plan ceiling checked per required plugin, turning something OFF always allowed. Reports the plugins it could not switch on rather than failing the whole call.';
|
|
185
|
+
|
|
186
|
+
-- ── §5 app_config() learns to answer "which apps" ─────────────────────────
|
|
187
|
+
--
|
|
188
|
+
-- Re-emitted whole rather than patched: this is the one function the shell asks
|
|
189
|
+
-- on boot, and a reader should see the entire answer in one place. The only
|
|
190
|
+
-- change is the 'apps' key.
|
|
191
|
+
|
|
192
|
+
CREATE OR REPLACE FUNCTION public.app_config() RETURNS jsonb
|
|
193
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
194
|
+
SET search_path TO ''
|
|
195
|
+
AS $$
|
|
196
|
+
DECLARE
|
|
197
|
+
v_tenant uuid := app.current_tenant_id();
|
|
198
|
+
BEGIN
|
|
199
|
+
IF v_tenant IS NULL THEN
|
|
200
|
+
-- Fail closed and legibly: a caller with no resolvable tenant gets an empty
|
|
201
|
+
-- configuration, not somebody else's.
|
|
202
|
+
RETURN jsonb_build_object('tenant_id', NULL, 'plugins', '[]'::jsonb, 'nav', '[]'::jsonb, 'apps', '[]'::jsonb);
|
|
203
|
+
END IF;
|
|
204
|
+
|
|
205
|
+
RETURN jsonb_build_object(
|
|
206
|
+
'tenant_id', v_tenant,
|
|
207
|
+
'branding', public.tenant_branding(),
|
|
208
|
+
'settings', public.tenant_settings(),
|
|
209
|
+
'plugins', coalesce((
|
|
210
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
211
|
+
'plugin', t.plugin_id,
|
|
212
|
+
'facet', nullif(t.facet, ''),
|
|
213
|
+
'status', t.status,
|
|
214
|
+
'source', t.source,
|
|
215
|
+
'config', t.config,
|
|
216
|
+
'expires_at', t.expires_at
|
|
217
|
+
) ORDER BY t.plugin_id, t.facet)
|
|
218
|
+
FROM app.tenant_plugins t
|
|
219
|
+
WHERE t.tenant_id = v_tenant
|
|
220
|
+
AND t.status = 'active'
|
|
221
|
+
AND (t.expires_at IS NULL OR t.expires_at > now())
|
|
222
|
+
), '[]'::jsonb),
|
|
223
|
+
'apps', coalesce((
|
|
224
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
225
|
+
'id', a.id,
|
|
226
|
+
'name', a.name,
|
|
227
|
+
'icon', a.icon,
|
|
228
|
+
'accent_color', a.accent_color,
|
|
229
|
+
'url', a.url
|
|
230
|
+
) ORDER BY a.name)
|
|
231
|
+
FROM app.tenant_apps ta
|
|
232
|
+
JOIN app.apps a ON a.id = ta.app_id
|
|
233
|
+
WHERE ta.tenant_id = v_tenant
|
|
234
|
+
AND ta.status = 'active'
|
|
235
|
+
AND a.active
|
|
236
|
+
), '[]'::jsonb),
|
|
237
|
+
'nav', coalesce((
|
|
238
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
239
|
+
'key', n.entry_key, 'hidden', n.hidden, 'label', n.label,
|
|
240
|
+
'section', n.section, 'position', n.position
|
|
241
|
+
) ORDER BY coalesce(n.position, 999), n.entry_key)
|
|
242
|
+
FROM app.tenant_nav n
|
|
243
|
+
WHERE n.tenant_id = v_tenant
|
|
244
|
+
), '[]'::jsonb),
|
|
245
|
+
'permissions', coalesce((
|
|
246
|
+
SELECT jsonb_agg(DISTINCT rp.permission)
|
|
247
|
+
FROM app.memberships m
|
|
248
|
+
JOIN app.role_permissions rp ON rp.role_id = m.role_id AND rp.tenant_id = m.tenant_id
|
|
249
|
+
WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.active
|
|
250
|
+
), '[]'::jsonb),
|
|
251
|
+
'generated_at', now()
|
|
252
|
+
);
|
|
253
|
+
END $$;
|
|
254
|
+
|
|
255
|
+
-- ── §6 the registry's first rows ──────────────────────────────────────────
|
|
256
|
+
--
|
|
257
|
+
-- `requires` mirrors the signals in packages/admin/src/onboarding/infer-vertical.ts,
|
|
258
|
+
-- which already answers "which vertical is this app" from the plugins the bundle
|
|
259
|
+
-- carries. Same evidence, read in the other direction — a second, disagreeing
|
|
260
|
+
-- list is exactly how the four vertical vocabularies happened.
|
|
261
|
+
--
|
|
262
|
+
-- `url` is the dev origin. A cluster that publishes these apps elsewhere updates
|
|
263
|
+
-- the row; nothing in code hardcodes a port.
|
|
264
|
+
|
|
265
|
+
-- The names are the products' OUTWARD names (ADR: "Control" is the family, not
|
|
266
|
+
-- the app). "Control" is spoken once — in the dropdown's own header — and each
|
|
267
|
+
-- app's rail says only its vertical: Chef, Store, Studio. The switcher's list is
|
|
268
|
+
-- outward, so it carries the full name.
|
|
269
|
+
INSERT INTO app.apps (id, name, icon, accent_color, url, requires) VALUES
|
|
270
|
+
('resto', 'ChefControl', 'ChefHat', '#DC2626', 'http://localhost:5302', ARRAY['menu', 'financial', 'inventory']),
|
|
271
|
+
('shop', 'StoreControl', 'ShoppingBag', '#0F9D58', 'http://localhost:5305', ARRAY['shop', 'financial', 'inventory']),
|
|
272
|
+
('beauty', 'StudioControl', 'Scissors', '#7C3AED', 'http://localhost:5301', ARRAY['agenda', 'crm', 'financial']),
|
|
273
|
+
-- The school product has no agreed outward name yet; the vertical stands in.
|
|
274
|
+
('school', 'Escola', 'GraduationCap', '#D97706', 'http://localhost:5311', ARRAY['courses', 'agenda', 'financial'])
|
|
275
|
+
ON CONFLICT (id) DO UPDATE
|
|
276
|
+
SET name = EXCLUDED.name, icon = EXCLUDED.icon, accent_color = EXCLUDED.accent_color,
|
|
277
|
+
url = EXCLUDED.url, requires = EXCLUDED.requires;
|
|
278
|
+
|
|
279
|
+
-- ── §7 backfill: what every existing tenant was already running ───────────
|
|
280
|
+
--
|
|
281
|
+
-- Read from vertical_id, which is the only record that exists. Deliberately a
|
|
282
|
+
-- one-shot backfill and NOT a trigger: vertical_id being applied once at INSERT
|
|
283
|
+
-- and never revisited is the defect this file exists to end, and reproducing
|
|
284
|
+
-- that mechanism for apps would reproduce the defect. From here on the writer
|
|
285
|
+
-- is tenant_app_set(), called by onboarding or by a person.
|
|
286
|
+
--
|
|
287
|
+
-- The mapping is wider than app.vertical_defaults' five values because the live
|
|
288
|
+
-- column holds seven, four of which that table has never heard of.
|
|
289
|
+
|
|
290
|
+
INSERT INTO app.tenant_apps (tenant_id, app_id, status, source)
|
|
291
|
+
SELECT t.id,
|
|
292
|
+
CASE t.vertical_id
|
|
293
|
+
WHEN 'restaurant' THEN 'resto'
|
|
294
|
+
WHEN 'food' THEN 'resto'
|
|
295
|
+
WHEN 'commerce' THEN 'shop'
|
|
296
|
+
WHEN 'ecommerce' THEN 'shop'
|
|
297
|
+
WHEN 'retail' THEN 'shop'
|
|
298
|
+
WHEN 'salon' THEN 'beauty'
|
|
299
|
+
WHEN 'clinic' THEN 'beauty'
|
|
300
|
+
WHEN 'beauty' THEN 'beauty'
|
|
301
|
+
WHEN 'school' THEN 'school'
|
|
302
|
+
WHEN 'education' THEN 'school'
|
|
303
|
+
END,
|
|
304
|
+
'active', 'default'
|
|
305
|
+
FROM public.tenants t
|
|
306
|
+
WHERE t.vertical_id IS NOT NULL
|
|
307
|
+
AND CASE t.vertical_id
|
|
308
|
+
WHEN 'restaurant' THEN 'resto' WHEN 'food' THEN 'resto'
|
|
309
|
+
WHEN 'commerce' THEN 'shop' WHEN 'ecommerce' THEN 'shop'
|
|
310
|
+
WHEN 'retail' THEN 'shop' WHEN 'salon' THEN 'beauty'
|
|
311
|
+
WHEN 'clinic' THEN 'beauty' WHEN 'beauty' THEN 'beauty'
|
|
312
|
+
WHEN 'school' THEN 'school' WHEN 'education' THEN 'school'
|
|
313
|
+
END IS NOT NULL
|
|
314
|
+
ON CONFLICT (tenant_id, app_id) DO NOTHING;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 002_the_spine_drops_the_vendor_and_the_dead — fayz_search_sources and
|
|
3
|
+
-- the superseded grant table leave (#352).
|
|
4
|
+
--
|
|
5
|
+
-- ── fayz_search_sources ────────────────────────────────────────────────────
|
|
6
|
+
-- 137 took the vendor out of the spine's names: a table should say what it
|
|
7
|
+
-- holds, not who built it. `fayz_search_sources` became `search_sources` and
|
|
8
|
+
-- 141 left the old name answering until callers moved. One caller never did,
|
|
9
|
+
-- and it is the spine's own: public.fayz_search_reindex(). Redefined below
|
|
10
|
+
-- from 000_baseline's body with that one name changed, then the view goes.
|
|
11
|
+
--
|
|
12
|
+
-- (The function keeps its own vendor name. 137 renamed three objects and this
|
|
13
|
+
-- was not one of them; renaming it here would be a second decision wearing
|
|
14
|
+
-- this file's clothes, and it needs its own with its callers counted.)
|
|
15
|
+
--
|
|
16
|
+
-- ── public.resource_grants, and the two functions that read it ─────────────
|
|
17
|
+
-- #242 superseded it with app.resource_grants, 064 carried the rows across,
|
|
18
|
+
-- and its own COMMENT has said since then that "it no longer governs any
|
|
19
|
+
-- policy". Measured, and that is exactly true: ZERO policies reference either
|
|
20
|
+
-- function that reads it, no view does, and no other function does. What is
|
|
21
|
+
-- left is two SECURITY DEFINER functions selecting from an empty table that
|
|
22
|
+
-- nothing calls — dead code over a dead table, which is the most expensive
|
|
23
|
+
-- kind to leave, because the next person to find it has to prove it is dead
|
|
24
|
+
-- all over again.
|
|
25
|
+
--
|
|
26
|
+
-- Dropped together and in order: a SQL (not plpgsql) function binds its names
|
|
27
|
+
-- at creation, so the table cannot go first.
|
|
28
|
+
--
|
|
29
|
+
-- ── addresses gets the FK it always should have had ───────────────────────
|
|
30
|
+
-- public.addresses is the address model (017, 113, 142) and carries no foreign
|
|
31
|
+
-- key at all. For the OWNER that is deliberate and unavoidable — owner_type is
|
|
32
|
+
-- polymorphic across six kinds and no single FK can express it. For tenant_id
|
|
33
|
+
-- it is neither: every other core table has it, and without it a deleted
|
|
34
|
+
-- tenant leaves its addresses behind. Added guarded, so a pool that somehow
|
|
35
|
+
-- has orphans is told rather than failed.
|
|
36
|
+
-- ============================================================================
|
|
37
|
+
|
|
38
|
+
CREATE OR REPLACE FUNCTION public.fayz_search_reindex() RETURNS integer
|
|
39
|
+
LANGUAGE plpgsql
|
|
40
|
+
SET search_path TO 'public', 'extensions', 'pg_temp'
|
|
41
|
+
AS $$
|
|
42
|
+
DECLARE
|
|
43
|
+
v_src record;
|
|
44
|
+
v_rel text;
|
|
45
|
+
v_name text;
|
|
46
|
+
v_built integer := 0;
|
|
47
|
+
v_has_trgm boolean := EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm');
|
|
48
|
+
v_has_gin boolean := EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'btree_gin');
|
|
49
|
+
BEGIN
|
|
50
|
+
IF NOT v_has_trgm THEN
|
|
51
|
+
RAISE NOTICE 'fayz search: pg_trgm missing — no index built, search falls back to sequential LIKE';
|
|
52
|
+
RETURN 0;
|
|
53
|
+
END IF;
|
|
54
|
+
|
|
55
|
+
FOR v_src IN
|
|
56
|
+
SELECT * FROM public.search_sources WHERE enabled ORDER BY entity_key
|
|
57
|
+
LOOP
|
|
58
|
+
v_rel := to_regclass(v_src.relation)::text;
|
|
59
|
+
CONTINUE WHEN v_rel IS NULL;
|
|
60
|
+
-- A view cannot be indexed; only its base tables can.
|
|
61
|
+
CONTINUE WHEN (SELECT c.relkind FROM pg_class c WHERE c.oid = to_regclass(v_src.relation))
|
|
62
|
+
NOT IN ('r', 'p');
|
|
63
|
+
|
|
64
|
+
-- Name derived from (relation, expression): two sources over the same
|
|
65
|
+
-- columns share one index instead of building it twice.
|
|
66
|
+
v_name := 'fayz_search_' || substr(md5(v_rel || '|' || v_src.haystack_expr), 1, 20);
|
|
67
|
+
|
|
68
|
+
BEGIN
|
|
69
|
+
IF v_has_gin THEN
|
|
70
|
+
-- Composite: the tenant equality is resolved INSIDE the index, so a
|
|
71
|
+
-- 500-tenant pool does not trigram-scan its neighbours' rows.
|
|
72
|
+
EXECUTE format(
|
|
73
|
+
'CREATE INDEX IF NOT EXISTS %I ON %s USING gin (%I, public.fayz_norm(%s) gin_trgm_ops)',
|
|
74
|
+
v_name, v_rel, v_src.tenant_column, v_src.haystack_expr);
|
|
75
|
+
ELSE
|
|
76
|
+
EXECUTE format(
|
|
77
|
+
'CREATE INDEX IF NOT EXISTS %I ON %s USING gin (public.fayz_norm(%s) gin_trgm_ops)',
|
|
78
|
+
v_name, v_rel, v_src.haystack_expr);
|
|
79
|
+
END IF;
|
|
80
|
+
v_built := v_built + 1;
|
|
81
|
+
EXCEPTION WHEN OTHERS THEN
|
|
82
|
+
-- Most likely cause: btree_gin present but with no GIN opclass for the
|
|
83
|
+
-- tenant column's type. A trigram-only index still serves every query;
|
|
84
|
+
-- the tenant equality just becomes a recheck.
|
|
85
|
+
BEGIN
|
|
86
|
+
EXECUTE format(
|
|
87
|
+
'CREATE INDEX IF NOT EXISTS %I ON %s USING gin (public.fayz_norm(%s) gin_trgm_ops)',
|
|
88
|
+
v_name, v_rel, v_src.haystack_expr);
|
|
89
|
+
v_built := v_built + 1;
|
|
90
|
+
EXCEPTION WHEN OTHERS THEN
|
|
91
|
+
RAISE NOTICE 'fayz search: could not index % (%): %', v_src.entity_key, v_rel, SQLERRM;
|
|
92
|
+
END;
|
|
93
|
+
END;
|
|
94
|
+
END LOOP;
|
|
95
|
+
|
|
96
|
+
RETURN v_built;
|
|
97
|
+
END $$;
|
|
98
|
+
|
|
99
|
+
DROP VIEW IF EXISTS public.fayz_search_sources;
|
|
100
|
+
|
|
101
|
+
DROP FUNCTION IF EXISTS public.granted_record_ids(text);
|
|
102
|
+
DROP FUNCTION IF EXISTS public.restricted_record_ids(text);
|
|
103
|
+
DROP TABLE IF EXISTS public.resource_grants;
|
|
104
|
+
|
|
105
|
+
DO $addr$
|
|
106
|
+
DECLARE v_orphans bigint;
|
|
107
|
+
BEGIN
|
|
108
|
+
IF to_regclass('public.addresses') IS NULL THEN RETURN; END IF;
|
|
109
|
+
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'addresses_tenant_id_fkey') THEN RETURN; END IF;
|
|
110
|
+
|
|
111
|
+
SELECT count(*) INTO v_orphans
|
|
112
|
+
FROM public.addresses a
|
|
113
|
+
WHERE NOT EXISTS (SELECT 1 FROM public.tenants t WHERE t.id = a.tenant_id);
|
|
114
|
+
|
|
115
|
+
IF v_orphans > 0 THEN
|
|
116
|
+
RAISE NOTICE 'addresses: % row(s) name a tenant that does not exist — FK not added, clean them first', v_orphans;
|
|
117
|
+
RETURN;
|
|
118
|
+
END IF;
|
|
119
|
+
|
|
120
|
+
ALTER TABLE public.addresses
|
|
121
|
+
ADD CONSTRAINT addresses_tenant_id_fkey
|
|
122
|
+
FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
|
123
|
+
END $addr$;
|
|
124
|
+
|
|
125
|
+
-- ── fayz_global_search, the third of 137's renames ─────────────────────────
|
|
126
|
+
-- Same shim, different shape: a FUNCTION forwarding to public.global_search
|
|
127
|
+
-- rather than a view forwarding to a table. Same verdict — no caller in the
|
|
128
|
+
-- code, no view, no other function.
|
|
129
|
+
DROP FUNCTION IF EXISTS public.fayz_global_search(text, uuid, text[], integer, integer);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 003_the_role_rpcs_finish_and_the_mirrors_go — #136's last four tables, and
|
|
3
|
+
-- the two RPCs that were missing for them to leave (#352).
|
|
4
|
+
--
|
|
5
|
+
-- #136 moved authorization into app.* and left four write-through mirrors in
|
|
6
|
+
-- public so @fayz-ai/admin's permission matrix kept working. Each carries its
|
|
7
|
+
-- own COMMENT saying it is deprecated and naming what replaces it, and every
|
|
8
|
+
-- one of those comments has ended with "Drop in Phase 1" since Phase 0.
|
|
9
|
+
--
|
|
10
|
+
-- public.permissions -> public.v_authz_permissions
|
|
11
|
+
-- public.role_permissions -> always empty; app.role_permissions is per
|
|
12
|
+
-- tenant and this global table never held a
|
|
13
|
+
-- row. Measured: zero, in every pool.
|
|
14
|
+
-- public.tenant_roles -> public.v_authz_roles
|
|
15
|
+
-- public.tenant_role_overrides -> public.v_authz_role_permissions
|
|
16
|
+
--
|
|
17
|
+
-- WHY THEY COULD NOT LEAVE. The read side was ready — the v_authz_* views have
|
|
18
|
+
-- existed all along, and they ARE security_invoker, so RLS on app.* still
|
|
19
|
+
-- applies to whoever selects them. The WRITE side was not: role_create_from_
|
|
20
|
+
-- template and role_set_permissions cover creating a role and setting its
|
|
21
|
+
-- grants, and nothing covered RENAMING or DELETING one. The admin did both by
|
|
22
|
+
-- writing public.tenant_roles and letting a trigger redirect it. So the mirror
|
|
23
|
+
-- was not kept for the matrix; it was kept for two verbs nobody had written.
|
|
24
|
+
--
|
|
25
|
+
-- They are written here, to the same shape as the two that exist: SECURITY
|
|
26
|
+
-- DEFINER with an empty search_path, the tenant from the session rather than a
|
|
27
|
+
-- parameter, roles.manage required, and a system role refused — with delete
|
|
28
|
+
-- refusing on top of that any role somebody is still assigned, because taking
|
|
29
|
+
-- a member's role away as a side effect of tidying is not a rename.
|
|
30
|
+
-- ============================================================================
|
|
31
|
+
|
|
32
|
+
CREATE OR REPLACE FUNCTION public.role_rename(p_role_id uuid, p_name text, p_description text DEFAULT NULL)
|
|
33
|
+
RETURNS uuid
|
|
34
|
+
LANGUAGE plpgsql
|
|
35
|
+
SECURITY DEFINER
|
|
36
|
+
SET search_path TO ''
|
|
37
|
+
AS $function$
|
|
38
|
+
DECLARE
|
|
39
|
+
v_tenant uuid := app.current_tenant_id();
|
|
40
|
+
v_role app.roles%ROWTYPE;
|
|
41
|
+
BEGIN
|
|
42
|
+
IF auth.uid() IS NULL OR v_tenant IS NULL THEN
|
|
43
|
+
RAISE EXCEPTION 'role_rename: no active tenant for this session' USING ERRCODE = '42501';
|
|
44
|
+
END IF;
|
|
45
|
+
IF NOT app.has_permission('roles.manage') THEN
|
|
46
|
+
RAISE EXCEPTION 'role_rename: roles.manage is required' USING ERRCODE = '42501';
|
|
47
|
+
END IF;
|
|
48
|
+
|
|
49
|
+
SELECT * INTO v_role FROM app.roles r WHERE r.tenant_id = v_tenant AND r.id = p_role_id;
|
|
50
|
+
IF NOT FOUND THEN
|
|
51
|
+
RAISE EXCEPTION 'role_rename: role not found in the active tenant' USING ERRCODE = 'P0002';
|
|
52
|
+
END IF;
|
|
53
|
+
-- A system role's name is the platform's, not the tenant's: it is what the
|
|
54
|
+
-- permission templates and every vertical's seed refer to.
|
|
55
|
+
IF v_role.is_system THEN
|
|
56
|
+
RAISE EXCEPTION 'role_rename: a system role cannot be renamed' USING ERRCODE = '42501';
|
|
57
|
+
END IF;
|
|
58
|
+
IF p_name IS NOT NULL AND btrim(p_name) = '' THEN
|
|
59
|
+
RAISE EXCEPTION 'role_rename: a name cannot be blank' USING ERRCODE = '22023';
|
|
60
|
+
END IF;
|
|
61
|
+
|
|
62
|
+
UPDATE app.roles
|
|
63
|
+
SET name = coalesce(btrim(p_name), name),
|
|
64
|
+
-- NULL description is "clear it", which is why this is not coalesce:
|
|
65
|
+
-- the caller passing NULL for a field it did not touch would be the
|
|
66
|
+
-- bug, and the admin sends the field only when the form changed it.
|
|
67
|
+
description = p_description,
|
|
68
|
+
updated_at = now()
|
|
69
|
+
WHERE tenant_id = v_tenant AND id = p_role_id;
|
|
70
|
+
|
|
71
|
+
RETURN p_role_id;
|
|
72
|
+
END $function$;
|
|
73
|
+
|
|
74
|
+
REVOKE ALL ON FUNCTION public.role_rename(uuid, text, text) FROM PUBLIC;
|
|
75
|
+
GRANT EXECUTE ON FUNCTION public.role_rename(uuid, text, text) TO authenticated, service_role;
|
|
76
|
+
|
|
77
|
+
COMMENT ON FUNCTION public.role_rename(uuid, text, text) IS
|
|
78
|
+
'Rename a tenant-created role (PRD 01 / #136). System roles are refused: their names belong to the templates. Pass p_description explicitly — NULL clears it.';
|
|
79
|
+
|
|
80
|
+
CREATE OR REPLACE FUNCTION public.role_delete(p_role_id uuid)
|
|
81
|
+
RETURNS void
|
|
82
|
+
LANGUAGE plpgsql
|
|
83
|
+
SECURITY DEFINER
|
|
84
|
+
SET search_path TO ''
|
|
85
|
+
AS $function$
|
|
86
|
+
DECLARE
|
|
87
|
+
v_tenant uuid := app.current_tenant_id();
|
|
88
|
+
v_role app.roles%ROWTYPE;
|
|
89
|
+
v_holders integer;
|
|
90
|
+
BEGIN
|
|
91
|
+
IF auth.uid() IS NULL OR v_tenant IS NULL THEN
|
|
92
|
+
RAISE EXCEPTION 'role_delete: no active tenant for this session' USING ERRCODE = '42501';
|
|
93
|
+
END IF;
|
|
94
|
+
IF NOT app.has_permission('roles.manage') THEN
|
|
95
|
+
RAISE EXCEPTION 'role_delete: roles.manage is required' USING ERRCODE = '42501';
|
|
96
|
+
END IF;
|
|
97
|
+
|
|
98
|
+
SELECT * INTO v_role FROM app.roles r WHERE r.tenant_id = v_tenant AND r.id = p_role_id;
|
|
99
|
+
IF NOT FOUND THEN
|
|
100
|
+
RAISE EXCEPTION 'role_delete: role not found in the active tenant' USING ERRCODE = 'P0002';
|
|
101
|
+
END IF;
|
|
102
|
+
IF v_role.is_system THEN
|
|
103
|
+
RAISE EXCEPTION 'role_delete: a system role cannot be deleted' USING ERRCODE = '42501';
|
|
104
|
+
END IF;
|
|
105
|
+
|
|
106
|
+
-- Deleting a role somebody holds silently demotes them. Refuse and say how
|
|
107
|
+
-- many, so the caller reassigns first: an authorization change should never
|
|
108
|
+
-- be a side effect of tidying up a list.
|
|
109
|
+
SELECT count(*) INTO v_holders
|
|
110
|
+
FROM app.memberships m
|
|
111
|
+
WHERE m.tenant_id = v_tenant AND m.role_id = p_role_id AND m.active;
|
|
112
|
+
IF v_holders > 0 THEN
|
|
113
|
+
RAISE EXCEPTION 'role_delete: % member(s) still hold this role — reassign them first', v_holders
|
|
114
|
+
USING ERRCODE = '23503';
|
|
115
|
+
END IF;
|
|
116
|
+
|
|
117
|
+
DELETE FROM app.role_permissions WHERE tenant_id = v_tenant AND role_id = p_role_id;
|
|
118
|
+
DELETE FROM app.roles WHERE tenant_id = v_tenant AND id = p_role_id;
|
|
119
|
+
END $function$;
|
|
120
|
+
|
|
121
|
+
REVOKE ALL ON FUNCTION public.role_delete(uuid) FROM PUBLIC;
|
|
122
|
+
GRANT EXECUTE ON FUNCTION public.role_delete(uuid) TO authenticated, service_role;
|
|
123
|
+
|
|
124
|
+
COMMENT ON FUNCTION public.role_delete(uuid) IS
|
|
125
|
+
'Delete a tenant-created role and its grants (PRD 01 / #136). Refuses a system role, and refuses any role an active member still holds.';
|
|
126
|
+
|
|
127
|
+
-- ── the four mirrors, now that the verbs exist ──────────────────────────────
|
|
128
|
+
-- No view depends on any of them (checked), the triggers that redirected their
|
|
129
|
+
-- writes are dropped with the tables they sit on, and every function that
|
|
130
|
+
-- names them is plpgsql, which resolves names when it runs rather than when it
|
|
131
|
+
-- is created.
|
|
132
|
+
DROP TABLE IF EXISTS public.tenant_role_overrides;
|
|
133
|
+
DROP TABLE IF EXISTS public.tenant_roles;
|
|
134
|
+
DROP TABLE IF EXISTS public.role_permissions;
|
|
135
|
+
DROP TABLE IF EXISTS public.permissions;
|