@fayz-ai/db 0.28.1 → 0.29.1

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.
@@ -0,0 +1,9 @@
1
+ -- 213: the department is a declared grouping. V1's Cadastros › Departamentos
2
+ -- (Financeiro, Cozinha, Marketing…) is an area of the company: it owns cost
3
+ -- centres and the people whose record points at it. In V2 it is one more
4
+ -- species of the category archetype, next to `staff_team` (139) — same table,
5
+ -- same shape — so the registry can write it without a 23503 on save.
6
+ -- Idempotent: `register_kind` is an upsert by species.
7
+
8
+ SELECT public.register_kind('category', 'staff_department', 'registry', NULL, NULL,
9
+ 'A department of the company — the area that owns cost centres and the people whose record points at it (Financeiro, Cozinha, Marketing).');
@@ -0,0 +1,72 @@
1
+ -- 218: the getting-started journey reaches a new kitchen.
2
+ --
3
+ -- The plugin ships in the app bundle, but `app.apps.preset.modules` (097) is the
4
+ -- OFFER: a module no trade offers stays out of the runtime even when it is born
5
+ -- `defaultEnabled` (resolvePluginRuntime). The resto preset never listed
6
+ -- `getting-started`, so every new restaurant landed on the generic checklist —
7
+ -- no "Sua loja no ar" card, no "Teste grátis" tab, no "Iniciar teste" CTA.
8
+ --
9
+ -- Four additive, idempotent steps:
10
+ -- 1. every food trade offers the module;
11
+ -- 2. a food tenant is BORN with the binding — `app.vertical_defaults` is what
12
+ -- `tenant_apply_vertical_defaults` reads inside `create_tenant` (010/113),
13
+ -- so this holds with or without the client doing anything;
14
+ -- 3. the onboarding is never behind the plan ceiling: every plan that lists
15
+ -- grants gets `getting-started` as included (plan_permits is what both
16
+ -- the defaults and tenant_plugin_set check);
17
+ -- 4. the food tenants that already exist get the binding, source 'default'.
18
+ --
19
+ -- Nothing is removed, no tenant row other than a new binding is written.
20
+
21
+ -- ── 1. The trade offers the journey ───────────────────────────────────────────
22
+ -- Only where the offer is already an explicit, non-empty list. An app with no
23
+ -- `modules` (or an empty one) offers EVERYTHING it compiles (runtime rule);
24
+ -- writing a one-item list there would turn "all" into "only this".
25
+ UPDATE app.apps
26
+ SET preset = jsonb_set(preset, '{modules}',
27
+ (preset -> 'modules') || '["getting-started"]'::jsonb)
28
+ WHERE (id = 'resto' OR vertical_id = 'food')
29
+ AND jsonb_typeof(preset -> 'modules') = 'array'
30
+ AND jsonb_array_length(preset -> 'modules') > 0
31
+ AND NOT (preset -> 'modules' ? 'getting-started');
32
+
33
+ DO $$
34
+ DECLARE
35
+ v_plan text;
36
+ BEGIN
37
+ -- ── 2. A food tenant is born with it ──────────────────────────────────────
38
+ IF to_regclass('app.vertical_defaults') IS NOT NULL THEN
39
+ INSERT INTO app.vertical_defaults (vertical_id, plugin_id, facet, note)
40
+ VALUES ('food', 'getting-started', '',
41
+ 'The store-live journey: hours, payments, menu, test order and the trial clock (218).')
42
+ ON CONFLICT (vertical_id, plugin_id, facet) DO NOTHING;
43
+ END IF;
44
+
45
+ -- ── 3. Never behind the ceiling ───────────────────────────────────────────
46
+ -- A plan with no grants permits everything (plan_permits); a plan WITH grants
47
+ -- permits only what is listed, and the onboarding has to be on that list.
48
+ IF to_regprocedure('public.register_plan_grant(text,text,text,text,text)') IS NOT NULL
49
+ AND to_regclass('app.plan_grants') IS NOT NULL THEN
50
+ FOR v_plan IN SELECT DISTINCT g.plan_id FROM app.plan_grants g LOOP
51
+ IF NOT EXISTS (SELECT 1 FROM app.plan_grants x
52
+ WHERE x.plan_id = v_plan AND x.plugin_id = 'getting-started' AND x.facet = '') THEN
53
+ PERFORM public.register_plan_grant(v_plan, 'getting-started', '', 'included',
54
+ 'The onboarding journey is never behind the plan ceiling (218).');
55
+ END IF;
56
+ END LOOP;
57
+ END IF;
58
+
59
+ -- ── 4. The kitchens that already exist ────────────────────────────────────
60
+ -- Same shape as 203: a direct insert, because tenant_plugin_set resolves the
61
+ -- tenant from the session and a migration has none. A row that is already
62
+ -- there — active or deliberately disabled — is left exactly as it is.
63
+ IF to_regclass('app.tenant_plugins') IS NOT NULL AND to_regclass('app.tenant_apps') IS NOT NULL THEN
64
+ INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, config, source)
65
+ SELECT DISTINCT ta.tenant_id, 'getting-started', '', 'active', '{}'::jsonb, 'default'
66
+ FROM app.tenant_apps ta
67
+ JOIN app.apps a ON a.id = ta.app_id
68
+ WHERE ta.status = 'active'
69
+ AND (a.id = 'resto' OR a.vertical_id = 'food')
70
+ ON CONFLICT (tenant_id, plugin_id, facet) DO NOTHING;
71
+ END IF;
72
+ END $$;
@@ -0,0 +1,31 @@
1
+ -- 219: the Faya manages its customers from inside GoControl.
2
+ --
3
+ -- One additive, idempotent step for plugin-customer-success: `customer-success`
4
+ -- as an OPTIONAL grant on every plan that lists grants, so plan_permits never
5
+ -- blocks tenant_plugin_set for the Faya's workspace. Optional, not included: the
6
+ -- plugin is installed on one tenant by configuration
7
+ -- (scripts/customer-success-enable.mjs), never by plan.
8
+ --
9
+ -- The "Gestão de clientes" rail group is NOT a preset rubric: the plugin declares
10
+ -- the group line itself and the shell nests its sub-paths (and the helpdesk
11
+ -- console, which the app routes under /customers). A rubric in preset.rail
12
+ -- claiming one row would flatten that row and drop its children (rail-preset).
13
+ --
14
+ -- No tenant row is written.
15
+
16
+ DO $$
17
+ DECLARE
18
+ v_plan text;
19
+ BEGIN
20
+ -- ── Never behind the plan ceiling ──────────────────────────────────────
21
+ IF to_regprocedure('public.register_plan_grant(text,text,text,text,text)') IS NOT NULL
22
+ AND to_regclass('app.plan_grants') IS NOT NULL THEN
23
+ FOR v_plan IN SELECT DISTINCT g.plan_id FROM app.plan_grants g LOOP
24
+ IF NOT EXISTS (SELECT 1 FROM app.plan_grants x
25
+ WHERE x.plan_id = v_plan AND x.plugin_id = 'customer-success' AND x.facet = '') THEN
26
+ PERFORM public.register_plan_grant(v_plan, 'customer-success', '', 'optional',
27
+ 'The Faya''s customer console: installed per tenant by configuration, never by plan (219).');
28
+ END IF;
29
+ END LOOP;
30
+ END IF;
31
+ END $$;
@@ -0,0 +1,66 @@
1
+ -- 220: a test order is not a sale the platform hears about.
2
+ --
3
+ -- `trg_orders_emit_completed` emits `order.completed` whenever an order
4
+ -- closes, and that is how inventory consumes the recipe, notifications tell
5
+ -- the customer and automations fire. The onboarding's "pedido de teste"
6
+ -- (`orders.metadata.test = true`) also closes — and it is not a sale: it
7
+ -- exists to prove that ticket, kitchen and till talk to each other. Taking
8
+ -- stock out of the house for it, or messaging a customer who does not exist,
9
+ -- is the same lie 072 stopped for the port: describing something that did
10
+ -- not happen as if it had.
11
+ --
12
+ -- The smallest safe change is at the source, as in 072: the fact stays in
13
+ -- `public.orders` (the journey reads it from there — `getting_started_minimums`
14
+ -- and the PDV's own sensor), only the announcement is skipped. Every
15
+ -- subscriber (inventory, notifications, automations) stays untouched and
16
+ -- never sees a test; a subscriber that one day WANTS the test can read the
17
+ -- table, which is where the truth is.
18
+ --
19
+ -- Idempotent.
20
+
21
+ CREATE OR REPLACE FUNCTION public.trg_orders_emit_completed()
22
+ RETURNS trigger
23
+ LANGUAGE plpgsql
24
+ SECURITY DEFINER
25
+ SET search_path TO ''
26
+ AS $function$
27
+ BEGIN
28
+ -- O porte escreve com `migration.writer = 'on'` (072): um pedido que ele
29
+ -- fecha é um fato ANTIGO chegando agora.
30
+ IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN
31
+ RETURN NULL;
32
+ END IF;
33
+
34
+ IF NEW.status IS DISTINCT FROM 'completed' THEN
35
+ RETURN NULL;
36
+ END IF;
37
+ IF TG_OP = 'UPDATE' AND OLD.status IS NOT DISTINCT FROM 'completed' THEN
38
+ RETURN NULL;
39
+ END IF;
40
+
41
+ -- A test order (220) is not a sale: no stock moves, no customer is told.
42
+ IF coalesce(NEW.metadata, '{}'::jsonb) @> '{"test":true}'::jsonb THEN
43
+ RETURN NULL;
44
+ END IF;
45
+
46
+ PERFORM public.plg_emit_event(
47
+ 'order.completed',
48
+ jsonb_build_object(
49
+ 'order_id', NEW.id,
50
+ 'kind', NEW.kind,
51
+ 'unit_id', NEW.unit_id,
52
+ 'party_id', NEW.party_id,
53
+ 'total', NEW.total
54
+ ),
55
+ 'order',
56
+ NEW.id::text,
57
+ NEW.tenant_id,
58
+ NULL
59
+ );
60
+ RETURN NULL;
61
+ END $function$;
62
+
63
+ COMMENT ON FUNCTION public.trg_orders_emit_completed() IS
64
+ 'Avisa a plataforma que uma venda fechou. Silencioso quando quem fecha é o '
65
+ 'porte (migration.writer = on, 072) e quando o pedido é um pedido de teste '
66
+ '(metadata.test, 220): nenhum dos dois é uma venda que acabou de acontecer.';
@@ -0,0 +1,57 @@
1
+ -- 221: the trial clock is read-only for the browser — for real this time.
2
+ --
3
+ -- `tenants_trial_is_readonly` (000 baseline) is the BEFORE UPDATE trigger
4
+ -- that keeps `tenants.trial_ends_at` out of the client's hands: the gateway's
5
+ -- webhook (service_role) and the operator on psql/Management API (postgres)
6
+ -- may move the date, the browser may not. Its body, as it stands today:
7
+ --
8
+ -- IF auth.role() = 'service_role' OR current_user IN ('postgres', 'supabase_admin') THEN
9
+ -- RETURN NEW;
10
+ -- END IF;
11
+ -- -- otherwise: NEW.trial_ends_at := OLD.trial_ends_at
12
+ --
13
+ -- and the function is SECURITY DEFINER. That is the hole: inside a DEFINER
14
+ -- function `current_user` IS THE OWNER — postgres on every pool — whoever the
15
+ -- session is. So the bypass branch is always true, for a tenant admin doing a
16
+ -- plain `UPDATE tenants SET trial_ends_at = …` through PostgREST as much as
17
+ -- for the operator. `tenants_update` lets `is_tenant_admin` write the row, so
18
+ -- the admin of any tenant could extend their own trial by one UPDATE. The
19
+ -- journey (getting-started 001) and the design (decision 1) both assume this
20
+ -- gate works.
21
+ --
22
+ -- The fix is to stop running as the owner. As SECURITY INVOKER the trigger
23
+ -- sees the real context: a browser UPDATE runs as `authenticated` (blocked);
24
+ -- the operator on psql runs as `postgres` (through); the webhook carries the
25
+ -- service_role claim (through); and the plugin's own DEFINER RPCs
26
+ -- (`getting_started_start_trial`, `getting_started_trial_set`) execute their
27
+ -- UPDATE as their owner, postgres, so `current_user` inside the trigger is
28
+ -- postgres there too (through) — which is exactly the sanctioned path.
29
+ -- `session_user` alone would NOT do: through PostgREST it is always
30
+ -- `authenticator`, so it would also block those RPCs and silently revert the
31
+ -- date they just wrote. It is kept as a second bypass for a direct session.
32
+ --
33
+ -- Idempotent: CREATE OR REPLACE on the function; the trigger binding stays.
34
+
35
+ CREATE OR REPLACE FUNCTION public.tenants_trial_is_readonly() RETURNS trigger
36
+ LANGUAGE plpgsql SECURITY INVOKER
37
+ SET search_path TO 'public'
38
+ AS $$
39
+ BEGIN
40
+ -- service_role (gateway webhook) passes by claim. A direct operator session
41
+ -- (psql, Management API) passes by role. A DEFINER RPC owned by postgres
42
+ -- passes because, under INVOKER, this trigger inherits ITS context — while
43
+ -- a plain client UPDATE arrives as `authenticated` and is held.
44
+ IF auth.role() = 'service_role'
45
+ OR session_user IN ('postgres', 'supabase_admin')
46
+ OR current_user IN ('postgres', 'supabase_admin') THEN
47
+ RETURN NEW;
48
+ END IF;
49
+ IF NEW.trial_ends_at IS DISTINCT FROM OLD.trial_ends_at THEN
50
+ NEW.trial_ends_at := OLD.trial_ends_at;
51
+ END IF;
52
+ RETURN NEW;
53
+ END;
54
+ $$;
55
+
56
+ COMMENT ON FUNCTION public.tenants_trial_is_readonly() IS
57
+ 'Keeps tenants.trial_ends_at out of the browser''s hands. SECURITY INVOKER on purpose (221): as DEFINER, current_user was always the owner and the gate let every client UPDATE through. service_role, a direct postgres session and the plugin''s DEFINER RPCs pass; a plain authenticated UPDATE keeps the old date.';
@@ -0,0 +1,65 @@
1
+ -- 223: a quote's line answers to the quote.
2
+ --
3
+ -- 207 made public.items ask the permission of the document a line belongs to,
4
+ -- but it only named invoice and fulfillment: `parent_kind = 'quote'` fell into
5
+ -- the ELSE and asked orders.*. A seller on the CRM (crm.quotes.*, no orders.*)
6
+ -- opened a proposal and saw "Itens 0" — the lines were there, the quote list and
7
+ -- the PDF (which read the snapshot in metadata) were right, and the detail,
8
+ -- which reads public.items by quote_id, came back empty. The same seller could
9
+ -- not save a quote with lines either: plugin-crm inserts/deletes them from the
10
+ -- browser (data/supabase.ts, create and update).
11
+ --
12
+ -- A quote line now asks crm.quotes.*, the vocabulary public.quotes itself
13
+ -- uses. Changing a line IS editing the quote — the editor replaces the lines
14
+ -- (delete + insert) on every save — so:
15
+ -- read crm.quotes.read
16
+ -- create crm.quotes.create OR crm.quotes.edit (a new quote, or an edit)
17
+ -- edit crm.quotes.edit
18
+ -- delete crm.quotes.edit OR crm.quotes.delete (dropping a line on edit)
19
+ -- Deleting the quote itself cascades by FK and never reaches these policies.
20
+ -- order / invoice / fulfillment lines render exactly as 207 left them: same
21
+ -- CASE, same permissions. orders.* no longer reads a quote line — it never
22
+ -- read the quote it hangs from (quotes_authz_select asks crm.quotes.read), and
23
+ -- crm_quote_convert is SECURITY DEFINER with its own crm.quotes.edit check.
24
+ --
25
+ -- No helper is SECURITY DEFINER here: scaffold_line_perm_sql only renders text;
26
+ -- scaffold_refresh_policies keeps 207's body and is not redefined.
27
+ -- Additive and idempotent: CREATE OR REPLACE + DROP POLICY IF EXISTS / CREATE.
28
+
29
+ CREATE OR REPLACE FUNCTION app.scaffold_line_perm_sql(p_action text)
30
+ RETURNS text
31
+ LANGUAGE sql IMMUTABLE
32
+ SET search_path TO ''
33
+ AS $$
34
+ SELECT format(
35
+ '(CASE parent_kind WHEN %L THEN %s ELSE (select app.has_permission(CASE parent_kind WHEN %L THEN %L WHEN %L THEN %L ELSE %L END, unit_id)) END)',
36
+ 'quote',
37
+ CASE p_action
38
+ WHEN 'create' THEN '((select app.has_permission(''crm.quotes.create'', unit_id)) OR (select app.has_permission(''crm.quotes.edit'', unit_id)))'
39
+ WHEN 'delete' THEN '((select app.has_permission(''crm.quotes.edit'', unit_id)) OR (select app.has_permission(''crm.quotes.delete'', unit_id)))'
40
+ ELSE format('(select app.has_permission(%L, unit_id))', 'crm.quotes.' || p_action)
41
+ END,
42
+ 'invoice', 'financial.' || p_action, 'fulfillment', 'inventory.' || p_action, 'orders.' || p_action);
43
+ $$;
44
+ REVOKE ALL ON FUNCTION app.scaffold_line_perm_sql(text) FROM PUBLIC;
45
+ GRANT ALL ON FUNCTION app.scaffold_line_perm_sql(text) TO service_role;
46
+
47
+ COMMENT ON FUNCTION app.scaffold_line_perm_sql(text) IS
48
+ 'Renders the permission check of a public.items policy from the line''s document (#207, #223): quote → crm.quotes.* (create/delete also accept crm.quotes.edit — changing a line is editing the quote), invoice → financial.*, fulfillment → inventory.*, order → orders.*.';
49
+
50
+ -- select / update / delete: the 207 generator, which renders from the function above.
51
+ SELECT app.scaffold_refresh_policies('public.items')
52
+ WHERE EXISTS (SELECT 1 FROM app.scaffold_registry WHERE table_schema = 'public' AND table_name = 'items');
53
+
54
+ -- insert: scaffold_refresh_policies does not render it, so it is rebuilt here
55
+ -- with the template's shape (scaffold_table) and the line's permission.
56
+ DO $$
57
+ BEGIN
58
+ IF to_regclass('public.items') IS NULL THEN
59
+ RETURN;
60
+ END IF;
61
+ EXECUTE 'DROP POLICY IF EXISTS items_authz_insert ON public.items';
62
+ EXECUTE format(
63
+ 'CREATE POLICY items_authz_insert ON public.items FOR INSERT TO authenticated WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND %s)',
64
+ app.scaffold_line_perm_sql('create'));
65
+ END $$;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.28.1",
6
+ "version": "0.29.1",
7
7
  "description": "Fayz SDK database layer — Drizzle schema primitives, spine references, and migration helpers shared across plugins.",
8
8
  "type": "module",
9
9
  "sideEffects": false,