@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,134 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 087_catalog_packages.sql — the catalog contract, part 3 (PRD 04 R4 / #139):
|
|
3
|
+
-- the `packages` facet — service_packages + service_package_items. Contract
|
|
4
|
+
-- only: tables, scaffold, invariants. No RPC, no purchase/consumption model
|
|
5
|
+
-- (client_package_purchases is Phase 3's — RN-CAT-01 needs the package to be
|
|
6
|
+
-- SOLD as a package, never exploded into items; that is the orders slice).
|
|
7
|
+
-- ============================================================================
|
|
8
|
+
|
|
9
|
+
-- ── 0. the V1 tables that got here first ────────────────────────────────
|
|
10
|
+
-- beauty shipped packages before the catalog contract named the columns, so a
|
|
11
|
+
-- salon pool already has both tables under the older vocabulary: an item points
|
|
12
|
+
-- at `service_id` from when services were their own table, and counts sessions
|
|
13
|
+
-- in `included_quantity`. CREATE TABLE IF NOT EXISTS sees the NAME and returns
|
|
14
|
+
-- happy, and every statement below then asks for a column that never arrived.
|
|
15
|
+
--
|
|
16
|
+
-- 085 folded services into products keeping the SAME id, so `service_id`
|
|
17
|
+
-- already holds a valid products id — this is a rename, not a conversion, and
|
|
18
|
+
-- no row moves.
|
|
19
|
+
--
|
|
20
|
+
-- What is genuinely NEW (currency, sessions_total, metadata) is added, and what
|
|
21
|
+
-- V1 had and the contract does not name (max_uses, unit_price) is LEFT WHERE IT
|
|
22
|
+
-- IS. Dropping a column to make a table match a diagram is how a pool loses the
|
|
23
|
+
-- one field a screen still reads.
|
|
24
|
+
DO $$
|
|
25
|
+
BEGIN
|
|
26
|
+
IF to_regclass('public.service_package_items') IS NOT NULL THEN
|
|
27
|
+
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
28
|
+
WHERE table_schema='public' AND table_name='service_package_items' AND column_name='service_id')
|
|
29
|
+
AND NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
30
|
+
WHERE table_schema='public' AND table_name='service_package_items' AND column_name='product_id') THEN
|
|
31
|
+
ALTER TABLE public.service_package_items RENAME COLUMN service_id TO product_id;
|
|
32
|
+
END IF;
|
|
33
|
+
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
34
|
+
WHERE table_schema='public' AND table_name='service_package_items' AND column_name='included_quantity')
|
|
35
|
+
AND NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
36
|
+
WHERE table_schema='public' AND table_name='service_package_items' AND column_name='quantity') THEN
|
|
37
|
+
ALTER TABLE public.service_package_items RENAME COLUMN included_quantity TO quantity;
|
|
38
|
+
-- a session count was integer; the contract measures quantity, which can
|
|
39
|
+
-- be fractional for a service sold by the half-hour.
|
|
40
|
+
ALTER TABLE public.service_package_items ALTER COLUMN quantity TYPE numeric;
|
|
41
|
+
END IF;
|
|
42
|
+
ALTER TABLE public.service_package_items ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
43
|
+
END IF;
|
|
44
|
+
|
|
45
|
+
IF to_regclass('public.service_packages') IS NOT NULL THEN
|
|
46
|
+
ALTER TABLE public.service_packages ADD COLUMN IF NOT EXISTS currency text NOT NULL DEFAULT 'BRL';
|
|
47
|
+
ALTER TABLE public.service_packages ADD COLUMN IF NOT EXISTS sessions_total integer;
|
|
48
|
+
ALTER TABLE public.service_packages ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL;
|
|
49
|
+
-- max_uses is the same count under the older name. It is COPIED, not
|
|
50
|
+
-- renamed: something may still read it, and a copy costs a column while a
|
|
51
|
+
-- rename costs a screen.
|
|
52
|
+
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
53
|
+
WHERE table_schema='public' AND table_name='service_packages' AND column_name='max_uses') THEN
|
|
54
|
+
UPDATE public.service_packages SET sessions_total = max_uses
|
|
55
|
+
WHERE sessions_total IS NULL AND max_uses IS NOT NULL;
|
|
56
|
+
COMMENT ON COLUMN public.service_packages.max_uses IS
|
|
57
|
+
'DEPRECATED (087): the contract counts sessions_total. Kept as a mirror until the readers move.';
|
|
58
|
+
END IF;
|
|
59
|
+
END IF;
|
|
60
|
+
END $$;
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS public.service_packages (
|
|
63
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
64
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
65
|
+
name text NOT NULL,
|
|
66
|
+
description text,
|
|
67
|
+
price numeric NOT NULL DEFAULT 0 CHECK (price >= 0),
|
|
68
|
+
currency text NOT NULL DEFAULT 'BRL',
|
|
69
|
+
sessions_total integer CHECK (sessions_total IS NULL OR sessions_total > 0),
|
|
70
|
+
validity_days integer CHECK (validity_days IS NULL OR validity_days > 0),
|
|
71
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
72
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
73
|
+
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
74
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
75
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
76
|
+
);
|
|
77
|
+
SELECT app.scaffold_table('public.service_packages', 'catalog.service_package', 'catalog', false, 'enforce');
|
|
78
|
+
DROP TRIGGER IF EXISTS service_packages_created_by ON public.service_packages;
|
|
79
|
+
CREATE TRIGGER service_packages_created_by BEFORE INSERT ON public.service_packages
|
|
80
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_created_by();
|
|
81
|
+
COMMENT ON TABLE public.service_packages IS
|
|
82
|
+
'Catalog facet "packages" (PRD 04 R4, RN-CAT-01): a combo of services sold as one item — price, total sessions, validity. V1 service_packages (+ package_settings/package_unit_shares as metadata / available_in grants). Contract only in Phase 0.';
|
|
83
|
+
|
|
84
|
+
-- A tenant-wide package is released per unit like any catalog item.
|
|
85
|
+
DROP POLICY IF EXISTS service_packages_available_in ON public.service_packages;
|
|
86
|
+
CREATE POLICY service_packages_available_in ON public.service_packages AS RESTRICTIVE FOR SELECT TO authenticated
|
|
87
|
+
USING (unit_id IS NOT NULL OR (select app.available_in_my_units('catalog.service_package', id)));
|
|
88
|
+
|
|
89
|
+
CREATE TABLE IF NOT EXISTS public.service_package_items (
|
|
90
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
91
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
92
|
+
package_id uuid NOT NULL,
|
|
93
|
+
product_id uuid NOT NULL, -- a products row with kind = 'service'
|
|
94
|
+
quantity numeric NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
|
95
|
+
sort_order integer NOT NULL DEFAULT 0,
|
|
96
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
97
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
98
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
99
|
+
CONSTRAINT service_package_items_package_fk FOREIGN KEY (tenant_id, package_id) REFERENCES public.service_packages (tenant_id, id) ON DELETE CASCADE,
|
|
100
|
+
CONSTRAINT service_package_items_product_fk FOREIGN KEY (tenant_id, product_id) REFERENCES public.products (tenant_id, id) ON DELETE CASCADE,
|
|
101
|
+
CONSTRAINT service_package_items_unique UNIQUE (package_id, product_id)
|
|
102
|
+
);
|
|
103
|
+
SELECT app.scaffold_table('public.service_package_items', 'catalog.service_package_item', 'catalog', false, 'enforce');
|
|
104
|
+
CREATE INDEX IF NOT EXISTS service_package_items_product_idx ON public.service_package_items (tenant_id, product_id);
|
|
105
|
+
-- an item of a package is not more visible than the package
|
|
106
|
+
DROP POLICY IF EXISTS service_package_items_available_in ON public.service_package_items;
|
|
107
|
+
CREATE POLICY service_package_items_available_in ON public.service_package_items AS RESTRICTIVE FOR SELECT TO authenticated
|
|
108
|
+
USING ((select app.available_in_my_units('catalog.service_package', package_id)));
|
|
109
|
+
COMMENT ON TABLE public.service_package_items IS
|
|
110
|
+
'Services (products kind=service) inside a package with their quantity of sessions. V1 service_package_items. Inherits (tenant_id, unit_id) from the package.';
|
|
111
|
+
|
|
112
|
+
-- Invariants: the item is a service; the item lives in the package's scope.
|
|
113
|
+
CREATE OR REPLACE FUNCTION app.trg_catalog_service_package_items_guard()
|
|
114
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
115
|
+
DECLARE v_kind text; v_tenant uuid; v_unit uuid;
|
|
116
|
+
BEGIN
|
|
117
|
+
SELECT p.kind INTO v_kind FROM public.products p WHERE p.id = NEW.product_id;
|
|
118
|
+
IF v_kind IS DISTINCT FROM 'service' THEN
|
|
119
|
+
RAISE EXCEPTION 'service_package_items: product % is not a service', NEW.product_id USING ERRCODE = '23514';
|
|
120
|
+
END IF;
|
|
121
|
+
SELECT s.tenant_id, s.unit_id INTO v_tenant, v_unit FROM public.service_packages s WHERE s.id = NEW.package_id;
|
|
122
|
+
IF v_tenant IS NULL THEN
|
|
123
|
+
RAISE EXCEPTION 'service_package_items: package % does not exist', NEW.package_id USING ERRCODE = '23503';
|
|
124
|
+
END IF;
|
|
125
|
+
NEW.tenant_id := v_tenant;
|
|
126
|
+
NEW.unit_id := v_unit;
|
|
127
|
+
RETURN NEW;
|
|
128
|
+
END $$;
|
|
129
|
+
DROP TRIGGER IF EXISTS service_package_items_guard ON public.service_package_items;
|
|
130
|
+
CREATE TRIGGER service_package_items_guard BEFORE INSERT OR UPDATE ON public.service_package_items
|
|
131
|
+
FOR EACH ROW EXECUTE FUNCTION app.trg_catalog_service_package_items_guard();
|
|
132
|
+
|
|
133
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.service_packages, public.service_package_items TO authenticated;
|
|
134
|
+
GRANT ALL ON public.service_packages, public.service_package_items TO service_role;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 090_orders_venda.sql — orders is the Venda, and only the Venda (#140).
|
|
3
|
+
--
|
|
4
|
+
-- ADR 0004 (amended): the sale (atendimento, comanda, quote, shop order) lives
|
|
5
|
+
-- in the core `orders`; money lives in the financial plugin as an invoice
|
|
6
|
+
-- (plg_financial_invoices). `orders.kind` may therefore no longer be
|
|
7
|
+
-- 'invoice_receivable' / 'invoice_payable' — those rows are data-migrated into
|
|
8
|
+
-- invoices by plugin-financial 016 and the kinds are rejected here from now on.
|
|
9
|
+
--
|
|
10
|
+
-- Sale kinds are open: a plugin registers its kind as a ROW in
|
|
11
|
+
-- public.order_kinds (never DDL), optionally with the statuses it uses and the
|
|
12
|
+
-- transitions it allows. A registered kind with `transitions` gets its state
|
|
13
|
+
-- machine enforced by a BEFORE trigger; a kind registered without transitions
|
|
14
|
+
-- (agenda's appointment, the CRM's quote/deal, the shop's order) keeps its
|
|
15
|
+
-- lifecycle in the plugin until that plugin's own slice tightens it — nothing
|
|
16
|
+
-- that writes orders today changes behaviour. The default machine, applied when
|
|
17
|
+
-- a kind is registered with no explicit statuses/transitions, is
|
|
18
|
+
-- draft → confirmed → completed | cancelled.
|
|
19
|
+
--
|
|
20
|
+
-- Extension naming (documented in docs/data/phase-0/05-orders-invoices-ledger.md):
|
|
21
|
+
-- an app or plugin that extends a core table names the extension
|
|
22
|
+
-- plg_<plugin>_order_ext / plg_<plugin>_order_item_ext — never the core name.
|
|
23
|
+
-- The readiness gate (scripts/migration-readiness.mjs, check `collision`)
|
|
24
|
+
-- enforces it.
|
|
25
|
+
--
|
|
26
|
+
-- Idempotent and replay-safe.
|
|
27
|
+
-- ============================================================================
|
|
28
|
+
|
|
29
|
+
-- ── the kind registry ────────────────────────────────────────────────────
|
|
30
|
+
CREATE TABLE IF NOT EXISTS public.order_kinds (
|
|
31
|
+
kind text PRIMARY KEY,
|
|
32
|
+
plugin text NOT NULL,
|
|
33
|
+
description text,
|
|
34
|
+
statuses text[], -- NULL = any status accepted
|
|
35
|
+
transitions jsonb, -- NULL = free; else {"from": ["to", …]} — "*" wildcards allowed
|
|
36
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
37
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
38
|
+
CHECK (kind ~ '^[a-z][a-z0-9_]*$'),
|
|
39
|
+
CHECK (kind NOT IN ('invoice_receivable', 'invoice_payable'))
|
|
40
|
+
);
|
|
41
|
+
DROP TRIGGER IF EXISTS order_kinds_updated_at ON public.order_kinds;
|
|
42
|
+
CREATE TRIGGER order_kinds_updated_at BEFORE UPDATE ON public.order_kinds
|
|
43
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
44
|
+
|
|
45
|
+
ALTER TABLE public.order_kinds ENABLE ROW LEVEL SECURITY;
|
|
46
|
+
-- The registry is global configuration, harmless to read: every session may
|
|
47
|
+
-- list it (the UI renders the machine); only the platform writes it.
|
|
48
|
+
GRANT SELECT ON public.order_kinds TO anon, authenticated, service_role;
|
|
49
|
+
GRANT ALL ON public.order_kinds TO service_role;
|
|
50
|
+
REVOKE INSERT, UPDATE, DELETE ON public.order_kinds FROM anon, authenticated;
|
|
51
|
+
DROP POLICY IF EXISTS order_kinds_select ON public.order_kinds;
|
|
52
|
+
CREATE POLICY order_kinds_select ON public.order_kinds FOR SELECT TO anon, authenticated, service_role USING (true);
|
|
53
|
+
|
|
54
|
+
-- ── register_order_kind: a plugin declares (or refines) its sale kind ─────
|
|
55
|
+
-- Idempotent upsert. With p_statuses/p_transitions NULL on first registration
|
|
56
|
+
-- the kind gets the default machine; passing explicit values (re)defines it;
|
|
57
|
+
-- passing '{}'::jsonb… is a machine with no allowed transition (rarely useful),
|
|
58
|
+
-- while NULL transitions on an EXISTING row is left untouched (a re-register
|
|
59
|
+
-- with defaults never loosens what a plugin tightened).
|
|
60
|
+
CREATE OR REPLACE FUNCTION public.register_order_kind(
|
|
61
|
+
p_kind text,
|
|
62
|
+
p_plugin text,
|
|
63
|
+
p_statuses text[] DEFAULT NULL,
|
|
64
|
+
p_transitions jsonb DEFAULT NULL,
|
|
65
|
+
p_description text DEFAULT NULL
|
|
66
|
+
) RETURNS void
|
|
67
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
68
|
+
AS $$
|
|
69
|
+
DECLARE
|
|
70
|
+
v_statuses text[] := p_statuses;
|
|
71
|
+
v_transitions jsonb := p_transitions;
|
|
72
|
+
BEGIN
|
|
73
|
+
IF p_kind IN ('invoice_receivable', 'invoice_payable') THEN
|
|
74
|
+
RAISE EXCEPTION 'register_order_kind: % is not a sale kind — a receivable/payable is a plg_financial_invoices row', p_kind
|
|
75
|
+
USING ERRCODE = '23514';
|
|
76
|
+
END IF;
|
|
77
|
+
IF p_statuses IS NULL AND p_transitions IS NULL THEN
|
|
78
|
+
v_statuses := ARRAY['draft', 'confirmed', 'completed', 'cancelled'];
|
|
79
|
+
v_transitions := '{"draft": ["confirmed", "cancelled"], "confirmed": ["completed", "cancelled"]}'::jsonb;
|
|
80
|
+
END IF;
|
|
81
|
+
INSERT INTO public.order_kinds (kind, plugin, description, statuses, transitions)
|
|
82
|
+
VALUES (p_kind, p_plugin, p_description, v_statuses, v_transitions)
|
|
83
|
+
ON CONFLICT (kind) DO UPDATE
|
|
84
|
+
SET plugin = EXCLUDED.plugin,
|
|
85
|
+
description = coalesce(EXCLUDED.description, public.order_kinds.description),
|
|
86
|
+
statuses = CASE WHEN p_statuses IS NULL AND p_transitions IS NULL THEN public.order_kinds.statuses ELSE EXCLUDED.statuses END,
|
|
87
|
+
transitions = CASE WHEN p_statuses IS NULL AND p_transitions IS NULL THEN public.order_kinds.transitions ELSE EXCLUDED.transitions END;
|
|
88
|
+
END $$;
|
|
89
|
+
REVOKE ALL ON FUNCTION public.register_order_kind(text, text, text[], jsonb, text) FROM public, anon, authenticated;
|
|
90
|
+
GRANT EXECUTE ON FUNCTION public.register_order_kind(text, text, text[], jsonb, text) TO service_role;
|
|
91
|
+
|
|
92
|
+
-- ── the platform's own kinds ─────────────────────────────────────────────
|
|
93
|
+
-- 'sale' and 'dine_in' are the core Venda kinds and carry the default machine.
|
|
94
|
+
-- The kinds plugins already write today are registered with the statuses they
|
|
95
|
+
-- use and NO transition enforcement (their slices own their lifecycles) so this
|
|
96
|
+
-- migration changes nothing they do; the CRM's quote/deal flows are already
|
|
97
|
+
-- constrained by its own kind→status CHECK.
|
|
98
|
+
SELECT public.register_order_kind('sale', 'orders', NULL, NULL, 'A sale (atendimento, comanda fechada, venda avulsa): the core Venda');
|
|
99
|
+
SELECT public.register_order_kind('dine_in', 'orders', NULL, NULL, 'A table order (comanda de mesa) — restaurant vertical');
|
|
100
|
+
SELECT public.register_order_kind('appointment', 'agenda',
|
|
101
|
+
ARRAY['scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show', 'invoiced', 'paid'], NULL,
|
|
102
|
+
'An appointment''s commercial document (agenda); lifecycle owned by plugin-agenda');
|
|
103
|
+
SELECT public.register_order_kind('service_order', 'agenda',
|
|
104
|
+
ARRAY['draft', 'scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'invoiced', 'paid'], NULL,
|
|
105
|
+
'A service order (agenda); lifecycle owned by plugin-agenda');
|
|
106
|
+
SELECT public.register_order_kind('quote', 'crm',
|
|
107
|
+
ARRAY['draft', 'sent', 'approved', 'rejected', 'expired', 'converted', 'archived'], NULL,
|
|
108
|
+
'A quote (CRM); lifecycle owned by plugin-crm');
|
|
109
|
+
SELECT public.register_order_kind('deal', 'crm',
|
|
110
|
+
ARRAY['open', 'won', 'lost', 'archived'], NULL,
|
|
111
|
+
'A deal (CRM); lifecycle owned by plugin-crm');
|
|
112
|
+
SELECT public.register_order_kind('shop', 'shop', NULL, '{"*": ["*"]}'::jsonb,
|
|
113
|
+
'A storefront order mirrored into the core (@fayz-ai/shop); status is the shop order status');
|
|
114
|
+
-- Legacy payable "invoices" (orders kind=invoice_payable, migrated into payable
|
|
115
|
+
-- invoices by plugin-financial 016) keep their document — items, cost centre and
|
|
116
|
+
-- chart-of-accounts classification — as kind 'purchase'. New payables have no
|
|
117
|
+
-- order at all: an invoice can exist without a Venda.
|
|
118
|
+
SELECT public.register_order_kind('purchase', 'financial', NULL, '{"*": ["*"]}'::jsonb,
|
|
119
|
+
'A purchase/expense document behind a legacy payable invoice; new payables carry no order');
|
|
120
|
+
|
|
121
|
+
-- ── the guard: no invoice kinds; per-kind lifecycle ──────────────────────
|
|
122
|
+
CREATE OR REPLACE FUNCTION public.trg_orders_kind_guard()
|
|
123
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
124
|
+
DECLARE
|
|
125
|
+
v_kind public.order_kinds%ROWTYPE;
|
|
126
|
+
v_allowed jsonb;
|
|
127
|
+
BEGIN
|
|
128
|
+
IF NEW.kind IN ('invoice_receivable', 'invoice_payable') THEN
|
|
129
|
+
RAISE EXCEPTION 'orders.kind % is retired: a receivable/payable is an invoice (public.financial_create_invoice / plg_financial_invoices), not an order', NEW.kind
|
|
130
|
+
USING ERRCODE = '23514', HINT = 'ADR 0004: orders is the Venda; money lives in the financial plugin.';
|
|
131
|
+
END IF;
|
|
132
|
+
|
|
133
|
+
SELECT * INTO v_kind FROM public.order_kinds k WHERE k.kind = NEW.kind;
|
|
134
|
+
IF NOT FOUND THEN
|
|
135
|
+
RETURN NEW; -- unregistered kind: no machine (register it to opt in)
|
|
136
|
+
END IF;
|
|
137
|
+
|
|
138
|
+
IF v_kind.statuses IS NOT NULL AND NEW.status IS NOT NULL AND NOT (NEW.status = ANY (v_kind.statuses)) THEN
|
|
139
|
+
RAISE EXCEPTION 'orders: status % is not valid for kind % (allowed: %)', NEW.status, NEW.kind, array_to_string(v_kind.statuses, ', ')
|
|
140
|
+
USING ERRCODE = '23514';
|
|
141
|
+
END IF;
|
|
142
|
+
|
|
143
|
+
-- Transitions are checked between two states of the SAME machine: a row that
|
|
144
|
+
-- changes kind, or whose previous status predates the machine (a migrated
|
|
145
|
+
-- legacy row), is entering it, not moving inside it.
|
|
146
|
+
IF TG_OP = 'UPDATE' AND v_kind.transitions IS NOT NULL
|
|
147
|
+
AND OLD.kind = NEW.kind
|
|
148
|
+
AND OLD.status IS DISTINCT FROM NEW.status AND OLD.status IS NOT NULL
|
|
149
|
+
AND (v_kind.statuses IS NULL OR OLD.status = ANY (v_kind.statuses)) THEN
|
|
150
|
+
v_allowed := coalesce(v_kind.transitions -> OLD.status, v_kind.transitions -> '*', '[]'::jsonb);
|
|
151
|
+
IF NOT (v_allowed ? coalesce(NEW.status, '') OR v_allowed ? '*') THEN
|
|
152
|
+
RAISE EXCEPTION 'orders: kind % cannot go from % to %', NEW.kind, OLD.status, coalesce(NEW.status, '<null>')
|
|
153
|
+
USING ERRCODE = '23514', HINT = 'The allowed transitions of a kind are the row of public.order_kinds.';
|
|
154
|
+
END IF;
|
|
155
|
+
END IF;
|
|
156
|
+
|
|
157
|
+
IF NEW.status = 'completed' AND (TG_OP = 'INSERT' OR OLD.status IS DISTINCT FROM 'completed') THEN
|
|
158
|
+
NEW.completed_at := coalesce(NEW.completed_at, now());
|
|
159
|
+
END IF;
|
|
160
|
+
RETURN NEW;
|
|
161
|
+
END $$;
|
|
162
|
+
REVOKE ALL ON FUNCTION public.trg_orders_kind_guard() FROM public, anon, authenticated;
|
|
163
|
+
|
|
164
|
+
DROP TRIGGER IF EXISTS orders_kind_guard ON public.orders;
|
|
165
|
+
CREATE TRIGGER orders_kind_guard BEFORE INSERT OR UPDATE OF kind, status ON public.orders
|
|
166
|
+
FOR EACH ROW EXECUTE FUNCTION public.trg_orders_kind_guard();
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 091_orders_scaffold.sql — the Venda and its lines on the authz template (#140).
|
|
3
|
+
--
|
|
4
|
+
-- public.orders and public.order_items get the ONE RLS template of the authz
|
|
5
|
+
-- core (app.scaffold_table, enforce mode): tenant_id / unit_id / owner_id,
|
|
6
|
+
-- unique(tenant_id, id), composite FKs to app.units / app.memberships,
|
|
7
|
+
-- tenant-first indexes, forced RLS and the four `<table>_authz_*` policies
|
|
8
|
+
-- keyed on orders.read/create/edit/delete. owner_id stays NULL: the sale is a
|
|
9
|
+
-- unit-scoped document, not a personal one (owner scoping off in both vertical
|
|
10
|
+
-- templates).
|
|
11
|
+
--
|
|
12
|
+
-- order_items, until now a child table without tenant_id (RLS through the
|
|
13
|
+
-- parent's FK), becomes a first-class business table: tenant_id backfilled from
|
|
14
|
+
-- the order and kept in step by a BEFORE trigger (legacy writers insert lines
|
|
15
|
+
-- with order_id only), unit_id inherited the same way, composite FK
|
|
16
|
+
-- (tenant_id, order_id) → orders(tenant_id, id), and the promoted column
|
|
17
|
+
-- execution_status (ADR 0009: what participates in a state machine or a money
|
|
18
|
+
-- invariant is a column, not JSONB — the clinic's is_executed/execution_* lands
|
|
19
|
+
-- here, everything else stays in metadata).
|
|
20
|
+
--
|
|
21
|
+
-- Idempotent and replay-safe: scaffold_table converges (a replay of 002/004/006
|
|
22
|
+
-- re-creates the generic policies; this file's enforce pass drops them again).
|
|
23
|
+
-- ============================================================================
|
|
24
|
+
|
|
25
|
+
-- ── orders ───────────────────────────────────────────────────────────────
|
|
26
|
+
SELECT app.scaffold_table('public.orders', 'orders.order', 'orders', false, 'enforce');
|
|
27
|
+
|
|
28
|
+
-- ── order_items: structural columns first, then the scaffold ─────────────
|
|
29
|
+
ALTER TABLE public.order_items ADD COLUMN IF NOT EXISTS tenant_id uuid;
|
|
30
|
+
ALTER TABLE public.order_items ADD COLUMN IF NOT EXISTS unit_id uuid;
|
|
31
|
+
ALTER TABLE public.order_items ADD COLUMN IF NOT EXISTS execution_status text;
|
|
32
|
+
ALTER TABLE public.order_items ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
|
33
|
+
|
|
34
|
+
UPDATE public.order_items oi
|
|
35
|
+
SET tenant_id = o.tenant_id
|
|
36
|
+
FROM public.orders o
|
|
37
|
+
WHERE o.id = oi.order_id AND oi.tenant_id IS NULL;
|
|
38
|
+
UPDATE public.order_items oi
|
|
39
|
+
SET unit_id = o.unit_id
|
|
40
|
+
FROM public.orders o
|
|
41
|
+
WHERE o.id = oi.order_id AND oi.unit_id IS NULL AND o.unit_id IS NOT NULL;
|
|
42
|
+
|
|
43
|
+
DO $$
|
|
44
|
+
BEGIN
|
|
45
|
+
-- Only after the backfill: an orphan line (order gone) would block NOT NULL.
|
|
46
|
+
IF NOT EXISTS (SELECT 1 FROM public.order_items WHERE tenant_id IS NULL) THEN
|
|
47
|
+
ALTER TABLE public.order_items ALTER COLUMN tenant_id SET NOT NULL;
|
|
48
|
+
END IF;
|
|
49
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.order_items'::regclass AND conname = 'order_items_tenant_id_fkey') THEN
|
|
50
|
+
ALTER TABLE public.order_items ADD CONSTRAINT order_items_tenant_id_fkey
|
|
51
|
+
FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
|
52
|
+
END IF;
|
|
53
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.order_items'::regclass AND conname = 'order_items_tenant_order_fk') THEN
|
|
54
|
+
ALTER TABLE public.order_items ADD CONSTRAINT order_items_tenant_order_fk
|
|
55
|
+
FOREIGN KEY (tenant_id, order_id) REFERENCES public.orders(tenant_id, id) ON DELETE CASCADE;
|
|
56
|
+
END IF;
|
|
57
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.order_items'::regclass AND conname = 'order_items_execution_status_check') THEN
|
|
58
|
+
ALTER TABLE public.order_items ADD CONSTRAINT order_items_execution_status_check
|
|
59
|
+
CHECK (execution_status IS NULL OR execution_status IN ('pending', 'in_progress', 'executed', 'cancelled'));
|
|
60
|
+
END IF;
|
|
61
|
+
END $$;
|
|
62
|
+
CREATE INDEX IF NOT EXISTS order_items_tenant_order_idx ON public.order_items (tenant_id, order_id);
|
|
63
|
+
|
|
64
|
+
-- A line inherits tenant and unit from its order (legacy writers send order_id
|
|
65
|
+
-- only); a line can never claim another tenant than its order.
|
|
66
|
+
CREATE OR REPLACE FUNCTION public.trg_order_items_inherit()
|
|
67
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
68
|
+
DECLARE v_tenant uuid; v_unit uuid;
|
|
69
|
+
BEGIN
|
|
70
|
+
SELECT o.tenant_id, o.unit_id INTO v_tenant, v_unit FROM public.orders o WHERE o.id = NEW.order_id;
|
|
71
|
+
IF v_tenant IS NULL THEN
|
|
72
|
+
RAISE EXCEPTION 'order_items: order % not found', NEW.order_id USING ERRCODE = '23503';
|
|
73
|
+
END IF;
|
|
74
|
+
IF NEW.tenant_id IS NOT NULL AND NEW.tenant_id <> v_tenant THEN
|
|
75
|
+
RAISE EXCEPTION 'order_items: tenant_id must be the order''s tenant' USING ERRCODE = '23514';
|
|
76
|
+
END IF;
|
|
77
|
+
NEW.tenant_id := v_tenant;
|
|
78
|
+
IF NEW.unit_id IS NULL THEN NEW.unit_id := v_unit; END IF;
|
|
79
|
+
RETURN NEW;
|
|
80
|
+
END $$;
|
|
81
|
+
REVOKE ALL ON FUNCTION public.trg_order_items_inherit() FROM public, anon, authenticated;
|
|
82
|
+
DROP TRIGGER IF EXISTS order_items_inherit ON public.order_items;
|
|
83
|
+
CREATE TRIGGER order_items_inherit BEFORE INSERT OR UPDATE OF order_id, tenant_id ON public.order_items
|
|
84
|
+
FOR EACH ROW EXECUTE FUNCTION public.trg_order_items_inherit();
|
|
85
|
+
|
|
86
|
+
SELECT app.scaffold_table('public.order_items', 'orders.order_item', 'orders', false, 'enforce');
|
|
87
|
+
|
|
88
|
+
-- Grants: 004 already granted ALL on every public table to authenticated and
|
|
89
|
+
-- service_role; restated for the two tables so a pool that pruned grants
|
|
90
|
+
-- converges (RLS narrows).
|
|
91
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.orders, public.order_items TO authenticated;
|
|
92
|
+
GRANT ALL ON public.orders, public.order_items TO service_role;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 095_config_field_rules.sql — FieldRules with three states (PRD 07 R1 / #144).
|
|
3
|
+
--
|
|
4
|
+
-- RN-CLI-02: for each context (pre-registration, scheduling, check-in, internal
|
|
5
|
+
-- form, …) every field of an entity is hidden · visible · required. The rule
|
|
6
|
+
-- lives per tenant AND per unit: a row with unit_id NULL is the tenant default
|
|
7
|
+
-- (the network's decision — the franchisor "replicates the configuration to
|
|
8
|
+
-- every unit" by writing it once here); a row with a unit_id is that unit's
|
|
9
|
+
-- override. A rule may also be per role (role_key): a field hidden for the
|
|
10
|
+
-- reception never reaches her payload, whatever the client sends.
|
|
11
|
+
--
|
|
12
|
+
-- The validation is a server-side contract any plugin's RPC reuses:
|
|
13
|
+
-- field_rules_resolve(context, reference, unit) → (field_key, state) as the caller sees it
|
|
14
|
+
-- field_rules_validate(context, reference, payload, unit) → {ok, missing, hidden, payload}
|
|
15
|
+
-- field_rules_check(context, reference, payload, unit) → the stripped payload, or RAISE
|
|
16
|
+
-- RN-AGD-07: the check-in RPC calls field_rules_check('check_in', 'person', …)
|
|
17
|
+
-- and a missing required field stops it with a message in pt-BR (the one place
|
|
18
|
+
-- the PRD literally requires the product copy to come from the database).
|
|
19
|
+
--
|
|
20
|
+
-- Vocabulary is the field registry's (035 plg_entity_fields.reference/key), so
|
|
21
|
+
-- a rule names a declared field and the check reads its label from there.
|
|
22
|
+
-- Contexts are rows in field_rule_contexts — a plugin adds one with an INSERT.
|
|
23
|
+
--
|
|
24
|
+
-- Idempotent and replay-safe.
|
|
25
|
+
-- ============================================================================
|
|
26
|
+
|
|
27
|
+
-- ── permission keys of the plugin 'config' + role templates ─────────────
|
|
28
|
+
-- read/create/edit/delete/manage: the five actions app.scaffold_table's policy
|
|
29
|
+
-- template expects; manage implies every action.
|
|
30
|
+
SELECT app.seed_permissions('[
|
|
31
|
+
{"key":"config.read","plugin":"config","description":"Read tenant/unit configuration (field rules, holidays, access grants)"},
|
|
32
|
+
{"key":"config.create","plugin":"config","description":"Create configuration rows"},
|
|
33
|
+
{"key":"config.edit","plugin":"config","description":"Edit configuration rows"},
|
|
34
|
+
{"key":"config.delete","plugin":"config","description":"Delete configuration rows"},
|
|
35
|
+
{"key":"config.manage","plugin":"config","description":"Manage tenant/unit configuration (field rules, holidays, access grants)"}
|
|
36
|
+
]'::jsonb);
|
|
37
|
+
|
|
38
|
+
-- Managers configure; everyone else reads (the calendar and the form rules are
|
|
39
|
+
-- what every role renders from). Rows land in the templates; existing tenants
|
|
40
|
+
-- whose template is known (tenants.settings->>'template') pick them up below,
|
|
41
|
+
-- the synthetic tenants are re-seeded by seed/080_config.sql, and the owner
|
|
42
|
+
-- role gets every new key by the 043 trigger.
|
|
43
|
+
SELECT app.seed_role_template_rows('generic', 'admin', 'Administrator', 1, ARRAY['config.manage']);
|
|
44
|
+
SELECT app.seed_role_template_rows('generic', 'manager', 'Manager', 2, ARRAY['config.manage']);
|
|
45
|
+
SELECT app.seed_role_template_rows('generic', 'staff', 'Staff', 3, ARRAY['config.read']);
|
|
46
|
+
SELECT app.seed_role_template_rows('generic', 'viewer', 'Viewer', 4, ARRAY['config.read']);
|
|
47
|
+
SELECT app.seed_role_template_rows('salon', 'manager', 'Gerente', 1, ARRAY['config.manage']);
|
|
48
|
+
SELECT app.seed_role_template_rows('salon', 'reception', 'Recepção', 2, ARRAY['config.read']);
|
|
49
|
+
SELECT app.seed_role_template_rows('salon', 'professional', 'Profissional', 3, ARRAY['config.read']);
|
|
50
|
+
SELECT app.seed_role_template_rows('salon', 'financial', 'Financeiro', 4, ARRAY['config.read']);
|
|
51
|
+
SELECT app.seed_role_template_rows('clinic', 'manager', 'Coordenação', 1, ARRAY['config.manage']);
|
|
52
|
+
SELECT app.seed_role_template_rows('clinic', 'reception', 'Recepção', 2, ARRAY['config.read']);
|
|
53
|
+
SELECT app.seed_role_template_rows('clinic', 'professional', 'Profissional', 3, ARRAY['config.read']);
|
|
54
|
+
SELECT app.seed_role_template_rows('clinic', 'financial', 'Financeiro', 4, ARRAY['config.read']);
|
|
55
|
+
SELECT app.seed_role_template_rows('restaurant', 'manager', 'Gerente', 1, ARRAY['config.manage']);
|
|
56
|
+
SELECT app.seed_role_template_rows('restaurant', 'cashier', 'Caixa', 2, ARRAY['config.read']);
|
|
57
|
+
SELECT app.seed_role_template_rows('restaurant', 'waiter', 'Garçom', 3, ARRAY['config.read']);
|
|
58
|
+
SELECT app.seed_role_template_rows('restaurant', 'kitchen', 'Cozinha', 4, ARRAY['config.read']);
|
|
59
|
+
|
|
60
|
+
-- Tenants already instantiated from a template: add the new keys to their system
|
|
61
|
+
-- roles (never removes anything — same contract as app.seed_role_template).
|
|
62
|
+
DO $$
|
|
63
|
+
DECLARE r record;
|
|
64
|
+
BEGIN
|
|
65
|
+
FOR r IN SELECT t.id, t.settings->>'template' AS template FROM public.tenants t
|
|
66
|
+
WHERE t.settings ? 'template'
|
|
67
|
+
AND EXISTS (SELECT 1 FROM app.role_templates rt WHERE rt.template = t.settings->>'template') LOOP
|
|
68
|
+
PERFORM app.seed_role_template(r.id, r.template);
|
|
69
|
+
END LOOP;
|
|
70
|
+
END $$;
|
|
71
|
+
|
|
72
|
+
-- ── contexts: an open list ──────────────────────────────────────────────
|
|
73
|
+
CREATE TABLE IF NOT EXISTS public.field_rule_contexts (
|
|
74
|
+
key text PRIMARY KEY CHECK (key ~ '^[a-z][a-z0-9_]*$'),
|
|
75
|
+
plugin text NOT NULL,
|
|
76
|
+
description text,
|
|
77
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
78
|
+
);
|
|
79
|
+
INSERT INTO public.field_rule_contexts (key, plugin, description) VALUES
|
|
80
|
+
('pre_registration', 'people', 'Public pre-registration form (QR at the unit)'),
|
|
81
|
+
('scheduling', 'agenda', 'Booking / scheduling form'),
|
|
82
|
+
('check_in', 'agenda', 'Check-in: the client arrives (status Aguardando)'),
|
|
83
|
+
('internal_form', 'people', 'Internal record form filled by staff'),
|
|
84
|
+
('person', 'people', 'The person record itself'),
|
|
85
|
+
('order', 'orders', 'Order / sale form')
|
|
86
|
+
ON CONFLICT (key) DO NOTHING;
|
|
87
|
+
|
|
88
|
+
REVOKE ALL ON public.field_rule_contexts FROM anon, authenticated;
|
|
89
|
+
ALTER TABLE public.field_rule_contexts ENABLE ROW LEVEL SECURITY;
|
|
90
|
+
GRANT SELECT ON public.field_rule_contexts TO authenticated;
|
|
91
|
+
GRANT ALL ON public.field_rule_contexts TO service_role;
|
|
92
|
+
DROP POLICY IF EXISTS field_rule_contexts_select ON public.field_rule_contexts;
|
|
93
|
+
CREATE POLICY field_rule_contexts_select ON public.field_rule_contexts FOR SELECT TO authenticated USING (true);
|
|
94
|
+
|
|
95
|
+
-- ── the rules ───────────────────────────────────────────────────────────
|
|
96
|
+
CREATE TABLE IF NOT EXISTS public.field_rules (
|
|
97
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
98
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
99
|
+
unit_id uuid, -- NULL = tenant default (network), else the unit's override
|
|
100
|
+
context text NOT NULL REFERENCES public.field_rule_contexts(key) ON DELETE RESTRICT,
|
|
101
|
+
reference text NOT NULL CHECK (reference ~ '^[a-z][a-z0-9_]*$'), -- 035 plg_entity_fields.reference
|
|
102
|
+
field_key text NOT NULL CHECK (field_key ~ '^[a-z][a-zA-Z0-9_]*$'), -- 035 plg_entity_fields.key
|
|
103
|
+
state text NOT NULL CHECK (state IN ('hidden', 'visible', 'required')),
|
|
104
|
+
role_key text, -- NULL = every role; else app.roles.key of this tenant
|
|
105
|
+
sort_order integer NOT NULL DEFAULT 0,
|
|
106
|
+
updated_by uuid,
|
|
107
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
108
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
109
|
+
CONSTRAINT field_rules_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE CASCADE,
|
|
110
|
+
CONSTRAINT field_rules_role_fk FOREIGN KEY (tenant_id, role_key) REFERENCES app.roles(tenant_id, key) ON DELETE CASCADE
|
|
111
|
+
);
|
|
112
|
+
-- One rule per (tenant, unit-or-default, context, reference, field, role-or-any).
|
|
113
|
+
CREATE UNIQUE INDEX IF NOT EXISTS field_rules_one_per_scope
|
|
114
|
+
ON public.field_rules (tenant_id, coalesce(unit_id, '00000000-0000-0000-0000-000000000000'::uuid), context, reference, field_key, coalesce(role_key, ''));
|
|
115
|
+
CREATE INDEX IF NOT EXISTS field_rules_lookup
|
|
116
|
+
ON public.field_rules (tenant_id, context, reference);
|
|
117
|
+
|
|
118
|
+
-- The one RLS template (043): tenant + unit scope, config.* actions, no owner scoping.
|
|
119
|
+
SELECT app.scaffold_table('public.field_rules', 'config.field_rule', 'config', false, 'enforce');
|
|
120
|
+
REVOKE ALL ON public.field_rules FROM anon;
|
|
121
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.field_rules TO authenticated;
|
|
122
|
+
GRANT ALL ON public.field_rules TO service_role;
|
|
123
|
+
|
|
124
|
+
-- updated_by is the actor, derived — never trusted from the row.
|
|
125
|
+
CREATE OR REPLACE FUNCTION public.field_rules_stamp_actor()
|
|
126
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
127
|
+
BEGIN
|
|
128
|
+
NEW.updated_by := coalesce(auth.uid(), NEW.updated_by);
|
|
129
|
+
RETURN NEW;
|
|
130
|
+
END $$;
|
|
131
|
+
REVOKE ALL ON FUNCTION public.field_rules_stamp_actor() FROM public, anon, authenticated;
|
|
132
|
+
DROP TRIGGER IF EXISTS field_rules_actor ON public.field_rules;
|
|
133
|
+
CREATE TRIGGER field_rules_actor BEFORE INSERT OR UPDATE ON public.field_rules
|
|
134
|
+
FOR EACH ROW EXECUTE FUNCTION public.field_rules_stamp_actor();
|
|
135
|
+
|
|
136
|
+
-- ── resolve: what the caller sees for (context, reference) in a unit ────
|
|
137
|
+
-- Precedence: unit+role > unit > tenant+role > tenant. Role = the caller's
|
|
138
|
+
-- effective role in that unit (§8). A tenant default is "the network default":
|
|
139
|
+
-- with no available_in grant it applies everywhere; once released to specific
|
|
140
|
+
-- units (resource_grants 'available_in' on config.field_rule) it applies only
|
|
141
|
+
-- there. Fields with no rule are not returned — the caller treats them as visible.
|
|
142
|
+
CREATE OR REPLACE FUNCTION public.field_rules_resolve(p_context text, p_reference text, p_unit uuid DEFAULT NULL)
|
|
143
|
+
RETURNS TABLE (field_key text, state text, source text, sort_order integer)
|
|
144
|
+
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
|
|
145
|
+
AS $$
|
|
146
|
+
WITH me AS (
|
|
147
|
+
SELECT app.current_tenant_id() AS tenant_id,
|
|
148
|
+
(SELECT r.key FROM app.roles r
|
|
149
|
+
WHERE r.id = app.effective_role_id(app.current_tenant_id(), auth.uid(), p_unit)) AS role_key
|
|
150
|
+
),
|
|
151
|
+
cand AS (
|
|
152
|
+
SELECT fr.field_key, fr.state, fr.sort_order,
|
|
153
|
+
CASE WHEN fr.unit_id IS NULL THEN 'tenant' ELSE 'unit' END
|
|
154
|
+
|| CASE WHEN fr.role_key IS NULL THEN '' ELSE ':role' END AS source,
|
|
155
|
+
(CASE WHEN fr.unit_id IS NULL THEN 0 ELSE 2 END) + (CASE WHEN fr.role_key IS NULL THEN 0 ELSE 1 END) AS prio
|
|
156
|
+
FROM public.field_rules fr, me
|
|
157
|
+
WHERE fr.tenant_id = me.tenant_id
|
|
158
|
+
AND fr.context = p_context
|
|
159
|
+
AND fr.reference = p_reference
|
|
160
|
+
AND (fr.unit_id IS NULL OR fr.unit_id = p_unit)
|
|
161
|
+
AND (fr.role_key IS NULL OR fr.role_key = me.role_key)
|
|
162
|
+
AND (
|
|
163
|
+
fr.unit_id IS NOT NULL
|
|
164
|
+
OR p_unit IS NULL
|
|
165
|
+
OR NOT EXISTS (SELECT 1 FROM app.resource_grants g
|
|
166
|
+
WHERE g.tenant_id = fr.tenant_id AND g.resource_type = 'config.field_rule'
|
|
167
|
+
AND g.resource_id = fr.id AND g.relation = 'available_in')
|
|
168
|
+
OR EXISTS (SELECT 1 FROM app.resource_grants g
|
|
169
|
+
WHERE g.tenant_id = fr.tenant_id AND g.resource_type = 'config.field_rule'
|
|
170
|
+
AND g.resource_id = fr.id AND g.relation = 'available_in'
|
|
171
|
+
AND g.subject_type = 'unit' AND g.subject_id = p_unit)
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
SELECT DISTINCT ON (c.field_key) c.field_key, c.state, c.source, c.sort_order
|
|
175
|
+
FROM cand c
|
|
176
|
+
ORDER BY c.field_key, c.prio DESC;
|
|
177
|
+
$$;
|
|
178
|
+
|
|
179
|
+
-- ── validate: the payload against the resolved rules ────────────────────
|
|
180
|
+
-- {ok, missing:[keys], hidden:[keys], payload} — payload is the input with
|
|
181
|
+
-- every hidden key stripped (a hidden field never leaves the server), missing
|
|
182
|
+
-- lists required keys that are absent, null or blank.
|
|
183
|
+
CREATE OR REPLACE FUNCTION public.field_rules_validate(p_context text, p_reference text, p_payload jsonb, p_unit uuid DEFAULT NULL)
|
|
184
|
+
RETURNS jsonb
|
|
185
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
186
|
+
AS $$
|
|
187
|
+
DECLARE
|
|
188
|
+
v_payload jsonb := coalesce(p_payload, '{}'::jsonb);
|
|
189
|
+
v_missing text[] := '{}';
|
|
190
|
+
v_hidden text[] := '{}';
|
|
191
|
+
r record;
|
|
192
|
+
BEGIN
|
|
193
|
+
IF jsonb_typeof(v_payload) <> 'object' THEN
|
|
194
|
+
RAISE EXCEPTION 'field_rules_validate: payload must be a JSON object' USING ERRCODE = '22023';
|
|
195
|
+
END IF;
|
|
196
|
+
FOR r IN SELECT * FROM public.field_rules_resolve(p_context, p_reference, p_unit) ORDER BY sort_order, field_key LOOP
|
|
197
|
+
IF r.state = 'hidden' THEN
|
|
198
|
+
IF v_payload ? r.field_key THEN
|
|
199
|
+
v_hidden := v_hidden || r.field_key;
|
|
200
|
+
v_payload := v_payload - r.field_key;
|
|
201
|
+
END IF;
|
|
202
|
+
ELSIF r.state = 'required' THEN
|
|
203
|
+
IF NOT (v_payload ? r.field_key)
|
|
204
|
+
OR jsonb_typeof(v_payload -> r.field_key) = 'null'
|
|
205
|
+
OR (jsonb_typeof(v_payload -> r.field_key) = 'string' AND btrim(v_payload ->> r.field_key) = '') THEN
|
|
206
|
+
v_missing := v_missing || r.field_key;
|
|
207
|
+
END IF;
|
|
208
|
+
END IF;
|
|
209
|
+
END LOOP;
|
|
210
|
+
RETURN jsonb_build_object(
|
|
211
|
+
'ok', cardinality(v_missing) = 0,
|
|
212
|
+
'missing', to_jsonb(v_missing),
|
|
213
|
+
'hidden', to_jsonb(v_hidden),
|
|
214
|
+
'payload', v_payload
|
|
215
|
+
);
|
|
216
|
+
END $$;
|
|
217
|
+
|
|
218
|
+
-- ── check: the RPC contract ─────────────────────────────────────────────
|
|
219
|
+
-- Returns the stripped payload; a missing required field RAISES 23514 whose
|
|
220
|
+
-- MESSAGE is the pt-BR product copy the PRD requires ("Campo obrigatório: …",
|
|
221
|
+
-- labels from the field registry when declared) and whose DETAIL carries the
|
|
222
|
+
-- machine-readable keys. Any plugin RPC: v_payload := field_rules_check(...).
|
|
223
|
+
CREATE OR REPLACE FUNCTION public.field_rules_check(p_context text, p_reference text, p_payload jsonb, p_unit uuid DEFAULT NULL)
|
|
224
|
+
RETURNS jsonb
|
|
225
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
226
|
+
AS $$
|
|
227
|
+
DECLARE
|
|
228
|
+
v_res jsonb := public.field_rules_validate(p_context, p_reference, p_payload, p_unit);
|
|
229
|
+
v_tenant uuid := app.current_tenant_id();
|
|
230
|
+
v_labels text;
|
|
231
|
+
BEGIN
|
|
232
|
+
IF (v_res ->> 'ok')::boolean THEN
|
|
233
|
+
RETURN v_res -> 'payload';
|
|
234
|
+
END IF;
|
|
235
|
+
SELECT string_agg(coalesce(
|
|
236
|
+
(SELECT f.label FROM public.plg_entity_fields f
|
|
237
|
+
WHERE f.reference = p_reference AND f.key = k.key
|
|
238
|
+
AND (f.tenant_id IS NULL OR f.tenant_id = v_tenant)
|
|
239
|
+
AND f.archived_at IS NULL AND nullif(btrim(f.label), '') IS NOT NULL
|
|
240
|
+
ORDER BY f.tenant_id NULLS LAST LIMIT 1),
|
|
241
|
+
k.key), ', ' ORDER BY k.ord)
|
|
242
|
+
INTO v_labels
|
|
243
|
+
FROM jsonb_array_elements_text(v_res -> 'missing') WITH ORDINALITY AS k(key, ord);
|
|
244
|
+
RAISE EXCEPTION 'Campo obrigatório: %', v_labels
|
|
245
|
+
USING ERRCODE = '23514',
|
|
246
|
+
DETAIL = format('field_rules_check: required fields missing for %s/%s: %s', p_context, p_reference, (v_res -> 'missing')::text),
|
|
247
|
+
HINT = 'Preencha os campos obrigatórios antes de continuar.';
|
|
248
|
+
END $$;
|
|
249
|
+
|
|
250
|
+
REVOKE ALL ON FUNCTION public.field_rules_resolve(text, text, uuid) FROM public, anon;
|
|
251
|
+
REVOKE ALL ON FUNCTION public.field_rules_validate(text, text, jsonb, uuid) FROM public, anon;
|
|
252
|
+
REVOKE ALL ON FUNCTION public.field_rules_check(text, text, jsonb, uuid) FROM public, anon;
|
|
253
|
+
GRANT EXECUTE ON FUNCTION public.field_rules_resolve(text, text, uuid) TO authenticated, service_role;
|
|
254
|
+
GRANT EXECUTE ON FUNCTION public.field_rules_validate(text, text, jsonb, uuid) TO authenticated, service_role;
|
|
255
|
+
GRANT EXECUTE ON FUNCTION public.field_rules_check(text, text, jsonb, uuid) TO authenticated, service_role;
|
|
256
|
+
|
|
257
|
+
COMMENT ON TABLE public.field_rules IS
|
|
258
|
+
'FieldRules (PRD 07 R1): per tenant (unit_id NULL = network default) and per unit, per context and optional role, '
|
|
259
|
+
'each declared field is hidden | visible | required. Resolved by field_rules_resolve(); enforced by field_rules_check().';
|
|
260
|
+
COMMENT ON TABLE public.field_rule_contexts IS
|
|
261
|
+
'Open list of FieldRule contexts (pre_registration, scheduling, check_in, internal_form, person, order, …). A plugin adds one with an INSERT.';
|