@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.
- package/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/040_resource_grants.sql +2 -2
- package/migrations/041_scoped_columns.sql +2 -2
- package/migrations/042_unit_scope_policies.sql +1 -1
- package/migrations/043_view_invoker.sql +12 -3
- package/migrations/044_unit_member_facts.sql +1 -1
- package/migrations/045_unit_entry.sql +1 -1
- package/migrations/046_membership_visible_to_members.sql +1 -1
- package/migrations/047_tasks.sql +4 -4
- package/migrations/048_every_login_is_a_person.sql +1 -1
- package/migrations/049_bookable_people.sql +1 -1
- package/migrations/050_authz_core.sql +230 -0
- package/migrations/051_authz_helpers.sql +330 -0
- package/migrations/052_authz_fanout.sql +253 -0
- package/migrations/053_authz_catalog_templates.sql +402 -0
- package/migrations/060_tenancy_seam.sql +132 -0
- package/migrations/061_create_tenant.sql +125 -0
- package/migrations/062_invitations.sql +329 -0
- package/migrations/063_legacy_tenancy_retired.sql +568 -0
- package/migrations/064_agent_guard_authz.sql +118 -0
- package/migrations/064b_supersede_unit_scope.sql +205 -0
- package/migrations/065_scaffold_sweep.sql +139 -0
- package/migrations/066_shadow_report.sql +122 -0
- package/migrations/070_permission_catalog.sql +205 -0
- package/migrations/071_v1_permission_map.sql +266 -0
- package/migrations/072_role_rpcs.sql +126 -0
- package/migrations/073_legacy_rbac_retire.sql +471 -0
- package/migrations/075_company_legal_entities.sql +169 -0
- package/migrations/076_tenant_settings.sql +251 -0
- package/migrations/080_people_roles.sql +269 -0
- package/migrations/081_people_identity.sql +271 -0
- package/migrations/082_people_membership_link.sql +142 -0
- package/migrations/083_people_data_migration.sql +75 -0
- package/migrations/084_people_registrations_tokens.sql +282 -0
- package/migrations/085_catalog_products.sql +333 -0
- package/migrations/086_catalog_pricing.sql +194 -0
- package/migrations/087_catalog_packages.sql +134 -0
- package/migrations/090_orders_venda.sql +166 -0
- package/migrations/091_orders_scaffold.sql +92 -0
- package/migrations/095_config_field_rules.sql +261 -0
- package/migrations/096_audit_append_only.sql +256 -0
- package/migrations/097_sequences_authorized.sql +111 -0
- package/migrations/098_holidays.sql +80 -0
- package/migrations/099_access_grants.sql +293 -0
- package/migrations/100_migration_schema.sql +217 -0
- package/migrations/101_migration_allowlist.sql +202 -0
- package/migrations/102_migration_rpcs.sql +933 -0
- package/migrations/103_migration_reconcile_ponr.sql +132 -0
- package/migrations/105_policies_to_authenticated.sql +71 -0
- package/migrations/106_function_search_path.sql +55 -0
- package/migrations/107_core_table_prefix.sql +361 -0
- package/migrations/108_authz_write_guards.sql +149 -0
- package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
- package/migrations/110_people_kind_vocabulary.sql +120 -0
- package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
- package/migrations/112_money_precision_standard.sql +132 -0
- package/migrations/113_addresses_units_companies.sql +114 -0
- package/migrations/114_migrations_ledger_immutable.sql +65 -0
- package/migrations/115_registration_honours_field_rules.sql +172 -0
- package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
- package/migrations/117_authz_reconciliation_schedule.sql +82 -0
- package/migrations/118_migration_catalog_writers.sql +450 -0
- package/migrations/119_migration_config_writers.sql +193 -0
- package/migrations/120_migration_facet_writer.sql +139 -0
- package/migrations/121_migration_financial_writers.sql +294 -0
- package/migrations/122_migration_inventory_writers.sql +245 -0
- package/migrations/123_migration_order_item_writer.sql +144 -0
- package/migrations/124_migration_ledger_writers.sql +170 -0
- package/migrations/125_migration_authz_people_writers.sql +205 -0
- package/migrations/126_owner_scoping_is_configuration.sql +247 -0
- package/migrations/127_scaffold_audit_hook.sql +98 -0
- package/migrations/128_shadow_tables_enforced.sql +93 -0
- package/migrations/129_order_kind_terminal_states.sql +57 -0
- package/migrations/130_every_login_is_a_person.sql +196 -0
- package/migrations/131_migration_invoice_split.sql +127 -0
- package/migrations/132_audit_prune_scheduled.sql +28 -0
- package/migrations/133_unit_is_a_session_property.sql +155 -0
- package/migrations/134_field_rules_on_read.sql +341 -0
- package/migrations/135_distribution_polarity.sql +158 -0
- package/migrations/136_unit_id_means_locality.sql +358 -0
- package/migrations/137_vendor_named_spine_objects.sql +126 -0
- package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
- package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
- package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
- package/migrations/142_a_person_has_addresses.sql +212 -0
- package/migrations/143_a_professional_has_a_rate.sql +75 -0
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
- package/migrations/146_products_stock_has_one_writer.sql +67 -0
- package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
- package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
- package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
- package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
- package/migrations/152_a_file_is_an_archetype.sql +222 -0
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
- package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
- package/migrations/155_a_tenant_can_leave.sql +271 -0
- package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
- package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
- package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
- package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
- package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
- package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
- package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
- package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
- package/package.json +3 -2
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 149_the_event_log_gets_a_consumer.sql — the log stops being a pile of rows
|
|
3
|
+
-- nobody reads (#277).
|
|
4
|
+
--
|
|
5
|
+
-- The decision (rodada 3, Q2): the domain-event log is the CONTRACT between
|
|
6
|
+
-- plugins for business effect; the in-memory bus stays, demoted to reactions on
|
|
7
|
+
-- a screen. The reason is not taste — the bus only reaches listeners inside the
|
|
8
|
+
-- app process, so an order placed on the storefront at 2am, an import in bulk,
|
|
9
|
+
-- or a plugin running in another cluster all emit into silence. A row in a table
|
|
10
|
+
-- survives all three, and survives the consumer being down.
|
|
11
|
+
--
|
|
12
|
+
-- What was missing was the consumer. 027 built the emitter and the ledger, and
|
|
13
|
+
-- said so plainly in the status vocabulary: `recorded` means "the trigger fired
|
|
14
|
+
-- and nobody acted yet (today's state: the catalog exists, message dispatch does
|
|
15
|
+
-- not)". This file is that missing half.
|
|
16
|
+
--
|
|
17
|
+
-- THREE THINGS IT HAS TO GET RIGHT, and how:
|
|
18
|
+
--
|
|
19
|
+
-- Idempotence — a fact must not act twice. Delivery is a row keyed
|
|
20
|
+
-- (event, plugin, handler): the consumer inserts it BEFORE calling, so a
|
|
21
|
+
-- double run collides instead of double-charging. This is the same shape as
|
|
22
|
+
-- the effect claim in 030.
|
|
23
|
+
--
|
|
24
|
+
-- Concurrency — two consumers must not fight. Claiming is
|
|
25
|
+
-- `FOR UPDATE SKIP LOCKED`, so a second worker takes the next fact instead of
|
|
26
|
+
-- waiting on the first.
|
|
27
|
+
--
|
|
28
|
+
-- Failure — a handler that throws must not stop the queue or lose the fact.
|
|
29
|
+
-- Each delivery runs in its own subtransaction: it fails alone, records why,
|
|
30
|
+
-- and is retried with backoff until `max_attempts`, after which it is
|
|
31
|
+
-- `failed` and visible rather than retried forever.
|
|
32
|
+
--
|
|
33
|
+
-- WHAT THIS FILE DOES NOT DO. It does not schedule anything. Who calls
|
|
34
|
+
-- `consume_event_log()` — pg_cron, an edge function, the app — is deployment,
|
|
35
|
+
-- and a migration that assumed one would be wrong in every pool that uses
|
|
36
|
+
-- another.
|
|
37
|
+
-- ============================================================================
|
|
38
|
+
|
|
39
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
40
|
+
-- §1 Subscriptions — a plugin declares what it reacts to, as a row
|
|
41
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS public.event_subscriptions (
|
|
44
|
+
plugin text NOT NULL,
|
|
45
|
+
event_key text NOT NULL,
|
|
46
|
+
handler text NOT NULL,
|
|
47
|
+
description text,
|
|
48
|
+
max_attempts integer NOT NULL DEFAULT 5 CHECK (max_attempts BETWEEN 1 AND 50),
|
|
49
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
50
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
51
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
52
|
+
PRIMARY KEY (plugin, event_key, handler),
|
|
53
|
+
CONSTRAINT event_subscriptions_handler_shape CHECK (handler ~ '^[a-z][a-z0-9_]*$')
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
COMMENT ON TABLE public.event_subscriptions IS
|
|
57
|
+
'Who reacts to which fact (149). A plugin declares its subscription as a ROW in its own migration — never by DDL on someone else''s table. handler is the name of a public function taking (jsonb) and returning jsonb.';
|
|
58
|
+
COMMENT ON COLUMN public.event_subscriptions.handler IS
|
|
59
|
+
'A public function name. It receives the whole event row as jsonb and returns jsonb (its result, recorded on the delivery). Raising is how it says it failed.';
|
|
60
|
+
|
|
61
|
+
ALTER TABLE public.event_subscriptions ENABLE ROW LEVEL SECURITY;
|
|
62
|
+
DROP POLICY IF EXISTS event_subscriptions_read ON public.event_subscriptions;
|
|
63
|
+
CREATE POLICY event_subscriptions_read ON public.event_subscriptions
|
|
64
|
+
FOR SELECT TO authenticated, service_role USING (true);
|
|
65
|
+
GRANT SELECT ON public.event_subscriptions TO authenticated, service_role;
|
|
66
|
+
GRANT ALL ON public.event_subscriptions TO service_role;
|
|
67
|
+
REVOKE INSERT, UPDATE, DELETE ON public.event_subscriptions FROM anon, authenticated;
|
|
68
|
+
|
|
69
|
+
DROP TRIGGER IF EXISTS event_subscriptions_updated_at ON public.event_subscriptions;
|
|
70
|
+
CREATE TRIGGER event_subscriptions_updated_at BEFORE UPDATE ON public.event_subscriptions
|
|
71
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
72
|
+
|
|
73
|
+
CREATE OR REPLACE FUNCTION public.register_event_subscription(
|
|
74
|
+
p_plugin text,
|
|
75
|
+
p_event_key text,
|
|
76
|
+
p_handler text,
|
|
77
|
+
p_description text DEFAULT NULL,
|
|
78
|
+
p_max_attempts integer DEFAULT 5
|
|
79
|
+
) RETURNS void
|
|
80
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
81
|
+
AS $$
|
|
82
|
+
BEGIN
|
|
83
|
+
INSERT INTO public.event_subscriptions (plugin, event_key, handler, description, max_attempts)
|
|
84
|
+
VALUES (p_plugin, p_event_key, p_handler, p_description, p_max_attempts)
|
|
85
|
+
ON CONFLICT (plugin, event_key, handler) DO UPDATE
|
|
86
|
+
SET description = coalesce(EXCLUDED.description, public.event_subscriptions.description),
|
|
87
|
+
max_attempts = EXCLUDED.max_attempts,
|
|
88
|
+
is_active = true;
|
|
89
|
+
END $$;
|
|
90
|
+
|
|
91
|
+
REVOKE ALL ON FUNCTION public.register_event_subscription(text, text, text, text, integer) FROM public, anon, authenticated;
|
|
92
|
+
GRANT EXECUTE ON FUNCTION public.register_event_subscription(text, text, text, text, integer) TO service_role;
|
|
93
|
+
|
|
94
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
95
|
+
-- §2 Deliveries — one row per (fact, subscriber), which is what makes a
|
|
96
|
+
-- second run harmless
|
|
97
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
CREATE TABLE IF NOT EXISTS public.event_deliveries (
|
|
100
|
+
event_id uuid NOT NULL REFERENCES public.event_log(id) ON DELETE CASCADE,
|
|
101
|
+
plugin text NOT NULL,
|
|
102
|
+
handler text NOT NULL,
|
|
103
|
+
status text NOT NULL DEFAULT 'pending'
|
|
104
|
+
CHECK (status IN ('pending', 'delivered', 'failed', 'abandoned')),
|
|
105
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
106
|
+
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
|
107
|
+
result jsonb,
|
|
108
|
+
error text,
|
|
109
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
110
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
111
|
+
PRIMARY KEY (event_id, plugin, handler)
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
COMMENT ON TABLE public.event_deliveries IS
|
|
115
|
+
'One row per (fact, subscriber) — the idempotence guarantee (149). The consumer inserts the row before calling the handler, so a second run collides instead of acting twice. `abandoned` is a delivery that used up max_attempts: still visible, no longer retried.';
|
|
116
|
+
|
|
117
|
+
CREATE INDEX IF NOT EXISTS event_deliveries_due
|
|
118
|
+
ON public.event_deliveries (next_attempt_at)
|
|
119
|
+
WHERE status = 'pending';
|
|
120
|
+
|
|
121
|
+
ALTER TABLE public.event_deliveries ENABLE ROW LEVEL SECURITY;
|
|
122
|
+
DROP POLICY IF EXISTS event_deliveries_read ON public.event_deliveries;
|
|
123
|
+
CREATE POLICY event_deliveries_read ON public.event_deliveries
|
|
124
|
+
FOR SELECT TO authenticated, service_role USING (true);
|
|
125
|
+
GRANT SELECT ON public.event_deliveries TO authenticated, service_role;
|
|
126
|
+
GRANT ALL ON public.event_deliveries TO service_role;
|
|
127
|
+
REVOKE INSERT, UPDATE, DELETE ON public.event_deliveries FROM anon, authenticated;
|
|
128
|
+
|
|
129
|
+
DROP TRIGGER IF EXISTS event_deliveries_updated_at ON public.event_deliveries;
|
|
130
|
+
CREATE TRIGGER event_deliveries_updated_at BEFORE UPDATE ON public.event_deliveries
|
|
131
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
132
|
+
|
|
133
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
134
|
+
-- §3 The consumer
|
|
135
|
+
--
|
|
136
|
+
-- Claims up to p_limit facts that still have work, delivers each subscription in
|
|
137
|
+
-- its own subtransaction, and moves the fact's status only when every
|
|
138
|
+
-- subscription has finished. Returns a summary so a scheduler can log something
|
|
139
|
+
-- meaningful and back off when there is nothing to do.
|
|
140
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
CREATE OR REPLACE FUNCTION public.consume_event_log(
|
|
143
|
+
p_limit integer DEFAULT 100,
|
|
144
|
+
p_event_key text DEFAULT NULL
|
|
145
|
+
) RETURNS TABLE (
|
|
146
|
+
events_claimed integer,
|
|
147
|
+
deliveries_ok integer,
|
|
148
|
+
deliveries_failed integer,
|
|
149
|
+
deliveries_abandoned integer
|
|
150
|
+
)
|
|
151
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
152
|
+
AS $$
|
|
153
|
+
DECLARE
|
|
154
|
+
v_event record;
|
|
155
|
+
v_sub record;
|
|
156
|
+
v_result jsonb;
|
|
157
|
+
v_attempts integer;
|
|
158
|
+
v_claimed integer := 0;
|
|
159
|
+
v_ok integer := 0;
|
|
160
|
+
v_failed integer := 0;
|
|
161
|
+
v_abandoned integer := 0;
|
|
162
|
+
v_open integer;
|
|
163
|
+
BEGIN
|
|
164
|
+
FOR v_event IN
|
|
165
|
+
SELECT e.*
|
|
166
|
+
FROM public.event_log e
|
|
167
|
+
WHERE e.status = 'recorded'
|
|
168
|
+
AND (p_event_key IS NULL OR e.event_key = p_event_key)
|
|
169
|
+
ORDER BY e.occurred_at
|
|
170
|
+
LIMIT greatest(p_limit, 0)
|
|
171
|
+
FOR UPDATE SKIP LOCKED
|
|
172
|
+
LOOP
|
|
173
|
+
v_claimed := v_claimed + 1;
|
|
174
|
+
|
|
175
|
+
-- No subscriber is not a failure: the fact is recorded and nobody asked for
|
|
176
|
+
-- it. `skipped` is 027's own word for that.
|
|
177
|
+
IF NOT EXISTS (
|
|
178
|
+
SELECT 1 FROM public.event_subscriptions s
|
|
179
|
+
WHERE s.event_key = v_event.event_key AND s.is_active
|
|
180
|
+
) THEN
|
|
181
|
+
UPDATE public.event_log SET status = 'skipped' WHERE id = v_event.id;
|
|
182
|
+
CONTINUE;
|
|
183
|
+
END IF;
|
|
184
|
+
|
|
185
|
+
FOR v_sub IN
|
|
186
|
+
SELECT s.* FROM public.event_subscriptions s
|
|
187
|
+
WHERE s.event_key = v_event.event_key AND s.is_active
|
|
188
|
+
ORDER BY s.plugin, s.handler
|
|
189
|
+
LOOP
|
|
190
|
+
-- The claim comes first: this row existing is what a second consumer
|
|
191
|
+
-- collides with.
|
|
192
|
+
INSERT INTO public.event_deliveries (event_id, plugin, handler)
|
|
193
|
+
VALUES (v_event.id, v_sub.plugin, v_sub.handler)
|
|
194
|
+
ON CONFLICT (event_id, plugin, handler) DO NOTHING;
|
|
195
|
+
|
|
196
|
+
SELECT d.attempts INTO v_attempts
|
|
197
|
+
FROM public.event_deliveries d
|
|
198
|
+
WHERE d.event_id = v_event.id AND d.plugin = v_sub.plugin AND d.handler = v_sub.handler
|
|
199
|
+
AND d.status = 'pending' AND d.next_attempt_at <= now()
|
|
200
|
+
FOR UPDATE SKIP LOCKED;
|
|
201
|
+
|
|
202
|
+
IF NOT FOUND THEN
|
|
203
|
+
CONTINUE; -- already delivered, abandoned, or not due yet
|
|
204
|
+
END IF;
|
|
205
|
+
|
|
206
|
+
BEGIN
|
|
207
|
+
EXECUTE format('SELECT public.%I($1)', v_sub.handler)
|
|
208
|
+
INTO v_result
|
|
209
|
+
USING to_jsonb(v_event);
|
|
210
|
+
|
|
211
|
+
UPDATE public.event_deliveries
|
|
212
|
+
SET status = 'delivered', attempts = v_attempts + 1, result = v_result, error = NULL
|
|
213
|
+
WHERE event_id = v_event.id AND plugin = v_sub.plugin AND handler = v_sub.handler;
|
|
214
|
+
v_ok := v_ok + 1;
|
|
215
|
+
|
|
216
|
+
EXCEPTION WHEN OTHERS THEN
|
|
217
|
+
-- Fails alone. The subtransaction rolls back whatever the handler did,
|
|
218
|
+
-- so a half-applied effect cannot survive its own error.
|
|
219
|
+
IF v_attempts + 1 >= v_sub.max_attempts THEN
|
|
220
|
+
UPDATE public.event_deliveries
|
|
221
|
+
SET status = 'abandoned', attempts = v_attempts + 1, error = SQLERRM
|
|
222
|
+
WHERE event_id = v_event.id AND plugin = v_sub.plugin AND handler = v_sub.handler;
|
|
223
|
+
v_abandoned := v_abandoned + 1;
|
|
224
|
+
ELSE
|
|
225
|
+
UPDATE public.event_deliveries
|
|
226
|
+
SET status = 'pending', attempts = v_attempts + 1, error = SQLERRM,
|
|
227
|
+
-- Backoff, capped: 2^n seconds up to an hour.
|
|
228
|
+
next_attempt_at = now() + least(interval '1 hour', (power(2, v_attempts + 1) || ' seconds')::interval)
|
|
229
|
+
WHERE event_id = v_event.id AND plugin = v_sub.plugin AND handler = v_sub.handler;
|
|
230
|
+
v_failed := v_failed + 1;
|
|
231
|
+
END IF;
|
|
232
|
+
END;
|
|
233
|
+
END LOOP;
|
|
234
|
+
|
|
235
|
+
-- The fact moves only when nothing is still owed.
|
|
236
|
+
SELECT count(*)::int INTO v_open
|
|
237
|
+
FROM public.event_deliveries d
|
|
238
|
+
WHERE d.event_id = v_event.id AND d.status = 'pending';
|
|
239
|
+
|
|
240
|
+
IF v_open = 0 THEN
|
|
241
|
+
UPDATE public.event_log
|
|
242
|
+
SET status = CASE
|
|
243
|
+
WHEN EXISTS (SELECT 1 FROM public.event_deliveries d WHERE d.event_id = v_event.id AND d.status = 'abandoned')
|
|
244
|
+
THEN 'failed' ELSE 'sent' END,
|
|
245
|
+
result = (SELECT jsonb_object_agg(d.plugin || '.' || d.handler, coalesce(d.result, to_jsonb(d.status)))
|
|
246
|
+
FROM public.event_deliveries d WHERE d.event_id = v_event.id),
|
|
247
|
+
error = (SELECT string_agg(d.plugin || '.' || d.handler || ': ' || d.error, '; ')
|
|
248
|
+
FROM public.event_deliveries d WHERE d.event_id = v_event.id AND d.error IS NOT NULL)
|
|
249
|
+
WHERE id = v_event.id;
|
|
250
|
+
END IF;
|
|
251
|
+
END LOOP;
|
|
252
|
+
|
|
253
|
+
RETURN QUERY SELECT v_claimed, v_ok, v_failed, v_abandoned;
|
|
254
|
+
END $$;
|
|
255
|
+
|
|
256
|
+
COMMENT ON FUNCTION public.consume_event_log(integer, text) IS
|
|
257
|
+
'Delivers recorded facts to their declared subscribers (149). Idempotent by (event, plugin, handler); concurrent-safe by SKIP LOCKED; a failing handler fails alone, is retried with capped backoff and is abandoned — visibly — after max_attempts. Scheduling is deployment, not schema.';
|
|
258
|
+
|
|
259
|
+
REVOKE ALL ON FUNCTION public.consume_event_log(integer, text) FROM public, anon, authenticated;
|
|
260
|
+
GRANT EXECUTE ON FUNCTION public.consume_event_log(integer, text) TO service_role;
|
|
261
|
+
|
|
262
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
263
|
+
-- §4 What the queue looks like from outside
|
|
264
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
CREATE OR REPLACE VIEW public.v_event_queue
|
|
267
|
+
WITH (security_invoker = true) AS
|
|
268
|
+
SELECT e.event_key,
|
|
269
|
+
e.status,
|
|
270
|
+
count(*)::bigint AS facts,
|
|
271
|
+
min(e.occurred_at) AS oldest,
|
|
272
|
+
count(*) FILTER (WHERE d.status = 'pending')::bigint AS deliveries_pending,
|
|
273
|
+
count(*) FILTER (WHERE d.status = 'abandoned')::bigint AS deliveries_abandoned
|
|
274
|
+
FROM public.event_log e
|
|
275
|
+
LEFT JOIN public.event_deliveries d ON d.event_id = e.id
|
|
276
|
+
GROUP BY e.event_key, e.status;
|
|
277
|
+
|
|
278
|
+
COMMENT ON VIEW public.v_event_queue IS
|
|
279
|
+
'The queue at a glance (149): facts by key and status, with what is still owed and what was abandoned. "Why did the stock not move?" is answered here rather than by asking each plugin.';
|
|
280
|
+
|
|
281
|
+
GRANT SELECT ON public.v_event_queue TO authenticated, service_role;
|
|
282
|
+
|
|
283
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
284
|
+
-- §5 The first fact worth reacting to: a Venda reaches completed
|
|
285
|
+
--
|
|
286
|
+
-- Emitted AFTER the row is written, so the fact is recorded only if the sale
|
|
287
|
+
-- actually happened, and outside the writer's decision — nothing a subscriber
|
|
288
|
+
-- does can roll the sale back. That asymmetry is the point of the log.
|
|
289
|
+
--
|
|
290
|
+
-- Only the transition INTO a completed state emits: an UPDATE that touches a
|
|
291
|
+
-- note on an already-completed order is not a new fact.
|
|
292
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
CREATE OR REPLACE FUNCTION public.trg_orders_emit_completed()
|
|
295
|
+
RETURNS trigger
|
|
296
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
297
|
+
AS $$
|
|
298
|
+
BEGIN
|
|
299
|
+
IF NEW.status IS DISTINCT FROM 'completed' THEN
|
|
300
|
+
RETURN NULL;
|
|
301
|
+
END IF;
|
|
302
|
+
IF TG_OP = 'UPDATE' AND OLD.status IS NOT DISTINCT FROM 'completed' THEN
|
|
303
|
+
RETURN NULL;
|
|
304
|
+
END IF;
|
|
305
|
+
|
|
306
|
+
PERFORM public.plg_emit_event(
|
|
307
|
+
'order.completed',
|
|
308
|
+
jsonb_build_object(
|
|
309
|
+
'order_id', NEW.id,
|
|
310
|
+
'kind', NEW.kind,
|
|
311
|
+
'unit_id', NEW.unit_id,
|
|
312
|
+
'party_id', NEW.party_id,
|
|
313
|
+
'total', NEW.total
|
|
314
|
+
),
|
|
315
|
+
'order',
|
|
316
|
+
NEW.id::text,
|
|
317
|
+
NEW.tenant_id,
|
|
318
|
+
NULL
|
|
319
|
+
);
|
|
320
|
+
RETURN NULL;
|
|
321
|
+
END $$;
|
|
322
|
+
|
|
323
|
+
COMMENT ON FUNCTION public.trg_orders_emit_completed() IS
|
|
324
|
+
'Records the fact that a Venda completed (149). AFTER, so the fact follows the write; only on the transition, so an edit to a completed order is not a second fact.';
|
|
325
|
+
|
|
326
|
+
REVOKE ALL ON FUNCTION public.trg_orders_emit_completed() FROM public, anon, authenticated;
|
|
327
|
+
|
|
328
|
+
DROP TRIGGER IF EXISTS orders_emit_completed ON public.orders;
|
|
329
|
+
CREATE TRIGGER orders_emit_completed
|
|
330
|
+
AFTER INSERT OR UPDATE OF status ON public.orders
|
|
331
|
+
FOR EACH ROW EXECUTE FUNCTION public.trg_orders_emit_completed();
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 150_the_app_is_configured_in_the_database.sql — what a tenant HAS stops being
|
|
3
|
+
-- a file in a repository (#280, ADR 0023).
|
|
4
|
+
--
|
|
5
|
+
-- The split the decision fixes: the MANIFEST is what a plugin CAN do — routes,
|
|
6
|
+
-- screens, the permissions it declares, the species it registers. The DATABASE
|
|
7
|
+
-- is what THIS tenant HAS — which plugins are on, which parts of them, how the
|
|
8
|
+
-- menu is composed, what the branding is. Those were the same thing, living in
|
|
9
|
+
-- `app.manifest.json` next to the code, which is why eleven applications needed
|
|
10
|
+
-- eleven repositories and why turning a feature on meant a deploy.
|
|
11
|
+
--
|
|
12
|
+
-- WHAT WAS ALREADY HERE, and is not rebuilt: `tenants.settings` with the
|
|
13
|
+
-- registry in `app.tenant_setting_keys` (076) already governs branding, locale
|
|
14
|
+
-- and every other keyed setting, RPCs included. This file adds the two things
|
|
15
|
+
-- that had no home at all:
|
|
16
|
+
--
|
|
17
|
+
-- §1 ACTIVATION — which plugins and which facets this tenant has. The type
|
|
18
|
+
-- `TenantPluginBinding` has existed in @fayz-ai/core since the runtime was
|
|
19
|
+
-- written, and nothing ever persisted it: activation lived in a file.
|
|
20
|
+
-- §2 NAVIGATION — the tenant's own composition over what the plugins offer:
|
|
21
|
+
-- order, hidden entries, renamed labels.
|
|
22
|
+
--
|
|
23
|
+
-- §3 is the one call that returns all of it, which is what makes the shell able
|
|
24
|
+
-- to render from the database instead of from a bundle.
|
|
25
|
+
--
|
|
26
|
+
-- WHAT THIS FILE DOES NOT DECIDE. Whether the plan permits an activation is
|
|
27
|
+
-- #281's question, and the column it will read (`source`) is already here so
|
|
28
|
+
-- that answer does not need a second migration to change shape. Until then any
|
|
29
|
+
-- activation is allowed and `source` records who did it.
|
|
30
|
+
--
|
|
31
|
+
-- Idempotent and replay-safe.
|
|
32
|
+
-- ============================================================================
|
|
33
|
+
|
|
34
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
35
|
+
-- §1 Activation — plugin and facet, per tenant
|
|
36
|
+
--
|
|
37
|
+
-- One table, not two: a facet is a part of a plugin, and (tenant, plugin, '')
|
|
38
|
+
-- is the plugin itself. Two tables would have made "is this on?" a union in
|
|
39
|
+
-- every reader.
|
|
40
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS app.tenant_plugins (
|
|
43
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
44
|
+
plugin_id text NOT NULL CHECK (plugin_id ~ '^[a-z][a-z0-9-]*$'),
|
|
45
|
+
facet text NOT NULL DEFAULT '' CHECK (facet = '' OR facet ~ '^[a-z][a-z0-9_-]*$'),
|
|
46
|
+
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
|
|
47
|
+
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
48
|
+
source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'plan', 'trial', 'agent', 'default')),
|
|
49
|
+
expires_at timestamptz,
|
|
50
|
+
activated_by uuid,
|
|
51
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
52
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
53
|
+
PRIMARY KEY (tenant_id, plugin_id, facet)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
COMMENT ON TABLE app.tenant_plugins IS
|
|
57
|
+
'What this tenant HAS (150, ADR 0023): which plugins are on, and which facets of them. The manifest says what a plugin CAN do; this says what is on here. facet = '''' is the plugin itself.';
|
|
58
|
+
COMMENT ON COLUMN app.tenant_plugins.source IS
|
|
59
|
+
'Who turned it on: manual (an operator), plan (an entitlement), trial (with expires_at), agent (onboarding), default (the plugin ships defaultEnabled). #281 reads this to know what a plan change may switch off.';
|
|
60
|
+
COMMENT ON COLUMN app.tenant_plugins.expires_at IS
|
|
61
|
+
'When a trial or courtesy ends. NULL is forever. Enforced by the reader — a row that has expired is not active, and nothing has to run to make that true.';
|
|
62
|
+
|
|
63
|
+
CREATE INDEX IF NOT EXISTS tenant_plugins_active
|
|
64
|
+
ON app.tenant_plugins (tenant_id)
|
|
65
|
+
WHERE status = 'active';
|
|
66
|
+
|
|
67
|
+
ALTER TABLE app.tenant_plugins ENABLE ROW LEVEL SECURITY;
|
|
68
|
+
DROP POLICY IF EXISTS tenant_plugins_read ON app.tenant_plugins;
|
|
69
|
+
CREATE POLICY tenant_plugins_read ON app.tenant_plugins
|
|
70
|
+
FOR SELECT TO authenticated
|
|
71
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
72
|
+
GRANT SELECT ON app.tenant_plugins TO authenticated;
|
|
73
|
+
GRANT ALL ON app.tenant_plugins TO service_role;
|
|
74
|
+
|
|
75
|
+
DROP TRIGGER IF EXISTS tenant_plugins_updated_at ON app.tenant_plugins;
|
|
76
|
+
CREATE TRIGGER tenant_plugins_updated_at BEFORE UPDATE ON app.tenant_plugins
|
|
77
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
78
|
+
|
|
79
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
80
|
+
-- §2 Navigation — the tenant's composition over what the plugins offer
|
|
81
|
+
--
|
|
82
|
+
-- Only the DIFFERENCE is stored. A plugin's nav entry is the default; a row
|
|
83
|
+
-- here hides it, renames it or moves it. Storing the whole menu would mean a
|
|
84
|
+
-- tenant stops receiving new entries the day a plugin ships one.
|
|
85
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
CREATE TABLE IF NOT EXISTS app.tenant_nav (
|
|
88
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
89
|
+
entry_key text NOT NULL,
|
|
90
|
+
hidden boolean NOT NULL DEFAULT false,
|
|
91
|
+
label text,
|
|
92
|
+
section text CHECK (section IS NULL OR section IN ('main', 'secondary', 'settings')),
|
|
93
|
+
position integer,
|
|
94
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
95
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
96
|
+
PRIMARY KEY (tenant_id, entry_key)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
COMMENT ON TABLE app.tenant_nav IS
|
|
100
|
+
'The tenant''s own menu composition (150): only the difference from what the plugins offer. A plugin that ships a new entry reaches every tenant; a tenant that hid one keeps it hidden.';
|
|
101
|
+
COMMENT ON COLUMN app.tenant_nav.entry_key IS
|
|
102
|
+
'The nav entry''s stable key — `<plugin>:<route>` as the shell composes it.';
|
|
103
|
+
|
|
104
|
+
ALTER TABLE app.tenant_nav ENABLE ROW LEVEL SECURITY;
|
|
105
|
+
DROP POLICY IF EXISTS tenant_nav_read ON app.tenant_nav;
|
|
106
|
+
CREATE POLICY tenant_nav_read ON app.tenant_nav
|
|
107
|
+
FOR SELECT TO authenticated
|
|
108
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
109
|
+
GRANT SELECT ON app.tenant_nav TO authenticated;
|
|
110
|
+
GRANT ALL ON app.tenant_nav TO service_role;
|
|
111
|
+
|
|
112
|
+
DROP TRIGGER IF EXISTS tenant_nav_updated_at ON app.tenant_nav;
|
|
113
|
+
CREATE TRIGGER tenant_nav_updated_at BEFORE UPDATE ON app.tenant_nav
|
|
114
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
115
|
+
|
|
116
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
117
|
+
-- §3 Is it on? — one function, so no reader has to know about expiry
|
|
118
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
CREATE OR REPLACE FUNCTION public.tenant_plugin_active(p_plugin text, p_facet text DEFAULT '')
|
|
121
|
+
RETURNS boolean
|
|
122
|
+
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
|
|
123
|
+
AS $$
|
|
124
|
+
SELECT EXISTS (
|
|
125
|
+
SELECT 1 FROM app.tenant_plugins t
|
|
126
|
+
WHERE t.tenant_id = app.current_tenant_id()
|
|
127
|
+
AND t.plugin_id = p_plugin
|
|
128
|
+
AND t.facet = coalesce(p_facet, '')
|
|
129
|
+
AND t.status = 'active'
|
|
130
|
+
AND (t.expires_at IS NULL OR t.expires_at > now())
|
|
131
|
+
)
|
|
132
|
+
$$;
|
|
133
|
+
|
|
134
|
+
COMMENT ON FUNCTION public.tenant_plugin_active(text, text) IS
|
|
135
|
+
'Whether this plugin (or one of its facets) is on for the current tenant, expiry included (150). One place, so a trial that lapsed cannot be on in one reader and off in another.';
|
|
136
|
+
|
|
137
|
+
GRANT EXECUTE ON FUNCTION public.tenant_plugin_active(text, text) TO authenticated, service_role;
|
|
138
|
+
|
|
139
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
140
|
+
-- §4 The single initial load
|
|
141
|
+
--
|
|
142
|
+
-- One call, one round trip, cacheable: everything the shell needs to render
|
|
143
|
+
-- before it knows anything else. Branding comes from 076's RPC — which never
|
|
144
|
+
-- raises, because branding must not be able to block a login.
|
|
145
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
CREATE OR REPLACE FUNCTION public.app_config()
|
|
148
|
+
RETURNS jsonb
|
|
149
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
150
|
+
AS $$
|
|
151
|
+
DECLARE
|
|
152
|
+
v_tenant uuid := app.current_tenant_id();
|
|
153
|
+
BEGIN
|
|
154
|
+
IF v_tenant IS NULL THEN
|
|
155
|
+
-- Fail closed and legibly: a caller with no resolvable tenant gets an empty
|
|
156
|
+
-- configuration, not somebody else's.
|
|
157
|
+
RETURN jsonb_build_object('tenant_id', NULL, 'plugins', '[]'::jsonb, 'nav', '[]'::jsonb);
|
|
158
|
+
END IF;
|
|
159
|
+
|
|
160
|
+
RETURN jsonb_build_object(
|
|
161
|
+
'tenant_id', v_tenant,
|
|
162
|
+
'branding', public.tenant_branding(),
|
|
163
|
+
'settings', public.tenant_settings(),
|
|
164
|
+
'plugins', coalesce((
|
|
165
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
166
|
+
'plugin', t.plugin_id,
|
|
167
|
+
'facet', nullif(t.facet, ''),
|
|
168
|
+
'status', t.status,
|
|
169
|
+
'source', t.source,
|
|
170
|
+
'config', t.config,
|
|
171
|
+
'expires_at', t.expires_at
|
|
172
|
+
) ORDER BY t.plugin_id, t.facet)
|
|
173
|
+
FROM app.tenant_plugins t
|
|
174
|
+
WHERE t.tenant_id = v_tenant
|
|
175
|
+
AND t.status = 'active'
|
|
176
|
+
AND (t.expires_at IS NULL OR t.expires_at > now())
|
|
177
|
+
), '[]'::jsonb),
|
|
178
|
+
'nav', coalesce((
|
|
179
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
180
|
+
'key', n.entry_key, 'hidden', n.hidden, 'label', n.label,
|
|
181
|
+
'section', n.section, 'position', n.position
|
|
182
|
+
) ORDER BY coalesce(n.position, 999), n.entry_key)
|
|
183
|
+
FROM app.tenant_nav n
|
|
184
|
+
WHERE n.tenant_id = v_tenant
|
|
185
|
+
), '[]'::jsonb),
|
|
186
|
+
'permissions', coalesce((
|
|
187
|
+
SELECT jsonb_agg(DISTINCT rp.permission)
|
|
188
|
+
FROM app.memberships m
|
|
189
|
+
JOIN app.role_permissions rp ON rp.role_id = m.role_id AND rp.tenant_id = m.tenant_id
|
|
190
|
+
WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.active
|
|
191
|
+
), '[]'::jsonb),
|
|
192
|
+
'generated_at', now()
|
|
193
|
+
);
|
|
194
|
+
END $$;
|
|
195
|
+
|
|
196
|
+
COMMENT ON FUNCTION public.app_config() IS
|
|
197
|
+
'Everything the shell needs to render, in one call (150, ADR 0023): branding, settings, active plugins and facets, the tenant''s nav composition and the caller''s permissions. Cacheable per (tenant, user); invalidated by any write through the RPCs below.';
|
|
198
|
+
|
|
199
|
+
GRANT EXECUTE ON FUNCTION public.app_config() TO authenticated, service_role;
|
|
200
|
+
|
|
201
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
202
|
+
-- §5 The write surface — scoped, permissioned, and the only door
|
|
203
|
+
--
|
|
204
|
+
-- The agent configures the system through this, never through raw SQL: it is
|
|
205
|
+
-- bounded by the caller's tenant and by `settings.manage`, so an onboarding
|
|
206
|
+
-- assistant cannot reach another tenant even if it tries.
|
|
207
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
CREATE OR REPLACE FUNCTION public.tenant_plugin_set(
|
|
210
|
+
p_plugin text,
|
|
211
|
+
p_status text DEFAULT 'active',
|
|
212
|
+
p_facet text DEFAULT '',
|
|
213
|
+
p_config jsonb DEFAULT NULL,
|
|
214
|
+
p_source text DEFAULT 'manual',
|
|
215
|
+
p_expires timestamptz DEFAULT NULL
|
|
216
|
+
) RETURNS jsonb
|
|
217
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
218
|
+
AS $$
|
|
219
|
+
DECLARE
|
|
220
|
+
v_tenant uuid := app.current_tenant_id();
|
|
221
|
+
BEGIN
|
|
222
|
+
IF v_tenant IS NULL THEN
|
|
223
|
+
RAISE EXCEPTION 'no tenant in scope' USING ERRCODE = '42501';
|
|
224
|
+
END IF;
|
|
225
|
+
IF NOT app.has_permission('settings.manage') THEN
|
|
226
|
+
RAISE EXCEPTION 'settings.manage is required to change what this account has'
|
|
227
|
+
USING ERRCODE = '42501';
|
|
228
|
+
END IF;
|
|
229
|
+
IF p_status NOT IN ('active', 'disabled') THEN
|
|
230
|
+
RAISE EXCEPTION 'status must be active or disabled' USING ERRCODE = '22023';
|
|
231
|
+
END IF;
|
|
232
|
+
|
|
233
|
+
INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, config, source, expires_at, activated_by)
|
|
234
|
+
VALUES (v_tenant, p_plugin, coalesce(p_facet, ''), p_status, coalesce(p_config, '{}'::jsonb), p_source, p_expires, auth.uid())
|
|
235
|
+
ON CONFLICT (tenant_id, plugin_id, facet) DO UPDATE
|
|
236
|
+
SET status = EXCLUDED.status,
|
|
237
|
+
config = coalesce(p_config, app.tenant_plugins.config),
|
|
238
|
+
source = EXCLUDED.source,
|
|
239
|
+
expires_at = EXCLUDED.expires_at,
|
|
240
|
+
activated_by = EXCLUDED.activated_by;
|
|
241
|
+
|
|
242
|
+
RETURN jsonb_build_object('plugin', p_plugin, 'facet', nullif(coalesce(p_facet, ''), ''), 'status', p_status);
|
|
243
|
+
END $$;
|
|
244
|
+
|
|
245
|
+
COMMENT ON FUNCTION public.tenant_plugin_set(text, text, text, jsonb, text, timestamptz) IS
|
|
246
|
+
'Turns a plugin or facet on or off for the CURRENT tenant (150). The only door: bounded by the caller''s tenant and by settings.manage, which is what lets the onboarding agent use it without being able to reach anyone else. Turning off changes what is seen, never what is stored.';
|
|
247
|
+
|
|
248
|
+
REVOKE ALL ON FUNCTION public.tenant_plugin_set(text, text, text, jsonb, text, timestamptz) FROM public, anon;
|
|
249
|
+
GRANT EXECUTE ON FUNCTION public.tenant_plugin_set(text, text, text, jsonb, text, timestamptz) TO authenticated, service_role;
|
|
250
|
+
|
|
251
|
+
CREATE OR REPLACE FUNCTION public.tenant_nav_set(
|
|
252
|
+
p_entry_key text,
|
|
253
|
+
p_hidden boolean DEFAULT NULL,
|
|
254
|
+
p_label text DEFAULT NULL,
|
|
255
|
+
p_section text DEFAULT NULL,
|
|
256
|
+
p_position integer DEFAULT NULL
|
|
257
|
+
) RETURNS jsonb
|
|
258
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
259
|
+
AS $$
|
|
260
|
+
DECLARE
|
|
261
|
+
v_tenant uuid := app.current_tenant_id();
|
|
262
|
+
BEGIN
|
|
263
|
+
IF v_tenant IS NULL THEN
|
|
264
|
+
RAISE EXCEPTION 'no tenant in scope' USING ERRCODE = '42501';
|
|
265
|
+
END IF;
|
|
266
|
+
IF NOT app.has_permission('settings.manage') THEN
|
|
267
|
+
RAISE EXCEPTION 'settings.manage is required to change the menu' USING ERRCODE = '42501';
|
|
268
|
+
END IF;
|
|
269
|
+
|
|
270
|
+
INSERT INTO app.tenant_nav (tenant_id, entry_key, hidden, label, section, position)
|
|
271
|
+
VALUES (v_tenant, p_entry_key, coalesce(p_hidden, false), p_label, p_section, p_position)
|
|
272
|
+
ON CONFLICT (tenant_id, entry_key) DO UPDATE
|
|
273
|
+
SET hidden = coalesce(p_hidden, app.tenant_nav.hidden),
|
|
274
|
+
label = coalesce(p_label, app.tenant_nav.label),
|
|
275
|
+
section = coalesce(p_section, app.tenant_nav.section),
|
|
276
|
+
position = coalesce(p_position, app.tenant_nav.position);
|
|
277
|
+
|
|
278
|
+
RETURN jsonb_build_object('entry', p_entry_key);
|
|
279
|
+
END $$;
|
|
280
|
+
|
|
281
|
+
COMMENT ON FUNCTION public.tenant_nav_set(text, boolean, text, text, integer) IS
|
|
282
|
+
'Hides, renames or moves one menu entry for the current tenant (150). Only the difference is stored, so a plugin that ships a new entry still reaches everyone.';
|
|
283
|
+
|
|
284
|
+
REVOKE ALL ON FUNCTION public.tenant_nav_set(text, boolean, text, text, integer) FROM public, anon;
|
|
285
|
+
GRANT EXECUTE ON FUNCTION public.tenant_nav_set(text, boolean, text, text, integer) TO authenticated, service_role;
|