@fayz-ai/db 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/040_resource_grants.sql +2 -2
  4. package/migrations/041_scoped_columns.sql +2 -2
  5. package/migrations/042_unit_scope_policies.sql +1 -1
  6. package/migrations/043_view_invoker.sql +12 -3
  7. package/migrations/044_unit_member_facts.sql +1 -1
  8. package/migrations/045_unit_entry.sql +1 -1
  9. package/migrations/046_membership_visible_to_members.sql +1 -1
  10. package/migrations/047_tasks.sql +4 -4
  11. package/migrations/048_every_login_is_a_person.sql +1 -1
  12. package/migrations/049_bookable_people.sql +1 -1
  13. package/migrations/050_authz_core.sql +230 -0
  14. package/migrations/051_authz_helpers.sql +330 -0
  15. package/migrations/052_authz_fanout.sql +253 -0
  16. package/migrations/053_authz_catalog_templates.sql +402 -0
  17. package/migrations/060_tenancy_seam.sql +132 -0
  18. package/migrations/061_create_tenant.sql +125 -0
  19. package/migrations/062_invitations.sql +329 -0
  20. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  21. package/migrations/064_agent_guard_authz.sql +118 -0
  22. package/migrations/064b_supersede_unit_scope.sql +205 -0
  23. package/migrations/065_scaffold_sweep.sql +139 -0
  24. package/migrations/066_shadow_report.sql +122 -0
  25. package/migrations/070_permission_catalog.sql +205 -0
  26. package/migrations/071_v1_permission_map.sql +266 -0
  27. package/migrations/072_role_rpcs.sql +126 -0
  28. package/migrations/073_legacy_rbac_retire.sql +471 -0
  29. package/migrations/075_company_legal_entities.sql +169 -0
  30. package/migrations/076_tenant_settings.sql +251 -0
  31. package/migrations/080_people_roles.sql +269 -0
  32. package/migrations/081_people_identity.sql +271 -0
  33. package/migrations/082_people_membership_link.sql +142 -0
  34. package/migrations/083_people_data_migration.sql +75 -0
  35. package/migrations/084_people_registrations_tokens.sql +282 -0
  36. package/migrations/085_catalog_products.sql +333 -0
  37. package/migrations/086_catalog_pricing.sql +194 -0
  38. package/migrations/087_catalog_packages.sql +134 -0
  39. package/migrations/090_orders_venda.sql +166 -0
  40. package/migrations/091_orders_scaffold.sql +92 -0
  41. package/migrations/095_config_field_rules.sql +261 -0
  42. package/migrations/096_audit_append_only.sql +256 -0
  43. package/migrations/097_sequences_authorized.sql +111 -0
  44. package/migrations/098_holidays.sql +80 -0
  45. package/migrations/099_access_grants.sql +293 -0
  46. package/migrations/100_migration_schema.sql +217 -0
  47. package/migrations/101_migration_allowlist.sql +202 -0
  48. package/migrations/102_migration_rpcs.sql +933 -0
  49. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  50. package/migrations/105_policies_to_authenticated.sql +71 -0
  51. package/migrations/106_function_search_path.sql +55 -0
  52. package/migrations/107_core_table_prefix.sql +361 -0
  53. package/migrations/108_authz_write_guards.sql +149 -0
  54. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  55. package/migrations/110_people_kind_vocabulary.sql +120 -0
  56. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  57. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  58. package/migrations/112_money_precision_standard.sql +132 -0
  59. package/migrations/113_addresses_units_companies.sql +114 -0
  60. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  61. package/migrations/115_registration_honours_field_rules.sql +172 -0
  62. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  63. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  64. package/migrations/118_migration_catalog_writers.sql +450 -0
  65. package/migrations/119_migration_config_writers.sql +193 -0
  66. package/migrations/120_migration_facet_writer.sql +139 -0
  67. package/migrations/121_migration_financial_writers.sql +294 -0
  68. package/migrations/122_migration_inventory_writers.sql +245 -0
  69. package/migrations/123_migration_order_item_writer.sql +144 -0
  70. package/migrations/124_migration_ledger_writers.sql +170 -0
  71. package/migrations/125_migration_authz_people_writers.sql +205 -0
  72. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  73. package/migrations/127_scaffold_audit_hook.sql +98 -0
  74. package/migrations/128_shadow_tables_enforced.sql +93 -0
  75. package/migrations/129_order_kind_terminal_states.sql +57 -0
  76. package/migrations/130_every_login_is_a_person.sql +196 -0
  77. package/migrations/131_migration_invoice_split.sql +127 -0
  78. package/migrations/132_audit_prune_scheduled.sql +28 -0
  79. package/migrations/133_unit_is_a_session_property.sql +155 -0
  80. package/migrations/134_field_rules_on_read.sql +341 -0
  81. package/migrations/135_distribution_polarity.sql +158 -0
  82. package/migrations/136_unit_id_means_locality.sql +358 -0
  83. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  84. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  85. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  86. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  87. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  88. package/migrations/142_a_person_has_addresses.sql +212 -0
  89. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  90. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  91. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  92. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  93. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  94. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  95. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  96. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  97. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  98. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  99. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  100. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  101. package/migrations/155_a_tenant_can_leave.sql +271 -0
  102. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  103. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  104. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  105. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  106. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  107. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  108. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  109. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  110. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  111. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  112. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  113. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  114. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  115. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  116. package/package.json +3 -2
@@ -0,0 +1,333 @@
1
+ -- ============================================================================
2
+ -- 085_catalog_products.sql — the catalog contract, part 1 (PRD 04 / #139):
3
+ -- ONE catalog item archetype. `public.products` carries `kind good|service`
4
+ -- (DATA-MODEL §1: a sixth "services" table was rejected); the drifted
5
+ -- `public.services` table is folded into products and kept as a compatibility
6
+ -- MIRROR; products and categories get the authz scaffold plus the catalog
7
+ -- release rule (available_in).
8
+ --
9
+ -- What this file does, in order (every step idempotent + replay-safe):
10
+ -- 1. products: kind (good|service), subkind (vertical/plugin discriminator),
11
+ -- duration_minutes (core, services), service_kind (ADR 0009 promoted
12
+ -- column: standard|evaluation|return — a schedulable state, not JSONB).
13
+ -- 2. authz scaffold on products, categories and services (mirror), plus the
14
+ -- RESTRICTIVE release policy: a tenant-wide item (unit_id null) is visible
15
+ -- in a unit only if it has no `available_in` tuple or one for that unit
16
+ -- ("no tuples ⇒ everywhere", §9.1); a unit-scoped item is governed by
17
+ -- has_unit as every business row.
18
+ -- 3. a BEFORE trigger normalises writes: legacy `kind` values the fleet
19
+ -- wrote before this contract ('menu_item', 'sale', 'ingredient', 'asset',
20
+ -- 'intermediate', 'course') map to kind + subkind so old TS keeps working;
21
+ -- service_kind is filled for services and cleared for goods. Unknown
22
+ -- kinds are left for the CHECK to reject.
23
+ -- 4. FOLD: every public.services row becomes a products row with the SAME id
24
+ -- (ADR 0007 spirit — order_items.service_id keeps meaning); the FKs of
25
+ -- order_items.service_id and appointment_items.service_id are repointed
26
+ -- to products(id).
27
+ -- 5. COMPAT: public.services stays a physical table (a VIEW would break the
28
+ -- replay of 004/006/025/034, which ALTER/INDEX/POLICY it — applied files
29
+ -- are never edited). It is a projection of products WHERE kind='service'
30
+ -- maintained by triggers in BOTH directions, so `.from('services')` reads
31
+ -- AND writes keep working; a transaction-local flag stops the echo.
32
+ -- Deprecation: Phase 1 makes it read-only, Phase 2 drops it
33
+ -- (docs/data/phase-0/04-catalog-contract.md).
34
+ -- 6. stock/min_stock stay (inventory owns balances, #143) — commented as
35
+ -- deprecated; global search sources split goods and services.
36
+ -- ============================================================================
37
+
38
+ -- ── 1. products columns ─────────────────────────────────────────────────
39
+ ALTER TABLE public.products ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'good';
40
+ ALTER TABLE public.products ADD COLUMN IF NOT EXISTS subkind text;
41
+ ALTER TABLE public.products ADD COLUMN IF NOT EXISTS duration_minutes integer;
42
+ ALTER TABLE public.products ADD COLUMN IF NOT EXISTS service_kind text;
43
+
44
+ COMMENT ON COLUMN public.products.kind IS
45
+ 'Catalog archetype: good | service. The only discriminator the core knows (DATA-MODEL §1). Vertical/plugin refinements go in subkind.';
46
+ COMMENT ON COLUMN public.products.subkind IS
47
+ 'Vertical or plugin discriminator inside a kind (menu_item, ingredient, asset, intermediate, course, …). Free text: a new subkind is a row, not a migration.';
48
+ COMMENT ON COLUMN public.products.duration_minutes IS
49
+ 'Core duration of a service (kind=service); the agenda reads it. NULL for goods.';
50
+ COMMENT ON COLUMN public.products.service_kind IS
51
+ 'ADR 0009 promoted column: standard | evaluation | return — a schedulable state (RN-CAT-03), never JSONB. NULL for goods, standard by default for services.';
52
+ COMMENT ON COLUMN public.products.stock IS
53
+ 'deprecated: inventory owns balances (#143). Kept only until the inventory slice moves the last reader; never write it from the catalog.';
54
+ COMMENT ON COLUMN public.products.min_stock IS
55
+ 'deprecated: inventory owns balances (#143). Kept only until the inventory slice moves the last reader; never write it from the catalog.';
56
+
57
+ CREATE INDEX IF NOT EXISTS products_tenant_kind_idx ON public.products (tenant_id, kind);
58
+ CREATE INDEX IF NOT EXISTS products_tenant_subkind_idx ON public.products (tenant_id, subkind) WHERE subkind IS NOT NULL;
59
+
60
+ -- ── 2. authz scaffold (§4/§7 template) ─────────────────────────────────
61
+ -- Catalog is a tenant-wide resource: unit_id is null by default; owner scoping
62
+ -- off. products / categories / services (mirror) share the 'catalog' keys.
63
+ -- enforce mode replaces the 004/006 tenant-only policies (a replay of 004/006
64
+ -- recreates them; this call converges the table again).
65
+ SELECT app.scaffold_table('public.products', 'catalog.product', 'catalog', false, 'enforce');
66
+ SELECT app.scaffold_table('public.categories', 'catalog.category', 'catalog', false, 'enforce');
67
+ SELECT app.scaffold_table('public.services', 'catalog.product', 'catalog', false, 'enforce');
68
+
69
+ -- The release mechanism (§9.1). Permissive policies OR together, so a
70
+ -- restriction has to be AS RESTRICTIVE: it ANDs with the scaffold's select.
71
+ -- unit-scoped item (unit_id set) → has_unit decides (scaffold), this is true
72
+ -- tenant-wide item (unit_id null) → visible where available_in says; no tuple
73
+ -- ⇒ everywhere
74
+ -- Created AFTER the scaffold (enforce drops every foreign policy) so a replay
75
+ -- converges: scaffold drops it, this recreates it.
76
+ DROP POLICY IF EXISTS products_available_in ON public.products;
77
+ CREATE POLICY products_available_in ON public.products AS RESTRICTIVE FOR SELECT TO authenticated
78
+ USING (unit_id IS NOT NULL OR (select app.available_in_my_units('catalog.product', id)));
79
+ DROP POLICY IF EXISTS services_available_in ON public.services;
80
+ CREATE POLICY services_available_in ON public.services AS RESTRICTIVE FOR SELECT TO authenticated
81
+ USING (unit_id IS NOT NULL OR (select app.available_in_my_units('catalog.product', id)));
82
+
83
+ -- categories: shared taxonomy with hierarchy — the parent must be a category of
84
+ -- the same tenant. ON DELETE SET NULL (parent_id): only the pointer is cleared,
85
+ -- never tenant_id (a bare SET NULL on a composite FK would null both).
86
+ DO $$
87
+ BEGIN
88
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.categories'::regclass AND conname = 'categories_parent_tenant_fk') THEN
89
+ ALTER TABLE public.categories
90
+ ADD CONSTRAINT categories_parent_tenant_fk FOREIGN KEY (tenant_id, parent_id)
91
+ REFERENCES public.categories (tenant_id, id) ON DELETE SET NULL (parent_id);
92
+ END IF;
93
+ END $$;
94
+ CREATE INDEX IF NOT EXISTS categories_tenant_parent_idx ON public.categories (tenant_id, parent_id);
95
+ COMMENT ON COLUMN public.categories.kind IS
96
+ 'Taxonomy namespace. Catalog categories use the product kind they classify (good | service); other plugins keep their own (menu_category, …).';
97
+
98
+ -- ── 3. write normalisation (legacy kind compat + service_kind pairing) ──
99
+ -- Runs BEFORE the CHECKs. Legacy vocabulary written by the fleet before this
100
+ -- contract: plugin-menu 'menu_item', plugin-inventory 'sale' | 'ingredient' |
101
+ -- 'asset' (+ V1 'intermediate'), courses 'course'. Anything else is left as
102
+ -- written so the CHECK rejects it (an unknown kind must not silently become a
103
+ -- good). Deprecated: once every writer speaks kind+subkind the map goes away.
104
+ CREATE OR REPLACE FUNCTION app.trg_catalog_products_normalise()
105
+ RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$
106
+ BEGIN
107
+ IF NEW.kind IS NULL THEN
108
+ NEW.kind := 'good';
109
+ END IF;
110
+ IF NEW.kind NOT IN ('good', 'service') THEN
111
+ CASE NEW.kind
112
+ WHEN 'menu_item' THEN NEW.subkind := coalesce(NEW.subkind, 'menu_item'); NEW.kind := 'good';
113
+ WHEN 'ingredient' THEN NEW.subkind := coalesce(NEW.subkind, 'ingredient'); NEW.kind := 'good';
114
+ WHEN 'asset' THEN NEW.subkind := coalesce(NEW.subkind, 'asset'); NEW.kind := 'good';
115
+ WHEN 'intermediate' THEN NEW.subkind := coalesce(NEW.subkind, 'intermediate'); NEW.kind := 'good';
116
+ WHEN 'sale' THEN NEW.kind := 'good';
117
+ WHEN 'course' THEN NEW.subkind := coalesce(NEW.subkind, 'course'); NEW.kind := 'service';
118
+ ELSE NULL; -- unknown: the CHECK constraint rejects it
119
+ END CASE;
120
+ END IF;
121
+ IF NEW.kind = 'service' THEN
122
+ NEW.service_kind := coalesce(NEW.service_kind, 'standard');
123
+ ELSE
124
+ NEW.service_kind := NULL;
125
+ END IF;
126
+ RETURN NEW;
127
+ END $$;
128
+ DROP TRIGGER IF EXISTS products_catalog_normalise ON public.products;
129
+ CREATE TRIGGER products_catalog_normalise
130
+ BEFORE INSERT OR UPDATE ON public.products
131
+ FOR EACH ROW EXECUTE FUNCTION app.trg_catalog_products_normalise();
132
+
133
+ -- ── 4. FOLD services → products (same id) ──────────────────────────────
134
+ INSERT INTO public.products (
135
+ id, tenant_id, category_id, name, description, price, cost, currency, image_url,
136
+ status, is_active, tags, metadata, custom_fields, created_by, created_at, updated_at,
137
+ unit_id, owner_id, kind, duration_minutes
138
+ )
139
+ SELECT
140
+ s.id, s.tenant_id, s.category_id, s.name, s.description, s.price, s.cost, s.currency, s.image_url,
141
+ s.status, s.is_active, s.tags, s.metadata, s.custom_fields, s.created_by, s.created_at, s.updated_at,
142
+ s.unit_id, s.owner_id, 'service', s.duration_minutes
143
+ FROM public.services s
144
+ ON CONFLICT (id) DO NOTHING;
145
+
146
+ -- ── 4b. the rows that were already there ────────────────────────────────
147
+ -- ADD COLUMN IF NOT EXISTS matches a NAME, not a CONTRACT. A pool that already
148
+ -- had a free-text `kind` keeps every value it ever wrote — salon carries 8 NULL,
149
+ -- 4 'sale' and 1 'asset' — and the normaliser above only ever sees a WRITE, so
150
+ -- those rows reach the CHECK untouched and it refuses the whole file.
151
+ --
152
+ -- The map is not restated here. A no-op UPDATE puts each stale row through the
153
+ -- BEFORE trigger, so the legacy vocabulary is translated in exactly ONE place;
154
+ -- a second copy would be the thing that drifts. Only rows that need it are
155
+ -- touched, so a pool already speaking kind+subkind moves nothing.
156
+ UPDATE public.products SET kind = kind
157
+ WHERE kind IS NULL OR kind NOT IN ('good', 'service');
158
+
159
+ -- CHECKs after the fold (existing goods have service_kind NULL; folded services
160
+ -- went through the normaliser and carry 'standard').
161
+ DO $$
162
+ BEGIN
163
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.products'::regclass AND conname = 'products_kind_check') THEN
164
+ ALTER TABLE public.products ADD CONSTRAINT products_kind_check CHECK (kind IN ('good', 'service'));
165
+ END IF;
166
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.products'::regclass AND conname = 'products_service_kind_check') THEN
167
+ ALTER TABLE public.products ADD CONSTRAINT products_service_kind_check
168
+ CHECK (service_kind IS NULL OR service_kind IN ('standard', 'evaluation', 'return'));
169
+ END IF;
170
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.products'::regclass AND conname = 'products_service_kind_pairing_check') THEN
171
+ ALTER TABLE public.products ADD CONSTRAINT products_service_kind_pairing_check
172
+ CHECK ((kind = 'service') = (service_kind IS NOT NULL));
173
+ END IF;
174
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.products'::regclass AND conname = 'products_duration_check') THEN
175
+ ALTER TABLE public.products ADD CONSTRAINT products_duration_check
176
+ CHECK (duration_minutes IS NULL OR duration_minutes >= 0);
177
+ END IF;
178
+ END $$;
179
+
180
+ -- FKs that pointed at services now point at products (rows carry the same id).
181
+ DO $$
182
+ DECLARE r record;
183
+ BEGIN
184
+ FOR r IN
185
+ SELECT c.conname, c.conrelid::regclass AS tbl
186
+ FROM pg_constraint c
187
+ WHERE c.contype = 'f'
188
+ AND c.confrelid = 'public.services'::regclass
189
+ AND c.conrelid IN ('public.order_items'::regclass, 'public.appointment_items'::regclass)
190
+ LOOP
191
+ EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.tbl, r.conname);
192
+ END LOOP;
193
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.order_items'::regclass AND conname = 'order_items_service_id_fkey') THEN
194
+ ALTER TABLE public.order_items
195
+ ADD CONSTRAINT order_items_service_id_fkey FOREIGN KEY (service_id) REFERENCES public.products(id) ON DELETE SET NULL;
196
+ END IF;
197
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.appointment_items'::regclass AND conname = 'appointment_items_service_id_fkey') THEN
198
+ ALTER TABLE public.appointment_items
199
+ ADD CONSTRAINT appointment_items_service_id_fkey FOREIGN KEY (service_id) REFERENCES public.products(id) ON DELETE SET NULL;
200
+ END IF;
201
+ END $$;
202
+
203
+ -- ── 5. COMPAT mirror: public.services ⇄ public.products (kind='service') ─
204
+ -- The flag `fayz.catalog_sync` (transaction-local GUC) marks a write that the
205
+ -- other side already reflects, so the two triggers never echo each other.
206
+ -- Both are SECURITY DEFINER: the authz decision was taken by RLS on the table
207
+ -- the caller wrote (both carry the same catalog policies); the projection is a
208
+ -- mechanical consequence and must not be filtered by the caller's scope.
209
+
210
+ -- products → services
211
+ CREATE OR REPLACE FUNCTION app.trg_catalog_products_mirror()
212
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
213
+ BEGIN
214
+ IF coalesce(current_setting('fayz.catalog_sync', true), '') = 'on' THEN
215
+ RETURN NULL;
216
+ END IF;
217
+ PERFORM set_config('fayz.catalog_sync', 'on', true);
218
+ IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND NEW.kind <> 'service') THEN
219
+ DELETE FROM public.services s WHERE s.id = OLD.id;
220
+ END IF;
221
+ IF TG_OP IN ('INSERT', 'UPDATE') AND NEW.kind = 'service' THEN
222
+ INSERT INTO public.services (
223
+ id, tenant_id, category_id, name, description, price, cost, currency, duration_minutes, image_url,
224
+ status, is_active, tags, metadata, custom_fields, created_by, created_at, updated_at, unit_id, owner_id
225
+ ) VALUES (
226
+ NEW.id, NEW.tenant_id, NEW.category_id, NEW.name, NEW.description, NEW.price, NEW.cost, NEW.currency, NEW.duration_minutes, NEW.image_url,
227
+ NEW.status, NEW.is_active, NEW.tags, NEW.metadata, NEW.custom_fields, NEW.created_by, NEW.created_at, NEW.updated_at, NEW.unit_id, NEW.owner_id
228
+ )
229
+ ON CONFLICT (id) DO UPDATE SET
230
+ tenant_id = EXCLUDED.tenant_id, category_id = EXCLUDED.category_id, name = EXCLUDED.name,
231
+ description = EXCLUDED.description, price = EXCLUDED.price, cost = EXCLUDED.cost, currency = EXCLUDED.currency,
232
+ duration_minutes = EXCLUDED.duration_minutes, image_url = EXCLUDED.image_url, status = EXCLUDED.status,
233
+ is_active = EXCLUDED.is_active, tags = EXCLUDED.tags, metadata = EXCLUDED.metadata, custom_fields = EXCLUDED.custom_fields,
234
+ created_by = EXCLUDED.created_by, created_at = EXCLUDED.created_at, updated_at = EXCLUDED.updated_at,
235
+ unit_id = EXCLUDED.unit_id, owner_id = EXCLUDED.owner_id;
236
+ END IF;
237
+ PERFORM set_config('fayz.catalog_sync', '', true);
238
+ RETURN NULL;
239
+ END $$;
240
+ DROP TRIGGER IF EXISTS products_catalog_mirror ON public.products;
241
+ CREATE TRIGGER products_catalog_mirror
242
+ AFTER INSERT OR UPDATE OR DELETE ON public.products
243
+ FOR EACH ROW EXECUTE FUNCTION app.trg_catalog_products_mirror();
244
+
245
+ -- services → products (old TS writing `.from('services')`). BEFORE row trigger:
246
+ -- the write is forwarded to products, the flag tells the products trigger not
247
+ -- to project it back, and the statement then lands on services itself so
248
+ -- INSERT … RETURNING keeps working. Named zz_* so it fires after the created_by
249
+ -- / updated_at triggers of the table (BEFORE triggers run in name order).
250
+ CREATE OR REPLACE FUNCTION app.trg_catalog_services_forward()
251
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
252
+ BEGIN
253
+ IF coalesce(current_setting('fayz.catalog_sync', true), '') = 'on' THEN
254
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
255
+ END IF;
256
+ IF TG_OP = 'UPDATE' AND NEW.id IS DISTINCT FROM OLD.id THEN
257
+ RAISE EXCEPTION 'services: changing the id of a service is not supported (compat mirror)' USING ERRCODE = '0A000';
258
+ END IF;
259
+ PERFORM set_config('fayz.catalog_sync', 'on', true);
260
+ IF TG_OP = 'DELETE' THEN
261
+ DELETE FROM public.products p WHERE p.id = OLD.id;
262
+ ELSE
263
+ INSERT INTO public.products (
264
+ id, tenant_id, category_id, name, description, price, cost, currency, image_url,
265
+ status, is_active, tags, metadata, custom_fields, created_by, created_at, updated_at,
266
+ unit_id, owner_id, kind, duration_minutes
267
+ ) VALUES (
268
+ NEW.id, NEW.tenant_id, NEW.category_id, NEW.name, NEW.description, NEW.price, NEW.cost, NEW.currency, NEW.image_url,
269
+ NEW.status, NEW.is_active, NEW.tags, NEW.metadata, NEW.custom_fields, NEW.created_by, NEW.created_at, NEW.updated_at,
270
+ NEW.unit_id, NEW.owner_id, 'service', NEW.duration_minutes
271
+ )
272
+ ON CONFLICT (id) DO UPDATE SET
273
+ tenant_id = EXCLUDED.tenant_id, category_id = EXCLUDED.category_id, name = EXCLUDED.name,
274
+ description = EXCLUDED.description, price = EXCLUDED.price, cost = EXCLUDED.cost, currency = EXCLUDED.currency,
275
+ image_url = EXCLUDED.image_url, status = EXCLUDED.status, is_active = EXCLUDED.is_active, tags = EXCLUDED.tags,
276
+ metadata = EXCLUDED.metadata, custom_fields = EXCLUDED.custom_fields, created_by = EXCLUDED.created_by,
277
+ updated_at = EXCLUDED.updated_at, unit_id = EXCLUDED.unit_id, owner_id = EXCLUDED.owner_id,
278
+ kind = 'service', duration_minutes = EXCLUDED.duration_minutes;
279
+ END IF;
280
+ PERFORM set_config('fayz.catalog_sync', '', true);
281
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
282
+ END $$;
283
+ DROP TRIGGER IF EXISTS zz_services_catalog_forward ON public.services;
284
+ CREATE TRIGGER zz_services_catalog_forward
285
+ BEFORE INSERT OR UPDATE OR DELETE ON public.services
286
+ FOR EACH ROW EXECUTE FUNCTION app.trg_catalog_services_forward();
287
+
288
+ -- Reconcile the mirror from the source of truth: add missing projections,
289
+ -- drop orphans (a service row whose product is gone or is no longer a
290
+ -- service). Returns the diff; a non-zero diff outside a migration is a bug.
291
+ CREATE OR REPLACE FUNCTION app.catalog_reconcile_services_mirror(OUT added integer, OUT removed integer)
292
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
293
+ BEGIN
294
+ PERFORM set_config('fayz.catalog_sync', 'on', true);
295
+ WITH del AS (
296
+ DELETE FROM public.services s
297
+ WHERE NOT EXISTS (SELECT 1 FROM public.products p WHERE p.id = s.id AND p.kind = 'service')
298
+ RETURNING 1
299
+ ) SELECT count(*) INTO removed FROM del;
300
+ WITH ins AS (
301
+ INSERT INTO public.services (
302
+ id, tenant_id, category_id, name, description, price, cost, currency, duration_minutes, image_url,
303
+ status, is_active, tags, metadata, custom_fields, created_by, created_at, updated_at, unit_id, owner_id
304
+ )
305
+ SELECT p.id, p.tenant_id, p.category_id, p.name, p.description, p.price, p.cost, p.currency, p.duration_minutes, p.image_url,
306
+ p.status, p.is_active, p.tags, p.metadata, p.custom_fields, p.created_by, p.created_at, p.updated_at, p.unit_id, p.owner_id
307
+ FROM public.products p
308
+ WHERE p.kind = 'service'
309
+ AND NOT EXISTS (SELECT 1 FROM public.services s WHERE s.id = p.id)
310
+ RETURNING 1
311
+ ) SELECT count(*) INTO added FROM ins;
312
+ PERFORM set_config('fayz.catalog_sync', '', true);
313
+ END $$;
314
+ REVOKE ALL ON FUNCTION app.catalog_reconcile_services_mirror() FROM public, anon, authenticated;
315
+ GRANT EXECUTE ON FUNCTION app.catalog_reconcile_services_mirror() TO service_role;
316
+ SELECT app.catalog_reconcile_services_mirror();
317
+
318
+ COMMENT ON TABLE public.services IS
319
+ 'DEPRECATED compat mirror of public.products WHERE kind = ''service'' (PRD 04 / #139). Reads and writes are forwarded by triggers; the source of truth is products. Phase 1: read-only; Phase 2: dropped. New code reads products.';
320
+
321
+ -- ── 6. global search: goods and services are two sources over one table ─
322
+ UPDATE public.fayz_search_sources
323
+ SET filter_expr = 'kind = ''good'''
324
+ WHERE entity_key = 'product' AND relation = 'public.products';
325
+ UPDATE public.fayz_search_sources
326
+ SET relation = 'public.products', filter_expr = 'kind = ''service'''
327
+ WHERE entity_key = 'service';
328
+ SELECT public.fayz_search_reindex();
329
+
330
+ -- ── grants: unchanged for the three pre-existing tables (004 granted ALL to
331
+ -- authenticated/service_role; RLS narrows). The compat mirror follows products.
332
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.products, public.categories, public.services TO authenticated;
333
+ GRANT ALL ON public.products, public.categories, public.services TO service_role;
@@ -0,0 +1,194 @@
1
+ -- ============================================================================
2
+ -- 086_catalog_pricing.sql — the catalog contract, part 2 (PRD 04 R3 / #139):
3
+ -- price tables with validity and scope (unit / channel), price table items,
4
+ -- product variants (the "variations" facet), and catalog_price() — the one
5
+ -- resolver every seller (POS, online, agenda) calls.
6
+ --
7
+ -- The base price stays on the item (products.price). A price table overrides
8
+ -- it for a scope and a validity window; the resolver picks the most specific
9
+ -- applicable table: unit-specific > channel-specific > default > base price.
10
+ --
11
+ -- All three tables are business tables of plugin 'catalog' (scaffold, enforce).
12
+ -- price_table_items inherit (tenant_id, unit_id) from their table so RLS on the
13
+ -- rows agrees with RLS on the table they belong to.
14
+ -- ============================================================================
15
+
16
+ -- ── price_tables ────────────────────────────────────────────────────────
17
+ CREATE TABLE IF NOT EXISTS public.price_tables (
18
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
19
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
20
+ name text NOT NULL,
21
+ description text,
22
+ -- scope: a unit (null = tenant-wide) and/or a sales channel (null = every
23
+ -- channel). Channel vocabulary: 'pos' | 'online' | 'app' — free text so a new
24
+ -- channel is a row, not a migration.
25
+ channel text,
26
+ currency text NOT NULL DEFAULT 'BRL',
27
+ valid_from date,
28
+ valid_to date,
29
+ is_default boolean NOT NULL DEFAULT false,
30
+ priority integer NOT NULL DEFAULT 0,
31
+ is_active boolean NOT NULL DEFAULT true,
32
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
33
+ created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
34
+ created_at timestamptz NOT NULL DEFAULT now(),
35
+ updated_at timestamptz NOT NULL DEFAULT now(),
36
+ CONSTRAINT price_tables_validity_check CHECK (valid_from IS NULL OR valid_to IS NULL OR valid_to >= valid_from)
37
+ );
38
+ -- A pool that already carries a V1-shaped `price_tables` skips the CREATE above
39
+ -- (IF NOT EXISTS matches the NAME, not the shape), and the scope index below
40
+ -- then asks for columns that were never added. Additive, so the existing rows
41
+ -- and their prices are untouched — a cluster's own table converges instead of
42
+ -- failing the apply.
43
+ ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS name text;
44
+ ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS channel text;
45
+ ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS currency text NOT NULL DEFAULT 'BRL';
46
+ ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS valid_to date;
47
+ ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
48
+ ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS priority integer NOT NULL DEFAULT 0;
49
+ ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb;
50
+ ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL;
51
+ -- `name` is NOT NULL on a fresh pool; on a converging one the V1 rows have no
52
+ -- name yet, so it is backfilled from what that table did carry rather than
53
+ -- rejecting rows the merchant already owns.
54
+ UPDATE public.price_tables SET name = COALESCE(name, description, 'Tabela ' || left(id::text, 8))
55
+ WHERE name IS NULL;
56
+ ALTER TABLE public.price_tables ALTER COLUMN name SET NOT NULL;
57
+
58
+ SELECT app.scaffold_table('public.price_tables', 'catalog.price_table', 'catalog', false, 'enforce');
59
+ CREATE INDEX IF NOT EXISTS price_tables_tenant_scope_idx
60
+ ON public.price_tables (tenant_id, unit_id, channel) WHERE is_active;
61
+ DROP TRIGGER IF EXISTS price_tables_created_by ON public.price_tables;
62
+ CREATE TRIGGER price_tables_created_by BEFORE INSERT ON public.price_tables
63
+ FOR EACH ROW EXECUTE FUNCTION public.handle_created_by();
64
+ COMMENT ON TABLE public.price_tables IS
65
+ 'Catalog price tables (PRD 04 R3): a set of item prices valid for a window and a scope (unit_id, channel). V1 price_tables. Resolved by public.catalog_price().';
66
+
67
+ -- ── price_table_items ───────────────────────────────────────────────────
68
+ CREATE TABLE IF NOT EXISTS public.price_table_items (
69
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
70
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
71
+ price_table_id uuid NOT NULL,
72
+ product_id uuid NOT NULL,
73
+ price numeric NOT NULL CHECK (price >= 0),
74
+ min_qty numeric NOT NULL DEFAULT 1 CHECK (min_qty > 0),
75
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
76
+ created_at timestamptz NOT NULL DEFAULT now(),
77
+ updated_at timestamptz NOT NULL DEFAULT now(),
78
+ CONSTRAINT price_table_items_table_fk FOREIGN KEY (tenant_id, price_table_id) REFERENCES public.price_tables (tenant_id, id) ON DELETE CASCADE,
79
+ CONSTRAINT price_table_items_product_fk FOREIGN KEY (tenant_id, product_id) REFERENCES public.products (tenant_id, id) ON DELETE CASCADE,
80
+ CONSTRAINT price_table_items_unique UNIQUE (price_table_id, product_id, min_qty)
81
+ );
82
+ SELECT app.scaffold_table('public.price_table_items', 'catalog.price_table_item', 'catalog', false, 'enforce');
83
+ CREATE INDEX IF NOT EXISTS price_table_items_product_idx ON public.price_table_items (tenant_id, product_id);
84
+ -- an item's price is not more visible than the item itself (release rule of 075)
85
+ DROP POLICY IF EXISTS price_table_items_available_in ON public.price_table_items;
86
+ CREATE POLICY price_table_items_available_in ON public.price_table_items AS RESTRICTIVE FOR SELECT TO authenticated
87
+ USING ((select app.available_in_my_units('catalog.product', product_id)));
88
+ COMMENT ON TABLE public.price_table_items IS
89
+ 'One price of one product in one price table (optional quantity tier via min_qty). V1 price_table_items. Inherits (tenant_id, unit_id) from the table.';
90
+
91
+ -- Items live in the scope of their table: copy (tenant_id, unit_id) from the
92
+ -- parent on every write so has_unit() answers the same for both.
93
+ CREATE OR REPLACE FUNCTION app.trg_catalog_price_table_items_scope()
94
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
95
+ DECLARE v_tenant uuid; v_unit uuid;
96
+ BEGIN
97
+ SELECT t.tenant_id, t.unit_id INTO v_tenant, v_unit FROM public.price_tables t WHERE t.id = NEW.price_table_id;
98
+ IF v_tenant IS NULL THEN
99
+ RAISE EXCEPTION 'price_table_items: price table % does not exist', NEW.price_table_id USING ERRCODE = '23503';
100
+ END IF;
101
+ NEW.tenant_id := v_tenant;
102
+ NEW.unit_id := v_unit;
103
+ RETURN NEW;
104
+ END $$;
105
+ DROP TRIGGER IF EXISTS price_table_items_scope ON public.price_table_items;
106
+ CREATE TRIGGER price_table_items_scope BEFORE INSERT OR UPDATE ON public.price_table_items
107
+ FOR EACH ROW EXECUTE FUNCTION app.trg_catalog_price_table_items_scope();
108
+ -- …and when a table changes scope, its items follow.
109
+ CREATE OR REPLACE FUNCTION app.trg_catalog_price_tables_rescope_items()
110
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
111
+ BEGIN
112
+ IF NEW.unit_id IS DISTINCT FROM OLD.unit_id THEN
113
+ UPDATE public.price_table_items i SET unit_id = NEW.unit_id WHERE i.price_table_id = NEW.id;
114
+ END IF;
115
+ RETURN NULL;
116
+ END $$;
117
+ DROP TRIGGER IF EXISTS price_tables_rescope_items ON public.price_tables;
118
+ CREATE TRIGGER price_tables_rescope_items AFTER UPDATE OF unit_id ON public.price_tables
119
+ FOR EACH ROW EXECUTE FUNCTION app.trg_catalog_price_tables_rescope_items();
120
+
121
+ -- ── product_variants (the "variations" facet) ───────────────────────────
122
+ CREATE TABLE IF NOT EXISTS public.product_variants (
123
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
124
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
125
+ product_id uuid NOT NULL,
126
+ name text NOT NULL,
127
+ sku text,
128
+ -- either an absolute price or a delta on the resolved item price
129
+ price numeric CHECK (price IS NULL OR price >= 0),
130
+ price_delta numeric NOT NULL DEFAULT 0,
131
+ attributes jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"size":"M","color":"blue"}
132
+ sort_order integer NOT NULL DEFAULT 0,
133
+ is_active boolean NOT NULL DEFAULT true,
134
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
135
+ created_at timestamptz NOT NULL DEFAULT now(),
136
+ updated_at timestamptz NOT NULL DEFAULT now(),
137
+ CONSTRAINT product_variants_product_fk FOREIGN KEY (tenant_id, product_id) REFERENCES public.products (tenant_id, id) ON DELETE CASCADE
138
+ );
139
+ SELECT app.scaffold_table('public.product_variants', 'catalog.product_variant', 'catalog', false, 'enforce');
140
+ CREATE INDEX IF NOT EXISTS product_variants_product_idx ON public.product_variants (tenant_id, product_id);
141
+ -- a variant is not more visible than its item (release rule of 075)
142
+ DROP POLICY IF EXISTS product_variants_available_in ON public.product_variants;
143
+ CREATE POLICY product_variants_available_in ON public.product_variants AS RESTRICTIVE FOR SELECT TO authenticated
144
+ USING ((select app.available_in_my_units('catalog.product', product_id)));
145
+ CREATE UNIQUE INDEX IF NOT EXISTS product_variants_sku_uq ON public.product_variants (tenant_id, sku) WHERE sku IS NOT NULL;
146
+ COMMENT ON TABLE public.product_variants IS
147
+ 'Variations of a catalog item (size, colour, volume…): own sku, absolute price or delta on the resolved price. Catalog facet "variations" (PRD 04 R3).';
148
+
149
+ -- ── catalog_price(product, unit, channel, at, qty) ──────────────────────
150
+ -- The resolver. SECURITY INVOKER on purpose: the caller sees the price tables
151
+ -- her RLS lets her see (a unit-scoped table is invisible outside its unit), so
152
+ -- the answer is always one she is entitled to. Ranking: unit-specific >
153
+ -- channel-specific > higher priority > the default table > highest quantity
154
+ -- tier that applies > most recent validity; then the item's base price.
155
+ CREATE OR REPLACE FUNCTION public.catalog_price(
156
+ p_product uuid,
157
+ p_unit uuid DEFAULT NULL,
158
+ p_channel text DEFAULT NULL,
159
+ p_at date DEFAULT current_date,
160
+ p_qty numeric DEFAULT 1
161
+ )
162
+ RETURNS numeric
163
+ LANGUAGE sql STABLE SET search_path = ''
164
+ AS $$
165
+ SELECT coalesce(
166
+ (SELECT i.price
167
+ FROM public.price_table_items i
168
+ JOIN public.price_tables t ON t.tenant_id = i.tenant_id AND t.id = i.price_table_id
169
+ WHERE i.product_id = p_product
170
+ AND t.is_active
171
+ AND (t.valid_from IS NULL OR t.valid_from <= p_at)
172
+ AND (t.valid_to IS NULL OR t.valid_to >= p_at)
173
+ AND (t.unit_id IS NULL OR t.unit_id = p_unit)
174
+ AND (t.channel IS NULL OR t.channel = p_channel)
175
+ AND i.min_qty <= coalesce(p_qty, 1)
176
+ ORDER BY (t.unit_id IS NOT NULL) DESC,
177
+ (t.channel IS NOT NULL) DESC,
178
+ t.priority DESC,
179
+ t.is_default DESC,
180
+ i.min_qty DESC,
181
+ t.valid_from DESC NULLS LAST,
182
+ t.created_at DESC
183
+ LIMIT 1),
184
+ (SELECT p.price FROM public.products p WHERE p.id = p_product)
185
+ );
186
+ $$;
187
+ REVOKE ALL ON FUNCTION public.catalog_price(uuid, uuid, text, date, numeric) FROM public, anon;
188
+ GRANT EXECUTE ON FUNCTION public.catalog_price(uuid, uuid, text, date, numeric) TO authenticated, service_role;
189
+ COMMENT ON FUNCTION public.catalog_price(uuid, uuid, text, date, numeric) IS
190
+ 'Resolves the selling price of a catalog item for a unit, a channel, a date and a quantity: the most specific applicable price table (unit > channel > default), else products.price.';
191
+
192
+ -- ── grants ──────────────────────────────────────────────────────────────
193
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.price_tables, public.price_table_items, public.product_variants TO authenticated;
194
+ GRANT ALL ON public.price_tables, public.price_table_items, public.product_variants TO service_role;