@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.
Files changed (119) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
  4. package/migrations/038_onboarding_responses.sql +103 -0
  5. package/migrations/039_unit_tree.sql +270 -0
  6. package/migrations/040_resource_grants.sql +474 -0
  7. package/migrations/041_scoped_columns.sql +192 -0
  8. package/migrations/042_unit_scope_policies.sql +145 -0
  9. package/migrations/043_view_invoker.sql +90 -0
  10. package/migrations/044_unit_member_facts.sql +47 -0
  11. package/migrations/045_unit_entry.sql +236 -0
  12. package/migrations/046_membership_visible_to_members.sql +85 -0
  13. package/migrations/047_tasks.sql +266 -0
  14. package/migrations/048_every_login_is_a_person.sql +190 -0
  15. package/migrations/049_bookable_people.sql +126 -0
  16. package/migrations/050_authz_core.sql +230 -0
  17. package/migrations/051_authz_helpers.sql +330 -0
  18. package/migrations/052_authz_fanout.sql +253 -0
  19. package/migrations/053_authz_catalog_templates.sql +402 -0
  20. package/migrations/060_tenancy_seam.sql +132 -0
  21. package/migrations/061_create_tenant.sql +125 -0
  22. package/migrations/062_invitations.sql +329 -0
  23. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  24. package/migrations/064_agent_guard_authz.sql +118 -0
  25. package/migrations/064b_supersede_unit_scope.sql +205 -0
  26. package/migrations/065_scaffold_sweep.sql +139 -0
  27. package/migrations/066_shadow_report.sql +122 -0
  28. package/migrations/070_permission_catalog.sql +205 -0
  29. package/migrations/071_v1_permission_map.sql +266 -0
  30. package/migrations/072_role_rpcs.sql +126 -0
  31. package/migrations/073_legacy_rbac_retire.sql +471 -0
  32. package/migrations/075_company_legal_entities.sql +169 -0
  33. package/migrations/076_tenant_settings.sql +251 -0
  34. package/migrations/080_people_roles.sql +269 -0
  35. package/migrations/081_people_identity.sql +271 -0
  36. package/migrations/082_people_membership_link.sql +142 -0
  37. package/migrations/083_people_data_migration.sql +75 -0
  38. package/migrations/084_people_registrations_tokens.sql +282 -0
  39. package/migrations/085_catalog_products.sql +333 -0
  40. package/migrations/086_catalog_pricing.sql +194 -0
  41. package/migrations/087_catalog_packages.sql +134 -0
  42. package/migrations/090_orders_venda.sql +166 -0
  43. package/migrations/091_orders_scaffold.sql +92 -0
  44. package/migrations/095_config_field_rules.sql +261 -0
  45. package/migrations/096_audit_append_only.sql +256 -0
  46. package/migrations/097_sequences_authorized.sql +111 -0
  47. package/migrations/098_holidays.sql +80 -0
  48. package/migrations/099_access_grants.sql +293 -0
  49. package/migrations/100_migration_schema.sql +217 -0
  50. package/migrations/101_migration_allowlist.sql +202 -0
  51. package/migrations/102_migration_rpcs.sql +933 -0
  52. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  53. package/migrations/105_policies_to_authenticated.sql +71 -0
  54. package/migrations/106_function_search_path.sql +55 -0
  55. package/migrations/107_core_table_prefix.sql +361 -0
  56. package/migrations/108_authz_write_guards.sql +149 -0
  57. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  58. package/migrations/110_people_kind_vocabulary.sql +120 -0
  59. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  60. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  61. package/migrations/112_money_precision_standard.sql +132 -0
  62. package/migrations/113_addresses_units_companies.sql +114 -0
  63. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  64. package/migrations/115_registration_honours_field_rules.sql +172 -0
  65. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  66. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  67. package/migrations/118_migration_catalog_writers.sql +450 -0
  68. package/migrations/119_migration_config_writers.sql +193 -0
  69. package/migrations/120_migration_facet_writer.sql +139 -0
  70. package/migrations/121_migration_financial_writers.sql +294 -0
  71. package/migrations/122_migration_inventory_writers.sql +245 -0
  72. package/migrations/123_migration_order_item_writer.sql +144 -0
  73. package/migrations/124_migration_ledger_writers.sql +170 -0
  74. package/migrations/125_migration_authz_people_writers.sql +205 -0
  75. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  76. package/migrations/127_scaffold_audit_hook.sql +98 -0
  77. package/migrations/128_shadow_tables_enforced.sql +93 -0
  78. package/migrations/129_order_kind_terminal_states.sql +57 -0
  79. package/migrations/130_every_login_is_a_person.sql +196 -0
  80. package/migrations/131_migration_invoice_split.sql +127 -0
  81. package/migrations/132_audit_prune_scheduled.sql +28 -0
  82. package/migrations/133_unit_is_a_session_property.sql +155 -0
  83. package/migrations/134_field_rules_on_read.sql +341 -0
  84. package/migrations/135_distribution_polarity.sql +158 -0
  85. package/migrations/136_unit_id_means_locality.sql +358 -0
  86. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  87. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  88. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  89. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  90. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  91. package/migrations/142_a_person_has_addresses.sql +212 -0
  92. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  93. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  94. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  95. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  96. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  97. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  98. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  99. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  100. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  101. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  102. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  103. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  104. package/migrations/155_a_tenant_can_leave.sql +271 -0
  105. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  106. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  107. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  108. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  109. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  110. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  111. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  112. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  113. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  114. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  115. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  116. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  117. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  118. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  119. 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;