@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,142 +0,0 @@
|
|
|
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;
|
|
@@ -1,75 +0,0 @@
|
|
|
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 $$;
|
|
@@ -1,282 +0,0 @@
|
|
|
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.';
|