@fayz-ai/db 0.29.0 → 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.
- package/migrations/218_the_journey_is_offered_to_every_kitchen.sql +72 -0
- package/migrations/219_a_faya_gere_seus_clientes.sql +31 -0
- package/migrations/220_a_test_order_is_not_a_sale_the_platform_hears_about.sql +66 -0
- package/migrations/221_the_trial_clock_is_read_only_for_the_browser_for_real.sql +57 -0
- package/migrations/224_a_quote_line_answers_to_the_quote.sql +65 -0
- package/package.json +1 -1
|
@@ -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