@fayz-ai/db 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/canonical-schema.json +132 -0
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/schema/spine.d.ts +42 -37
- package/dist/schema/spine.d.ts.map +1 -1
- package/migrations/000_baseline.sql +29667 -0
- package/migrations/001_the_tenant_has_apps.sql +314 -0
- package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
- package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
- package/migrations/004_the_projections_get_a_successor.sql +312 -0
- package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
- package/migrations/006_the_helpers_read_the_source.sql +153 -0
- package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
- package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
- package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
- package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
- package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
- package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
- package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
- package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
- package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
- package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
- package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
- package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
- package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
- package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
- package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
- package/package.json +3 -2
- package/migrations/0000_legacy_quarantine.sql +0 -86
- package/migrations/0000b_straggler_sweep.sql +0 -37
- package/migrations/000_core_v1_convert.sql +0 -343
- package/migrations/001_core.sql +0 -372
- package/migrations/002_rls_user_tenant_ids.sql +0 -64
- package/migrations/003_invitations.sql +0 -17
- package/migrations/004_archetypes.sql +0 -343
- package/migrations/005_locations_archetype.sql +0 -18
- package/migrations/006_archetype_rls.sql +0 -74
- package/migrations/007_archetype_grants.sql +0 -4
- package/migrations/008_grants.sql +0 -5
- package/migrations/009_anon_hardening.sql +0 -19
- package/migrations/010_migration_ledger.sql +0 -36
- package/migrations/011_anon_write_revoke.sql +0 -12
- package/migrations/012_rename_migration_ledger.sql +0 -61
- package/migrations/013_sequences_rls.sql +0 -22
- package/migrations/014_plan_entitlements.sql +0 -28
- package/migrations/015_agent_guard.sql +0 -108
- package/migrations/016_agent_guard_actor.sql +0 -102
- package/migrations/017_core_addresses.sql +0 -115
- package/migrations/018_global_search.sql +0 -482
- package/migrations/019_tenant_members_person_id.sql +0 -20
- package/migrations/020_fayz_projects.sql +0 -63
- package/migrations/021_tenant_roles.sql +0 -74
- package/migrations/022_invite_acceptance.sql +0 -91
- package/migrations/023_team_visibility.sql +0 -75
- package/migrations/024_analytics_engine.sql +0 -317
- package/migrations/025_created_by.sql +0 -75
- package/migrations/026_audit_trail.sql +0 -73
- package/migrations/027_domain_events.sql +0 -266
- package/migrations/028_tenant_scoped_token.sql +0 -127
- package/migrations/029_connections.sql +0 -186
- package/migrations/030_effect_idempotency.sql +0 -159
- package/migrations/031_sync_run_message.sql +0 -39
- package/migrations/032_connection_secrets.sql +0 -227
- package/migrations/033_sync_schedule.sql +0 -651
- package/migrations/034_custom_fields.sql +0 -55
- package/migrations/035_field_registry.sql +0 -148
- package/migrations/036_analytics_run_batch.sql +0 -84
- package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
- package/migrations/038_onboarding_responses.sql +0 -103
- package/migrations/039_unit_tree.sql +0 -270
- package/migrations/040_resource_grants.sql +0 -474
- package/migrations/041_scoped_columns.sql +0 -192
- package/migrations/042_unit_scope_policies.sql +0 -145
- package/migrations/043_view_invoker.sql +0 -81
- package/migrations/044_unit_member_facts.sql +0 -47
- package/migrations/045_unit_entry.sql +0 -236
- package/migrations/046_membership_visible_to_members.sql +0 -85
- package/migrations/047_tasks.sql +0 -266
- package/migrations/048_every_login_is_a_person.sql +0 -190
- package/migrations/049_bookable_people.sql +0 -126
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 034 — custom_fields: the tenant's own fields, on every core record.
|
|
3
|
-
--
|
|
4
|
-
-- The rule this exists to make true: customization lives in DATA, never in
|
|
5
|
-
-- schema. Every tenant in a pool shares one identical schema — no per-tenant
|
|
6
|
-
-- DDL, no schema branch. A merchant asking for "tipo de pele on the client
|
|
7
|
-
-- record" gets an INSERT into plg_entity_fields (035), not a migration.
|
|
8
|
-
--
|
|
9
|
-
-- Why a column and not `metadata`: metadata is the system's bag (a block's
|
|
10
|
-
-- title, an order's direction, Google's etag). custom_fields is the merchant's
|
|
11
|
-
-- content, and only it is governed by the registry. Keeping them apart is what
|
|
12
|
-
-- lets a future connector ship "this tenant's fields" as one predictable
|
|
13
|
-
-- payload instead of guessing which keys are internal.
|
|
14
|
-
--
|
|
15
|
-
-- All eleven archetypes at once. A partial rollout produces "works on the
|
|
16
|
-
-- entities somebody remembered", which is the failure mode 026 refused when it
|
|
17
|
-
-- declined to put an audit trigger on one table at a time.
|
|
18
|
-
--
|
|
19
|
-
-- Idempotent.
|
|
20
|
-
-- ============================================================================
|
|
21
|
-
|
|
22
|
-
ALTER TABLE public.people ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
23
|
-
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
24
|
-
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
25
|
-
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
26
|
-
ALTER TABLE public.orders ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
27
|
-
ALTER TABLE public.order_items ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
28
|
-
ALTER TABLE public.transactions ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
29
|
-
ALTER TABLE public.appointments ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
30
|
-
ALTER TABLE public.appointment_items ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
31
|
-
ALTER TABLE public.schedules ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
32
|
-
ALTER TABLE public.locations ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
33
|
-
|
|
34
|
-
COMMENT ON COLUMN public.people.custom_fields IS
|
|
35
|
-
'Fields this tenant declared for its own records. Keys are governed by '
|
|
36
|
-
'plg_entity_fields (035) and validated on write; never free-form. Distinct '
|
|
37
|
-
'from metadata, which is the system''s own bag.';
|
|
38
|
-
|
|
39
|
-
-- ---------------------------------------------------------------------------
|
|
40
|
-
-- Indexes: only where there is use, and only by platform decision.
|
|
41
|
-
--
|
|
42
|
-
-- The reason `metadata` became unfilterable is not jsonb — it is that no
|
|
43
|
-
-- metadata column in this tree has ever had an index (128 references, zero).
|
|
44
|
-
-- jsonb_path_ops because the filter path compiles to containment (@>), the same
|
|
45
|
-
-- choice and same reason as plg_entity_records_data_gin.
|
|
46
|
-
--
|
|
47
|
-
-- The other eight archetypes carry the column and no index until something
|
|
48
|
-
-- actually queries them. Promoting one is a migration, deliberately.
|
|
49
|
-
-- ---------------------------------------------------------------------------
|
|
50
|
-
CREATE INDEX IF NOT EXISTS people_custom_fields_gin
|
|
51
|
-
ON public.people USING gin (custom_fields jsonb_path_ops);
|
|
52
|
-
CREATE INDEX IF NOT EXISTS appointments_custom_fields_gin
|
|
53
|
-
ON public.appointments USING gin (custom_fields jsonb_path_ops);
|
|
54
|
-
CREATE INDEX IF NOT EXISTS products_custom_fields_gin
|
|
55
|
-
ON public.products USING gin (custom_fields jsonb_path_ops);
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 035 — plg_entity_fields: which fields exist, for whom, and what they accept.
|
|
3
|
-
--
|
|
4
|
-
-- The half of 034 that makes custom_fields governed instead of free-form. A key
|
|
5
|
-
-- that is not declared here is refused on write; UI, validation and reports are
|
|
6
|
-
-- generated from these rows, not hardcoded.
|
|
7
|
-
--
|
|
8
|
-
-- tenant_id NULL is the vertical's own vocabulary (the salon anamnesis a pool
|
|
9
|
-
-- ships with); non-null is what ONE merchant defined. Same convention, and the
|
|
10
|
-
-- same reason, as plg_shop_rule_attributes: widening the vocabulary is an
|
|
11
|
-
-- INSERT. Two partial unique indexes keep the halves from colliding.
|
|
12
|
-
--
|
|
13
|
-
-- This is also the AI builder's whole surface for extra fields: a row here, no
|
|
14
|
-
-- DDL, no migration. The column it fills already exists everywhere (034), which
|
|
15
|
-
-- is what makes that boundary enforceable rather than merely stated.
|
|
16
|
-
--
|
|
17
|
-
-- Idempotent.
|
|
18
|
-
-- ============================================================================
|
|
19
|
-
|
|
20
|
-
CREATE TABLE IF NOT EXISTS public.plg_entity_fields (
|
|
21
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
22
|
-
|
|
23
|
-
-- NULL = the vertical's vocabulary, readable by every tenant in the pool and
|
|
24
|
-
-- writable by none of them. Non-null = this tenant's own field.
|
|
25
|
-
tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
26
|
-
|
|
27
|
-
-- The record kind this field lives on: 'person', 'appointment', 'product'…
|
|
28
|
-
-- No CHECK — the next archetype is a value, not a migration. The closed set
|
|
29
|
-
-- lives in TS, where it can throw a useful error naming the valid ones.
|
|
30
|
-
reference text NOT NULL CHECK (reference ~ '^[a-z][a-z0-9_]*$'),
|
|
31
|
-
|
|
32
|
-
-- The key inside custom_fields. IMMUTABLE once written — see the trigger.
|
|
33
|
-
key text NOT NULL CHECK (key ~ '^[a-z][a-zA-Z0-9_]*$'),
|
|
34
|
-
|
|
35
|
-
-- A closed vocabulary on purpose: a type is a MODEL change (this CHECK, the
|
|
36
|
-
-- TS union, and every renderer), not an insert. Mirrors ENTITY_FIELD_TYPES.
|
|
37
|
-
type text NOT NULL CHECK (type IN (
|
|
38
|
-
'text', 'richtext', 'number', 'boolean', 'json',
|
|
39
|
-
'image', 'url', 'ref', 'point', 'date', 'datetime', 'select'
|
|
40
|
-
)),
|
|
41
|
-
|
|
42
|
-
label text,
|
|
43
|
-
help text,
|
|
44
|
-
required boolean NOT NULL DEFAULT false,
|
|
45
|
-
default_value jsonb,
|
|
46
|
-
-- min/max/pattern/enum — read by validateEntityRecordData, not by Postgres.
|
|
47
|
-
validation jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
48
|
-
-- The choices, when type = 'select'.
|
|
49
|
-
options jsonb,
|
|
50
|
-
position integer NOT NULL DEFAULT 0,
|
|
51
|
-
|
|
52
|
-
-- 'declared' came from code and is re-asserted on every deploy; 'merchant'
|
|
53
|
-
-- came from the admin with no deploy. A deploy never deletes a merchant row —
|
|
54
|
-
-- a declared key ADOPTS a colliding merchant key instead.
|
|
55
|
-
origin text NOT NULL DEFAULT 'merchant' CHECK (origin IN ('declared', 'merchant')),
|
|
56
|
-
owner text,
|
|
57
|
-
|
|
58
|
-
-- Soft: a value already written to custom_fields must stay readable after the
|
|
59
|
-
-- field leaves the form.
|
|
60
|
-
archived_at timestamptz,
|
|
61
|
-
|
|
62
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
63
|
-
updated_at timestamptz NOT NULL DEFAULT now()
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
CREATE UNIQUE INDEX IF NOT EXISTS plg_entity_fields_core_key
|
|
67
|
-
ON public.plg_entity_fields (reference, key) WHERE tenant_id IS NULL;
|
|
68
|
-
CREATE UNIQUE INDEX IF NOT EXISTS plg_entity_fields_tenant_key
|
|
69
|
-
ON public.plg_entity_fields (tenant_id, reference, key) WHERE tenant_id IS NOT NULL;
|
|
70
|
-
CREATE INDEX IF NOT EXISTS plg_entity_fields_lookup
|
|
71
|
-
ON public.plg_entity_fields (tenant_id, reference, position) WHERE archived_at IS NULL;
|
|
72
|
-
|
|
73
|
-
-- ---------------------------------------------------------------------------
|
|
74
|
-
-- The machine key does not move.
|
|
75
|
-
--
|
|
76
|
-
-- The label is the merchant's to rename whenever they like. The key is the name
|
|
77
|
-
-- of data already written into custom_fields on every record, and the thing an
|
|
78
|
-
-- integration maps to. Renaming it silently orphans every value ever stored.
|
|
79
|
-
-- ---------------------------------------------------------------------------
|
|
80
|
-
CREATE OR REPLACE FUNCTION public.plg_entity_fields_key_is_immutable()
|
|
81
|
-
RETURNS trigger
|
|
82
|
-
LANGUAGE plpgsql
|
|
83
|
-
SET search_path = public
|
|
84
|
-
AS $fn$
|
|
85
|
-
BEGIN
|
|
86
|
-
IF NEW.key IS DISTINCT FROM OLD.key OR NEW.reference IS DISTINCT FROM OLD.reference THEN
|
|
87
|
-
RAISE EXCEPTION
|
|
88
|
-
'A chave de máquina de um campo não muda (%.% → %.%). O rótulo é livre; a '
|
|
89
|
-
'chave é o nome do dado já gravado em custom_fields e o que uma integração '
|
|
90
|
-
'mapeia. Para trocar, arquive este campo e crie outro.',
|
|
91
|
-
OLD.reference, OLD.key, NEW.reference, NEW.key;
|
|
92
|
-
END IF;
|
|
93
|
-
RETURN NEW;
|
|
94
|
-
END;
|
|
95
|
-
$fn$;
|
|
96
|
-
|
|
97
|
-
DROP TRIGGER IF EXISTS plg_entity_fields_immutable_key ON public.plg_entity_fields;
|
|
98
|
-
CREATE TRIGGER plg_entity_fields_immutable_key
|
|
99
|
-
BEFORE UPDATE ON public.plg_entity_fields
|
|
100
|
-
FOR EACH ROW EXECUTE FUNCTION public.plg_entity_fields_key_is_immutable();
|
|
101
|
-
|
|
102
|
-
DROP TRIGGER IF EXISTS plg_entity_fields_updated_at ON public.plg_entity_fields;
|
|
103
|
-
CREATE TRIGGER plg_entity_fields_updated_at
|
|
104
|
-
BEFORE UPDATE ON public.plg_entity_fields
|
|
105
|
-
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
106
|
-
|
|
107
|
-
-- ---------------------------------------------------------------------------
|
|
108
|
-
-- RLS and the default-privilege trap.
|
|
109
|
-
--
|
|
110
|
-
-- 011 closed `anon` on every table and on future ones. It did NOT close
|
|
111
|
-
-- `authenticated`, which is still born with the full set on any new public
|
|
112
|
-
-- table — here that would be every tenant editing every other tenant's field
|
|
113
|
-
-- definitions. Stripped first, granted back only what is needed (029's block).
|
|
114
|
-
-- ---------------------------------------------------------------------------
|
|
115
|
-
REVOKE ALL ON public.plg_entity_fields FROM anon, authenticated;
|
|
116
|
-
|
|
117
|
-
ALTER TABLE public.plg_entity_fields ENABLE ROW LEVEL SECURITY;
|
|
118
|
-
|
|
119
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_entity_fields TO authenticated;
|
|
120
|
-
|
|
121
|
-
-- Reads the vertical's vocabulary AND its own. The NULL half is what a pool
|
|
122
|
-
-- ships with, and a tenant has to see it to render a form from it.
|
|
123
|
-
DROP POLICY IF EXISTS plg_entity_fields_member_read ON public.plg_entity_fields;
|
|
124
|
-
CREATE POLICY plg_entity_fields_member_read ON public.plg_entity_fields
|
|
125
|
-
FOR SELECT TO authenticated
|
|
126
|
-
USING (tenant_id IS NULL OR tenant_id IN (SELECT public.user_tenant_ids()));
|
|
127
|
-
|
|
128
|
-
-- Writes only its own. `tenant_id IS NULL` fails these three by construction —
|
|
129
|
-
-- NULL IN (…) is never true — so no merchant can edit the pool's vocabulary.
|
|
130
|
-
DROP POLICY IF EXISTS plg_entity_fields_member_insert ON public.plg_entity_fields;
|
|
131
|
-
CREATE POLICY plg_entity_fields_member_insert ON public.plg_entity_fields
|
|
132
|
-
FOR INSERT TO authenticated
|
|
133
|
-
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
134
|
-
|
|
135
|
-
DROP POLICY IF EXISTS plg_entity_fields_member_update ON public.plg_entity_fields;
|
|
136
|
-
CREATE POLICY plg_entity_fields_member_update ON public.plg_entity_fields
|
|
137
|
-
FOR UPDATE TO authenticated
|
|
138
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()))
|
|
139
|
-
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
140
|
-
|
|
141
|
-
DROP POLICY IF EXISTS plg_entity_fields_member_delete ON public.plg_entity_fields;
|
|
142
|
-
CREATE POLICY plg_entity_fields_member_delete ON public.plg_entity_fields
|
|
143
|
-
FOR DELETE TO authenticated
|
|
144
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
145
|
-
|
|
146
|
-
COMMENT ON TABLE public.plg_entity_fields IS
|
|
147
|
-
'Per-tenant field registry for custom_fields (034). tenant_id NULL is the '
|
|
148
|
-
'vertical vocabulary — readable by every tenant, writable by none.';
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- analytics_run_batch — one round trip for a whole dashboard
|
|
3
|
-
-- ----------------------------------------------------------------------------
|
|
4
|
-
-- A card fetches itself. That is the right ownership (a tile added to a pack
|
|
5
|
-
-- must not require editing a page-level query), and it is why opening
|
|
6
|
-
-- /analytics fired FORTY-ONE requests: twenty-two cards, most of them also
|
|
7
|
-
-- asking for the comparison window, each its own POST. Change the date range
|
|
8
|
-
-- and all forty-one go again.
|
|
9
|
-
--
|
|
10
|
-
-- Nothing about the ownership needs to change — only the transport. This takes
|
|
11
|
-
-- an ARRAY of the exact same arguments analytics_run already accepts and
|
|
12
|
-
-- returns the answers in order, so a dashboard is one call and a card is still
|
|
13
|
-
-- the thing that declares its own query.
|
|
14
|
-
--
|
|
15
|
-
-- The engine itself is untouched: every element goes through analytics_run,
|
|
16
|
-
-- which means the allowlist, the identifier guard, the fixed aggregate
|
|
17
|
-
-- vocabulary and SECURITY INVOKER still decide what any of this can read. A
|
|
18
|
-
-- batch cannot reach a row a single call could not.
|
|
19
|
-
--
|
|
20
|
-
-- Per-element failure is contained. One card naming a column that does not
|
|
21
|
-
-- exist must not blank the other twenty-one, so each iteration runs in its own
|
|
22
|
-
-- subtransaction and a failure is RETURNED as {"error", "code"} at that index
|
|
23
|
-
-- rather than raised — which is also what lets the client render one broken
|
|
24
|
-
-- tile beside twenty-one live ones.
|
|
25
|
-
--
|
|
26
|
-
-- Requires 024_analytics_engine.sql. Idempotent.
|
|
27
|
-
-- ============================================================================
|
|
28
|
-
|
|
29
|
-
CREATE OR REPLACE FUNCTION public.analytics_run_batch(p_requests jsonb)
|
|
30
|
-
RETURNS jsonb
|
|
31
|
-
LANGUAGE plpgsql
|
|
32
|
-
STABLE
|
|
33
|
-
AS $$
|
|
34
|
-
DECLARE
|
|
35
|
-
v_req jsonb;
|
|
36
|
-
v_result jsonb;
|
|
37
|
-
v_out jsonb := '[]'::jsonb;
|
|
38
|
-
BEGIN
|
|
39
|
-
IF p_requests IS NULL OR jsonb_typeof(p_requests) <> 'array' THEN
|
|
40
|
-
RAISE EXCEPTION 'analytics: batch expects an array of requests' USING ERRCODE = '22023';
|
|
41
|
-
END IF;
|
|
42
|
-
|
|
43
|
-
-- A cap, not a convenience: this is a caller-supplied loop over a function
|
|
44
|
-
-- that reads tables, and an unbounded array is a way to ask one request to do
|
|
45
|
-
-- unbounded work. The largest pack in the product is ~30 cards.
|
|
46
|
-
IF jsonb_array_length(p_requests) > 100 THEN
|
|
47
|
-
RAISE EXCEPTION 'analytics: batch of % exceeds the limit of 100',
|
|
48
|
-
jsonb_array_length(p_requests) USING ERRCODE = '22023';
|
|
49
|
-
END IF;
|
|
50
|
-
|
|
51
|
-
FOR v_req IN SELECT * FROM jsonb_array_elements(p_requests)
|
|
52
|
-
LOOP
|
|
53
|
-
BEGIN
|
|
54
|
-
v_result := public.analytics_run(
|
|
55
|
-
p_source => v_req ->> 'source',
|
|
56
|
-
p_tenant_id => NULLIF(v_req ->> 'tenant_id', '')::uuid,
|
|
57
|
-
p_from => NULLIF(v_req ->> 'from', '')::timestamptz,
|
|
58
|
-
p_to => NULLIF(v_req ->> 'to', '')::timestamptz,
|
|
59
|
-
p_dimensions => COALESCE(v_req -> 'dimensions', '[]'::jsonb),
|
|
60
|
-
p_measures => COALESCE(v_req -> 'measures', '[]'::jsonb),
|
|
61
|
-
p_filters => COALESCE(v_req -> 'filters', '{}'::jsonb),
|
|
62
|
-
p_search => NULLIF(v_req ->> 'search', ''),
|
|
63
|
-
p_search_columns => CASE
|
|
64
|
-
WHEN jsonb_typeof(v_req -> 'search_columns') = 'array'
|
|
65
|
-
THEN ARRAY(SELECT jsonb_array_elements_text(v_req -> 'search_columns'))
|
|
66
|
-
END,
|
|
67
|
-
p_sort => NULLIF(v_req ->> 'sort', ''),
|
|
68
|
-
p_dir => COALESCE(NULLIF(v_req ->> 'dir', ''), 'desc'),
|
|
69
|
-
p_limit => COALESCE((v_req ->> 'limit')::int, 500),
|
|
70
|
-
p_offset => COALESCE((v_req ->> 'offset')::int, 0)
|
|
71
|
-
);
|
|
72
|
-
EXCEPTION WHEN OTHERS THEN
|
|
73
|
-
v_result := jsonb_build_object('error', SQLERRM, 'code', SQLSTATE);
|
|
74
|
-
END;
|
|
75
|
-
|
|
76
|
-
v_out := v_out || jsonb_build_array(v_result);
|
|
77
|
-
END LOOP;
|
|
78
|
-
|
|
79
|
-
RETURN v_out;
|
|
80
|
-
END;
|
|
81
|
-
$$;
|
|
82
|
-
|
|
83
|
-
REVOKE ALL ON FUNCTION public.analytics_run_batch(jsonb) FROM public;
|
|
84
|
-
GRANT EXECUTE ON FUNCTION public.analytics_run_batch(jsonb) TO authenticated, service_role;
|
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 037_sync_tick_one_at_a_time.sql — a connection with a dispatch in flight is
|
|
3
|
-
-- not due.
|
|
4
|
-
--
|
|
5
|
-
-- 033 selects what to dispatch by ONE test: `next_attempt_at <= now()`. It never
|
|
6
|
-
-- asks whether the previous dispatch came back. In the ordinary case that is
|
|
7
|
-
-- invisible — the advance pushes the row a whole interval into the future in the
|
|
8
|
-
-- same statement that dispatches it, so nothing is due again for an hour.
|
|
9
|
-
--
|
|
10
|
-
-- It stops being invisible the moment anything else writes `next_attempt_at`.
|
|
11
|
-
-- The shop's wake trigger (packages/shop 0064) sets it to `now()` whenever an
|
|
12
|
-
-- order, a customer or a product changes — which is exactly what a catalogue
|
|
13
|
-
-- pull DOES, by the thousand, while it is still running. Measured on the
|
|
14
|
-
-- ecommerce pool: bling-sync dispatched, pulled, wrote products, each write
|
|
15
|
-
-- moved its own next attempt to now(), the next tick dispatched it again on top
|
|
16
|
-
-- of itself, and the merchant's history filled with an hourly connector running
|
|
17
|
-
-- every sixty seconds. The cadence was never wrong; nothing enforced it.
|
|
18
|
-
--
|
|
19
|
-
-- ── The guard ────────────────────────────────────────────────────────────────
|
|
20
|
-
-- `last_run_id IS NOT NULL` already means "dispatched, no verdict yet" — the
|
|
21
|
-
-- fold and the reaper both key off it. This file makes the selection honour the
|
|
22
|
-
-- same fact: one dispatch per connection per kind at a time, and the reaper
|
|
23
|
-
-- (timeout_seconds) is what ends it if a function never answers. A wake that
|
|
24
|
-
-- lands mid-run is not lost, it is served by the tick after the verdict.
|
|
25
|
-
--
|
|
26
|
-
-- This is a ceiling nobody could opt out of, which is the point: rate limits,
|
|
27
|
-
-- the provider's daily quota and the connector's own cursor all assume its
|
|
28
|
-
-- previous call finished. `max_per_tick` bounds a connector across connections;
|
|
29
|
-
-- nothing bounded ONE connection against itself.
|
|
30
|
-
--
|
|
31
|
-
-- ── And the orphan ───────────────────────────────────────────────────────────
|
|
32
|
-
-- A guard on a pointer needs the pointer to be honest. If the run a schedule
|
|
33
|
-
-- points at no longer exists, the fold cannot fold it and the reaper cannot reap
|
|
34
|
-
-- it, and under the new guard that connection would never be dispatched again.
|
|
35
|
-
-- Cleared before the fold, so a vanished run costs one tick instead of forever.
|
|
36
|
-
--
|
|
37
|
-
-- CREATE OR REPLACE of plg_sync_tick, and nothing else: no table, no grant, no
|
|
38
|
-
-- cron job changes. Idempotent, and safe to re-run — `fayz db apply` re-applies
|
|
39
|
-
-- a file whose checksum moved.
|
|
40
|
-
-- ============================================================================
|
|
41
|
-
|
|
42
|
-
CREATE OR REPLACE FUNCTION public.plg_sync_tick(p_kind text)
|
|
43
|
-
RETURNS integer
|
|
44
|
-
LANGUAGE plpgsql
|
|
45
|
-
SECURITY DEFINER
|
|
46
|
-
SET search_path = public, extensions, net, vault
|
|
47
|
-
AS $fn$
|
|
48
|
-
DECLARE
|
|
49
|
-
v_base text;
|
|
50
|
-
v_secret text;
|
|
51
|
-
v_have_net boolean;
|
|
52
|
-
v_row record;
|
|
53
|
-
v_run_id uuid;
|
|
54
|
-
v_next timestamptz;
|
|
55
|
-
v_expires timestamptz;
|
|
56
|
-
v_offset integer;
|
|
57
|
-
v_dispatched integer := 0;
|
|
58
|
-
v_reason text;
|
|
59
|
-
BEGIN
|
|
60
|
-
IF p_kind NOT IN ('reconcile', 'renew') THEN
|
|
61
|
-
RAISE EXCEPTION 'plg_sync_tick: unknown kind %', p_kind;
|
|
62
|
-
END IF;
|
|
63
|
-
|
|
64
|
-
IF NOT pg_try_advisory_xact_lock(hashtext('plg_sync_tick:' || p_kind)) THEN
|
|
65
|
-
RETURN 0;
|
|
66
|
-
END IF;
|
|
67
|
-
|
|
68
|
-
v_have_net := to_regprocedure('net.http_post(text, jsonb, jsonb, jsonb, integer)') IS NOT NULL;
|
|
69
|
-
|
|
70
|
-
BEGIN
|
|
71
|
-
SELECT decrypted_secret INTO v_base
|
|
72
|
-
FROM vault.decrypted_secrets WHERE name = 'fayz_functions_url'
|
|
73
|
-
ORDER BY created_at DESC LIMIT 1;
|
|
74
|
-
SELECT decrypted_secret INTO v_secret
|
|
75
|
-
FROM vault.decrypted_secrets WHERE name = 'fayz_scheduler_secret'
|
|
76
|
-
ORDER BY created_at DESC LIMIT 1;
|
|
77
|
-
EXCEPTION WHEN OTHERS THEN
|
|
78
|
-
v_base := NULL;
|
|
79
|
-
v_secret := NULL;
|
|
80
|
-
END;
|
|
81
|
-
|
|
82
|
-
-- ── orphan ────────────────────────────────────────────────────────────────
|
|
83
|
-
-- Before anything reads `last_run_id` as "in flight": a pointer at a run that
|
|
84
|
-
-- no longer exists is not a dispatch in flight, it is a dead end that would
|
|
85
|
-
-- park this connection permanently.
|
|
86
|
-
UPDATE public.plg_sync_schedule s
|
|
87
|
-
SET last_run_id = NULL
|
|
88
|
-
WHERE s.kind = p_kind
|
|
89
|
-
AND s.last_run_id IS NOT NULL
|
|
90
|
-
AND NOT EXISTS (SELECT 1 FROM public.plg_sync_runs r WHERE r.id = s.last_run_id);
|
|
91
|
-
|
|
92
|
-
-- ── reap ──────────────────────────────────────────────────────────────────
|
|
93
|
-
UPDATE public.plg_sync_runs r
|
|
94
|
-
SET status = 'error',
|
|
95
|
-
finished_at = now(),
|
|
96
|
-
error = COALESCE(r.error, 'the connector function returned no verdict within the schedule timeout')
|
|
97
|
-
FROM public.plg_sync_schedule s
|
|
98
|
-
JOIN public.plg_connections c ON c.id = s.connection_id
|
|
99
|
-
JOIN public.plg_connector_schedules d
|
|
100
|
-
ON d.connector_id = c.connector_id AND d.kind = s.kind
|
|
101
|
-
WHERE s.kind = p_kind
|
|
102
|
-
AND r.id = s.last_run_id
|
|
103
|
-
AND r.status = 'running'
|
|
104
|
-
AND r.started_at < now() - make_interval(secs => d.timeout_seconds);
|
|
105
|
-
|
|
106
|
-
-- ── fold ──────────────────────────────────────────────────────────────────
|
|
107
|
-
UPDATE public.plg_sync_schedule s
|
|
108
|
-
SET failures = CASE WHEN r.status IN ('success', 'partial') THEN 0 ELSE s.failures + 1 END,
|
|
109
|
-
last_run_id = NULL,
|
|
110
|
-
next_attempt_at = CASE
|
|
111
|
-
WHEN r.status IN ('success', 'partial') THEN s.next_attempt_at
|
|
112
|
-
ELSE GREATEST(
|
|
113
|
-
s.next_attempt_at,
|
|
114
|
-
now() + make_interval(secs => LEAST(
|
|
115
|
-
d.interval_seconds::bigint * (2 ^ LEAST(s.failures + 1, 6))::bigint,
|
|
116
|
-
86400)::double precision)
|
|
117
|
-
)
|
|
118
|
-
END
|
|
119
|
-
FROM public.plg_sync_runs r,
|
|
120
|
-
public.plg_connections c,
|
|
121
|
-
public.plg_connector_schedules d
|
|
122
|
-
WHERE s.kind = p_kind
|
|
123
|
-
AND r.id = s.last_run_id
|
|
124
|
-
AND r.finished_at IS NOT NULL
|
|
125
|
-
AND c.id = s.connection_id
|
|
126
|
-
AND d.connector_id = c.connector_id
|
|
127
|
-
AND d.kind = s.kind;
|
|
128
|
-
|
|
129
|
-
-- ── enroll ────────────────────────────────────────────────────────────────
|
|
130
|
-
INSERT INTO public.plg_sync_schedule (connection_id, kind, next_attempt_at)
|
|
131
|
-
SELECT q.id, p_kind,
|
|
132
|
-
now() + make_interval(secs => mod(abs(hashtext(q.id::text)::bigint), GREATEST(q.jitter_seconds, 1))::double precision)
|
|
133
|
-
FROM (
|
|
134
|
-
SELECT c.id, d.jitter_seconds
|
|
135
|
-
FROM public.plg_connections c
|
|
136
|
-
JOIN public.plg_connector_schedules d
|
|
137
|
-
ON d.connector_id = c.connector_id AND d.kind = p_kind AND d.enabled
|
|
138
|
-
LEFT JOIN public.plg_sync_schedule s
|
|
139
|
-
ON s.connection_id = c.id AND s.kind = p_kind
|
|
140
|
-
WHERE s.connection_id IS NULL
|
|
141
|
-
AND c.active
|
|
142
|
-
LIMIT 500
|
|
143
|
-
) q
|
|
144
|
-
ON CONFLICT (connection_id, kind) DO NOTHING;
|
|
145
|
-
|
|
146
|
-
-- ── dispatch ──────────────────────────────────────────────────────────────
|
|
147
|
-
FOR v_row IN
|
|
148
|
-
SELECT *
|
|
149
|
-
FROM (
|
|
150
|
-
SELECT s.connection_id, s.failures, c.tenant_id, c.connector_id, c.provider_state,
|
|
151
|
-
d.function_name, d.action, d.interval_seconds, d.jitter_seconds,
|
|
152
|
-
d.floor_seconds, d.lead_seconds, d.timeout_seconds, d.max_per_tick,
|
|
153
|
-
row_number() OVER (PARTITION BY c.connector_id ORDER BY s.next_attempt_at, s.connection_id) AS rn
|
|
154
|
-
FROM (
|
|
155
|
-
SELECT s2.*
|
|
156
|
-
FROM public.plg_sync_schedule s2
|
|
157
|
-
WHERE s2.kind = p_kind AND s2.next_attempt_at <= now()
|
|
158
|
-
-- THE GUARD. Due is not enough: a connection whose last dispatch
|
|
159
|
-
-- has no verdict is already being served. Cleared by the fold on
|
|
160
|
-
-- a verdict, or by the reaper at timeout_seconds.
|
|
161
|
-
AND s2.last_run_id IS NULL
|
|
162
|
-
ORDER BY s2.next_attempt_at
|
|
163
|
-
LIMIT 2000
|
|
164
|
-
) s
|
|
165
|
-
JOIN public.plg_connections c ON c.id = s.connection_id
|
|
166
|
-
JOIN public.plg_connector_schedules d
|
|
167
|
-
ON d.connector_id = c.connector_id AND d.kind = s.kind
|
|
168
|
-
WHERE d.enabled
|
|
169
|
-
AND c.active
|
|
170
|
-
AND c.status IN ('connected', 'error')
|
|
171
|
-
) ranked
|
|
172
|
-
WHERE ranked.rn <= ranked.max_per_tick
|
|
173
|
-
LOOP
|
|
174
|
-
INSERT INTO public.plg_sync_runs
|
|
175
|
-
(tenant_id, connection_id, connector_id, direction, trigger_kind, status, stats)
|
|
176
|
-
VALUES
|
|
177
|
-
(v_row.tenant_id, v_row.connection_id, v_row.connector_id,
|
|
178
|
-
CASE WHEN p_kind = 'renew' THEN 'outbound' ELSE 'inbound' END,
|
|
179
|
-
'scheduled', 'running',
|
|
180
|
-
jsonb_build_object('schedule_kind', p_kind, 'attempt', v_row.failures + 1))
|
|
181
|
-
RETURNING id INTO v_run_id;
|
|
182
|
-
|
|
183
|
-
v_reason := NULL;
|
|
184
|
-
IF NOT v_have_net THEN
|
|
185
|
-
v_reason := 'pg_net is not installed on this pool, so the scheduler cannot call the connector function';
|
|
186
|
-
ELSIF COALESCE(v_base, '') = '' OR COALESCE(v_secret, '') = '' THEN
|
|
187
|
-
v_reason := 'the scheduler endpoint is not configured: run plg_set_scheduler_endpoint on this pool';
|
|
188
|
-
ELSE
|
|
189
|
-
BEGIN
|
|
190
|
-
PERFORM net.http_post(
|
|
191
|
-
url := v_base || '/' || v_row.function_name,
|
|
192
|
-
headers := jsonb_build_object(
|
|
193
|
-
'Content-Type', 'application/json',
|
|
194
|
-
'Authorization', 'Bearer ' || v_secret,
|
|
195
|
-
'X-Fayz-Scheduler', '1'
|
|
196
|
-
),
|
|
197
|
-
body := jsonb_build_object(
|
|
198
|
-
'action', v_row.action,
|
|
199
|
-
'kind', p_kind,
|
|
200
|
-
'connectionId', v_row.connection_id,
|
|
201
|
-
'tenantId', v_row.tenant_id,
|
|
202
|
-
'connectorId', v_row.connector_id,
|
|
203
|
-
'runId', v_run_id,
|
|
204
|
-
'attempt', v_row.failures + 1,
|
|
205
|
-
'scheduledAt', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
|
206
|
-
),
|
|
207
|
-
timeout_milliseconds := v_row.timeout_seconds * 1000
|
|
208
|
-
);
|
|
209
|
-
EXCEPTION WHEN OTHERS THEN
|
|
210
|
-
v_reason := 'the scheduler could not post to the connector function: ' || SQLERRM;
|
|
211
|
-
END;
|
|
212
|
-
END IF;
|
|
213
|
-
|
|
214
|
-
IF v_reason IS NOT NULL THEN
|
|
215
|
-
UPDATE public.plg_sync_runs
|
|
216
|
-
SET status = 'error', error = v_reason, finished_at = now()
|
|
217
|
-
WHERE id = v_run_id;
|
|
218
|
-
END IF;
|
|
219
|
-
|
|
220
|
-
-- ── advance ─────────────────────────────────────────────────────────────
|
|
221
|
-
v_offset := mod(abs(hashtext(v_row.connection_id::text)::bigint),
|
|
222
|
-
GREATEST(v_row.jitter_seconds, 1))::integer;
|
|
223
|
-
v_next := now() + make_interval(secs => (v_row.interval_seconds + v_offset)::double precision);
|
|
224
|
-
|
|
225
|
-
IF p_kind = 'renew' THEN
|
|
226
|
-
BEGIN
|
|
227
|
-
v_expires := (v_row.provider_state ->> 'expiresAt')::timestamptz;
|
|
228
|
-
EXCEPTION WHEN OTHERS THEN
|
|
229
|
-
v_expires := NULL;
|
|
230
|
-
END;
|
|
231
|
-
IF v_expires IS NOT NULL THEN
|
|
232
|
-
v_next := LEAST(v_next, v_expires - make_interval(secs => v_row.lead_seconds::double precision));
|
|
233
|
-
END IF;
|
|
234
|
-
END IF;
|
|
235
|
-
|
|
236
|
-
v_next := GREATEST(v_next, now() + make_interval(secs => v_row.floor_seconds::double precision));
|
|
237
|
-
|
|
238
|
-
UPDATE public.plg_sync_schedule
|
|
239
|
-
SET next_attempt_at = v_next,
|
|
240
|
-
last_attempt_at = now(),
|
|
241
|
-
last_run_id = v_run_id
|
|
242
|
-
WHERE connection_id = v_row.connection_id AND kind = p_kind;
|
|
243
|
-
|
|
244
|
-
v_dispatched := v_dispatched + 1;
|
|
245
|
-
END LOOP;
|
|
246
|
-
|
|
247
|
-
RETURN v_dispatched;
|
|
248
|
-
END;
|
|
249
|
-
$fn$;
|
|
250
|
-
|
|
251
|
-
COMMENT ON FUNCTION public.plg_sync_tick(text) IS
|
|
252
|
-
'One tick of the sync clock: clear orphaned pointers, reap dead dispatches, '
|
|
253
|
-
'fold verdicts into backoff, enrol new connections with a deterministic '
|
|
254
|
-
'offset, dispatch what is due AND has no dispatch in flight, up to '
|
|
255
|
-
'max_per_tick per connector. Files a plg_sync_runs row for EVERY attempt, '
|
|
256
|
-
'including one that never reached the function. service_role and cron only.';
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 038 — plg_onboarding_responses: what the owner told us while creating the
|
|
3
|
-
-- workspace, and tenant_slug_available: can this handle still be taken.
|
|
4
|
-
--
|
|
5
|
-
-- The setup wizard asks things no table has a column for — whether they already
|
|
6
|
-
-- run another system, how many chairs, how they hear about us — and the answers
|
|
7
|
-
-- are worth keeping BECAUSE they are not settings: they are how we decide what
|
|
8
|
-
-- to configure for this merchant and what kind of merchant they are.
|
|
9
|
-
--
|
|
10
|
-
-- The answers live in ONE jsonb keyed by question id, not in typed columns. That
|
|
11
|
-
-- is the point: the questionnaire gets reworded every time we learn something
|
|
12
|
-
-- about a vertical, and rewording must never be a migration. The two things we
|
|
13
|
-
-- will actually filter and group by — the vertical and where they came from —
|
|
14
|
-
-- are lifted out as columns, because those are the queries we know we want.
|
|
15
|
-
--
|
|
16
|
-
-- One row per completed run. Nothing is written before the tenant exists: the
|
|
17
|
-
-- wizard holds its draft in localStorage until the org is created, so a fully
|
|
18
|
-
-- abandoned flow leaves no orphan rows to reconcile.
|
|
19
|
-
--
|
|
20
|
-
-- Idempotent.
|
|
21
|
-
-- ============================================================================
|
|
22
|
-
|
|
23
|
-
CREATE TABLE IF NOT EXISTS public.plg_onboarding_responses (
|
|
24
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
25
|
-
|
|
26
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
27
|
-
-- Who answered. Not the same as the tenant owner forever — an owner can be
|
|
28
|
-
-- transferred, and it stays true that THIS account filled the form.
|
|
29
|
-
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
30
|
-
|
|
31
|
-
-- Which questionnaire, and which revision of it. Answers from flow_version 1
|
|
32
|
-
-- are not comparable to version 3 once questions are dropped or rescaled, and
|
|
33
|
-
-- the stamp is what lets a report say so instead of silently averaging both.
|
|
34
|
-
flow_id text NOT NULL,
|
|
35
|
-
flow_version integer NOT NULL DEFAULT 1,
|
|
36
|
-
|
|
37
|
-
-- Copied from the tenant at answer time on purpose: a merchant who later
|
|
38
|
-
-- switches vertical must not retro-relabel the answers they gave as a salon.
|
|
39
|
-
vertical_id text,
|
|
40
|
-
-- Where the flow was opened from: first workspace after signup, or an extra
|
|
41
|
-
-- one from the switcher. Same questions, very different intent.
|
|
42
|
-
source text CHECK (source IS NULL OR source IN ('signup', 'switcher')),
|
|
43
|
-
-- Lifted out of `answers` because acquisition is the one question we will
|
|
44
|
-
-- group by across every tenant, and reaching into jsonb for it every time is
|
|
45
|
-
-- how a "how did they find us" report ends up not being written.
|
|
46
|
-
heard_from text,
|
|
47
|
-
|
|
48
|
-
-- { "<question id>": <answer> }. Scalars for single choice, arrays for multi.
|
|
49
|
-
answers jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
50
|
-
|
|
51
|
-
created_at timestamptz NOT NULL DEFAULT now()
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
-- The tenant's own answers, newest first — the read the admin does.
|
|
55
|
-
CREATE INDEX IF NOT EXISTS plg_onboarding_responses_tenant
|
|
56
|
-
ON public.plg_onboarding_responses (tenant_id, created_at DESC);
|
|
57
|
-
-- The cross-tenant read: "what did restaurants say", "where did they come from".
|
|
58
|
-
CREATE INDEX IF NOT EXISTS plg_onboarding_responses_segment
|
|
59
|
-
ON public.plg_onboarding_responses (vertical_id, heard_from);
|
|
60
|
-
|
|
61
|
-
-- ---------------------------------------------------------------------------
|
|
62
|
-
-- RLS. 011 closed `anon` on new public tables but not `authenticated`, which is
|
|
63
|
-
-- still born with the full grant — here that would be every tenant reading every
|
|
64
|
-
-- other tenant's answers. Stripped first, granted back deliberately.
|
|
65
|
-
-- ---------------------------------------------------------------------------
|
|
66
|
-
REVOKE ALL ON public.plg_onboarding_responses FROM anon, authenticated;
|
|
67
|
-
|
|
68
|
-
ALTER TABLE public.plg_onboarding_responses ENABLE ROW LEVEL SECURITY;
|
|
69
|
-
|
|
70
|
-
-- No UPDATE, no DELETE: this is a record of what someone said at a moment, and
|
|
71
|
-
-- an answer that can be rewritten later is not evidence of anything.
|
|
72
|
-
GRANT SELECT, INSERT ON public.plg_onboarding_responses TO authenticated;
|
|
73
|
-
|
|
74
|
-
DROP POLICY IF EXISTS plg_onboarding_responses_member_read ON public.plg_onboarding_responses;
|
|
75
|
-
CREATE POLICY plg_onboarding_responses_member_read ON public.plg_onboarding_responses
|
|
76
|
-
FOR SELECT TO authenticated
|
|
77
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
78
|
-
|
|
79
|
-
DROP POLICY IF EXISTS plg_onboarding_responses_member_insert ON public.plg_onboarding_responses;
|
|
80
|
-
CREATE POLICY plg_onboarding_responses_member_insert ON public.plg_onboarding_responses
|
|
81
|
-
FOR INSERT TO authenticated
|
|
82
|
-
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()) AND user_id = auth.uid());
|
|
83
|
-
|
|
84
|
-
-- ---------------------------------------------------------------------------
|
|
85
|
-
-- tenant_slug_available — is this handle free?
|
|
86
|
-
--
|
|
87
|
-
-- `tenants.slug` is UNIQUE, and RLS keeps a signed-in user from reading tenants
|
|
88
|
-
-- they do not belong to. Without this the wizard cannot tell someone their
|
|
89
|
-
-- handle is taken until the final submit fails, four questions after they chose
|
|
90
|
-
-- it. SECURITY DEFINER, returning only a boolean: it answers "is it free", never
|
|
91
|
-
-- "who has it".
|
|
92
|
-
-- ---------------------------------------------------------------------------
|
|
93
|
-
CREATE OR REPLACE FUNCTION public.tenant_slug_available(p_slug text)
|
|
94
|
-
RETURNS boolean
|
|
95
|
-
LANGUAGE sql STABLE SECURITY DEFINER
|
|
96
|
-
SET search_path = public, pg_temp
|
|
97
|
-
AS $$
|
|
98
|
-
SELECT p_slug ~ '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$'
|
|
99
|
-
AND NOT EXISTS (SELECT 1 FROM public.tenants t WHERE t.slug = lower(p_slug));
|
|
100
|
-
$$;
|
|
101
|
-
|
|
102
|
-
REVOKE ALL ON FUNCTION public.tenant_slug_available(text) FROM PUBLIC, anon;
|
|
103
|
-
GRANT EXECUTE ON FUNCTION public.tenant_slug_available(text) TO authenticated, service_role;
|