@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,269 @@
1
+ -- ============================================================================
2
+ -- 080_people_roles.sql — one Person per human, many roles (ADR 0003 / PRD 03 /
3
+ -- #137).
4
+ --
5
+ -- public.people keeps one row per human; public.person_roles says which hats
6
+ -- that human wears in the tenant (customer · professional · employee · contact
7
+ -- · supplier · lead), each with a validity window and an optional unit. The
8
+ -- SDK's single `people.kind` stays as a COMPAT column: it is DERIVED from the
9
+ -- primary active role by trigger, and a direct write to `kind` (every existing
10
+ -- code path: the archetype adapter, the CRM's lead=kind, the inventory
11
+ -- supplier registry, the agenda's public booking upsert) converges into a role
12
+ -- row, so old and new writers agree. Deprecation: kind is read-only in Phase 1
13
+ -- and dropped in Phase 2 (docs/data/phase-0/03-people-multirole.md).
14
+ --
15
+ -- Both tables are stamped with the authz scaffold (043) in enforce mode: the
16
+ -- 004/006 tenant_members policies on people are dropped on purpose — RLS on
17
+ -- people is now the one template (unit scope + owner scope + people.*).
18
+ -- Idempotent and replay-safe.
19
+ -- ============================================================================
20
+
21
+ -- ─────────────────────────────── vocabulary ────────────────────────────
22
+ -- role → the SDK kind old readers filter on. `professional` and `employee`
23
+ -- both read as 'staff' (the agenda's professionalKind, the team roster);
24
+ -- `supplier` reads as 'supplier' because that is what plugin-inventory's
25
+ -- registry filters on today (DATA-MODEL's 'vendor' has no reader — accepted as
26
+ -- an alias on the way in, never written back).
27
+ CREATE OR REPLACE FUNCTION public.person_role_to_kind(p_role text)
28
+ RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
29
+ SELECT CASE p_role
30
+ WHEN 'customer' THEN 'customer'
31
+ WHEN 'professional' THEN 'staff'
32
+ WHEN 'employee' THEN 'staff'
33
+ WHEN 'lead' THEN 'lead'
34
+ WHEN 'supplier' THEN 'supplier'
35
+ WHEN 'contact' THEN 'contact'
36
+ END
37
+ $$;
38
+
39
+ -- kind → the role a legacy write of `kind` means. NULL for kinds outside the
40
+ -- vocabulary (an app's own 'teacher', 'patient', …): those are left alone by
41
+ -- the derivation and get roles only when someone writes person_roles.
42
+ CREATE OR REPLACE FUNCTION public.person_kind_to_role(p_kind text)
43
+ RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
44
+ SELECT CASE p_kind
45
+ WHEN 'customer' THEN 'customer'
46
+ WHEN 'staff' THEN 'employee'
47
+ WHEN 'employee' THEN 'employee'
48
+ WHEN 'professional' THEN 'professional'
49
+ WHEN 'lead' THEN 'lead'
50
+ WHEN 'supplier' THEN 'supplier'
51
+ WHEN 'vendor' THEN 'supplier'
52
+ WHEN 'contact' THEN 'contact'
53
+ END
54
+ $$;
55
+
56
+ -- A role is active from valid_from (inclusive) until valid_to (exclusive):
57
+ -- valid_to is the first day the role no longer applies. Both dates are dates,
58
+ -- not timestamps — a hat is worn by the day.
59
+ CREATE OR REPLACE FUNCTION public.person_role_active(p_from date, p_to date)
60
+ RETURNS boolean LANGUAGE sql STABLE PARALLEL SAFE AS $$
61
+ SELECT p_from <= current_date AND (p_to IS NULL OR p_to > current_date)
62
+ $$;
63
+
64
+ GRANT EXECUTE ON FUNCTION public.person_role_to_kind(text), public.person_kind_to_role(text), public.person_role_active(date, date)
65
+ TO anon, authenticated, service_role;
66
+
67
+ -- ─────────────────────────────── people: scaffold ──────────────────────
68
+ -- Adds unit_id / owner_id / unique(tenant_id,id) / composite FKs / forced RLS
69
+ -- with the template. Owner scoping is ON here so the clinic template can fill
70
+ -- owner_id (a professional sees her own patients); the salon leaves it NULL
71
+ -- and the same policy text opens every row of the unit.
72
+ SELECT app.scaffold_table('public.people', 'people.person', 'people', true, 'enforce');
73
+
74
+ -- ─────────────────────────────── person_roles ──────────────────────────
75
+ CREATE TABLE IF NOT EXISTS public.person_roles (
76
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
77
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
78
+ person_id uuid NOT NULL,
79
+ role text NOT NULL CHECK (role IN ('customer', 'professional', 'employee', 'contact', 'supplier', 'lead')),
80
+ unit_id uuid, -- role valid in one unit; NULL = the whole tenant
81
+ valid_from date NOT NULL DEFAULT current_date,
82
+ valid_to date, -- first day the role no longer applies
83
+ is_primary boolean NOT NULL DEFAULT false,
84
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb, -- CBO, vínculo, origin/partnership refs (ADR 0009)
85
+ created_at timestamptz NOT NULL DEFAULT now(),
86
+ updated_at timestamptz NOT NULL DEFAULT now(),
87
+ CHECK (valid_to IS NULL OR valid_to >= valid_from),
88
+ FOREIGN KEY (tenant_id, person_id) REFERENCES public.people(tenant_id, id) ON DELETE CASCADE
89
+ );
90
+ -- one hat per (person, role, unit); NULL unit folds to the zero uuid so the
91
+ -- tenant-wide row is unique too
92
+ CREATE UNIQUE INDEX IF NOT EXISTS person_roles_person_role_unit_uidx
93
+ ON public.person_roles (tenant_id, person_id, role, (coalesce(unit_id, '00000000-0000-0000-0000-000000000000'::uuid)));
94
+ CREATE INDEX IF NOT EXISTS person_roles_tenant_person_idx ON public.person_roles (tenant_id, person_id);
95
+ CREATE INDEX IF NOT EXISTS person_roles_tenant_role_idx ON public.person_roles (tenant_id, role, person_id);
96
+
97
+ SELECT app.scaffold_table('public.person_roles', 'people.person_role', 'people', false, 'enforce');
98
+
99
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.person_roles TO authenticated;
100
+ GRANT ALL ON public.person_roles TO service_role;
101
+ REVOKE ALL ON public.person_roles FROM anon;
102
+
103
+ -- ─────────────────────────────── kind ⇄ roles ──────────────────────────
104
+ -- Trigger functions are SECURITY DEFINER on purpose: derived data must not
105
+ -- depend on the writer's RLS (a receptionist with people.create but without
106
+ -- people.edit still gets a consistent kind); they touch nothing but kind and
107
+ -- the role row the kind implies. `fayz.people_kind_sync` (transaction-local)
108
+ -- marks our own writes so the two directions never chase each other.
109
+
110
+ -- roles → kind: the primary active role; else the earliest active one, ties
111
+ -- broken by a fixed precedence (professional, employee, customer, supplier,
112
+ -- contact, lead — staff hats first: that is who the roster and the agenda
113
+ -- look for). A person whose every role expired keeps the last kind (kind is
114
+ -- NOT NULL). Kinds outside the vocabulary are never rewritten.
115
+ CREATE OR REPLACE FUNCTION public.person_role_precedence(p_role text)
116
+ RETURNS integer LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
117
+ SELECT CASE p_role WHEN 'professional' THEN 1 WHEN 'employee' THEN 2 WHEN 'customer' THEN 3
118
+ WHEN 'supplier' THEN 4 WHEN 'contact' THEN 5 WHEN 'lead' THEN 6 ELSE 9 END
119
+ $$;
120
+ GRANT EXECUTE ON FUNCTION public.person_role_precedence(text) TO anon, authenticated, service_role;
121
+
122
+ CREATE OR REPLACE FUNCTION public.people_sync_kind(p_tenant uuid, p_person uuid)
123
+ RETURNS void
124
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
125
+ AS $$
126
+ DECLARE
127
+ v_role text;
128
+ v_kind text;
129
+ v_current text;
130
+ BEGIN
131
+ SELECT r.role INTO v_role
132
+ FROM public.person_roles r
133
+ WHERE r.tenant_id = p_tenant AND r.person_id = p_person
134
+ AND public.person_role_active(r.valid_from, r.valid_to)
135
+ ORDER BY r.is_primary DESC, r.valid_from, r.created_at, public.person_role_precedence(r.role), r.id
136
+ LIMIT 1;
137
+ IF v_role IS NULL THEN
138
+ RETURN;
139
+ END IF;
140
+ v_kind := public.person_role_to_kind(v_role);
141
+ SELECT p.kind INTO v_current FROM public.people p WHERE p.tenant_id = p_tenant AND p.id = p_person;
142
+ IF NOT FOUND OR v_current = v_kind THEN
143
+ RETURN;
144
+ END IF;
145
+ IF v_current IS NOT NULL AND public.person_kind_to_role(v_current) IS NULL THEN
146
+ RETURN; -- an app-specific kind: frozen, not ours to rewrite
147
+ END IF;
148
+ PERFORM set_config('fayz.people_kind_sync', 'on', true);
149
+ UPDATE public.people SET kind = v_kind WHERE tenant_id = p_tenant AND id = p_person;
150
+ PERFORM set_config('fayz.people_kind_sync', 'off', true);
151
+ END $$;
152
+ REVOKE ALL ON FUNCTION public.people_sync_kind(uuid, uuid) FROM public, anon, authenticated;
153
+
154
+ CREATE OR REPLACE FUNCTION public.trg_person_roles_sync_kind()
155
+ RETURNS trigger
156
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
157
+ AS $$
158
+ BEGIN
159
+ IF TG_OP = 'DELETE' THEN
160
+ PERFORM public.people_sync_kind(OLD.tenant_id, OLD.person_id);
161
+ RETURN NULL;
162
+ END IF;
163
+ PERFORM public.people_sync_kind(NEW.tenant_id, NEW.person_id);
164
+ IF TG_OP = 'UPDATE' AND (OLD.person_id <> NEW.person_id OR OLD.tenant_id <> NEW.tenant_id) THEN
165
+ PERFORM public.people_sync_kind(OLD.tenant_id, OLD.person_id); -- a role moved (merge): the old person too
166
+ END IF;
167
+ RETURN NULL;
168
+ END $$;
169
+ DROP TRIGGER IF EXISTS person_roles_sync_kind ON public.person_roles;
170
+ CREATE TRIGGER person_roles_sync_kind
171
+ AFTER INSERT OR UPDATE OR DELETE ON public.person_roles
172
+ FOR EACH ROW EXECUTE FUNCTION public.trg_person_roles_sync_kind();
173
+
174
+ -- kind → role: a legacy writer that says "this person is a customer" gets a
175
+ -- customer role, made primary (kind WAS the type; the write is honoured). Other
176
+ -- roles are left as they are — a kind write cannot express "no longer X";
177
+ -- closing a role is an explicit valid_to on person_roles. An existing row of
178
+ -- that role (any unit) is reused; a closed one is reopened.
179
+ CREATE OR REPLACE FUNCTION public.trg_people_kind_to_role()
180
+ RETURNS trigger
181
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
182
+ AS $$
183
+ DECLARE
184
+ v_role text;
185
+ v_id uuid;
186
+ BEGIN
187
+ IF coalesce(current_setting('fayz.people_kind_sync', true), 'off') = 'on' THEN
188
+ RETURN NULL;
189
+ END IF;
190
+ IF TG_OP = 'UPDATE' AND NEW.kind IS NOT DISTINCT FROM OLD.kind THEN
191
+ RETURN NULL;
192
+ END IF;
193
+ v_role := public.person_kind_to_role(NEW.kind);
194
+ IF v_role IS NULL THEN
195
+ RETURN NULL;
196
+ END IF;
197
+
198
+ SELECT r.id INTO v_id
199
+ FROM public.person_roles r
200
+ WHERE r.tenant_id = NEW.tenant_id AND r.person_id = NEW.id AND r.role = v_role
201
+ ORDER BY public.person_role_active(r.valid_from, r.valid_to) DESC, r.is_primary DESC, r.created_at
202
+ LIMIT 1;
203
+
204
+ UPDATE public.person_roles SET is_primary = false
205
+ WHERE tenant_id = NEW.tenant_id AND person_id = NEW.id AND is_primary AND (v_id IS NULL OR id <> v_id);
206
+
207
+ IF v_id IS NULL THEN
208
+ INSERT INTO public.person_roles (tenant_id, person_id, role, unit_id, is_primary)
209
+ VALUES (NEW.tenant_id, NEW.id, v_role, NEW.unit_id, true)
210
+ ON CONFLICT DO NOTHING;
211
+ ELSE
212
+ UPDATE public.person_roles
213
+ SET is_primary = true,
214
+ valid_from = least(valid_from, current_date),
215
+ valid_to = CASE WHEN valid_to IS NOT NULL AND valid_to <= current_date THEN NULL ELSE valid_to END
216
+ WHERE id = v_id AND (NOT is_primary OR NOT public.person_role_active(valid_from, valid_to));
217
+ END IF;
218
+ RETURN NULL;
219
+ END $$;
220
+ DROP TRIGGER IF EXISTS people_kind_to_role ON public.people;
221
+ CREATE TRIGGER people_kind_to_role
222
+ AFTER INSERT OR UPDATE OF kind ON public.people
223
+ FOR EACH ROW EXECUTE FUNCTION public.trg_people_kind_to_role();
224
+
225
+ -- ─────────────────────────────── the read contract ─────────────────────
226
+ -- What the agenda, the roster and the CRM read from now on: the active hats of
227
+ -- live people. An expired role is not here; a merged tombstone (071) and an
228
+ -- inactive person are not here. security_invoker: the caller's RLS on both
229
+ -- tables decides.
230
+ CREATE OR REPLACE VIEW public.v_people_active_roles WITH (security_invoker = true) AS
231
+ SELECT r.tenant_id, r.person_id, r.id AS role_id, r.role, r.unit_id, r.is_primary,
232
+ r.valid_from, r.valid_to, r.metadata,
233
+ p.name AS person_name, p.kind AS person_kind, p.email, p.phone, p.document_number,
234
+ p.unit_id AS person_unit_id, p.owner_id AS person_owner_id
235
+ FROM public.person_roles r
236
+ JOIN public.people p ON p.tenant_id = r.tenant_id AND p.id = r.person_id
237
+ WHERE public.person_role_active(r.valid_from, r.valid_to)
238
+ AND coalesce(p.is_active, true);
239
+ GRANT SELECT ON public.v_people_active_roles TO authenticated, service_role;
240
+
241
+ -- ─────────────────────────────── shared RPC guard ──────────────────────
242
+ -- Every people RPC (071/072/074) checks the same three things: a session
243
+ -- tenant, unit membership when the row has a unit, and the permission in that
244
+ -- unit. Raises 42501 — the RLS code — so a denied RPC and a denied write read
245
+ -- the same to the app.
246
+ CREATE OR REPLACE FUNCTION public.people_assert_scope(p_perm text, p_unit uuid DEFAULT NULL)
247
+ RETURNS uuid
248
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
249
+ AS $$
250
+ DECLARE v_tenant uuid := app.current_tenant_id();
251
+ BEGIN
252
+ IF v_tenant IS NULL THEN
253
+ RAISE EXCEPTION 'no active tenant for this session' USING ERRCODE = '42501';
254
+ END IF;
255
+ IF p_unit IS NOT NULL AND NOT app.has_unit(p_unit) THEN
256
+ RAISE EXCEPTION 'unit out of scope' USING ERRCODE = '42501';
257
+ END IF;
258
+ IF NOT app.has_permission(p_perm, p_unit) THEN
259
+ RAISE EXCEPTION 'permission % denied', p_perm USING ERRCODE = '42501';
260
+ END IF;
261
+ RETURN v_tenant;
262
+ END $$;
263
+ REVOKE ALL ON FUNCTION public.people_assert_scope(text, uuid) FROM public, anon;
264
+ GRANT EXECUTE ON FUNCTION public.people_assert_scope(text, uuid) TO authenticated, service_role;
265
+
266
+ COMMENT ON TABLE public.person_roles IS
267
+ 'The hats a Person wears in the tenant (customer · professional · employee · contact · supplier · lead), each with a validity window and an optional unit. people.kind is derived from the primary active role (compat; read-only in Phase 1, dropped in Phase 2).';
268
+ COMMENT ON COLUMN public.people.kind IS
269
+ 'DEPRECATED compat: derived from person_roles (primary active role → customer|staff|lead|supplier|contact). Writing it still works and creates/promotes the matching role. Read roles from public.v_people_active_roles. Read-only in Phase 1, dropped in Phase 2.';
@@ -0,0 +1,271 @@
1
+ -- ============================================================================
2
+ -- 081_people_identity.sql — identity by document, dedup, merge, quarantine
3
+ -- (PRD 03 R2 · PRD 10 R4 · ADR 0003 / 0007 · #137).
4
+ --
5
+ -- One human per tenant per document (CPF/CNPJ): document_number is normalised
6
+ -- on write and a partial unique index holds `(tenant_id, document_number)`.
7
+ -- The index is created ONLY when the data allows it — if duplicates already
8
+ -- exist when this file runs, they are written to person_identity_quarantine,
9
+ -- a NOTICE is raised and the index is skipped (never a silent merge; PRD 10
10
+ -- R4). people_identity_index_ensure() is the same step as a callable, so the
11
+ -- operator re-runs it after resolving the quarantine.
12
+ --
13
+ -- Merge is a decision, not a heuristic: people_merge(survivor, duplicate,
14
+ -- reason) works only when both rows carry the same non-null document;
15
+ -- anything else is "ambiguous identity" and raises. The duplicate becomes a
16
+ -- tombstone (merged_into_id), every FK that pointed at it is repointed, its
17
+ -- roles move, and two rows record it: audit_logs and person_merges (the row
18
+ -- PRD 10's migration ledger points at). Idempotent and replay-safe.
19
+ -- ============================================================================
20
+
21
+ -- ─────────────────────────────── normalisation ─────────────────────────
22
+ -- Canonical form = separators stripped, letters upper-cased, empty → NULL:
23
+ -- '529.982.247-25' and '52998224725' are the same CPF; '11.222.333/0001-81'
24
+ -- and '11222333000181' the same CNPJ. Letters are KEPT (upper-cased) rather
25
+ -- than stripped: Brazil's CNPJ is alphanumeric from July 2026 (RFB IN
26
+ -- 2.229/2024) — a digits-only rule would silently corrupt those.
27
+ CREATE OR REPLACE FUNCTION public.people_normalize_document(p_doc text)
28
+ RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
29
+ SELECT nullif(upper(regexp_replace(coalesce(p_doc, ''), '[^0-9A-Za-z]', '', 'g')), '')
30
+ $$;
31
+ GRANT EXECUTE ON FUNCTION public.people_normalize_document(text) TO anon, authenticated, service_role;
32
+
33
+ CREATE OR REPLACE FUNCTION public.trg_people_normalize_document()
34
+ RETURNS trigger LANGUAGE plpgsql AS $$
35
+ BEGIN
36
+ NEW.document_number := public.people_normalize_document(NEW.document_number);
37
+ RETURN NEW;
38
+ END $$;
39
+ DROP TRIGGER IF EXISTS people_normalize_document ON public.people;
40
+ CREATE TRIGGER people_normalize_document
41
+ BEFORE INSERT OR UPDATE OF document_number ON public.people
42
+ FOR EACH ROW EXECUTE FUNCTION public.trg_people_normalize_document();
43
+
44
+ -- Rows written before the trigger existed converge to the canonical form
45
+ -- (format only — no identity is decided here).
46
+ UPDATE public.people
47
+ SET document_number = public.people_normalize_document(document_number)
48
+ WHERE document_number IS NOT NULL
49
+ AND document_number IS DISTINCT FROM public.people_normalize_document(document_number);
50
+
51
+ -- ─────────────────────────────── tombstone ─────────────────────────────
52
+ ALTER TABLE public.people ADD COLUMN IF NOT EXISTS merged_into_id uuid;
53
+ DO $$
54
+ BEGIN
55
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.people'::regclass AND conname = 'people_merged_into_fk') THEN
56
+ ALTER TABLE public.people ADD CONSTRAINT people_merged_into_fk
57
+ FOREIGN KEY (tenant_id, merged_into_id) REFERENCES public.people(tenant_id, id) ON DELETE SET NULL;
58
+ END IF;
59
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.people'::regclass AND conname = 'people_merged_into_not_self') THEN
60
+ ALTER TABLE public.people ADD CONSTRAINT people_merged_into_not_self CHECK (merged_into_id IS DISTINCT FROM id);
61
+ END IF;
62
+ END $$;
63
+ CREATE INDEX IF NOT EXISTS people_merged_into_idx ON public.people (tenant_id, merged_into_id) WHERE merged_into_id IS NOT NULL;
64
+ COMMENT ON COLUMN public.people.merged_into_id IS
65
+ 'Set by people_merge(): this row is a tombstone of the human now recorded at merged_into_id. Excluded from the document unique index and from v_people_active_roles.';
66
+
67
+ -- ─────────────────────────────── quarantine + merge ledger ─────────────
68
+ -- Not scaffolded on purpose: these are ledgers, read by people.manage and
69
+ -- written only by the migration and the merge RPC. The generic
70
+ -- tenant_isolation_* policies a replay of 002 may add are dropped here.
71
+ CREATE TABLE IF NOT EXISTS public.person_identity_quarantine (
72
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
73
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
74
+ document_number text, -- NULL when the ambiguity is not by document
75
+ person_ids uuid[] NOT NULL,
76
+ reason text NOT NULL,
77
+ context jsonb NOT NULL DEFAULT '{}'::jsonb,
78
+ created_at timestamptz NOT NULL DEFAULT now(),
79
+ resolved_at timestamptz,
80
+ resolved_by uuid,
81
+ resolution text
82
+ );
83
+ CREATE UNIQUE INDEX IF NOT EXISTS person_identity_quarantine_open_doc_uidx
84
+ ON public.person_identity_quarantine (tenant_id, document_number) WHERE resolved_at IS NULL AND document_number IS NOT NULL;
85
+ CREATE INDEX IF NOT EXISTS person_identity_quarantine_tenant_idx ON public.person_identity_quarantine (tenant_id, created_at DESC);
86
+
87
+ CREATE TABLE IF NOT EXISTS public.person_merges (
88
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
89
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
90
+ survivor_id uuid NOT NULL,
91
+ duplicate_id uuid NOT NULL,
92
+ document_number text,
93
+ reason text NOT NULL,
94
+ actor uuid,
95
+ details jsonb NOT NULL DEFAULT '{}'::jsonb,
96
+ created_at timestamptz NOT NULL DEFAULT now(),
97
+ FOREIGN KEY (tenant_id, survivor_id) REFERENCES public.people(tenant_id, id) ON DELETE CASCADE,
98
+ FOREIGN KEY (tenant_id, duplicate_id) REFERENCES public.people(tenant_id, id) ON DELETE CASCADE
99
+ );
100
+ CREATE INDEX IF NOT EXISTS person_merges_survivor_idx ON public.person_merges (tenant_id, survivor_id);
101
+ CREATE INDEX IF NOT EXISTS person_merges_duplicate_idx ON public.person_merges (tenant_id, duplicate_id);
102
+
103
+ DO $$
104
+ DECLARE t text; p text;
105
+ BEGIN
106
+ FOREACH t IN ARRAY ARRAY['person_identity_quarantine', 'person_merges'] LOOP
107
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
108
+ EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', t);
109
+ FOREACH p IN ARRAY ARRAY['tenant_isolation_select', 'tenant_isolation_insert', 'tenant_isolation_update', 'tenant_isolation_delete'] LOOP
110
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', p, t);
111
+ END LOOP;
112
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_select', t);
113
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission(''people.manage'')))', t || '_select', t);
114
+ EXECUTE format('REVOKE ALL ON public.%I FROM anon, authenticated', t);
115
+ EXECUTE format('GRANT SELECT ON public.%I TO authenticated', t);
116
+ EXECUTE format('GRANT ALL ON public.%I TO service_role', t);
117
+ END LOOP;
118
+ END $$;
119
+
120
+ -- ─────────────────────────────── the unique index, guarded ─────────────
121
+ -- Creates people_tenant_document_uidx when no live duplicate exists; otherwise
122
+ -- quarantines each duplicate group and returns false (with a NOTICE). Callable
123
+ -- again after the quarantine is resolved. Not for the app: migration / ops.
124
+ CREATE OR REPLACE FUNCTION public.people_identity_index_ensure()
125
+ RETURNS boolean
126
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
127
+ AS $$
128
+ DECLARE v_groups integer;
129
+ BEGIN
130
+ IF to_regclass('public.people_tenant_document_uidx') IS NOT NULL THEN
131
+ RETURN true;
132
+ END IF;
133
+ INSERT INTO public.person_identity_quarantine (tenant_id, document_number, person_ids, reason, context)
134
+ SELECT p.tenant_id, p.document_number, array_agg(p.id ORDER BY p.created_at, p.id),
135
+ 'duplicate document_number found while creating people_tenant_document_uidx',
136
+ jsonb_build_object('source', 'people_identity_index_ensure', 'count', count(*))
137
+ FROM public.people p
138
+ WHERE p.document_number IS NOT NULL AND p.merged_into_id IS NULL
139
+ GROUP BY p.tenant_id, p.document_number
140
+ HAVING count(*) > 1
141
+ ON CONFLICT (tenant_id, document_number) WHERE resolved_at IS NULL AND document_number IS NOT NULL DO NOTHING;
142
+
143
+ SELECT count(*) INTO v_groups
144
+ FROM (SELECT 1 FROM public.people p
145
+ WHERE p.document_number IS NOT NULL AND p.merged_into_id IS NULL
146
+ GROUP BY p.tenant_id, p.document_number HAVING count(*) > 1) d;
147
+ IF v_groups > 0 THEN
148
+ RAISE NOTICE 'people identity: % duplicate document group(s) quarantined (public.person_identity_quarantine); people_tenant_document_uidx NOT created — resolve with people_merge() and call people_identity_index_ensure() again', v_groups;
149
+ RETURN false;
150
+ END IF;
151
+ EXECUTE 'CREATE UNIQUE INDEX IF NOT EXISTS people_tenant_document_uidx ON public.people (tenant_id, document_number) WHERE document_number IS NOT NULL AND merged_into_id IS NULL';
152
+ RETURN true;
153
+ END $$;
154
+ REVOKE ALL ON FUNCTION public.people_identity_index_ensure() FROM public, anon, authenticated;
155
+ GRANT EXECUTE ON FUNCTION public.people_identity_index_ensure() TO service_role;
156
+
157
+ SELECT public.people_identity_index_ensure();
158
+
159
+ -- ─────────────────────────────── people_merge ──────────────────────────
160
+ -- Requires people.manage in the session tenant. Only exact document equality
161
+ -- merges; everything else raises P0001 'ambiguous identity — quarantine'.
162
+ -- Effects, all in one transaction: person_roles moved (a role the survivor
163
+ -- already wears is dropped from the duplicate and listed in details); every
164
+ -- FK column that references people(id) in schemas public/app repointed (a
165
+ -- unique violation there — e.g. both humans hold a login — aborts the merge:
166
+ -- a human decides); the duplicate tombstoned (merged_into_id, is_active
167
+ -- false); an audit_logs row; a person_merges row; open quarantine rows that
168
+ -- now hold at most one live person marked resolved. Returns the survivor id.
169
+ CREATE OR REPLACE FUNCTION public.people_merge(p_survivor uuid, p_duplicate uuid, p_reason text)
170
+ RETURNS uuid
171
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
172
+ AS $$
173
+ DECLARE
174
+ v_tenant uuid := public.people_assert_scope('people.manage');
175
+ v_actor uuid := auth.uid();
176
+ s public.people%ROWTYPE;
177
+ d public.people%ROWTYPE;
178
+ v_moved integer := 0;
179
+ v_dropped jsonb := '[]'::jsonb;
180
+ v_repoint jsonb := '[]'::jsonb;
181
+ v_n integer;
182
+ fk record;
183
+ v_merge uuid;
184
+ BEGIN
185
+ IF p_survivor IS NULL OR p_duplicate IS NULL OR p_survivor = p_duplicate THEN
186
+ RAISE EXCEPTION 'people_merge: survivor and duplicate must be two different people' USING ERRCODE = '22023';
187
+ END IF;
188
+ IF p_reason IS NULL OR btrim(p_reason) = '' THEN
189
+ RAISE EXCEPTION 'people_merge: a reason is required' USING ERRCODE = '22023';
190
+ END IF;
191
+ SELECT * INTO s FROM public.people WHERE tenant_id = v_tenant AND id = p_survivor FOR UPDATE;
192
+ IF NOT FOUND THEN RAISE EXCEPTION 'people_merge: survivor not found in this tenant' USING ERRCODE = 'P0002'; END IF;
193
+ SELECT * INTO d FROM public.people WHERE tenant_id = v_tenant AND id = p_duplicate FOR UPDATE;
194
+ IF NOT FOUND THEN RAISE EXCEPTION 'people_merge: duplicate not found in this tenant' USING ERRCODE = 'P0002'; END IF;
195
+ IF (s.unit_id IS NOT NULL AND NOT app.has_unit(s.unit_id)) OR (d.unit_id IS NOT NULL AND NOT app.has_unit(d.unit_id)) THEN
196
+ RAISE EXCEPTION 'people_merge: both people must be in units of your scope' USING ERRCODE = '42501';
197
+ END IF;
198
+ IF s.merged_into_id IS NOT NULL OR d.merged_into_id IS NOT NULL THEN
199
+ RAISE EXCEPTION 'people_merge: one of the rows is already a merge tombstone' USING ERRCODE = 'P0001';
200
+ END IF;
201
+ IF s.document_number IS NULL OR d.document_number IS NULL OR s.document_number <> d.document_number THEN
202
+ RAISE EXCEPTION 'ambiguous identity — quarantine (people_merge only merges two people with the same non-null document_number)' USING ERRCODE = 'P0001';
203
+ END IF;
204
+
205
+ -- roles: move what the survivor lacks, drop (and record) what she already has
206
+ SELECT coalesce(jsonb_agg(jsonb_build_object('role', r.role, 'unit_id', r.unit_id, 'valid_from', r.valid_from, 'valid_to', r.valid_to, 'metadata', r.metadata)), '[]'::jsonb)
207
+ INTO v_dropped
208
+ FROM public.person_roles r
209
+ WHERE r.tenant_id = v_tenant AND r.person_id = p_duplicate
210
+ AND EXISTS (SELECT 1 FROM public.person_roles x
211
+ WHERE x.tenant_id = v_tenant AND x.person_id = p_survivor AND x.role = r.role
212
+ AND coalesce(x.unit_id, '00000000-0000-0000-0000-000000000000'::uuid) = coalesce(r.unit_id, '00000000-0000-0000-0000-000000000000'::uuid));
213
+ DELETE FROM public.person_roles r
214
+ WHERE r.tenant_id = v_tenant AND r.person_id = p_duplicate
215
+ AND EXISTS (SELECT 1 FROM public.person_roles x
216
+ WHERE x.tenant_id = v_tenant AND x.person_id = p_survivor AND x.role = r.role
217
+ AND coalesce(x.unit_id, '00000000-0000-0000-0000-000000000000'::uuid) = coalesce(r.unit_id, '00000000-0000-0000-0000-000000000000'::uuid));
218
+ UPDATE public.person_roles r
219
+ SET person_id = p_survivor,
220
+ is_primary = CASE WHEN EXISTS (SELECT 1 FROM public.person_roles x WHERE x.tenant_id = v_tenant AND x.person_id = p_survivor AND x.is_primary) THEN false ELSE r.is_primary END
221
+ WHERE r.tenant_id = v_tenant AND r.person_id = p_duplicate;
222
+ GET DIAGNOSTICS v_moved = ROW_COUNT;
223
+
224
+ -- every reference to the duplicate now points at the survivor (public + app)
225
+ FOR fk IN
226
+ SELECT n.nspname AS sch, c.relname AS tbl, a.attname AS col
227
+ FROM pg_constraint k
228
+ JOIN pg_class c ON c.oid = k.conrelid
229
+ JOIN pg_namespace n ON n.oid = c.relnamespace
230
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY (k.conkey)
231
+ WHERE k.contype = 'f' AND k.confrelid = 'public.people'::regclass
232
+ AND n.nspname IN ('public', 'app')
233
+ AND NOT (c.relname = 'person_roles' AND a.attname = 'person_id')
234
+ AND NOT (c.relname = 'person_merges')
235
+ AND a.attname <> 'tenant_id'
236
+ LOOP
237
+ EXECUTE format('UPDATE %I.%I SET %I = $1 WHERE %I = $2', fk.sch, fk.tbl, fk.col, fk.col) USING p_survivor, p_duplicate;
238
+ GET DIAGNOSTICS v_n = ROW_COUNT;
239
+ IF v_n > 0 THEN
240
+ v_repoint := v_repoint || jsonb_build_object('table', fk.sch || '.' || fk.tbl, 'column', fk.col, 'rows', v_n);
241
+ END IF;
242
+ END LOOP;
243
+
244
+ UPDATE public.people
245
+ SET merged_into_id = p_survivor, is_active = false
246
+ WHERE tenant_id = v_tenant AND id = p_duplicate;
247
+
248
+ INSERT INTO public.person_merges (tenant_id, survivor_id, duplicate_id, document_number, reason, actor, details)
249
+ VALUES (v_tenant, p_survivor, p_duplicate, s.document_number, p_reason, v_actor,
250
+ jsonb_build_object('roles_moved', v_moved, 'roles_dropped', v_dropped, 'repointed', v_repoint,
251
+ 'duplicate_snapshot', jsonb_build_object('name', d.name, 'email', d.email, 'phone', d.phone, 'kind', d.kind)))
252
+ RETURNING id INTO v_merge;
253
+
254
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
255
+ VALUES (v_tenant, v_actor, 'people.merge', 'people', p_survivor::text,
256
+ jsonb_build_object('duplicate_id', p_duplicate, 'reason', p_reason, 'merge_id', v_merge, 'document_number', s.document_number));
257
+
258
+ UPDATE public.person_identity_quarantine q
259
+ SET resolved_at = now(), resolved_by = v_actor, resolution = 'merged:' || v_merge::text
260
+ WHERE q.tenant_id = v_tenant AND q.resolved_at IS NULL AND q.person_ids @> ARRAY[p_duplicate]
261
+ AND (SELECT count(*) FROM public.people p WHERE p.tenant_id = v_tenant AND p.id = ANY (q.person_ids) AND p.merged_into_id IS NULL) <= 1;
262
+
263
+ RETURN p_survivor;
264
+ END $$;
265
+ REVOKE ALL ON FUNCTION public.people_merge(uuid, uuid, text) FROM public, anon;
266
+ GRANT EXECUTE ON FUNCTION public.people_merge(uuid, uuid, text) TO authenticated, service_role;
267
+
268
+ COMMENT ON TABLE public.person_identity_quarantine IS
269
+ 'Identity ambiguities that must be decided by a human (PRD 10 R4): duplicate documents found when creating the unique index, registrations whose signals match people without a document match. Never merged silently.';
270
+ COMMENT ON TABLE public.person_merges IS
271
+ 'Ledger of people_merge(): survivor, duplicate, document, reason, actor and what moved. The migration ledger (PRD 10) points at these rows for humans that arrived as two V1 records.';