@fayz-ai/db 0.12.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 (116) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/040_resource_grants.sql +2 -2
  4. package/migrations/041_scoped_columns.sql +2 -2
  5. package/migrations/042_unit_scope_policies.sql +1 -1
  6. package/migrations/043_view_invoker.sql +12 -3
  7. package/migrations/044_unit_member_facts.sql +1 -1
  8. package/migrations/045_unit_entry.sql +1 -1
  9. package/migrations/046_membership_visible_to_members.sql +1 -1
  10. package/migrations/047_tasks.sql +4 -4
  11. package/migrations/048_every_login_is_a_person.sql +1 -1
  12. package/migrations/049_bookable_people.sql +1 -1
  13. package/migrations/050_authz_core.sql +230 -0
  14. package/migrations/051_authz_helpers.sql +330 -0
  15. package/migrations/052_authz_fanout.sql +253 -0
  16. package/migrations/053_authz_catalog_templates.sql +402 -0
  17. package/migrations/060_tenancy_seam.sql +132 -0
  18. package/migrations/061_create_tenant.sql +125 -0
  19. package/migrations/062_invitations.sql +329 -0
  20. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  21. package/migrations/064_agent_guard_authz.sql +118 -0
  22. package/migrations/064b_supersede_unit_scope.sql +205 -0
  23. package/migrations/065_scaffold_sweep.sql +139 -0
  24. package/migrations/066_shadow_report.sql +122 -0
  25. package/migrations/070_permission_catalog.sql +205 -0
  26. package/migrations/071_v1_permission_map.sql +266 -0
  27. package/migrations/072_role_rpcs.sql +126 -0
  28. package/migrations/073_legacy_rbac_retire.sql +471 -0
  29. package/migrations/075_company_legal_entities.sql +169 -0
  30. package/migrations/076_tenant_settings.sql +251 -0
  31. package/migrations/080_people_roles.sql +269 -0
  32. package/migrations/081_people_identity.sql +271 -0
  33. package/migrations/082_people_membership_link.sql +142 -0
  34. package/migrations/083_people_data_migration.sql +75 -0
  35. package/migrations/084_people_registrations_tokens.sql +282 -0
  36. package/migrations/085_catalog_products.sql +333 -0
  37. package/migrations/086_catalog_pricing.sql +194 -0
  38. package/migrations/087_catalog_packages.sql +134 -0
  39. package/migrations/090_orders_venda.sql +166 -0
  40. package/migrations/091_orders_scaffold.sql +92 -0
  41. package/migrations/095_config_field_rules.sql +261 -0
  42. package/migrations/096_audit_append_only.sql +256 -0
  43. package/migrations/097_sequences_authorized.sql +111 -0
  44. package/migrations/098_holidays.sql +80 -0
  45. package/migrations/099_access_grants.sql +293 -0
  46. package/migrations/100_migration_schema.sql +217 -0
  47. package/migrations/101_migration_allowlist.sql +202 -0
  48. package/migrations/102_migration_rpcs.sql +933 -0
  49. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  50. package/migrations/105_policies_to_authenticated.sql +71 -0
  51. package/migrations/106_function_search_path.sql +55 -0
  52. package/migrations/107_core_table_prefix.sql +361 -0
  53. package/migrations/108_authz_write_guards.sql +149 -0
  54. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  55. package/migrations/110_people_kind_vocabulary.sql +120 -0
  56. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  57. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  58. package/migrations/112_money_precision_standard.sql +132 -0
  59. package/migrations/113_addresses_units_companies.sql +114 -0
  60. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  61. package/migrations/115_registration_honours_field_rules.sql +172 -0
  62. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  63. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  64. package/migrations/118_migration_catalog_writers.sql +450 -0
  65. package/migrations/119_migration_config_writers.sql +193 -0
  66. package/migrations/120_migration_facet_writer.sql +139 -0
  67. package/migrations/121_migration_financial_writers.sql +294 -0
  68. package/migrations/122_migration_inventory_writers.sql +245 -0
  69. package/migrations/123_migration_order_item_writer.sql +144 -0
  70. package/migrations/124_migration_ledger_writers.sql +170 -0
  71. package/migrations/125_migration_authz_people_writers.sql +205 -0
  72. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  73. package/migrations/127_scaffold_audit_hook.sql +98 -0
  74. package/migrations/128_shadow_tables_enforced.sql +93 -0
  75. package/migrations/129_order_kind_terminal_states.sql +57 -0
  76. package/migrations/130_every_login_is_a_person.sql +196 -0
  77. package/migrations/131_migration_invoice_split.sql +127 -0
  78. package/migrations/132_audit_prune_scheduled.sql +28 -0
  79. package/migrations/133_unit_is_a_session_property.sql +155 -0
  80. package/migrations/134_field_rules_on_read.sql +341 -0
  81. package/migrations/135_distribution_polarity.sql +158 -0
  82. package/migrations/136_unit_id_means_locality.sql +358 -0
  83. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  84. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  85. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  86. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  87. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  88. package/migrations/142_a_person_has_addresses.sql +212 -0
  89. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  90. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  91. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  92. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  93. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  94. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  95. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  96. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  97. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  98. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  99. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  100. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  101. package/migrations/155_a_tenant_can_leave.sql +271 -0
  102. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  103. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  104. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  105. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  106. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  107. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  108. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  109. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  110. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  111. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  112. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  113. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  114. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  115. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  116. package/package.json +3 -2
@@ -0,0 +1,142 @@
1
+ -- ============================================================================
2
+ -- 082_people_membership_link.sql — the login and the Person are one human
3
+ -- (PRD 03 R3 · ADR 0003 / 0006 · #137).
4
+ --
5
+ -- app.memberships.person_id (040) now points at people(tenant_id, id): one
6
+ -- user = one Person per tenant (partial unique), and a membership can only be
7
+ -- linked to a live person of its own tenant. The link is made by
8
+ -- people_link_membership(person, user) (members.manage), which also gives the
9
+ -- person an employee role if she has no staff role yet — a login is a member
10
+ -- of the team. The reverse rule ("every professional/employee with a login
11
+ -- has person_id") cannot be a constraint — a professional without a login is
12
+ -- legitimate — so it is enforced softly: v_people_missing_membership_link
13
+ -- lists the memberships of the tenant that still have no Person, with the
14
+ -- suggested match by e-mail. Idempotent and replay-safe.
15
+ -- ============================================================================
16
+
17
+ DO $$
18
+ BEGIN
19
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'app.memberships'::regclass AND conname = 'memberships_person_fk') THEN
20
+ ALTER TABLE app.memberships ADD CONSTRAINT memberships_person_fk
21
+ FOREIGN KEY (tenant_id, person_id) REFERENCES public.people(tenant_id, id) ON DELETE SET NULL;
22
+ END IF;
23
+ END $$;
24
+ -- one Person ↔ at most one membership per tenant
25
+ CREATE UNIQUE INDEX IF NOT EXISTS memberships_tenant_person_uidx
26
+ ON app.memberships (tenant_id, person_id) WHERE person_id IS NOT NULL;
27
+
28
+ -- a membership never points at a merge tombstone
29
+ CREATE OR REPLACE FUNCTION app.trg_memberships_person_link_check()
30
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
31
+ BEGIN
32
+ IF NEW.person_id IS NULL THEN
33
+ RETURN NEW;
34
+ END IF;
35
+ IF EXISTS (SELECT 1 FROM public.people p WHERE p.tenant_id = NEW.tenant_id AND p.id = NEW.person_id AND p.merged_into_id IS NOT NULL) THEN
36
+ RAISE EXCEPTION 'memberships.person_id must point at a live person, not a merge tombstone' USING ERRCODE = '23514';
37
+ END IF;
38
+ RETURN NEW;
39
+ END $$;
40
+ DROP TRIGGER IF EXISTS memberships_person_link_check ON app.memberships;
41
+ CREATE TRIGGER memberships_person_link_check
42
+ BEFORE INSERT OR UPDATE OF person_id ON app.memberships
43
+ FOR EACH ROW EXECUTE FUNCTION app.trg_memberships_person_link_check();
44
+
45
+ -- ─────────────────────────────── the RPC ───────────────────────────────
46
+ -- Links the membership of p_user (in the session tenant) to p_person. The
47
+ -- person must be a live person of the tenant, in a unit of the caller's
48
+ -- scope; neither side may already be linked elsewhere. Ensures the person
49
+ -- wears at least one staff role (professional or employee) — creates an
50
+ -- employee role when she wears none. Returns the membership id.
51
+ CREATE OR REPLACE FUNCTION public.people_link_membership(p_person uuid, p_user uuid)
52
+ RETURNS uuid
53
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
54
+ AS $$
55
+ DECLARE
56
+ v_tenant uuid := public.people_assert_scope('members.manage');
57
+ p public.people%ROWTYPE;
58
+ m app.memberships%ROWTYPE;
59
+ v_other uuid;
60
+ BEGIN
61
+ SELECT * INTO p FROM public.people WHERE tenant_id = v_tenant AND id = p_person FOR UPDATE;
62
+ IF NOT FOUND THEN RAISE EXCEPTION 'people_link_membership: person not found in this tenant' USING ERRCODE = 'P0002'; END IF;
63
+ IF p.merged_into_id IS NOT NULL THEN
64
+ RAISE EXCEPTION 'people_link_membership: person is a merge tombstone (use %)', p.merged_into_id USING ERRCODE = 'P0001';
65
+ END IF;
66
+ IF p.unit_id IS NOT NULL AND NOT app.has_unit(p.unit_id) THEN
67
+ RAISE EXCEPTION 'people_link_membership: person is in a unit out of your scope' USING ERRCODE = '42501';
68
+ END IF;
69
+ SELECT * INTO m FROM app.memberships WHERE tenant_id = v_tenant AND user_id = p_user FOR UPDATE;
70
+ IF NOT FOUND THEN RAISE EXCEPTION 'people_link_membership: no membership for this user in this tenant' USING ERRCODE = 'P0002'; END IF;
71
+ IF m.person_id IS NOT NULL AND m.person_id <> p_person THEN
72
+ RAISE EXCEPTION 'people_link_membership: this login is already linked to another person (%)', m.person_id USING ERRCODE = '23505';
73
+ END IF;
74
+ SELECT x.user_id INTO v_other FROM app.memberships x WHERE x.tenant_id = v_tenant AND x.person_id = p_person AND x.user_id <> p_user;
75
+ IF v_other IS NOT NULL THEN
76
+ RAISE EXCEPTION 'people_link_membership: this person is already linked to another login (%)', v_other USING ERRCODE = '23505';
77
+ END IF;
78
+
79
+ IF m.person_id IS DISTINCT FROM p_person THEN
80
+ UPDATE app.memberships SET person_id = p_person WHERE tenant_id = v_tenant AND user_id = p_user;
81
+ END IF;
82
+
83
+ -- a login is a member of the team: at least one staff role
84
+ IF NOT EXISTS (SELECT 1 FROM public.person_roles r
85
+ WHERE r.tenant_id = v_tenant AND r.person_id = p_person
86
+ AND r.role IN ('professional', 'employee')
87
+ AND public.person_role_active(r.valid_from, r.valid_to)) THEN
88
+ INSERT INTO public.person_roles (tenant_id, person_id, role, unit_id, is_primary, metadata)
89
+ VALUES (v_tenant, p_person, 'employee', p.unit_id,
90
+ NOT EXISTS (SELECT 1 FROM public.person_roles r WHERE r.tenant_id = v_tenant AND r.person_id = p_person AND r.is_primary),
91
+ jsonb_build_object('source', 'people_link_membership'))
92
+ ON CONFLICT DO NOTHING;
93
+ -- an expired employee row for the same unit blocks the insert above: reopen it
94
+ UPDATE public.person_roles r
95
+ SET valid_to = NULL, valid_from = least(r.valid_from, current_date)
96
+ WHERE r.tenant_id = v_tenant AND r.person_id = p_person AND r.role = 'employee'
97
+ AND NOT public.person_role_active(r.valid_from, r.valid_to)
98
+ AND NOT EXISTS (SELECT 1 FROM public.person_roles x WHERE x.tenant_id = v_tenant AND x.person_id = p_person
99
+ AND x.role IN ('professional', 'employee') AND public.person_role_active(x.valid_from, x.valid_to));
100
+ END IF;
101
+
102
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
103
+ VALUES (v_tenant, auth.uid(), 'people.link_membership', 'people', p_person::text, jsonb_build_object('user_id', p_user, 'membership_id', m.id));
104
+ RETURN m.id;
105
+ END $$;
106
+ REVOKE ALL ON FUNCTION public.people_link_membership(uuid, uuid) FROM public, anon;
107
+ GRANT EXECUTE ON FUNCTION public.people_link_membership(uuid, uuid) TO authenticated, service_role;
108
+
109
+ -- ─────────────────────────────── the soft rule, visible ────────────────
110
+ -- The person a human would probably link to a login: same e-mail as the
111
+ -- auth user, live, wearing a staff role. auth.users is not readable by API
112
+ -- roles, so this is a definer function that answers only for members.read
113
+ -- holders of the tenant (the same gate as the memberships read policy).
114
+ CREATE OR REPLACE FUNCTION public.people_suggest_for_user(p_tenant uuid, p_user uuid)
115
+ RETURNS uuid
116
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
117
+ AS $$
118
+ SELECT p.id
119
+ FROM auth.users u
120
+ JOIN public.people p ON p.tenant_id = p_tenant AND p.merged_into_id IS NULL
121
+ AND p.email IS NOT NULL AND lower(p.email) = lower(u.email)
122
+ WHERE u.id = p_user
123
+ AND p_tenant = app.current_tenant_id()
124
+ AND app.has_permission('members.read')
125
+ AND EXISTS (SELECT 1 FROM public.person_roles r
126
+ WHERE r.tenant_id = p.tenant_id AND r.person_id = p.id
127
+ AND r.role IN ('professional', 'employee')
128
+ AND public.person_role_active(r.valid_from, r.valid_to))
129
+ ORDER BY p.created_at
130
+ LIMIT 1;
131
+ $$;
132
+ REVOKE ALL ON FUNCTION public.people_suggest_for_user(uuid, uuid) FROM public, anon;
133
+ GRANT EXECUTE ON FUNCTION public.people_suggest_for_user(uuid, uuid) TO authenticated, service_role;
134
+
135
+ -- Memberships of the tenant with no Person yet (+ the suggested match). Reads
136
+ -- through the caller's RLS on memberships (members.read or self).
137
+ CREATE OR REPLACE VIEW public.v_people_missing_membership_link WITH (security_invoker = true) AS
138
+ SELECT m.tenant_id, m.id AS membership_id, m.user_id, m.role_id, m.active,
139
+ public.people_suggest_for_user(m.tenant_id, m.user_id) AS suggested_person_id
140
+ FROM app.memberships m
141
+ WHERE m.person_id IS NULL;
142
+ GRANT SELECT ON public.v_people_missing_membership_link TO authenticated, service_role;
@@ -0,0 +1,75 @@
1
+ -- ============================================================================
2
+ -- 083_people_data_migration.sql — every existing Person gets its roles
3
+ -- (PRD 03 R1/R4 · #137). Data only, idempotent, guarded.
4
+ --
5
+ -- Rows written before 070 have a kind and no role. They converge here:
6
+ -- people.kind customer → customer · staff → employee · lead → lead (CRM) ·
7
+ -- supplier|vendor → supplier (inventory) · contact → contact ·
8
+ -- professional → professional · employee → employee
9
+ -- staff_members (the resto-saas / salon app extension, when present) →
10
+ -- professional role in addition to employee: v_staff is who
11
+ -- the agenda assigns and pays a rate to
12
+ -- Kinds outside the vocabulary (an app's 'teacher', 'patient', …) get no
13
+ -- role and keep their kind — they are the app's, not the platform's.
14
+ -- Nothing here merges people or touches documents (071 quarantines; humans
15
+ -- decide). Re-running is a no-op: every insert is ON CONFLICT DO NOTHING.
16
+ -- ============================================================================
17
+
18
+ -- 1. kind → the primary role. Roles are inserted through the same trigger
19
+ -- path a new writer would take, so kind and role agree afterwards
20
+ -- ('vendor' converges to 'supplier').
21
+ INSERT INTO public.person_roles (tenant_id, person_id, role, unit_id, is_primary, metadata)
22
+ SELECT p.tenant_id, p.id, public.person_kind_to_role(p.kind), p.unit_id, true,
23
+ jsonb_build_object('source', 'migration:073', 'from_kind', p.kind)
24
+ FROM public.people p
25
+ WHERE public.person_kind_to_role(p.kind) IS NOT NULL
26
+ AND p.merged_into_id IS NULL
27
+ AND NOT EXISTS (SELECT 1 FROM public.person_roles r WHERE r.tenant_id = p.tenant_id AND r.person_id = p.id)
28
+ ON CONFLICT DO NOTHING;
29
+
30
+ -- 2. staff_members (app extension, PK person_id) → professional role. Guarded
31
+ -- on the table AND its columns: the extension is the app's, not ours.
32
+ DO $$
33
+ DECLARE v_n integer := 0;
34
+ BEGIN
35
+ IF to_regclass('public.staff_members') IS NOT NULL
36
+ AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'staff_members' AND column_name = 'person_id') THEN
37
+ INSERT INTO public.person_roles (tenant_id, person_id, role, unit_id, is_primary, metadata)
38
+ SELECT p.tenant_id, p.id, 'professional', p.unit_id, false,
39
+ jsonb_build_object('source', 'migration:073', 'from', 'staff_members')
40
+ FROM public.staff_members s
41
+ JOIN public.people p ON p.id = s.person_id
42
+ WHERE p.merged_into_id IS NULL
43
+ AND NOT EXISTS (SELECT 1 FROM public.person_roles r
44
+ WHERE r.tenant_id = p.tenant_id AND r.person_id = p.id AND r.role = 'professional')
45
+ ON CONFLICT DO NOTHING;
46
+ GET DIAGNOSTICS v_n = ROW_COUNT;
47
+ RAISE NOTICE 'people: % staff_members row(s) received a professional role', v_n;
48
+ END IF;
49
+ END $$;
50
+
51
+ -- 3. CRM leads (kind = 'lead') and inventory suppliers (kind = 'supplier') are
52
+ -- covered by step 1 — both plugins write kind, so their rows already carry
53
+ -- the right role. Nothing plugin-specific to do here; the plugins keep
54
+ -- working on kind until they read v_people_active_roles (Phase 1).
55
+
56
+ -- 4. Reconcile: a person whose kind disagrees with her primary active role
57
+ -- (only possible for rows edited between 070 and now) is re-derived.
58
+ DO $$
59
+ DECLARE r record;
60
+ BEGIN
61
+ FOR r IN
62
+ SELECT p.tenant_id, p.id
63
+ FROM public.people p
64
+ WHERE p.merged_into_id IS NULL
65
+ AND public.person_kind_to_role(p.kind) IS NOT NULL
66
+ AND EXISTS (SELECT 1 FROM public.person_roles x WHERE x.tenant_id = p.tenant_id AND x.person_id = p.id)
67
+ AND p.kind IS DISTINCT FROM public.person_role_to_kind((
68
+ SELECT x.role FROM public.person_roles x
69
+ WHERE x.tenant_id = p.tenant_id AND x.person_id = p.id
70
+ AND public.person_role_active(x.valid_from, x.valid_to)
71
+ ORDER BY x.is_primary DESC, x.valid_from, x.created_at, public.person_role_precedence(x.role), x.id LIMIT 1))
72
+ LOOP
73
+ PERFORM public.people_sync_kind(r.tenant_id, r.id);
74
+ END LOOP;
75
+ END $$;
@@ -0,0 +1,282 @@
1
+ -- ============================================================================
2
+ -- 084_people_registrations_tokens.sql — the public edges of People, as
3
+ -- contracts (PRD 03 R6 · RN-CLI-01/02 · #137). No UI here.
4
+ --
5
+ -- person_registrations: the pre-registration queue (V1 client_registrations).
6
+ -- A row arrives with the raw payload (the QR form of the unit, fields per
7
+ -- FieldRules); person_registrations_process() turns it into a Person with
8
+ -- dedup by exact CPF: one match → 'merged' (the existing person, plus a
9
+ -- customer role); no signal → 'created'; anything ambiguous (2+ document
10
+ -- matches, or e-mail/phone matches without a document match) → 'quarantined'
11
+ -- with a person_identity_quarantine row — a human then resolves it with the
12
+ -- 'create' or 'merge' actions. Never a silent merge (PRD 10 R4).
13
+ --
14
+ -- person_access_tokens: opaque, single-use, expiring tokens for the client
15
+ -- portal (V1 client_access_tokens). Only the hash is stored; the token is
16
+ -- returned once by person_access_token_issue() (people.edit) and redeemed by
17
+ -- anon through person_access_token_redeem() — there is no anon SELECT, ever.
18
+ -- Idempotent and replay-safe.
19
+ -- ============================================================================
20
+
21
+ -- ─────────────────────────────── registrations ─────────────────────────
22
+ CREATE TABLE IF NOT EXISTS public.person_registrations (
23
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
24
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
25
+ unit_id uuid,
26
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb, -- the form as submitted (name, phone, document_number, email, …)
27
+ document_number text, -- normalised copy of payload.document_number
28
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'merged', 'created', 'quarantined', 'rejected')),
29
+ matched_person_id uuid,
30
+ quarantine_id uuid REFERENCES public.person_identity_quarantine(id) ON DELETE SET NULL,
31
+ created_at timestamptz NOT NULL DEFAULT now(),
32
+ updated_at timestamptz NOT NULL DEFAULT now(),
33
+ processed_at timestamptz,
34
+ processed_by uuid,
35
+ FOREIGN KEY (tenant_id, matched_person_id) REFERENCES public.people(tenant_id, id) ON DELETE SET NULL
36
+ );
37
+ CREATE INDEX IF NOT EXISTS person_registrations_pending_idx ON public.person_registrations (tenant_id, status, created_at);
38
+ CREATE INDEX IF NOT EXISTS person_registrations_document_idx ON public.person_registrations (tenant_id, document_number) WHERE document_number IS NOT NULL;
39
+
40
+ -- the document column mirrors the payload, normalised
41
+ CREATE OR REPLACE FUNCTION public.trg_person_registrations_normalize()
42
+ RETURNS trigger LANGUAGE plpgsql AS $$
43
+ BEGIN
44
+ NEW.document_number := public.people_normalize_document(coalesce(NEW.document_number, NEW.payload ->> 'document_number'));
45
+ RETURN NEW;
46
+ END $$;
47
+ DROP TRIGGER IF EXISTS person_registrations_normalize ON public.person_registrations;
48
+ CREATE TRIGGER person_registrations_normalize
49
+ BEFORE INSERT OR UPDATE OF payload, document_number ON public.person_registrations
50
+ FOR EACH ROW EXECUTE FUNCTION public.trg_person_registrations_normalize();
51
+
52
+ SELECT app.scaffold_table('public.person_registrations', 'people.person_registration', 'people', false, 'enforce');
53
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.person_registrations TO authenticated;
54
+ GRANT ALL ON public.person_registrations TO service_role;
55
+ REVOKE ALL ON public.person_registrations FROM anon;
56
+
57
+ -- Turns a registration into a Person. Actions:
58
+ -- 'process' (default) — the dedup rules above, from 'pending'
59
+ -- 'create' — a human decided it is a new person, from 'quarantined'
60
+ -- 'merge' — a human picked p_person_id, from 'quarantined' (people.manage)
61
+ -- 'reject' — from 'pending' or 'quarantined'
62
+ -- Requires people.create in the registration's unit. Returns
63
+ -- {registration_id, status, person_id}. Never edits an existing person's data.
64
+ CREATE OR REPLACE FUNCTION public.person_registrations_process(p_id uuid, p_action text DEFAULT 'process', p_person_id uuid DEFAULT NULL)
65
+ RETURNS jsonb
66
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
67
+ AS $$
68
+ DECLARE
69
+ v_tenant uuid := app.current_tenant_id();
70
+ v_actor uuid := auth.uid();
71
+ r public.person_registrations%ROWTYPE;
72
+ v_email text;
73
+ v_phone text;
74
+ v_by_doc uuid[];
75
+ v_by_sig uuid[];
76
+ v_person uuid;
77
+ v_q uuid;
78
+ v_status text;
79
+ BEGIN
80
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'no active tenant for this session' USING ERRCODE = '42501'; END IF;
81
+ IF p_action NOT IN ('process', 'create', 'merge', 'reject') THEN
82
+ RAISE EXCEPTION 'person_registrations_process: unknown action %', p_action USING ERRCODE = '22023';
83
+ END IF;
84
+ SELECT * INTO r FROM public.person_registrations WHERE tenant_id = v_tenant AND id = p_id FOR UPDATE;
85
+ IF NOT FOUND THEN RAISE EXCEPTION 'person_registrations_process: registration not found in this tenant' USING ERRCODE = 'P0002'; END IF;
86
+ PERFORM public.people_assert_scope(CASE WHEN p_action = 'merge' THEN 'people.manage' ELSE 'people.create' END, r.unit_id);
87
+
88
+ IF p_action = 'process' AND r.status <> 'pending' THEN
89
+ RAISE EXCEPTION 'person_registrations_process: only a pending registration can be processed (status %)', r.status USING ERRCODE = 'P0001';
90
+ END IF;
91
+ IF p_action IN ('create', 'merge') AND r.status <> 'quarantined' THEN
92
+ RAISE EXCEPTION 'person_registrations_process: % resolves a quarantined registration (status %)', p_action, r.status USING ERRCODE = 'P0001';
93
+ END IF;
94
+ IF p_action = 'reject' AND r.status NOT IN ('pending', 'quarantined') THEN
95
+ RAISE EXCEPTION 'person_registrations_process: registration already %', r.status USING ERRCODE = 'P0001';
96
+ END IF;
97
+
98
+ IF p_action = 'reject' THEN
99
+ UPDATE public.person_registrations SET status = 'rejected', processed_at = now(), processed_by = v_actor WHERE id = r.id;
100
+ RETURN jsonb_build_object('registration_id', r.id, 'status', 'rejected', 'person_id', NULL);
101
+ END IF;
102
+
103
+ IF p_action = 'merge' THEN
104
+ IF p_person_id IS NULL THEN RAISE EXCEPTION 'person_registrations_process: merge needs p_person_id' USING ERRCODE = '22023'; END IF;
105
+ SELECT p.id INTO v_person FROM public.people p WHERE p.tenant_id = v_tenant AND p.id = p_person_id AND p.merged_into_id IS NULL;
106
+ IF v_person IS NULL THEN RAISE EXCEPTION 'person_registrations_process: person not found in this tenant' USING ERRCODE = 'P0002'; END IF;
107
+ v_status := 'merged';
108
+ ELSIF p_action = 'create' THEN
109
+ v_person := NULL;
110
+ v_status := 'created';
111
+ ELSE
112
+ -- 'process': the dedup rules
113
+ v_email := nullif(lower(btrim(r.payload ->> 'email')), '');
114
+ v_phone := nullif(regexp_replace(coalesce(r.payload ->> 'phone', ''), '\D', '', 'g'), '');
115
+ IF r.document_number IS NOT NULL THEN
116
+ SELECT coalesce(array_agg(p.id ORDER BY p.created_at), '{}') INTO v_by_doc
117
+ FROM public.people p
118
+ WHERE p.tenant_id = v_tenant AND p.merged_into_id IS NULL AND p.document_number = r.document_number;
119
+ ELSE
120
+ v_by_doc := '{}';
121
+ END IF;
122
+ IF cardinality(v_by_doc) = 1 THEN
123
+ v_person := v_by_doc[1];
124
+ v_status := 'merged';
125
+ ELSIF cardinality(v_by_doc) > 1 THEN
126
+ v_status := 'quarantined';
127
+ v_by_sig := v_by_doc;
128
+ ELSE
129
+ -- no document match: a same e-mail / same phone person is a possible
130
+ -- duplicate the CPF cannot confirm — a human decides
131
+ SELECT coalesce(array_agg(p.id ORDER BY p.created_at), '{}') INTO v_by_sig
132
+ FROM public.people p
133
+ WHERE p.tenant_id = v_tenant AND p.merged_into_id IS NULL
134
+ AND ((v_email IS NOT NULL AND lower(p.email) = v_email)
135
+ OR (v_phone IS NOT NULL AND regexp_replace(coalesce(p.phone, ''), '\D', '', 'g') = v_phone));
136
+ IF cardinality(v_by_sig) > 0 THEN
137
+ v_status := 'quarantined';
138
+ ELSE
139
+ v_status := 'created';
140
+ END IF;
141
+ END IF;
142
+
143
+ IF v_status = 'quarantined' THEN
144
+ INSERT INTO public.person_identity_quarantine (tenant_id, document_number, person_ids, reason, context)
145
+ VALUES (v_tenant, r.document_number, v_by_sig,
146
+ CASE WHEN cardinality(v_by_doc) > 1 THEN 'registration: more than one person carries this document'
147
+ ELSE 'registration: e-mail/phone match without a document match' END,
148
+ jsonb_build_object('source', 'person_registrations_process', 'registration_id', r.id))
149
+ ON CONFLICT (tenant_id, document_number) WHERE resolved_at IS NULL AND document_number IS NOT NULL DO UPDATE
150
+ SET person_ids = EXCLUDED.person_ids, context = public.person_identity_quarantine.context || EXCLUDED.context
151
+ RETURNING id INTO v_q;
152
+ UPDATE public.person_registrations SET status = 'quarantined', quarantine_id = v_q, processed_at = now(), processed_by = v_actor WHERE id = r.id;
153
+ RETURN jsonb_build_object('registration_id', r.id, 'status', 'quarantined', 'person_id', NULL, 'quarantine_id', v_q, 'candidates', to_jsonb(v_by_sig));
154
+ END IF;
155
+ END IF;
156
+
157
+ IF v_status = 'created' THEN
158
+ -- kind = 'customer' goes through the compat trigger: the customer role is
159
+ -- created as primary. Vertical fields stay in metadata.registration.
160
+ INSERT INTO public.people (tenant_id, unit_id, kind, name, email, phone, document_number, date_of_birth,
161
+ address, city, state, country, postal_code, notes, metadata, created_by)
162
+ VALUES (v_tenant, r.unit_id, 'customer',
163
+ coalesce(nullif(btrim(r.payload ->> 'name'), ''), 'Sem nome'),
164
+ nullif(btrim(r.payload ->> 'email'), ''), nullif(btrim(r.payload ->> 'phone'), ''), r.document_number,
165
+ CASE WHEN (r.payload ->> 'date_of_birth') ~ '^\d{4}-\d{2}-\d{2}$' THEN (r.payload ->> 'date_of_birth')::date END,
166
+ nullif(r.payload ->> 'address', ''), nullif(r.payload ->> 'city', ''), nullif(r.payload ->> 'state', ''),
167
+ coalesce(nullif(r.payload ->> 'country', ''), 'BR'), nullif(r.payload ->> 'postal_code', ''), nullif(r.payload ->> 'notes', ''),
168
+ jsonb_build_object('registration', jsonb_build_object('id', r.id, 'unit_id', r.unit_id, 'payload', r.payload)),
169
+ v_actor)
170
+ RETURNING id INTO v_person;
171
+ ELSE
172
+ -- merged: the person exists; she is (also) a customer now. Nothing else is
173
+ -- overwritten — the registration keeps the payload for a human to compare.
174
+ IF NOT EXISTS (SELECT 1 FROM public.person_roles x WHERE x.tenant_id = v_tenant AND x.person_id = v_person AND x.role = 'customer'
175
+ AND public.person_role_active(x.valid_from, x.valid_to)) THEN
176
+ INSERT INTO public.person_roles (tenant_id, person_id, role, unit_id, is_primary, metadata)
177
+ VALUES (v_tenant, v_person, 'customer', r.unit_id,
178
+ NOT EXISTS (SELECT 1 FROM public.person_roles x WHERE x.tenant_id = v_tenant AND x.person_id = v_person AND x.is_primary),
179
+ jsonb_build_object('source', 'person_registrations_process', 'registration_id', r.id))
180
+ ON CONFLICT DO NOTHING;
181
+ UPDATE public.person_roles x SET valid_to = NULL, valid_from = least(x.valid_from, current_date)
182
+ WHERE x.tenant_id = v_tenant AND x.person_id = v_person AND x.role = 'customer' AND NOT public.person_role_active(x.valid_from, x.valid_to)
183
+ AND NOT EXISTS (SELECT 1 FROM public.person_roles y WHERE y.tenant_id = v_tenant AND y.person_id = v_person AND y.role = 'customer' AND public.person_role_active(y.valid_from, y.valid_to));
184
+ END IF;
185
+ END IF;
186
+
187
+ UPDATE public.person_registrations
188
+ SET status = v_status, matched_person_id = v_person, processed_at = now(), processed_by = v_actor
189
+ WHERE id = r.id;
190
+ IF r.quarantine_id IS NOT NULL THEN
191
+ UPDATE public.person_identity_quarantine SET resolved_at = now(), resolved_by = v_actor, resolution = v_status || ':' || v_person::text
192
+ WHERE id = r.quarantine_id AND resolved_at IS NULL;
193
+ END IF;
194
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
195
+ VALUES (v_tenant, v_actor, 'people.registration.' || v_status, 'people', v_person::text, jsonb_build_object('registration_id', r.id, 'action', p_action));
196
+ RETURN jsonb_build_object('registration_id', r.id, 'status', v_status, 'person_id', v_person);
197
+ END $$;
198
+ REVOKE ALL ON FUNCTION public.person_registrations_process(uuid, text, uuid) FROM public, anon;
199
+ GRANT EXECUTE ON FUNCTION public.person_registrations_process(uuid, text, uuid) TO authenticated, service_role;
200
+
201
+ -- ─────────────────────────────── access tokens ─────────────────────────
202
+ CREATE TABLE IF NOT EXISTS public.person_access_tokens (
203
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
204
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
205
+ person_id uuid NOT NULL,
206
+ token_hash text NOT NULL, -- sha256(token), hex; the token itself is never stored
207
+ purpose text NOT NULL, -- 'portal', 'pre_registration', 'consent', …
208
+ expires_at timestamptz NOT NULL,
209
+ used_at timestamptz,
210
+ created_by uuid,
211
+ created_at timestamptz NOT NULL DEFAULT now(),
212
+ updated_at timestamptz NOT NULL DEFAULT now(),
213
+ CHECK (purpose ~ '^[a-z][a-z0-9_]*$'),
214
+ FOREIGN KEY (tenant_id, person_id) REFERENCES public.people(tenant_id, id) ON DELETE CASCADE
215
+ );
216
+ CREATE UNIQUE INDEX IF NOT EXISTS person_access_tokens_hash_uidx ON public.person_access_tokens (token_hash);
217
+ CREATE INDEX IF NOT EXISTS person_access_tokens_person_idx ON public.person_access_tokens (tenant_id, person_id, expires_at DESC);
218
+
219
+ SELECT app.scaffold_table('public.person_access_tokens', 'people.person_access_token', 'people', false, 'enforce');
220
+ -- reads for the tenant's own tooling ("which tokens are open for this
221
+ -- person"), writes only through the RPCs below; anon nothing at all
222
+ REVOKE ALL ON public.person_access_tokens FROM anon, authenticated;
223
+ GRANT SELECT ON public.person_access_tokens TO authenticated;
224
+ GRANT ALL ON public.person_access_tokens TO service_role;
225
+
226
+ -- Issues a token for a person of the session tenant (people.edit in her
227
+ -- unit); returns the opaque token exactly once. 256 bits from the CSPRNG
228
+ -- behind gen_random_uuid(); only its sha256 is stored.
229
+ CREATE OR REPLACE FUNCTION public.person_access_token_issue(p_person uuid, p_purpose text, p_ttl interval DEFAULT interval '7 days')
230
+ RETURNS text
231
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
232
+ AS $$
233
+ DECLARE
234
+ v_tenant uuid := app.current_tenant_id();
235
+ p public.people%ROWTYPE;
236
+ v_token text;
237
+ BEGIN
238
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'no active tenant for this session' USING ERRCODE = '42501'; END IF;
239
+ SELECT * INTO p FROM public.people WHERE tenant_id = v_tenant AND id = p_person;
240
+ IF NOT FOUND THEN RAISE EXCEPTION 'person_access_token_issue: person not found in this tenant' USING ERRCODE = 'P0002'; END IF;
241
+ IF p.merged_into_id IS NOT NULL THEN RAISE EXCEPTION 'person_access_token_issue: person is a merge tombstone' USING ERRCODE = 'P0001'; END IF;
242
+ PERFORM public.people_assert_scope('people.edit', p.unit_id);
243
+ IF p_ttl IS NULL OR p_ttl <= interval '0' OR p_ttl > interval '90 days' THEN
244
+ RAISE EXCEPTION 'person_access_token_issue: ttl must be within (0, 90 days]' USING ERRCODE = '22023';
245
+ END IF;
246
+ v_token := replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', '');
247
+ INSERT INTO public.person_access_tokens (tenant_id, person_id, unit_id, token_hash, purpose, expires_at, created_by)
248
+ VALUES (v_tenant, p_person, p.unit_id, encode(sha256(convert_to(v_token, 'UTF8')), 'hex'), p_purpose, now() + p_ttl, auth.uid());
249
+ RETURN v_token;
250
+ END $$;
251
+ REVOKE ALL ON FUNCTION public.person_access_token_issue(uuid, text, interval) FROM public, anon;
252
+ GRANT EXECUTE ON FUNCTION public.person_access_token_issue(uuid, text, interval) TO authenticated, service_role;
253
+
254
+ -- Redeems a token: single use, expiring, callable by anon (the portal link).
255
+ -- Unknown, used and expired all fail the same way — no oracle. Returns the
256
+ -- person id; what the portal may then read is Phase 1's contract.
257
+ CREATE OR REPLACE FUNCTION public.person_access_token_redeem(p_token text)
258
+ RETURNS uuid
259
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
260
+ AS $$
261
+ DECLARE
262
+ t public.person_access_tokens%ROWTYPE;
263
+ BEGIN
264
+ IF p_token IS NULL OR length(p_token) < 32 THEN
265
+ RAISE EXCEPTION 'invalid or expired token' USING ERRCODE = '28000';
266
+ END IF;
267
+ SELECT * INTO t FROM public.person_access_tokens
268
+ WHERE token_hash = encode(sha256(convert_to(p_token, 'UTF8')), 'hex')
269
+ FOR UPDATE;
270
+ IF NOT FOUND OR t.used_at IS NOT NULL OR t.expires_at <= now() THEN
271
+ RAISE EXCEPTION 'invalid or expired token' USING ERRCODE = '28000';
272
+ END IF;
273
+ UPDATE public.person_access_tokens SET used_at = now() WHERE id = t.id;
274
+ RETURN t.person_id;
275
+ END $$;
276
+ REVOKE ALL ON FUNCTION public.person_access_token_redeem(text) FROM public;
277
+ GRANT EXECUTE ON FUNCTION public.person_access_token_redeem(text) TO anon, authenticated, service_role;
278
+
279
+ COMMENT ON TABLE public.person_registrations IS
280
+ 'Pre-registration queue (V1 client_registrations): the raw form per unit; person_registrations_process() creates or matches the Person with dedup by exact CPF, quarantining ambiguity.';
281
+ COMMENT ON TABLE public.person_access_tokens IS
282
+ 'Opaque single-use expiring tokens for the client portal (V1 client_access_tokens). Only sha256 hashes; issue via person_access_token_issue(), redeem via person_access_token_redeem() (anon-callable). No anon SELECT.';