@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.
Files changed (187) hide show
  1. package/canonical-schema.json +106 -23
  2. package/dist/index.cjs +4 -4
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/schema/spine.d.ts +42 -37
  9. package/dist/schema/spine.d.ts.map +1 -1
  10. package/migrations/000_baseline.sql +29667 -0
  11. package/migrations/001_the_tenant_has_apps.sql +314 -0
  12. package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
  13. package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
  14. package/migrations/004_the_projections_get_a_successor.sql +312 -0
  15. package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
  16. package/migrations/006_the_helpers_read_the_source.sql +153 -0
  17. package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
  18. package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
  19. package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
  20. package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
  21. package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
  22. package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
  23. package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
  24. package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
  25. package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
  26. package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
  27. package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
  28. package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
  29. package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
  30. package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
  31. package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
  32. package/package.json +1 -1
  33. package/migrations/0000_legacy_quarantine.sql +0 -86
  34. package/migrations/0000b_straggler_sweep.sql +0 -37
  35. package/migrations/000_core_v1_convert.sql +0 -343
  36. package/migrations/001_core.sql +0 -372
  37. package/migrations/002_rls_user_tenant_ids.sql +0 -64
  38. package/migrations/003_invitations.sql +0 -17
  39. package/migrations/004_archetypes.sql +0 -343
  40. package/migrations/005_locations_archetype.sql +0 -18
  41. package/migrations/006_archetype_rls.sql +0 -74
  42. package/migrations/007_archetype_grants.sql +0 -4
  43. package/migrations/008_grants.sql +0 -5
  44. package/migrations/009_anon_hardening.sql +0 -19
  45. package/migrations/010_migration_ledger.sql +0 -46
  46. package/migrations/011_anon_write_revoke.sql +0 -12
  47. package/migrations/012_rename_migration_ledger.sql +0 -61
  48. package/migrations/013_sequences_rls.sql +0 -22
  49. package/migrations/014_plan_entitlements.sql +0 -28
  50. package/migrations/015_agent_guard.sql +0 -108
  51. package/migrations/016_agent_guard_actor.sql +0 -102
  52. package/migrations/017_core_addresses.sql +0 -115
  53. package/migrations/018_global_search.sql +0 -482
  54. package/migrations/019_tenant_members_person_id.sql +0 -20
  55. package/migrations/020_fayz_projects.sql +0 -63
  56. package/migrations/021_tenant_roles.sql +0 -74
  57. package/migrations/022_invite_acceptance.sql +0 -91
  58. package/migrations/023_team_visibility.sql +0 -75
  59. package/migrations/024_analytics_engine.sql +0 -317
  60. package/migrations/025_created_by.sql +0 -75
  61. package/migrations/026_audit_trail.sql +0 -73
  62. package/migrations/027_domain_events.sql +0 -266
  63. package/migrations/028_tenant_scoped_token.sql +0 -127
  64. package/migrations/029_connections.sql +0 -186
  65. package/migrations/030_effect_idempotency.sql +0 -159
  66. package/migrations/031_sync_run_message.sql +0 -39
  67. package/migrations/032_connection_secrets.sql +0 -227
  68. package/migrations/033_sync_schedule.sql +0 -651
  69. package/migrations/034_custom_fields.sql +0 -55
  70. package/migrations/035_field_registry.sql +0 -148
  71. package/migrations/036_analytics_run_batch.sql +0 -84
  72. package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
  73. package/migrations/038_onboarding_responses.sql +0 -103
  74. package/migrations/039_unit_tree.sql +0 -270
  75. package/migrations/040_resource_grants.sql +0 -474
  76. package/migrations/041_scoped_columns.sql +0 -192
  77. package/migrations/042_unit_scope_policies.sql +0 -145
  78. package/migrations/043_view_invoker.sql +0 -90
  79. package/migrations/044_unit_member_facts.sql +0 -47
  80. package/migrations/045_unit_entry.sql +0 -236
  81. package/migrations/046_membership_visible_to_members.sql +0 -85
  82. package/migrations/047_tasks.sql +0 -266
  83. package/migrations/048_every_login_is_a_person.sql +0 -190
  84. package/migrations/049_bookable_people.sql +0 -126
  85. package/migrations/050_authz_core.sql +0 -230
  86. package/migrations/051_authz_helpers.sql +0 -330
  87. package/migrations/052_authz_fanout.sql +0 -253
  88. package/migrations/053_authz_catalog_templates.sql +0 -402
  89. package/migrations/060_tenancy_seam.sql +0 -132
  90. package/migrations/061_create_tenant.sql +0 -125
  91. package/migrations/062_invitations.sql +0 -329
  92. package/migrations/063_legacy_tenancy_retired.sql +0 -568
  93. package/migrations/064_agent_guard_authz.sql +0 -118
  94. package/migrations/064b_supersede_unit_scope.sql +0 -205
  95. package/migrations/065_scaffold_sweep.sql +0 -139
  96. package/migrations/066_shadow_report.sql +0 -122
  97. package/migrations/070_permission_catalog.sql +0 -205
  98. package/migrations/071_v1_permission_map.sql +0 -266
  99. package/migrations/072_role_rpcs.sql +0 -126
  100. package/migrations/073_legacy_rbac_retire.sql +0 -471
  101. package/migrations/075_company_legal_entities.sql +0 -169
  102. package/migrations/076_tenant_settings.sql +0 -251
  103. package/migrations/080_people_roles.sql +0 -269
  104. package/migrations/081_people_identity.sql +0 -271
  105. package/migrations/082_people_membership_link.sql +0 -142
  106. package/migrations/083_people_data_migration.sql +0 -75
  107. package/migrations/084_people_registrations_tokens.sql +0 -282
  108. package/migrations/085_catalog_products.sql +0 -333
  109. package/migrations/086_catalog_pricing.sql +0 -194
  110. package/migrations/087_catalog_packages.sql +0 -134
  111. package/migrations/090_orders_venda.sql +0 -166
  112. package/migrations/091_orders_scaffold.sql +0 -92
  113. package/migrations/095_config_field_rules.sql +0 -261
  114. package/migrations/096_audit_append_only.sql +0 -256
  115. package/migrations/097_sequences_authorized.sql +0 -111
  116. package/migrations/098_holidays.sql +0 -80
  117. package/migrations/099_access_grants.sql +0 -293
  118. package/migrations/100_migration_schema.sql +0 -217
  119. package/migrations/101_migration_allowlist.sql +0 -202
  120. package/migrations/102_migration_rpcs.sql +0 -933
  121. package/migrations/103_migration_reconcile_ponr.sql +0 -132
  122. package/migrations/105_policies_to_authenticated.sql +0 -71
  123. package/migrations/106_function_search_path.sql +0 -55
  124. package/migrations/107_core_table_prefix.sql +0 -361
  125. package/migrations/108_authz_write_guards.sql +0 -149
  126. package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
  127. package/migrations/110_people_kind_vocabulary.sql +0 -120
  128. package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
  129. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
  130. package/migrations/112_money_precision_standard.sql +0 -132
  131. package/migrations/113_addresses_units_companies.sql +0 -114
  132. package/migrations/114_migrations_ledger_immutable.sql +0 -65
  133. package/migrations/115_registration_honours_field_rules.sql +0 -172
  134. package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
  135. package/migrations/117_authz_reconciliation_schedule.sql +0 -82
  136. package/migrations/118_migration_catalog_writers.sql +0 -450
  137. package/migrations/119_migration_config_writers.sql +0 -193
  138. package/migrations/120_migration_facet_writer.sql +0 -139
  139. package/migrations/121_migration_financial_writers.sql +0 -294
  140. package/migrations/122_migration_inventory_writers.sql +0 -245
  141. package/migrations/123_migration_order_item_writer.sql +0 -144
  142. package/migrations/124_migration_ledger_writers.sql +0 -170
  143. package/migrations/125_migration_authz_people_writers.sql +0 -205
  144. package/migrations/126_owner_scoping_is_configuration.sql +0 -247
  145. package/migrations/127_scaffold_audit_hook.sql +0 -98
  146. package/migrations/128_shadow_tables_enforced.sql +0 -93
  147. package/migrations/129_order_kind_terminal_states.sql +0 -57
  148. package/migrations/130_every_login_is_a_person.sql +0 -196
  149. package/migrations/131_migration_invoice_split.sql +0 -127
  150. package/migrations/132_audit_prune_scheduled.sql +0 -28
  151. package/migrations/133_unit_is_a_session_property.sql +0 -155
  152. package/migrations/134_field_rules_on_read.sql +0 -341
  153. package/migrations/135_distribution_polarity.sql +0 -158
  154. package/migrations/136_unit_id_means_locality.sql +0 -358
  155. package/migrations/137_vendor_named_spine_objects.sql +0 -126
  156. package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
  157. package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
  158. package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
  159. package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
  160. package/migrations/142_a_person_has_addresses.sql +0 -212
  161. package/migrations/143_a_professional_has_a_rate.sql +0 -75
  162. package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
  163. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
  164. package/migrations/146_products_stock_has_one_writer.sql +0 -67
  165. package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
  166. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
  167. package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
  168. package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
  169. package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
  170. package/migrations/152_a_file_is_an_archetype.sql +0 -222
  171. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
  172. package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
  173. package/migrations/155_a_tenant_can_leave.sql +0 -271
  174. package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
  175. package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
  176. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
  177. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
  178. package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
  179. package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
  180. package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
  181. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
  182. package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
  183. package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
  184. package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
  185. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
  186. package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
  187. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
@@ -1,134 +0,0 @@
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;
@@ -1,166 +0,0 @@
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();
@@ -1,92 +0,0 @@
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;
@@ -1,261 +0,0 @@
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.';