@fayz-ai/db 0.11.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.
- package/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
- package/migrations/038_onboarding_responses.sql +103 -0
- package/migrations/039_unit_tree.sql +270 -0
- package/migrations/040_resource_grants.sql +474 -0
- package/migrations/041_scoped_columns.sql +192 -0
- package/migrations/042_unit_scope_policies.sql +145 -0
- package/migrations/043_view_invoker.sql +90 -0
- package/migrations/044_unit_member_facts.sql +47 -0
- package/migrations/045_unit_entry.sql +236 -0
- package/migrations/046_membership_visible_to_members.sql +85 -0
- package/migrations/047_tasks.sql +266 -0
- package/migrations/048_every_login_is_a_person.sql +190 -0
- package/migrations/049_bookable_people.sql +126 -0
- package/migrations/050_authz_core.sql +230 -0
- package/migrations/051_authz_helpers.sql +330 -0
- package/migrations/052_authz_fanout.sql +253 -0
- package/migrations/053_authz_catalog_templates.sql +402 -0
- package/migrations/060_tenancy_seam.sql +132 -0
- package/migrations/061_create_tenant.sql +125 -0
- package/migrations/062_invitations.sql +329 -0
- package/migrations/063_legacy_tenancy_retired.sql +568 -0
- package/migrations/064_agent_guard_authz.sql +118 -0
- package/migrations/064b_supersede_unit_scope.sql +205 -0
- package/migrations/065_scaffold_sweep.sql +139 -0
- package/migrations/066_shadow_report.sql +122 -0
- package/migrations/070_permission_catalog.sql +205 -0
- package/migrations/071_v1_permission_map.sql +266 -0
- package/migrations/072_role_rpcs.sql +126 -0
- package/migrations/073_legacy_rbac_retire.sql +471 -0
- package/migrations/075_company_legal_entities.sql +169 -0
- package/migrations/076_tenant_settings.sql +251 -0
- package/migrations/080_people_roles.sql +269 -0
- package/migrations/081_people_identity.sql +271 -0
- package/migrations/082_people_membership_link.sql +142 -0
- package/migrations/083_people_data_migration.sql +75 -0
- package/migrations/084_people_registrations_tokens.sql +282 -0
- package/migrations/085_catalog_products.sql +333 -0
- package/migrations/086_catalog_pricing.sql +194 -0
- package/migrations/087_catalog_packages.sql +134 -0
- package/migrations/090_orders_venda.sql +166 -0
- package/migrations/091_orders_scaffold.sql +92 -0
- package/migrations/095_config_field_rules.sql +261 -0
- package/migrations/096_audit_append_only.sql +256 -0
- package/migrations/097_sequences_authorized.sql +111 -0
- package/migrations/098_holidays.sql +80 -0
- package/migrations/099_access_grants.sql +293 -0
- package/migrations/100_migration_schema.sql +217 -0
- package/migrations/101_migration_allowlist.sql +202 -0
- package/migrations/102_migration_rpcs.sql +933 -0
- package/migrations/103_migration_reconcile_ponr.sql +132 -0
- package/migrations/105_policies_to_authenticated.sql +71 -0
- package/migrations/106_function_search_path.sql +55 -0
- package/migrations/107_core_table_prefix.sql +361 -0
- package/migrations/108_authz_write_guards.sql +149 -0
- package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
- package/migrations/110_people_kind_vocabulary.sql +120 -0
- package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
- package/migrations/112_money_precision_standard.sql +132 -0
- package/migrations/113_addresses_units_companies.sql +114 -0
- package/migrations/114_migrations_ledger_immutable.sql +65 -0
- package/migrations/115_registration_honours_field_rules.sql +172 -0
- package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
- package/migrations/117_authz_reconciliation_schedule.sql +82 -0
- package/migrations/118_migration_catalog_writers.sql +450 -0
- package/migrations/119_migration_config_writers.sql +193 -0
- package/migrations/120_migration_facet_writer.sql +139 -0
- package/migrations/121_migration_financial_writers.sql +294 -0
- package/migrations/122_migration_inventory_writers.sql +245 -0
- package/migrations/123_migration_order_item_writer.sql +144 -0
- package/migrations/124_migration_ledger_writers.sql +170 -0
- package/migrations/125_migration_authz_people_writers.sql +205 -0
- package/migrations/126_owner_scoping_is_configuration.sql +247 -0
- package/migrations/127_scaffold_audit_hook.sql +98 -0
- package/migrations/128_shadow_tables_enforced.sql +93 -0
- package/migrations/129_order_kind_terminal_states.sql +57 -0
- package/migrations/130_every_login_is_a_person.sql +196 -0
- package/migrations/131_migration_invoice_split.sql +127 -0
- package/migrations/132_audit_prune_scheduled.sql +28 -0
- package/migrations/133_unit_is_a_session_property.sql +155 -0
- package/migrations/134_field_rules_on_read.sql +341 -0
- package/migrations/135_distribution_polarity.sql +158 -0
- package/migrations/136_unit_id_means_locality.sql +358 -0
- package/migrations/137_vendor_named_spine_objects.sql +126 -0
- package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
- package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
- package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
- package/migrations/142_a_person_has_addresses.sql +212 -0
- package/migrations/143_a_professional_has_a_rate.sql +75 -0
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
- package/migrations/146_products_stock_has_one_writer.sql +67 -0
- package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
- package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
- package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
- package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
- package/migrations/152_a_file_is_an_archetype.sql +222 -0
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
- package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
- package/migrations/155_a_tenant_can_leave.sql +271 -0
- package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
- package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
- package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
- package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
- package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
- package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
- package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
- package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
- package/package.json +6 -5
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 154_the_plan_is_a_ceiling.sql — what was bought, down to the facet, and what a
|
|
3
|
+
-- plan change does on its own (#281, ADR 0025).
|
|
4
|
+
--
|
|
5
|
+
-- Two switches existed over the same tenant and never spoke. The PLAN is what is
|
|
6
|
+
-- paid for (public.plans, and 014's machine-readable entitlements). ACTIVATION is
|
|
7
|
+
-- what is turned on (150's app.tenant_plugins). Nothing connected them, so the
|
|
8
|
+
-- system could not tell the difference between a customer on the Pro plan and a
|
|
9
|
+
-- customer on Basic whose reception turned the financial plugin on.
|
|
10
|
+
--
|
|
11
|
+
-- THE THREE PARTS OF THE DECISION, and what each one costs:
|
|
12
|
+
--
|
|
13
|
+
-- 1. THE PLAN IS A CEILING, for everyone. Not only for the customer: for
|
|
14
|
+
-- support and for the onboarding agent too. Turning on something outside
|
|
15
|
+
-- the plan fails with a reason the caller can act on — "requires upgrade",
|
|
16
|
+
-- naming what to upgrade — instead of succeeding quietly and leaking the
|
|
17
|
+
-- revenue. `tenant_plugin_set` grew that check; the agent uses the same
|
|
18
|
+
-- door and meets the same ceiling.
|
|
19
|
+
--
|
|
20
|
+
-- 2. ENTITLEMENT GOES DOWN TO THE FACET. A plugin is not one thing you buy:
|
|
21
|
+
-- the financial base can be included and its reconciliation paid for. So
|
|
22
|
+
-- the grant names (plugin, facet), with facet '' meaning the plugin
|
|
23
|
+
-- itself, exactly as 150 keys activation.
|
|
24
|
+
--
|
|
25
|
+
-- 3. A PLAN CHANGE SYNCHRONISES. Upgrading turns on what the new plan
|
|
26
|
+
-- includes and the screens change immediately; downgrading turns off what
|
|
27
|
+
-- it no longer covers — but ONLY what the plan turned on. Something a
|
|
28
|
+
-- human switched on deliberately, or a courtesy with a date on it, is not
|
|
29
|
+
-- swept away by a billing event: 150's `source` column exists for exactly
|
|
30
|
+
-- this, and this is the file that reads it.
|
|
31
|
+
--
|
|
32
|
+
-- WHAT A DOWNGRADE NEVER DOES is delete anything. Turning off changes what is
|
|
33
|
+
-- seen; the rows stay, so an upgrade tomorrow returns the customer's data
|
|
34
|
+
-- rather than an empty screen.
|
|
35
|
+
--
|
|
36
|
+
-- Idempotent and replay-safe.
|
|
37
|
+
-- ============================================================================
|
|
38
|
+
|
|
39
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
40
|
+
-- §1 What a plan grants, at the grain activation uses
|
|
41
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS app.plan_grants (
|
|
44
|
+
plan_id text NOT NULL,
|
|
45
|
+
plugin_id text NOT NULL CHECK (plugin_id ~ '^[a-z][a-z0-9-]*$'),
|
|
46
|
+
facet text NOT NULL DEFAULT '' CHECK (facet = '' OR facet ~ '^[a-z][a-z0-9_-]*$'),
|
|
47
|
+
-- included: on when the plan is. optional: allowed, but someone has to turn
|
|
48
|
+
-- it on — which is how a plan can permit ten plugins without imposing ten menus.
|
|
49
|
+
mode text NOT NULL DEFAULT 'included' CHECK (mode IN ('included', 'optional')),
|
|
50
|
+
note text,
|
|
51
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
52
|
+
PRIMARY KEY (plan_id, plugin_id, facet)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
COMMENT ON TABLE app.plan_grants IS
|
|
56
|
+
'What a plan permits, at the same (plugin, facet) grain activation uses (154, ADR 0025). `included` comes on with the plan; `optional` is allowed but must be turned on — a plan that permits ten plugins should not impose ten menus.';
|
|
57
|
+
|
|
58
|
+
ALTER TABLE app.plan_grants ENABLE ROW LEVEL SECURITY;
|
|
59
|
+
DROP POLICY IF EXISTS plan_grants_read ON app.plan_grants;
|
|
60
|
+
CREATE POLICY plan_grants_read ON app.plan_grants FOR SELECT TO authenticated USING (true);
|
|
61
|
+
GRANT SELECT ON app.plan_grants TO authenticated;
|
|
62
|
+
GRANT ALL ON app.plan_grants TO service_role;
|
|
63
|
+
|
|
64
|
+
CREATE OR REPLACE FUNCTION public.register_plan_grant(
|
|
65
|
+
p_plan text, p_plugin text, p_facet text DEFAULT '', p_mode text DEFAULT 'included', p_note text DEFAULT NULL
|
|
66
|
+
) RETURNS void
|
|
67
|
+
LANGUAGE sql SECURITY DEFINER SET search_path = ''
|
|
68
|
+
AS $$
|
|
69
|
+
INSERT INTO app.plan_grants (plan_id, plugin_id, facet, mode, note)
|
|
70
|
+
VALUES (p_plan, p_plugin, coalesce(p_facet, ''), p_mode, p_note)
|
|
71
|
+
ON CONFLICT (plan_id, plugin_id, facet) DO UPDATE
|
|
72
|
+
SET mode = EXCLUDED.mode, note = coalesce(EXCLUDED.note, app.plan_grants.note)
|
|
73
|
+
$$;
|
|
74
|
+
|
|
75
|
+
REVOKE ALL ON FUNCTION public.register_plan_grant(text, text, text, text, text) FROM public, anon, authenticated;
|
|
76
|
+
GRANT EXECUTE ON FUNCTION public.register_plan_grant(text, text, text, text, text) TO service_role;
|
|
77
|
+
|
|
78
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
79
|
+
-- §2 The ceiling
|
|
80
|
+
--
|
|
81
|
+
-- A tenant with no plan is unrestricted: that is every pool today, and a gate
|
|
82
|
+
-- that started by locking everyone out would have been reverted within the hour.
|
|
83
|
+
-- The ceiling begins to bind the moment a plan is assigned.
|
|
84
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
CREATE OR REPLACE FUNCTION public.tenant_plan_id(p_tenant uuid DEFAULT NULL)
|
|
87
|
+
RETURNS text
|
|
88
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
89
|
+
AS $$
|
|
90
|
+
DECLARE
|
|
91
|
+
v_tenant uuid := coalesce(p_tenant, app.current_tenant_id());
|
|
92
|
+
v_plan text;
|
|
93
|
+
BEGIN
|
|
94
|
+
IF v_tenant IS NULL THEN RETURN NULL; END IF;
|
|
95
|
+
BEGIN
|
|
96
|
+
SELECT t.settings #>> '{billing,plan_id}' INTO v_plan FROM public.tenants t WHERE t.id = v_tenant;
|
|
97
|
+
EXCEPTION WHEN OTHERS THEN
|
|
98
|
+
RETURN NULL;
|
|
99
|
+
END;
|
|
100
|
+
RETURN v_plan;
|
|
101
|
+
END $$;
|
|
102
|
+
|
|
103
|
+
COMMENT ON FUNCTION public.tenant_plan_id(uuid) IS
|
|
104
|
+
'Which plan this tenant is on, from tenants.settings billing.plan_id (154). NULL means unassigned — and an unassigned tenant is unrestricted, which is what every pool is today.';
|
|
105
|
+
|
|
106
|
+
GRANT EXECUTE ON FUNCTION public.tenant_plan_id(uuid) TO authenticated, service_role;
|
|
107
|
+
|
|
108
|
+
CREATE OR REPLACE FUNCTION public.plan_permits(p_plugin text, p_facet text DEFAULT '', p_tenant uuid DEFAULT NULL)
|
|
109
|
+
RETURNS boolean
|
|
110
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
111
|
+
AS $$
|
|
112
|
+
DECLARE
|
|
113
|
+
v_plan text := public.tenant_plan_id(p_tenant);
|
|
114
|
+
BEGIN
|
|
115
|
+
-- No plan, no ceiling.
|
|
116
|
+
IF v_plan IS NULL THEN RETURN true; END IF;
|
|
117
|
+
-- A plan nobody described grants nothing yet, and refusing everything on that
|
|
118
|
+
-- basis would break a tenant for a missing row. Silence means unrestricted.
|
|
119
|
+
IF NOT EXISTS (SELECT 1 FROM app.plan_grants WHERE plan_id = v_plan) THEN RETURN true; END IF;
|
|
120
|
+
|
|
121
|
+
RETURN EXISTS (
|
|
122
|
+
SELECT 1 FROM app.plan_grants g
|
|
123
|
+
WHERE g.plan_id = v_plan
|
|
124
|
+
AND g.plugin_id = p_plugin
|
|
125
|
+
AND g.facet = coalesce(p_facet, '')
|
|
126
|
+
);
|
|
127
|
+
END $$;
|
|
128
|
+
|
|
129
|
+
COMMENT ON FUNCTION public.plan_permits(text, text, uuid) IS
|
|
130
|
+
'Whether the tenant''s plan permits this plugin or facet at all (154). True for a tenant with no plan and for a plan nobody has described — a ceiling that starts by locking everyone out gets reverted, not adopted.';
|
|
131
|
+
|
|
132
|
+
GRANT EXECUTE ON FUNCTION public.plan_permits(text, text, uuid) TO authenticated, service_role;
|
|
133
|
+
|
|
134
|
+
-- The door from 150, now meeting the ceiling. Everyone goes through it —
|
|
135
|
+
-- an operator, support, and the onboarding agent.
|
|
136
|
+
CREATE OR REPLACE FUNCTION public.tenant_plugin_set(
|
|
137
|
+
p_plugin text,
|
|
138
|
+
p_status text DEFAULT 'active',
|
|
139
|
+
p_facet text DEFAULT '',
|
|
140
|
+
p_config jsonb DEFAULT NULL,
|
|
141
|
+
p_source text DEFAULT 'manual',
|
|
142
|
+
p_expires timestamptz DEFAULT NULL
|
|
143
|
+
) RETURNS jsonb
|
|
144
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
145
|
+
AS $$
|
|
146
|
+
DECLARE
|
|
147
|
+
v_tenant uuid := app.current_tenant_id();
|
|
148
|
+
BEGIN
|
|
149
|
+
IF v_tenant IS NULL THEN
|
|
150
|
+
RAISE EXCEPTION 'no tenant in scope' USING ERRCODE = '42501';
|
|
151
|
+
END IF;
|
|
152
|
+
IF NOT app.has_permission('settings.manage') THEN
|
|
153
|
+
RAISE EXCEPTION 'settings.manage is required to change what this account has'
|
|
154
|
+
USING ERRCODE = '42501';
|
|
155
|
+
END IF;
|
|
156
|
+
IF p_status NOT IN ('active', 'disabled') THEN
|
|
157
|
+
RAISE EXCEPTION 'status must be active or disabled' USING ERRCODE = '22023';
|
|
158
|
+
END IF;
|
|
159
|
+
|
|
160
|
+
-- The ceiling (154). Turning something OFF is always allowed: a customer must
|
|
161
|
+
-- never be trapped in what they are paying for. A trial is the named
|
|
162
|
+
-- exception, and it carries a date so it is not a permanent hole.
|
|
163
|
+
IF p_status = 'active'
|
|
164
|
+
AND p_source <> 'trial'
|
|
165
|
+
AND NOT public.plan_permits(p_plugin, coalesce(p_facet, ''), v_tenant) THEN
|
|
166
|
+
RAISE EXCEPTION 'the % plan does not include %', coalesce(public.tenant_plan_id(v_tenant), 'current'),
|
|
167
|
+
p_plugin || CASE WHEN coalesce(p_facet, '') = '' THEN '' ELSE ' / ' || p_facet END
|
|
168
|
+
USING ERRCODE = '42501',
|
|
169
|
+
HINT = 'Upgrade the plan, or start a trial (source = trial, with an expiry).';
|
|
170
|
+
END IF;
|
|
171
|
+
|
|
172
|
+
INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, config, source, expires_at, activated_by)
|
|
173
|
+
VALUES (v_tenant, p_plugin, coalesce(p_facet, ''), p_status, coalesce(p_config, '{}'::jsonb), p_source, p_expires, auth.uid())
|
|
174
|
+
ON CONFLICT (tenant_id, plugin_id, facet) DO UPDATE
|
|
175
|
+
SET status = EXCLUDED.status,
|
|
176
|
+
config = coalesce(p_config, app.tenant_plugins.config),
|
|
177
|
+
source = EXCLUDED.source,
|
|
178
|
+
expires_at = EXCLUDED.expires_at,
|
|
179
|
+
activated_by = EXCLUDED.activated_by;
|
|
180
|
+
|
|
181
|
+
RETURN jsonb_build_object('plugin', p_plugin, 'facet', nullif(coalesce(p_facet, ''), ''), 'status', p_status);
|
|
182
|
+
END $$;
|
|
183
|
+
|
|
184
|
+
COMMENT ON FUNCTION public.tenant_plugin_set(text, text, text, jsonb, text, timestamptz) IS
|
|
185
|
+
'Turns a plugin or facet on or off for the current tenant (150), within what the plan permits (154). One door for everyone — operator, support and the onboarding agent meet the same ceiling. Turning OFF is always allowed; a trial is the named exception and carries an expiry.';
|
|
186
|
+
|
|
187
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
188
|
+
-- §3 A plan change, applied
|
|
189
|
+
--
|
|
190
|
+
-- Turns on what the new plan includes, turns off what it no longer covers, and
|
|
191
|
+
-- leaves alone anything a human chose or a courtesy granted. A billing event
|
|
192
|
+
-- must not silently undo a deliberate decision.
|
|
193
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
CREATE OR REPLACE FUNCTION public.tenant_apply_plan(p_tenant uuid, p_plan text)
|
|
196
|
+
RETURNS jsonb
|
|
197
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
198
|
+
AS $$
|
|
199
|
+
DECLARE
|
|
200
|
+
v_enabled integer := 0;
|
|
201
|
+
v_disabled integer := 0;
|
|
202
|
+
r record;
|
|
203
|
+
BEGIN
|
|
204
|
+
IF p_tenant IS NULL OR p_plan IS NULL THEN
|
|
205
|
+
RAISE EXCEPTION 'tenant and plan are required' USING ERRCODE = '22023';
|
|
206
|
+
END IF;
|
|
207
|
+
|
|
208
|
+
-- Not jsonb_set: it cannot create the intermediate object, so on a tenant
|
|
209
|
+
-- whose settings have no `billing` key it returns the original unchanged —
|
|
210
|
+
-- silently, which is the worst way for a plan change to fail.
|
|
211
|
+
UPDATE public.tenants
|
|
212
|
+
SET settings = coalesce(settings, '{}'::jsonb)
|
|
213
|
+
|| jsonb_build_object('billing',
|
|
214
|
+
coalesce(settings -> 'billing', '{}'::jsonb) || jsonb_build_object('plan_id', p_plan))
|
|
215
|
+
WHERE id = p_tenant;
|
|
216
|
+
|
|
217
|
+
-- On: everything the new plan includes and the tenant does not have active.
|
|
218
|
+
FOR r IN
|
|
219
|
+
SELECT g.plugin_id, g.facet
|
|
220
|
+
FROM app.plan_grants g
|
|
221
|
+
WHERE g.plan_id = p_plan AND g.mode = 'included'
|
|
222
|
+
LOOP
|
|
223
|
+
INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
|
|
224
|
+
VALUES (p_tenant, r.plugin_id, r.facet, 'active', 'plan')
|
|
225
|
+
ON CONFLICT (tenant_id, plugin_id, facet) DO UPDATE
|
|
226
|
+
SET status = 'active',
|
|
227
|
+
source = CASE WHEN app.tenant_plugins.source = 'plan' THEN 'plan' ELSE app.tenant_plugins.source END;
|
|
228
|
+
v_enabled := v_enabled + 1;
|
|
229
|
+
END LOOP;
|
|
230
|
+
|
|
231
|
+
-- Off: only what the PLAN turned on and the new plan no longer permits. A
|
|
232
|
+
-- manual switch, an agent's onboarding choice or a dated courtesy survives —
|
|
233
|
+
-- source is what tells them apart, and it is why 150 recorded it.
|
|
234
|
+
FOR r IN
|
|
235
|
+
SELECT t.plugin_id, t.facet
|
|
236
|
+
FROM app.tenant_plugins t
|
|
237
|
+
WHERE t.tenant_id = p_tenant
|
|
238
|
+
AND t.status = 'active'
|
|
239
|
+
AND t.source = 'plan'
|
|
240
|
+
AND NOT EXISTS (
|
|
241
|
+
SELECT 1 FROM app.plan_grants g
|
|
242
|
+
WHERE g.plan_id = p_plan AND g.plugin_id = t.plugin_id AND g.facet = t.facet
|
|
243
|
+
)
|
|
244
|
+
LOOP
|
|
245
|
+
UPDATE app.tenant_plugins
|
|
246
|
+
SET status = 'disabled'
|
|
247
|
+
WHERE tenant_id = p_tenant AND plugin_id = r.plugin_id AND facet = r.facet;
|
|
248
|
+
v_disabled := v_disabled + 1;
|
|
249
|
+
END LOOP;
|
|
250
|
+
|
|
251
|
+
RETURN jsonb_build_object('plan', p_plan, 'enabled', v_enabled, 'disabled', v_disabled);
|
|
252
|
+
END $$;
|
|
253
|
+
|
|
254
|
+
COMMENT ON FUNCTION public.tenant_apply_plan(uuid, text) IS
|
|
255
|
+
'Applies a plan change (154, ADR 0025): turns on what it includes, turns off only what the PLAN had turned on and it no longer covers, and never deletes. A manual switch or a dated courtesy survives a billing event — which is what source (150) is for.';
|
|
256
|
+
|
|
257
|
+
REVOKE ALL ON FUNCTION public.tenant_apply_plan(uuid, text) FROM public, anon, authenticated;
|
|
258
|
+
GRANT EXECUTE ON FUNCTION public.tenant_apply_plan(uuid, text) TO service_role;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 155_a_tenant_can_leave.sql — freeze, then purge, and the same mechanism
|
|
3
|
+
-- answers the LGPD (#284, rodada 3 F5).
|
|
4
|
+
--
|
|
5
|
+
-- Everything built so far assumes a tenant that stays. Two things happen to real
|
|
6
|
+
-- customers and neither had an answer: they cancel, and they ask to be erased.
|
|
7
|
+
--
|
|
8
|
+
-- The decision was retention with a deadline: freeze for N days so a change of
|
|
9
|
+
-- mind costs a click, then purge — recorded, per tenant. And the observation
|
|
10
|
+
-- that makes it worth building once: THE SAME MECHANISM ANSWERS BOTH. An LGPD
|
|
11
|
+
-- erasure is a purge scoped to one Person instead of to a tenant, and a platform
|
|
12
|
+
-- that has one will not need to invent the other under a deadline.
|
|
13
|
+
--
|
|
14
|
+
-- WHAT IS DELIBERATELY NOT DECIDED HERE. The value of N, what a customer may
|
|
15
|
+
-- export on the way out, and which rows the fiscal law requires kept regardless
|
|
16
|
+
-- are configuration and legal advice, not schema. They are read from settings so
|
|
17
|
+
-- they can differ per market without a migration.
|
|
18
|
+
--
|
|
19
|
+
-- WHAT A FREEZE IS. Not a delete and not a lock: the data is intact and the
|
|
20
|
+
-- tenant is marked. What a frozen tenant may still do is a product decision the
|
|
21
|
+
-- shell reads from here — the database's job is to know, unambiguously, that
|
|
22
|
+
-- this account is on its way out and since when.
|
|
23
|
+
--
|
|
24
|
+
-- WHY THE PURGE IS DELIBERATELY DUMB. It walks the tables that carry tenant_id
|
|
25
|
+
-- and deletes, in FK order, inside one transaction. No cleverness about what
|
|
26
|
+
-- matters: everything the tenant owns goes, and the receipt says how much. The
|
|
27
|
+
-- alternative — a curated list — is a list that silently stops being complete
|
|
28
|
+
-- the day someone adds a table.
|
|
29
|
+
--
|
|
30
|
+
-- Idempotent and replay-safe.
|
|
31
|
+
-- ============================================================================
|
|
32
|
+
|
|
33
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
34
|
+
-- §1 The lifecycle
|
|
35
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS app.tenant_lifecycle (
|
|
38
|
+
tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
39
|
+
state text NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'frozen', 'purged')),
|
|
40
|
+
frozen_at timestamptz,
|
|
41
|
+
purge_after timestamptz,
|
|
42
|
+
purged_at timestamptz,
|
|
43
|
+
reason text,
|
|
44
|
+
requested_by uuid,
|
|
45
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
46
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
COMMENT ON TABLE app.tenant_lifecycle IS
|
|
50
|
+
'Where a tenant is on its way out (155): active, frozen with a purge date, or purged. A freeze deletes nothing — it marks, so a change of mind costs a click and the deadline is visible to everyone rather than living in someone''s calendar.';
|
|
51
|
+
COMMENT ON COLUMN app.tenant_lifecycle.purge_after IS
|
|
52
|
+
'When the freeze becomes a purge. Computed from the retention setting at freeze time, so changing the policy later does not silently move a date a customer was told.';
|
|
53
|
+
|
|
54
|
+
ALTER TABLE app.tenant_lifecycle ENABLE ROW LEVEL SECURITY;
|
|
55
|
+
DROP POLICY IF EXISTS tenant_lifecycle_read ON app.tenant_lifecycle;
|
|
56
|
+
CREATE POLICY tenant_lifecycle_read ON app.tenant_lifecycle
|
|
57
|
+
FOR SELECT TO authenticated
|
|
58
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
59
|
+
GRANT SELECT ON app.tenant_lifecycle TO authenticated;
|
|
60
|
+
GRANT ALL ON app.tenant_lifecycle TO service_role;
|
|
61
|
+
|
|
62
|
+
DROP TRIGGER IF EXISTS tenant_lifecycle_updated_at ON app.tenant_lifecycle;
|
|
63
|
+
CREATE TRIGGER tenant_lifecycle_updated_at BEFORE UPDATE ON app.tenant_lifecycle
|
|
64
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
65
|
+
|
|
66
|
+
-- The policy, as data. Registered like any other setting so it can differ per
|
|
67
|
+
-- market without a migration.
|
|
68
|
+
DO $$
|
|
69
|
+
BEGIN
|
|
70
|
+
IF to_regproc('app.seed_setting_keys') IS NOT NULL THEN
|
|
71
|
+
PERFORM app.seed_setting_keys(ARRAY[
|
|
72
|
+
ROW('retention.freeze_days', 'integer', to_jsonb(90), 'Days a cancelled account stays frozen before it is purged', 'core', 'tenant')::app.setting_key_seed
|
|
73
|
+
]);
|
|
74
|
+
END IF;
|
|
75
|
+
EXCEPTION WHEN OTHERS THEN
|
|
76
|
+
-- The seeding helper's shape differs across pools; the default below is what
|
|
77
|
+
-- actually decides, and it is read at freeze time.
|
|
78
|
+
NULL;
|
|
79
|
+
END $$;
|
|
80
|
+
|
|
81
|
+
CREATE OR REPLACE FUNCTION public.tenant_freeze(p_tenant uuid, p_reason text DEFAULT NULL, p_days integer DEFAULT NULL)
|
|
82
|
+
RETURNS jsonb
|
|
83
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
84
|
+
AS $$
|
|
85
|
+
DECLARE
|
|
86
|
+
v_days integer := coalesce(
|
|
87
|
+
p_days,
|
|
88
|
+
(SELECT (t.settings #>> '{retention,freeze_days}')::int FROM public.tenants t WHERE t.id = p_tenant),
|
|
89
|
+
90
|
|
90
|
+
);
|
|
91
|
+
v_after timestamptz := now() + make_interval(days => v_days);
|
|
92
|
+
BEGIN
|
|
93
|
+
INSERT INTO app.tenant_lifecycle (tenant_id, state, frozen_at, purge_after, reason, requested_by)
|
|
94
|
+
VALUES (p_tenant, 'frozen', now(), v_after, p_reason, auth.uid())
|
|
95
|
+
ON CONFLICT (tenant_id) DO UPDATE
|
|
96
|
+
SET state = 'frozen', frozen_at = now(), purge_after = v_after,
|
|
97
|
+
reason = coalesce(EXCLUDED.reason, app.tenant_lifecycle.reason),
|
|
98
|
+
requested_by = EXCLUDED.requested_by, purged_at = NULL;
|
|
99
|
+
|
|
100
|
+
RETURN jsonb_build_object('tenant_id', p_tenant, 'state', 'frozen', 'purge_after', v_after, 'days', v_days);
|
|
101
|
+
END $$;
|
|
102
|
+
|
|
103
|
+
COMMENT ON FUNCTION public.tenant_freeze(uuid, text, integer) IS
|
|
104
|
+
'Marks a cancelled account frozen with the date its data will be purged (155). Deletes nothing: a change of mind is tenant_reactivate, and the deadline is visible instead of living in a calendar.';
|
|
105
|
+
|
|
106
|
+
CREATE OR REPLACE FUNCTION public.tenant_reactivate(p_tenant uuid)
|
|
107
|
+
RETURNS jsonb
|
|
108
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
109
|
+
AS $$
|
|
110
|
+
BEGIN
|
|
111
|
+
IF EXISTS (SELECT 1 FROM app.tenant_lifecycle WHERE tenant_id = p_tenant AND state = 'purged') THEN
|
|
112
|
+
RAISE EXCEPTION 'tenant % was purged — there is nothing to bring back', p_tenant
|
|
113
|
+
USING ERRCODE = '23514',
|
|
114
|
+
HINT = 'This is the difference between a freeze and a purge, and why the freeze exists.';
|
|
115
|
+
END IF;
|
|
116
|
+
UPDATE app.tenant_lifecycle
|
|
117
|
+
SET state = 'active', frozen_at = NULL, purge_after = NULL, reason = NULL
|
|
118
|
+
WHERE tenant_id = p_tenant;
|
|
119
|
+
RETURN jsonb_build_object('tenant_id', p_tenant, 'state', 'active');
|
|
120
|
+
END $$;
|
|
121
|
+
|
|
122
|
+
COMMENT ON FUNCTION public.tenant_reactivate(uuid) IS
|
|
123
|
+
'Undoes a freeze while there is still something to undo (155). After the purge it raises, which is the whole point of having two states.';
|
|
124
|
+
|
|
125
|
+
REVOKE ALL ON FUNCTION public.tenant_freeze(uuid, text, integer) FROM public, anon, authenticated;
|
|
126
|
+
REVOKE ALL ON FUNCTION public.tenant_reactivate(uuid) FROM public, anon, authenticated;
|
|
127
|
+
GRANT EXECUTE ON FUNCTION public.tenant_freeze(uuid, text, integer) TO service_role;
|
|
128
|
+
GRANT EXECUTE ON FUNCTION public.tenant_reactivate(uuid) TO service_role;
|
|
129
|
+
|
|
130
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
131
|
+
-- §2 The purge
|
|
132
|
+
--
|
|
133
|
+
-- Walks every table that carries tenant_id and deletes the tenant's rows, in
|
|
134
|
+
-- one transaction, retrying the pass while anything is still going — which is
|
|
135
|
+
-- how foreign keys are respected without a hand-maintained order that would rot.
|
|
136
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
CREATE OR REPLACE FUNCTION public.tenant_purge(p_tenant uuid, p_confirm text DEFAULT NULL)
|
|
139
|
+
RETURNS jsonb
|
|
140
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
141
|
+
AS $$
|
|
142
|
+
DECLARE
|
|
143
|
+
v_state text;
|
|
144
|
+
v_table record;
|
|
145
|
+
v_deleted bigint;
|
|
146
|
+
v_total bigint := 0;
|
|
147
|
+
v_receipt jsonb := '{}'::jsonb;
|
|
148
|
+
v_pass integer := 0;
|
|
149
|
+
v_moved boolean := true;
|
|
150
|
+
BEGIN
|
|
151
|
+
-- Two locks on the door, because this one has no undo.
|
|
152
|
+
IF p_confirm IS DISTINCT FROM p_tenant::text THEN
|
|
153
|
+
RAISE EXCEPTION 'tenant_purge: confirm must repeat the tenant id — this cannot be undone'
|
|
154
|
+
USING ERRCODE = '22023';
|
|
155
|
+
END IF;
|
|
156
|
+
|
|
157
|
+
SELECT state INTO v_state FROM app.tenant_lifecycle WHERE tenant_id = p_tenant;
|
|
158
|
+
IF v_state IS DISTINCT FROM 'frozen' THEN
|
|
159
|
+
RAISE EXCEPTION 'tenant_purge: % is not frozen (state %) — freeze first', p_tenant, coalesce(v_state, 'active')
|
|
160
|
+
USING ERRCODE = '23514',
|
|
161
|
+
HINT = 'The freeze is what gives a customer the window to change their mind.';
|
|
162
|
+
END IF;
|
|
163
|
+
|
|
164
|
+
-- Repeat the sweep until a pass deletes nothing: a child whose parent went
|
|
165
|
+
-- first fails, and succeeds on the next pass. Cheaper and far more honest than
|
|
166
|
+
-- a hand-ordered list that stops being complete the day someone adds a table.
|
|
167
|
+
WHILE v_moved AND v_pass < 12 LOOP
|
|
168
|
+
v_moved := false;
|
|
169
|
+
v_pass := v_pass + 1;
|
|
170
|
+
FOR v_table IN
|
|
171
|
+
SELECT c.relname AS table_name, n.nspname AS schema_name
|
|
172
|
+
FROM pg_class c
|
|
173
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
174
|
+
JOIN information_schema.columns col
|
|
175
|
+
ON col.table_schema = n.nspname AND col.table_name = c.relname AND col.column_name = 'tenant_id'
|
|
176
|
+
WHERE c.relkind = 'r'
|
|
177
|
+
AND n.nspname IN ('public', 'app')
|
|
178
|
+
AND c.relname <> 'tenants'
|
|
179
|
+
AND c.relname <> 'tenant_lifecycle'
|
|
180
|
+
ORDER BY c.relname
|
|
181
|
+
LOOP
|
|
182
|
+
BEGIN
|
|
183
|
+
EXECUTE format('DELETE FROM %I.%I WHERE tenant_id = $1', v_table.schema_name, v_table.table_name)
|
|
184
|
+
USING p_tenant;
|
|
185
|
+
GET DIAGNOSTICS v_deleted = ROW_COUNT;
|
|
186
|
+
IF v_deleted > 0 THEN
|
|
187
|
+
v_moved := true;
|
|
188
|
+
v_total := v_total + v_deleted;
|
|
189
|
+
v_receipt := v_receipt || jsonb_build_object(
|
|
190
|
+
v_table.schema_name || '.' || v_table.table_name,
|
|
191
|
+
coalesce((v_receipt ->> (v_table.schema_name || '.' || v_table.table_name))::bigint, 0) + v_deleted);
|
|
192
|
+
END IF;
|
|
193
|
+
EXCEPTION WHEN foreign_key_violation THEN
|
|
194
|
+
-- Its parent is still there; the next pass will find it empty.
|
|
195
|
+
NULL;
|
|
196
|
+
END;
|
|
197
|
+
END LOOP;
|
|
198
|
+
END LOOP;
|
|
199
|
+
|
|
200
|
+
UPDATE app.tenant_lifecycle
|
|
201
|
+
SET state = 'purged', purged_at = now()
|
|
202
|
+
WHERE tenant_id = p_tenant;
|
|
203
|
+
|
|
204
|
+
RETURN jsonb_build_object('tenant_id', p_tenant, 'rows_deleted', v_total, 'passes', v_pass, 'receipt', v_receipt);
|
|
205
|
+
END $$;
|
|
206
|
+
|
|
207
|
+
COMMENT ON FUNCTION public.tenant_purge(uuid, text) IS
|
|
208
|
+
'Deletes everything a frozen tenant owns, with a receipt (155). Deliberately dumb — every table carrying tenant_id, swept until a pass moves nothing — because a curated list stops being complete the day someone adds a table. Requires the id repeated: there is no undo.';
|
|
209
|
+
|
|
210
|
+
REVOKE ALL ON FUNCTION public.tenant_purge(uuid, text) FROM public, anon, authenticated;
|
|
211
|
+
GRANT EXECUTE ON FUNCTION public.tenant_purge(uuid, text) TO service_role;
|
|
212
|
+
|
|
213
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
214
|
+
-- §3 The same mechanism, scoped to one person — which is the LGPD request
|
|
215
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
CREATE OR REPLACE FUNCTION public.person_erase(p_person uuid, p_confirm text DEFAULT NULL)
|
|
218
|
+
RETURNS jsonb
|
|
219
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
220
|
+
AS $$
|
|
221
|
+
DECLARE
|
|
222
|
+
v_tenant uuid;
|
|
223
|
+
v_files bigint;
|
|
224
|
+
BEGIN
|
|
225
|
+
IF p_confirm IS DISTINCT FROM p_person::text THEN
|
|
226
|
+
RAISE EXCEPTION 'person_erase: confirm must repeat the person id — this cannot be undone'
|
|
227
|
+
USING ERRCODE = '22023';
|
|
228
|
+
END IF;
|
|
229
|
+
|
|
230
|
+
SELECT tenant_id INTO v_tenant FROM public.people WHERE id = p_person;
|
|
231
|
+
IF v_tenant IS NULL THEN
|
|
232
|
+
RAISE EXCEPTION 'person_erase: no such person' USING ERRCODE = '22023';
|
|
233
|
+
END IF;
|
|
234
|
+
|
|
235
|
+
-- Files first, and by their register — which is exactly why 152 put the
|
|
236
|
+
-- register in the base. The bytes are deleted by the storage service from this
|
|
237
|
+
-- list; the rows go here.
|
|
238
|
+
SELECT count(*) INTO v_files FROM public.documents WHERE person_id = p_person;
|
|
239
|
+
DELETE FROM public.documents WHERE person_id = p_person;
|
|
240
|
+
|
|
241
|
+
-- The identity itself. What references it with ON DELETE SET NULL keeps its
|
|
242
|
+
-- shape (a sale still happened) and loses the person, which is what the law
|
|
243
|
+
-- asks for and what an accountant needs.
|
|
244
|
+
DELETE FROM public.person_roles WHERE person_id = p_person;
|
|
245
|
+
DELETE FROM public.people WHERE id = p_person;
|
|
246
|
+
|
|
247
|
+
RETURN jsonb_build_object('person_id', p_person, 'tenant_id', v_tenant, 'files_removed', v_files);
|
|
248
|
+
END $$;
|
|
249
|
+
|
|
250
|
+
COMMENT ON FUNCTION public.person_erase(uuid, text) IS
|
|
251
|
+
'Erases one Person and everything registered about them (155) — the LGPD request, answered by the same mechanism as a tenant purge, scoped one level down. Records that reference them with ON DELETE SET NULL keep their shape and lose the person: a sale still happened, and the accountant still needs it.';
|
|
252
|
+
|
|
253
|
+
REVOKE ALL ON FUNCTION public.person_erase(uuid, text) FROM public, anon, authenticated;
|
|
254
|
+
GRANT EXECUTE ON FUNCTION public.person_erase(uuid, text) TO service_role;
|
|
255
|
+
|
|
256
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
257
|
+
-- §4 What is due
|
|
258
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
CREATE OR REPLACE VIEW public.v_tenants_due_for_purge
|
|
261
|
+
WITH (security_invoker = true) AS
|
|
262
|
+
SELECT l.tenant_id, t.name, l.frozen_at, l.purge_after, l.reason,
|
|
263
|
+
(now() - l.purge_after) AS overdue_by
|
|
264
|
+
FROM app.tenant_lifecycle l
|
|
265
|
+
JOIN public.tenants t ON t.id = l.tenant_id
|
|
266
|
+
WHERE l.state = 'frozen' AND l.purge_after <= now();
|
|
267
|
+
|
|
268
|
+
COMMENT ON VIEW public.v_tenants_due_for_purge IS
|
|
269
|
+
'Frozen tenants whose retention window has closed (155). A list somebody acts on — the purge is never automatic, because an irreversible act on a customer''s data should have a person behind it.';
|
|
270
|
+
|
|
271
|
+
GRANT SELECT ON public.v_tenants_due_for_purge TO service_role;
|