@fayz-ai/db 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
- package/migrations/038_onboarding_responses.sql +103 -0
- package/migrations/039_unit_tree.sql +270 -0
- package/migrations/040_resource_grants.sql +474 -0
- package/migrations/041_scoped_columns.sql +192 -0
- package/migrations/042_unit_scope_policies.sql +145 -0
- package/migrations/043_view_invoker.sql +90 -0
- package/migrations/044_unit_member_facts.sql +47 -0
- package/migrations/045_unit_entry.sql +236 -0
- package/migrations/046_membership_visible_to_members.sql +85 -0
- package/migrations/047_tasks.sql +266 -0
- package/migrations/048_every_login_is_a_person.sql +190 -0
- package/migrations/049_bookable_people.sql +126 -0
- package/migrations/050_authz_core.sql +230 -0
- package/migrations/051_authz_helpers.sql +330 -0
- package/migrations/052_authz_fanout.sql +253 -0
- package/migrations/053_authz_catalog_templates.sql +402 -0
- package/migrations/060_tenancy_seam.sql +132 -0
- package/migrations/061_create_tenant.sql +125 -0
- package/migrations/062_invitations.sql +329 -0
- package/migrations/063_legacy_tenancy_retired.sql +568 -0
- package/migrations/064_agent_guard_authz.sql +118 -0
- package/migrations/064b_supersede_unit_scope.sql +205 -0
- package/migrations/065_scaffold_sweep.sql +139 -0
- package/migrations/066_shadow_report.sql +122 -0
- package/migrations/070_permission_catalog.sql +205 -0
- package/migrations/071_v1_permission_map.sql +266 -0
- package/migrations/072_role_rpcs.sql +126 -0
- package/migrations/073_legacy_rbac_retire.sql +471 -0
- package/migrations/075_company_legal_entities.sql +169 -0
- package/migrations/076_tenant_settings.sql +251 -0
- package/migrations/080_people_roles.sql +269 -0
- package/migrations/081_people_identity.sql +271 -0
- package/migrations/082_people_membership_link.sql +142 -0
- package/migrations/083_people_data_migration.sql +75 -0
- package/migrations/084_people_registrations_tokens.sql +282 -0
- package/migrations/085_catalog_products.sql +333 -0
- package/migrations/086_catalog_pricing.sql +194 -0
- package/migrations/087_catalog_packages.sql +134 -0
- package/migrations/090_orders_venda.sql +166 -0
- package/migrations/091_orders_scaffold.sql +92 -0
- package/migrations/095_config_field_rules.sql +261 -0
- package/migrations/096_audit_append_only.sql +256 -0
- package/migrations/097_sequences_authorized.sql +111 -0
- package/migrations/098_holidays.sql +80 -0
- package/migrations/099_access_grants.sql +293 -0
- package/migrations/100_migration_schema.sql +217 -0
- package/migrations/101_migration_allowlist.sql +202 -0
- package/migrations/102_migration_rpcs.sql +933 -0
- package/migrations/103_migration_reconcile_ponr.sql +132 -0
- package/migrations/105_policies_to_authenticated.sql +71 -0
- package/migrations/106_function_search_path.sql +55 -0
- package/migrations/107_core_table_prefix.sql +361 -0
- package/migrations/108_authz_write_guards.sql +149 -0
- package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
- package/migrations/110_people_kind_vocabulary.sql +120 -0
- package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
- package/migrations/112_money_precision_standard.sql +132 -0
- package/migrations/113_addresses_units_companies.sql +114 -0
- package/migrations/114_migrations_ledger_immutable.sql +65 -0
- package/migrations/115_registration_honours_field_rules.sql +172 -0
- package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
- package/migrations/117_authz_reconciliation_schedule.sql +82 -0
- package/migrations/118_migration_catalog_writers.sql +450 -0
- package/migrations/119_migration_config_writers.sql +193 -0
- package/migrations/120_migration_facet_writer.sql +139 -0
- package/migrations/121_migration_financial_writers.sql +294 -0
- package/migrations/122_migration_inventory_writers.sql +245 -0
- package/migrations/123_migration_order_item_writer.sql +144 -0
- package/migrations/124_migration_ledger_writers.sql +170 -0
- package/migrations/125_migration_authz_people_writers.sql +205 -0
- package/migrations/126_owner_scoping_is_configuration.sql +247 -0
- package/migrations/127_scaffold_audit_hook.sql +98 -0
- package/migrations/128_shadow_tables_enforced.sql +93 -0
- package/migrations/129_order_kind_terminal_states.sql +57 -0
- package/migrations/130_every_login_is_a_person.sql +196 -0
- package/migrations/131_migration_invoice_split.sql +127 -0
- package/migrations/132_audit_prune_scheduled.sql +28 -0
- package/migrations/133_unit_is_a_session_property.sql +155 -0
- package/migrations/134_field_rules_on_read.sql +341 -0
- package/migrations/135_distribution_polarity.sql +158 -0
- package/migrations/136_unit_id_means_locality.sql +358 -0
- package/migrations/137_vendor_named_spine_objects.sql +126 -0
- package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
- package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
- package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
- package/migrations/142_a_person_has_addresses.sql +212 -0
- package/migrations/143_a_professional_has_a_rate.sql +75 -0
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
- package/migrations/146_products_stock_has_one_writer.sql +67 -0
- package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
- package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
- package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
- package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
- package/migrations/152_a_file_is_an_archetype.sql +222 -0
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
- package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
- package/migrations/155_a_tenant_can_leave.sql +271 -0
- package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
- package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
- package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
- package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
- package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
- package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
- package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
- package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
- package/package.json +6 -5
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
-- 108_authz_write_guards.sql
|
|
2
|
+
--
|
|
3
|
+
-- Closes the two privilege escalations the Phase 0 acceptance audit reproduced
|
|
4
|
+
-- against a live database (#196, #197). Both are the same shape: a rule that
|
|
5
|
+
-- exists in ONE writer, while the table underneath accepts the write from
|
|
6
|
+
-- anywhere else.
|
|
7
|
+
--
|
|
8
|
+
-- #196 — `members.manage` was enough to become owner. `memberships_insert` and
|
|
9
|
+
-- `memberships_delete` already carry `user_id <> auth.uid()`; `memberships_update`
|
|
10
|
+
-- does not, and `public.v_authz_memberships` is auto-updatable and granted UPDATE
|
|
11
|
+
-- to `authenticated` (043). So one PostgREST call promoted a seeded manager to
|
|
12
|
+
-- owner and handed her `billing.manage`. Two rules were missing, and neither is
|
|
13
|
+
-- new to this codebase — `invite_member` (052) has enforced both since #134:
|
|
14
|
+
-- (a) you never write your own membership row;
|
|
15
|
+
-- (b) you never grant a role holding a permission you do not hold.
|
|
16
|
+
--
|
|
17
|
+
-- #197 — the owner role was immutable only inside `role_set_permissions` (062).
|
|
18
|
+
-- `trg_legacy_overrides_redirect` (063) resolves the role by key with no
|
|
19
|
+
-- `is_system` filter and is SECURITY INVOKER, so an INSERT into the deprecated
|
|
20
|
+
-- `public.tenant_role_overrides` facade revoked `financial.read` from the salon
|
|
21
|
+
-- owner, permanently — `permissions_grant_owner` only fires for NEW catalog keys.
|
|
22
|
+
--
|
|
23
|
+
-- Why triggers and not policies. `app.memberships` and `app.role_permissions` are
|
|
24
|
+
-- FORCE RLS, but `postgres` holds BYPASSRLS, which is how the SECURITY DEFINER
|
|
25
|
+
-- RPCs (`create_tenant`, `accept_invitation`, `role_set_permissions`, the seed)
|
|
26
|
+
-- write these tables at all. A policy therefore cannot see those writers, and a
|
|
27
|
+
-- policy on the base table would not have closed #197 either — the facade writes
|
|
28
|
+
-- as the *invoker*. Triggers fire for everyone, which is what these rules need.
|
|
29
|
+
--
|
|
30
|
+
-- Why `current_user = 'authenticated'` is the gate. It separates the two worlds
|
|
31
|
+
-- exactly, with no session flag to set and no existing function to rewrite:
|
|
32
|
+
-- · SECURITY DEFINER RPCs owned by postgres run as `postgres` → skipped, they
|
|
33
|
+
-- carry their own guards (and `create_tenant` legitimately inserts the
|
|
34
|
+
-- caller's OWN owner membership, which rule (a) would otherwise refuse);
|
|
35
|
+
-- · `service_role` and migrations/seeds run as themselves → skipped,
|
|
36
|
+
-- matching the posture of every `TO authenticated` policy in the chain;
|
|
37
|
+
-- · a PostgREST call, and the SECURITY INVOKER legacy projections and facade,
|
|
38
|
+
-- run as `authenticated` → enforced.
|
|
39
|
+
--
|
|
40
|
+
-- Replay-safe: functions are CREATE OR REPLACE, triggers are dropped and
|
|
41
|
+
-- recreated, no data is touched.
|
|
42
|
+
|
|
43
|
+
-- ── #196: memberships ─────────────────────────────────────────────────────
|
|
44
|
+
CREATE OR REPLACE FUNCTION app.authz_membership_guard()
|
|
45
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
|
|
46
|
+
DECLARE v_actor uuid;
|
|
47
|
+
BEGIN
|
|
48
|
+
IF current_user <> 'authenticated' THEN
|
|
49
|
+
RETURN NEW;
|
|
50
|
+
END IF;
|
|
51
|
+
v_actor := (select auth.uid());
|
|
52
|
+
IF v_actor IS NULL THEN
|
|
53
|
+
RETURN NEW;
|
|
54
|
+
END IF;
|
|
55
|
+
|
|
56
|
+
-- (a) your own membership is never written directly: not the row you are on,
|
|
57
|
+
-- and not a row you are moving onto yourself. Scope and role of the actor
|
|
58
|
+
-- change through create_tenant, an invitation, or another member's action.
|
|
59
|
+
IF NEW.user_id = v_actor OR (TG_OP = 'UPDATE' AND OLD.user_id = v_actor) THEN
|
|
60
|
+
RAISE EXCEPTION
|
|
61
|
+
'app.memberships: you cannot write your own membership (self-promotion and self-widening are refused; use an invitation or another member with members.manage)'
|
|
62
|
+
USING ERRCODE = '42501';
|
|
63
|
+
END IF;
|
|
64
|
+
|
|
65
|
+
-- (b) no escalation: every permission of the granted role must be held by the
|
|
66
|
+
-- actor, tenant-wide. Same rule and same wording as invite_member (052).
|
|
67
|
+
IF EXISTS (SELECT 1 FROM app.role_permissions rp
|
|
68
|
+
WHERE rp.tenant_id = NEW.tenant_id AND rp.role_id = NEW.role_id
|
|
69
|
+
AND NOT app.has_permission(rp.permission)) THEN
|
|
70
|
+
RAISE EXCEPTION
|
|
71
|
+
'app.memberships: cannot grant a role holding permissions you do not have'
|
|
72
|
+
USING ERRCODE = '42501';
|
|
73
|
+
END IF;
|
|
74
|
+
|
|
75
|
+
RETURN NEW;
|
|
76
|
+
END $$;
|
|
77
|
+
REVOKE ALL ON FUNCTION app.authz_membership_guard() FROM public, anon, authenticated;
|
|
78
|
+
|
|
79
|
+
DROP TRIGGER IF EXISTS memberships_write_guard ON app.memberships;
|
|
80
|
+
CREATE TRIGGER memberships_write_guard
|
|
81
|
+
BEFORE INSERT OR UPDATE ON app.memberships
|
|
82
|
+
FOR EACH ROW EXECUTE FUNCTION app.authz_membership_guard();
|
|
83
|
+
|
|
84
|
+
-- The same two rules on the per-unit half: `membership_units` carries the unit
|
|
85
|
+
-- scope and an optional `role_override_id`, so without this a manager could widen
|
|
86
|
+
-- her own scope one unit at a time, or override herself into a stronger role.
|
|
87
|
+
CREATE OR REPLACE FUNCTION app.authz_membership_unit_guard()
|
|
88
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
|
|
89
|
+
DECLARE v_actor uuid; v_row record;
|
|
90
|
+
BEGIN
|
|
91
|
+
IF current_user <> 'authenticated' THEN
|
|
92
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
93
|
+
END IF;
|
|
94
|
+
v_actor := (select auth.uid());
|
|
95
|
+
IF v_actor IS NULL THEN
|
|
96
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
97
|
+
END IF;
|
|
98
|
+
v_row := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
99
|
+
|
|
100
|
+
IF v_row.user_id = v_actor OR (TG_OP = 'UPDATE' AND OLD.user_id = v_actor) THEN
|
|
101
|
+
RAISE EXCEPTION
|
|
102
|
+
'app.membership_units: you cannot write your own unit scope'
|
|
103
|
+
USING ERRCODE = '42501';
|
|
104
|
+
END IF;
|
|
105
|
+
|
|
106
|
+
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW.role_override_id IS NOT NULL
|
|
107
|
+
AND EXISTS (SELECT 1 FROM app.role_permissions rp
|
|
108
|
+
WHERE rp.tenant_id = NEW.tenant_id AND rp.role_id = NEW.role_override_id
|
|
109
|
+
AND NOT app.has_permission(rp.permission)) THEN
|
|
110
|
+
RAISE EXCEPTION
|
|
111
|
+
'app.membership_units: cannot override to a role holding permissions you do not have'
|
|
112
|
+
USING ERRCODE = '42501';
|
|
113
|
+
END IF;
|
|
114
|
+
|
|
115
|
+
RETURN v_row;
|
|
116
|
+
END $$;
|
|
117
|
+
REVOKE ALL ON FUNCTION app.authz_membership_unit_guard() FROM public, anon, authenticated;
|
|
118
|
+
|
|
119
|
+
DROP TRIGGER IF EXISTS membership_units_write_guard ON app.membership_units;
|
|
120
|
+
CREATE TRIGGER membership_units_write_guard
|
|
121
|
+
BEFORE INSERT OR UPDATE OR DELETE ON app.membership_units
|
|
122
|
+
FOR EACH ROW EXECUTE FUNCTION app.authz_membership_unit_guard();
|
|
123
|
+
|
|
124
|
+
-- ── #197: the owner role is immutable, from every direction ───────────────
|
|
125
|
+
-- The guard moves off role_set_permissions and onto the data, so the RPC, the
|
|
126
|
+
-- deprecated facade, and any future writer all inherit it. `permissions_grant_owner`
|
|
127
|
+
-- (043) keeps working: it runs as the definer while a catalog key is added.
|
|
128
|
+
CREATE OR REPLACE FUNCTION app.authz_owner_role_immutable()
|
|
129
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
|
|
130
|
+
DECLARE v_role uuid;
|
|
131
|
+
BEGIN
|
|
132
|
+
IF current_user <> 'authenticated' THEN
|
|
133
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
134
|
+
END IF;
|
|
135
|
+
v_role := coalesce(NEW.role_id, OLD.role_id);
|
|
136
|
+
IF EXISTS (SELECT 1 FROM app.roles r
|
|
137
|
+
WHERE r.id = v_role AND r.is_system AND r.key = 'owner') THEN
|
|
138
|
+
RAISE EXCEPTION
|
|
139
|
+
'app.role_permissions: the owner role is immutable — it always holds the whole catalog'
|
|
140
|
+
USING ERRCODE = '42501';
|
|
141
|
+
END IF;
|
|
142
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
143
|
+
END $$;
|
|
144
|
+
REVOKE ALL ON FUNCTION app.authz_owner_role_immutable() FROM public, anon, authenticated;
|
|
145
|
+
|
|
146
|
+
DROP TRIGGER IF EXISTS role_permissions_owner_immutable ON app.role_permissions;
|
|
147
|
+
CREATE TRIGGER role_permissions_owner_immutable
|
|
148
|
+
BEFORE INSERT OR UPDATE OR DELETE ON app.role_permissions
|
|
149
|
+
FOR EACH ROW EXECUTE FUNCTION app.authz_owner_role_immutable();
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
-- 109_catalog_migration_promoted_columns.sql
|
|
2
|
+
--
|
|
3
|
+
-- Every migrated V1 service would have arrived 30 minutes long. Fix for #198.
|
|
4
|
+
--
|
|
5
|
+
-- 091 maps `duration_minutes` and `service_kind` into the JSONB payload, with the
|
|
6
|
+
-- note "until #139 promotes it". #139 promoted both to real columns on
|
|
7
|
+
-- public.products (075:41-42) -- and `migration.upsert_product` was never taught
|
|
8
|
+
-- about them, so the destination wrote neither. `v_public_services` then does
|
|
9
|
+
-- COALESCE(duration_minutes, 30), so the entire migrated catalogue silently
|
|
10
|
+
-- became half-hour appointments with the schedulable state flattened to
|
|
11
|
+
-- 'standard'. Nothing raised; the ledger recorded a successful upsert.
|
|
12
|
+
--
|
|
13
|
+
-- Two changes, and they belong together -- repointing the allowlist without
|
|
14
|
+
-- teaching the writer just moves the drop one layer down:
|
|
15
|
+
-- 1. the `services` allowlist row maps both to the real columns;
|
|
16
|
+
-- 2. upsert_product writes them, translating V1's vocabulary to V2's
|
|
17
|
+
-- (`service` -> `standard`, the rest pass through) and QUARANTINING rather
|
|
18
|
+
-- than correcting anything outside it -- an unknown service_kind, one on a
|
|
19
|
+
-- good, or a non-positive duration. Silent correction is what #148 forbids,
|
|
20
|
+
-- and a defaulted duration is exactly the bug being fixed here.
|
|
21
|
+
--
|
|
22
|
+
-- Replay-safe: `_allow` upserts by source_table, the function is CREATE OR
|
|
23
|
+
-- REPLACE, no data is touched.
|
|
24
|
+
|
|
25
|
+
-- ── 1. the allowlist tells the truth about where these two land ───────────
|
|
26
|
+
-- The row is frozen, and the freeze exists to say exactly this: "core-A
|
|
27
|
+
-- allowlist changes are a migration file, not a write". This is that file, so it
|
|
28
|
+
-- takes the documented unlock. set_config(..., true) is transaction-scoped and a
|
|
29
|
+
-- DO block is one transaction, so the unlock cannot leak past these lines.
|
|
30
|
+
DO $unlock$
|
|
31
|
+
BEGIN
|
|
32
|
+
PERFORM set_config('migration.allowlist_unlock', 'on', true);
|
|
33
|
+
PERFORM migration._allow('services', 'public.products', 'catalog', 'product', '{"kind":"service"}', '{}',
|
|
34
|
+
'{"uuid":"id","tenant_id":"-","name":"name","description":"description","internal_code":"sku","category_id":"category_id@service_categories","price":"price","active":"is_active","duration_minutes":"duration_minutes","color":"jsonb:catalog.color","service_kind":"service_kind","revision_count":"jsonb:beauty.revision_count","revision_price":"jsonb:beauty.revision_price","revision_deadline_days":"jsonb:beauty.revision_deadline_days","validity_days":"jsonb:catalog.validity_days","contract_text":"jsonb:beauty.contract_text","post_service_text":"jsonb:beauty.post_service_text","reminder_text":"jsonb:beauty.reminder_text","scheduling_alert_text":"jsonb:beauty.scheduling_alert_text","always_ask_used_products":"jsonb:beauty.always_ask_used_products","suggest_quote_on_execution":"jsonb:beauty.suggest_quote_on_execution","category":"-","search_text":"-"}',
|
|
35
|
+
'PRD 04 R1: a service is a catalog item kind=service (public.services is drift, folded by #139). duration_minutes and service_kind are PROMOTED COLUMNS since #139 (ADR 0009, RN-CAT-03) -- they are core columns, not metadata; an unknown service_kind is quarantined, never defaulted (#198).');
|
|
36
|
+
END $unlock$;
|
|
37
|
+
|
|
38
|
+
-- ── 2. the writer that actually puts them there ───────────────────────────
|
|
39
|
+
CREATE OR REPLACE FUNCTION migration.upsert_product(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
|
|
40
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
41
|
+
DECLARE
|
|
42
|
+
v_kind text := coalesce(p_args ->> 'kind', 'good');
|
|
43
|
+
v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
|
|
44
|
+
-- #139 promoted these two out of JSONB and onto public.products; 091 still
|
|
45
|
+
-- described them as metadata and this writer wrote neither, so every migrated
|
|
46
|
+
-- service arrived with duration_minutes NULL and v_public_services COALESCEd
|
|
47
|
+
-- it to 30. See #198.
|
|
48
|
+
v_duration integer := nullif(btrim(coalesce(p_row ->> 'duration_minutes', '')), '')::integer;
|
|
49
|
+
-- V1 vocabulary is service|evaluation|return; V2's is standard|evaluation|return
|
|
50
|
+
-- ('service' was renamed because `kind` had already taken the archetype word).
|
|
51
|
+
-- 04-catalog-contract.md §V1 map, row `service_kind`.
|
|
52
|
+
v_service_kind_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'service_kind', ''))), '');
|
|
53
|
+
v_service_kind text := CASE v_service_kind_v1
|
|
54
|
+
WHEN 'service' THEN 'standard'
|
|
55
|
+
ELSE v_service_kind_v1
|
|
56
|
+
END;
|
|
57
|
+
v_existing_tenant uuid;
|
|
58
|
+
v_created boolean := false;
|
|
59
|
+
v_active boolean := coalesce((p_row ->> 'is_active')::boolean, true);
|
|
60
|
+
v_standalone boolean := migration._standalone();
|
|
61
|
+
BEGIN
|
|
62
|
+
IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
|
|
63
|
+
IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN
|
|
64
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a product needs an id and a name');
|
|
65
|
+
END IF;
|
|
66
|
+
-- No silent correction (#148): a service_kind outside the vocabulary is a
|
|
67
|
+
-- decision for a human, not a default. A good never carries one.
|
|
68
|
+
IF v_service_kind IS NOT NULL AND v_kind <> 'service' THEN
|
|
69
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
|
|
70
|
+
'reason', format('service_kind %L on a %L: only a service carries a schedulable state', v_service_kind_v1, v_kind));
|
|
71
|
+
END IF;
|
|
72
|
+
IF v_service_kind IS NOT NULL AND v_service_kind NOT IN ('standard', 'evaluation', 'return') THEN
|
|
73
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
|
|
74
|
+
'reason', format('unknown service_kind %L (V1 vocabulary is service|evaluation|return)', v_service_kind_v1));
|
|
75
|
+
END IF;
|
|
76
|
+
IF v_duration IS NOT NULL AND v_duration <= 0 THEN
|
|
77
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
|
|
78
|
+
'reason', format('duration_minutes %s is not a duration', v_duration));
|
|
79
|
+
END IF;
|
|
80
|
+
v_meta := migration._deep_set(v_meta, ARRAY['catalog', 'kind'], to_jsonb(v_kind));
|
|
81
|
+
SELECT tenant_id INTO v_existing_tenant FROM public.products WHERE id = p_id;
|
|
82
|
+
IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
|
|
83
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('product %s already exists in another tenant', p_id));
|
|
84
|
+
END IF;
|
|
85
|
+
IF v_existing_tenant IS NULL THEN
|
|
86
|
+
INSERT INTO public.products (id, tenant_id, category_id, name, description, sku, price, cost, currency, unit, image_url, status, is_active, metadata,
|
|
87
|
+
duration_minutes, service_kind)
|
|
88
|
+
VALUES (p_id, p_tenant, (p_row ->> 'category_id')::uuid, p_row ->> 'name', p_row ->> 'description', p_row ->> 'sku',
|
|
89
|
+
(p_row ->> 'price')::numeric, (p_row ->> 'cost')::numeric, coalesce(p_row ->> 'currency', 'BRL'), p_row ->> 'unit',
|
|
90
|
+
p_row ->> 'image_url', CASE WHEN v_active THEN 'active' ELSE 'inactive' END, v_active, v_meta,
|
|
91
|
+
v_duration, NULL); -- service_kind is set below, after `kind` lands
|
|
92
|
+
v_created := true;
|
|
93
|
+
ELSE
|
|
94
|
+
UPDATE public.products SET
|
|
95
|
+
name = p_row ->> 'name',
|
|
96
|
+
category_id = CASE WHEN p_row ? 'category_id' THEN (p_row ->> 'category_id')::uuid ELSE category_id END,
|
|
97
|
+
description = CASE WHEN p_row ? 'description' THEN p_row ->> 'description' ELSE description END,
|
|
98
|
+
sku = CASE WHEN p_row ? 'sku' THEN p_row ->> 'sku' ELSE sku END,
|
|
99
|
+
price = CASE WHEN p_row ? 'price' THEN (p_row ->> 'price')::numeric ELSE price END,
|
|
100
|
+
cost = CASE WHEN p_row ? 'cost' THEN (p_row ->> 'cost')::numeric ELSE cost END,
|
|
101
|
+
unit = CASE WHEN p_row ? 'unit' THEN p_row ->> 'unit' ELSE unit END,
|
|
102
|
+
image_url = CASE WHEN p_row ? 'image_url' THEN p_row ->> 'image_url' ELSE image_url END,
|
|
103
|
+
status = CASE WHEN p_row ? 'is_active' THEN (CASE WHEN v_active THEN 'active' ELSE 'inactive' END) ELSE status END,
|
|
104
|
+
is_active = CASE WHEN p_row ? 'is_active' THEN v_active ELSE is_active END,
|
|
105
|
+
metadata = migration._merge_meta(metadata, v_meta),
|
|
106
|
+
duration_minutes = CASE WHEN p_row ? 'duration_minutes' THEN v_duration ELSE duration_minutes END
|
|
107
|
+
WHERE id = p_id;
|
|
108
|
+
END IF;
|
|
109
|
+
IF migration._has_column('public.products', 'kind') THEN
|
|
110
|
+
EXECUTE 'UPDATE public.products SET kind = $1 WHERE id = $2' USING v_kind, p_id;
|
|
111
|
+
END IF;
|
|
112
|
+
-- ORDER MATTERS. `kind` is applied in its own statement above, so anything
|
|
113
|
+
-- written to service_kind before this point is undone by
|
|
114
|
+
-- app.trg_catalog_products_normalise: it clears the column for a good and
|
|
115
|
+
-- re-defaults it to 'standard' for a service. Writing it here, after kind has
|
|
116
|
+
-- landed, is the only point where the value survives — and only when the source
|
|
117
|
+
-- actually carried one, so a service that arrived without a state keeps the
|
|
118
|
+
-- trigger's 'standard' rather than being reset to NULL against the pairing CHECK.
|
|
119
|
+
IF v_service_kind IS NOT NULL AND v_kind = 'service'
|
|
120
|
+
AND migration._has_column('public.products', 'service_kind') THEN
|
|
121
|
+
EXECUTE 'UPDATE public.products SET service_kind = $1 WHERE id = $2' USING v_service_kind, p_id;
|
|
122
|
+
END IF;
|
|
123
|
+
PERFORM migration._set_unit('public.products', p_id, (p_row ->> 'unit_id')::uuid);
|
|
124
|
+
IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.products', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
|
|
125
|
+
RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
|
|
126
|
+
END $$;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
-- 110_people_kind_vocabulary.sql
|
|
2
|
+
--
|
|
3
|
+
-- A Person with no role at all. Fix for #204.
|
|
4
|
+
--
|
|
5
|
+
-- `person_kind_to_role` (070) maps the legacy `people.kind` vocabulary onto the
|
|
6
|
+
-- roles this slice made canonical, and returns NULL for anything outside it --
|
|
7
|
+
-- deliberately, so an app's own 'teacher' or 'patient' is left alone rather than
|
|
8
|
+
-- guessed at. But two shipped plugins default to a kind that IS meant to be in
|
|
9
|
+
-- the vocabulary and simply is not spelled the same way:
|
|
10
|
+
--
|
|
11
|
+
-- plugins/plugin-financial/src/index.ts:228 archetypeKind ?? 'client'
|
|
12
|
+
-- plugins/plugin-crm/src/data/supabase.ts:766 archetypeKind ?? 'client'
|
|
13
|
+
--
|
|
14
|
+
-- so those Persons resolve to no role. The seed already contains one. It is
|
|
15
|
+
-- invisible while readers query public.people directly and appears the moment a
|
|
16
|
+
-- reader moves to v_people_active_roles -- which is the contract #137 tells
|
|
17
|
+
-- everyone to use. At that point those customers are simply not there.
|
|
18
|
+
--
|
|
19
|
+
-- `client` is a synonym of `customer`, not a new concept, so it joins the map
|
|
20
|
+
-- rather than the CHECK. The plugin defaults are corrected in the same commit;
|
|
21
|
+
-- the alias is what covers the rows already written by the versions in the wild.
|
|
22
|
+
--
|
|
23
|
+
-- Second fix, same slice and the same class of bug: the role -> kind convergence
|
|
24
|
+
-- had nothing behind `is_primary`. `trg_people_kind_from_roles` (070) clears the
|
|
25
|
+
-- other primaries and sets one, which is correct in a single transaction and
|
|
26
|
+
-- races in two -- both can read "no primary" and both write one, after which
|
|
27
|
+
-- `people.kind` stops being derivable. Fixed in two layers: promotion demotes the
|
|
28
|
+
-- others atomically (so callers stop having to order two statements correctly,
|
|
29
|
+
-- which is what made the race reachable), and a partial unique index stands
|
|
30
|
+
-- behind it for any writer that skips the trigger.
|
|
31
|
+
--
|
|
32
|
+
-- Replay-safe: CREATE OR REPLACE + CREATE UNIQUE INDEX IF NOT EXISTS. The index
|
|
33
|
+
-- is created against existing data, so a pool that already raced will refuse it
|
|
34
|
+
-- -- which is the right outcome: that data needs a human, not a silent pick.
|
|
35
|
+
|
|
36
|
+
-- A PHONE IS NOT AN IDENTITY, AND THIS IS THE FILE THAT PROVES IT.
|
|
37
|
+
--
|
|
38
|
+
-- agenda/001 put a UNIQUE index on people (tenant_id, phone) WHERE kind =
|
|
39
|
+
-- 'customer' — sound while `kind` was typed by hand and only the public booking
|
|
40
|
+
-- form wrote customers. This file is what breaks that: `client` becomes
|
|
41
|
+
-- `customer`, so every Person an import ever labelled 'client' arrives under the
|
|
42
|
+
-- index at once, and a salon pool refuses the whole migration on eight people
|
|
43
|
+
-- who share the placeholder 21111111111 somebody typed to get past a required
|
|
44
|
+
-- field. They are eight different women with eight different appointment
|
|
45
|
+
-- histories, and no rule that treats them as one row is worth keeping.
|
|
46
|
+
--
|
|
47
|
+
-- Identity in this slice is the CPF (081, people_tenant_document_uidx). Phone is
|
|
48
|
+
-- a way to REACH someone: a mother books for a daughter, a couple shares a
|
|
49
|
+
-- landline, an import fills the column with 1s.
|
|
50
|
+
--
|
|
51
|
+
-- The booking upsert does not need the index — agenda/003 already replaced it
|
|
52
|
+
-- with a per-(tenant, phone) advisory lock, precisely because a
|
|
53
|
+
-- tenant-configured client kind could not be arbitrated by a customer-only
|
|
54
|
+
-- index. 001 now creates the index non-unique (it was always about lookup speed)
|
|
55
|
+
-- and 009 takes the same lock instead of ON CONFLICT. Dropping it here is what
|
|
56
|
+
-- lets a pool that already ran the old 001 move.
|
|
57
|
+
DROP INDEX IF EXISTS public.people_tenant_customer_phone;
|
|
58
|
+
|
|
59
|
+
CREATE OR REPLACE FUNCTION public.person_kind_to_role(p_kind text)
|
|
60
|
+
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
|
61
|
+
SELECT CASE p_kind
|
|
62
|
+
WHEN 'customer' THEN 'customer'
|
|
63
|
+
WHEN 'client' THEN 'customer' -- plugin-financial / plugin-crm default (#204)
|
|
64
|
+
WHEN 'staff' THEN 'employee'
|
|
65
|
+
WHEN 'employee' THEN 'employee'
|
|
66
|
+
WHEN 'professional' THEN 'professional'
|
|
67
|
+
WHEN 'lead' THEN 'lead'
|
|
68
|
+
WHEN 'supplier' THEN 'supplier'
|
|
69
|
+
WHEN 'vendor' THEN 'supplier'
|
|
70
|
+
WHEN 'contact' THEN 'contact'
|
|
71
|
+
END
|
|
72
|
+
$$;
|
|
73
|
+
|
|
74
|
+
-- Promotion becomes one atomic act: naming a primary demotes the others in the
|
|
75
|
+
-- same statement, so a caller no longer has to clear the old hat first and there
|
|
76
|
+
-- is no window where a person has two or none. Two concurrent promotions now
|
|
77
|
+
-- serialise on the row lock the demote takes, instead of both succeeding.
|
|
78
|
+
CREATE OR REPLACE FUNCTION app.trg_person_roles_single_primary()
|
|
79
|
+
RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$
|
|
80
|
+
BEGIN
|
|
81
|
+
IF NEW.is_primary THEN
|
|
82
|
+
UPDATE public.person_roles r SET is_primary = false
|
|
83
|
+
WHERE r.tenant_id = NEW.tenant_id AND r.person_id = NEW.person_id
|
|
84
|
+
AND r.is_primary AND r.id <> NEW.id;
|
|
85
|
+
END IF;
|
|
86
|
+
RETURN NEW;
|
|
87
|
+
END $$;
|
|
88
|
+
DROP TRIGGER IF EXISTS person_roles_single_primary ON public.person_roles;
|
|
89
|
+
CREATE TRIGGER person_roles_single_primary
|
|
90
|
+
BEFORE INSERT OR UPDATE OF is_primary ON public.person_roles
|
|
91
|
+
FOR EACH ROW WHEN (NEW.is_primary) EXECUTE FUNCTION app.trg_person_roles_single_primary();
|
|
92
|
+
|
|
93
|
+
-- …and the backstop the trigger cannot be, if a future writer bypasses it.
|
|
94
|
+
CREATE UNIQUE INDEX IF NOT EXISTS person_roles_one_primary_uidx
|
|
95
|
+
ON public.person_roles (tenant_id, person_id) WHERE is_primary;
|
|
96
|
+
|
|
97
|
+
-- Backfill the roles the alias was silently denying. Only for people whose kind
|
|
98
|
+
-- resolves to a role and who have none at all -- a person who already carries
|
|
99
|
+
-- hats is left exactly as she is.
|
|
100
|
+
DO $$
|
|
101
|
+
DECLARE v_added integer;
|
|
102
|
+
BEGIN
|
|
103
|
+
WITH missing AS (
|
|
104
|
+
SELECT p.tenant_id, p.id, public.person_kind_to_role(p.kind) AS role, p.unit_id
|
|
105
|
+
FROM public.people p
|
|
106
|
+
WHERE public.person_kind_to_role(p.kind) IS NOT NULL
|
|
107
|
+
AND NOT EXISTS (SELECT 1 FROM public.person_roles r
|
|
108
|
+
WHERE r.tenant_id = p.tenant_id AND r.person_id = p.id)
|
|
109
|
+
), ins AS (
|
|
110
|
+
INSERT INTO public.person_roles (tenant_id, person_id, role, unit_id, is_primary, metadata)
|
|
111
|
+
SELECT tenant_id, id, role, unit_id, true, jsonb_build_object('source', 'migration:100')
|
|
112
|
+
FROM missing
|
|
113
|
+
ON CONFLICT DO NOTHING
|
|
114
|
+
RETURNING 1
|
|
115
|
+
)
|
|
116
|
+
SELECT count(*)::int INTO v_added FROM ins;
|
|
117
|
+
IF v_added > 0 THEN
|
|
118
|
+
RAISE NOTICE '110_people_kind_vocabulary: gave a primary role to % person(s) whose kind resolved but had none', v_added;
|
|
119
|
+
END IF;
|
|
120
|
+
END $$;
|