@fayz-ai/db 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/canonical-schema.json +106 -23
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/schema/spine.d.ts +42 -37
- package/dist/schema/spine.d.ts.map +1 -1
- package/migrations/000_baseline.sql +29667 -0
- package/migrations/001_the_tenant_has_apps.sql +314 -0
- package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
- package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
- package/migrations/004_the_projections_get_a_successor.sql +312 -0
- package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
- package/migrations/006_the_helpers_read_the_source.sql +153 -0
- package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
- package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
- package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
- package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
- package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
- package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
- package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
- package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
- package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
- package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
- package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
- package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
- package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
- package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
- package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
- package/package.json +1 -1
- package/migrations/0000_legacy_quarantine.sql +0 -86
- package/migrations/0000b_straggler_sweep.sql +0 -37
- package/migrations/000_core_v1_convert.sql +0 -343
- package/migrations/001_core.sql +0 -372
- package/migrations/002_rls_user_tenant_ids.sql +0 -64
- package/migrations/003_invitations.sql +0 -17
- package/migrations/004_archetypes.sql +0 -343
- package/migrations/005_locations_archetype.sql +0 -18
- package/migrations/006_archetype_rls.sql +0 -74
- package/migrations/007_archetype_grants.sql +0 -4
- package/migrations/008_grants.sql +0 -5
- package/migrations/009_anon_hardening.sql +0 -19
- package/migrations/010_migration_ledger.sql +0 -46
- package/migrations/011_anon_write_revoke.sql +0 -12
- package/migrations/012_rename_migration_ledger.sql +0 -61
- package/migrations/013_sequences_rls.sql +0 -22
- package/migrations/014_plan_entitlements.sql +0 -28
- package/migrations/015_agent_guard.sql +0 -108
- package/migrations/016_agent_guard_actor.sql +0 -102
- package/migrations/017_core_addresses.sql +0 -115
- package/migrations/018_global_search.sql +0 -482
- package/migrations/019_tenant_members_person_id.sql +0 -20
- package/migrations/020_fayz_projects.sql +0 -63
- package/migrations/021_tenant_roles.sql +0 -74
- package/migrations/022_invite_acceptance.sql +0 -91
- package/migrations/023_team_visibility.sql +0 -75
- package/migrations/024_analytics_engine.sql +0 -317
- package/migrations/025_created_by.sql +0 -75
- package/migrations/026_audit_trail.sql +0 -73
- package/migrations/027_domain_events.sql +0 -266
- package/migrations/028_tenant_scoped_token.sql +0 -127
- package/migrations/029_connections.sql +0 -186
- package/migrations/030_effect_idempotency.sql +0 -159
- package/migrations/031_sync_run_message.sql +0 -39
- package/migrations/032_connection_secrets.sql +0 -227
- package/migrations/033_sync_schedule.sql +0 -651
- package/migrations/034_custom_fields.sql +0 -55
- package/migrations/035_field_registry.sql +0 -148
- package/migrations/036_analytics_run_batch.sql +0 -84
- package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
- package/migrations/038_onboarding_responses.sql +0 -103
- package/migrations/039_unit_tree.sql +0 -270
- package/migrations/040_resource_grants.sql +0 -474
- package/migrations/041_scoped_columns.sql +0 -192
- package/migrations/042_unit_scope_policies.sql +0 -145
- package/migrations/043_view_invoker.sql +0 -90
- package/migrations/044_unit_member_facts.sql +0 -47
- package/migrations/045_unit_entry.sql +0 -236
- package/migrations/046_membership_visible_to_members.sql +0 -85
- package/migrations/047_tasks.sql +0 -266
- package/migrations/048_every_login_is_a_person.sql +0 -190
- package/migrations/049_bookable_people.sql +0 -126
- package/migrations/050_authz_core.sql +0 -230
- package/migrations/051_authz_helpers.sql +0 -330
- package/migrations/052_authz_fanout.sql +0 -253
- package/migrations/053_authz_catalog_templates.sql +0 -402
- package/migrations/060_tenancy_seam.sql +0 -132
- package/migrations/061_create_tenant.sql +0 -125
- package/migrations/062_invitations.sql +0 -329
- package/migrations/063_legacy_tenancy_retired.sql +0 -568
- package/migrations/064_agent_guard_authz.sql +0 -118
- package/migrations/064b_supersede_unit_scope.sql +0 -205
- package/migrations/065_scaffold_sweep.sql +0 -139
- package/migrations/066_shadow_report.sql +0 -122
- package/migrations/070_permission_catalog.sql +0 -205
- package/migrations/071_v1_permission_map.sql +0 -266
- package/migrations/072_role_rpcs.sql +0 -126
- package/migrations/073_legacy_rbac_retire.sql +0 -471
- package/migrations/075_company_legal_entities.sql +0 -169
- package/migrations/076_tenant_settings.sql +0 -251
- package/migrations/080_people_roles.sql +0 -269
- package/migrations/081_people_identity.sql +0 -271
- package/migrations/082_people_membership_link.sql +0 -142
- package/migrations/083_people_data_migration.sql +0 -75
- package/migrations/084_people_registrations_tokens.sql +0 -282
- package/migrations/085_catalog_products.sql +0 -333
- package/migrations/086_catalog_pricing.sql +0 -194
- package/migrations/087_catalog_packages.sql +0 -134
- package/migrations/090_orders_venda.sql +0 -166
- package/migrations/091_orders_scaffold.sql +0 -92
- package/migrations/095_config_field_rules.sql +0 -261
- package/migrations/096_audit_append_only.sql +0 -256
- package/migrations/097_sequences_authorized.sql +0 -111
- package/migrations/098_holidays.sql +0 -80
- package/migrations/099_access_grants.sql +0 -293
- package/migrations/100_migration_schema.sql +0 -217
- package/migrations/101_migration_allowlist.sql +0 -202
- package/migrations/102_migration_rpcs.sql +0 -933
- package/migrations/103_migration_reconcile_ponr.sql +0 -132
- package/migrations/105_policies_to_authenticated.sql +0 -71
- package/migrations/106_function_search_path.sql +0 -55
- package/migrations/107_core_table_prefix.sql +0 -361
- package/migrations/108_authz_write_guards.sql +0 -149
- package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
- package/migrations/110_people_kind_vocabulary.sql +0 -120
- package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
- package/migrations/112_money_precision_standard.sql +0 -132
- package/migrations/113_addresses_units_companies.sql +0 -114
- package/migrations/114_migrations_ledger_immutable.sql +0 -65
- package/migrations/115_registration_honours_field_rules.sql +0 -172
- package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
- package/migrations/117_authz_reconciliation_schedule.sql +0 -82
- package/migrations/118_migration_catalog_writers.sql +0 -450
- package/migrations/119_migration_config_writers.sql +0 -193
- package/migrations/120_migration_facet_writer.sql +0 -139
- package/migrations/121_migration_financial_writers.sql +0 -294
- package/migrations/122_migration_inventory_writers.sql +0 -245
- package/migrations/123_migration_order_item_writer.sql +0 -144
- package/migrations/124_migration_ledger_writers.sql +0 -170
- package/migrations/125_migration_authz_people_writers.sql +0 -205
- package/migrations/126_owner_scoping_is_configuration.sql +0 -247
- package/migrations/127_scaffold_audit_hook.sql +0 -98
- package/migrations/128_shadow_tables_enforced.sql +0 -93
- package/migrations/129_order_kind_terminal_states.sql +0 -57
- package/migrations/130_every_login_is_a_person.sql +0 -196
- package/migrations/131_migration_invoice_split.sql +0 -127
- package/migrations/132_audit_prune_scheduled.sql +0 -28
- package/migrations/133_unit_is_a_session_property.sql +0 -155
- package/migrations/134_field_rules_on_read.sql +0 -341
- package/migrations/135_distribution_polarity.sql +0 -158
- package/migrations/136_unit_id_means_locality.sql +0 -358
- package/migrations/137_vendor_named_spine_objects.sql +0 -126
- package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
- package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
- package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
- package/migrations/142_a_person_has_addresses.sql +0 -212
- package/migrations/143_a_professional_has_a_rate.sql +0 -75
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
- package/migrations/146_products_stock_has_one_writer.sql +0 -67
- package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
- package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
- package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
- package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
- package/migrations/152_a_file_is_an_archetype.sql +0 -222
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
- package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
- package/migrations/155_a_tenant_can_leave.sql +0 -271
- package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
- package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
- package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
- package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
- package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
- package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
- package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
- package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
|
@@ -1,269 +0,0 @@
|
|
|
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.';
|
|
@@ -1,271 +0,0 @@
|
|
|
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.';
|