@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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
-- 008_the_switchers_see_across_accounts.sql — the two switchers stop lying.
|
|
2
|
+
--
|
|
3
|
+
-- 001 gave a tenant its apps and the switcher started working. What it got
|
|
4
|
+
-- wrong is WHOSE question each switcher answers, and the symptom was visible in
|
|
5
|
+
-- both directions at once:
|
|
6
|
+
--
|
|
7
|
+
-- · ChefControl listed "Clínica Sol" and "Espaco Renova Rio" as workspaces.
|
|
8
|
+
-- Neither runs the restaurant app. Opening one put a beauty account inside
|
|
9
|
+
-- a restaurant product, with a rail full of screens its data cannot fill.
|
|
10
|
+
--
|
|
11
|
+
-- · The same session could not reach StudioControl at all. `apps` was read
|
|
12
|
+
-- from the ACTIVE tenant, and Papa Léguas Tacos runs resto + shop — so the
|
|
13
|
+
-- product the person plainly has access to, through another account of
|
|
14
|
+
-- their own, simply was not in the list.
|
|
15
|
+
--
|
|
16
|
+
-- Fixing either alone makes it worse: a Chef that hides Clínica Sol, in a
|
|
17
|
+
-- session whose app list cannot show Studio, has no route to the beauty data at
|
|
18
|
+
-- all. They are one change.
|
|
19
|
+
--
|
|
20
|
+
-- THE MODEL, stated once: the app comes first, the account second.
|
|
21
|
+
--
|
|
22
|
+
-- apps = the union across every account you belong to. It answers "which
|
|
23
|
+
-- products do I have", not "which does this account run".
|
|
24
|
+
-- accounts = filtered to those that run the app you are looking at. It
|
|
25
|
+
-- answers "which of my workspaces does THIS product serve".
|
|
26
|
+
--
|
|
27
|
+
-- So each app carries `tenant_id` — where clicking it lands. The active tenant
|
|
28
|
+
-- when it runs the app (staying put is the common case), otherwise the first
|
|
29
|
+
-- account that does. Without it, clicking Studio from a restaurant account
|
|
30
|
+
-- would arrive at StudioControl and fall back to memberships[0], which is how
|
|
31
|
+
-- somebody lands in an account they did not pick.
|
|
32
|
+
--
|
|
33
|
+
-- There is deliberately no `current` flag here. Which app you are LOOKING at is
|
|
34
|
+
-- the bundle's own fact — VITE_APP_ID, resolved client-side — and the database
|
|
35
|
+
-- cannot see it: Papa Léguas runs both resto and shop, so any server-side
|
|
36
|
+
-- "current" would mark two apps at once and the checkmark would land on both.
|
|
37
|
+
-- Whether the active account runs an app is already readable from `tenant_id`
|
|
38
|
+
-- (it equals the active tenant, or it does not), so a second field would only
|
|
39
|
+
-- be a second chance to disagree.
|
|
40
|
+
|
|
41
|
+
-- ── §1 the bridge view ────────────────────────────────────────────────────
|
|
42
|
+
--
|
|
43
|
+
-- PostgREST cannot reach the `app` schema, so the workspace list — which runs
|
|
44
|
+
-- in the client, through supabase-js — has no way to ask which accounts run an
|
|
45
|
+
-- app. Same reason v_team_members and the other v_* bridges exist (#134).
|
|
46
|
+
--
|
|
47
|
+
-- security_invoker: app.tenant_apps' own RLS (tenant_apps_read, scoped to
|
|
48
|
+
-- user_tenant_ids()) decides the rows. The view adds no reach of its own.
|
|
49
|
+
|
|
50
|
+
CREATE OR REPLACE VIEW public.v_tenant_apps WITH (security_invoker = 'true') AS
|
|
51
|
+
SELECT ta.tenant_id,
|
|
52
|
+
ta.app_id,
|
|
53
|
+
ta.status,
|
|
54
|
+
a.name AS app_name,
|
|
55
|
+
a.icon AS app_icon,
|
|
56
|
+
a.url AS app_url
|
|
57
|
+
FROM app.tenant_apps ta
|
|
58
|
+
JOIN app.apps a ON a.id = ta.app_id
|
|
59
|
+
WHERE ta.status = 'active'
|
|
60
|
+
AND a.active;
|
|
61
|
+
|
|
62
|
+
COMMENT ON VIEW public.v_tenant_apps IS
|
|
63
|
+
'Which accounts run which apps (008), for the workspace switcher to filter itself by the app it is inside. security_invoker — app.tenant_apps RLS decides the rows.';
|
|
64
|
+
|
|
65
|
+
GRANT SELECT ON public.v_tenant_apps TO authenticated;
|
|
66
|
+
GRANT ALL ON public.v_tenant_apps TO service_role;
|
|
67
|
+
|
|
68
|
+
-- ── §2 app_config() answers for the person, not just the account ──────────
|
|
69
|
+
--
|
|
70
|
+
-- Re-emitted whole, for the reason 001 gave: this is the one function the shell
|
|
71
|
+
-- asks on boot, and a reader should see the entire answer in one place. Every
|
|
72
|
+
-- other key is byte-identical to 001; only 'apps' changed.
|
|
73
|
+
|
|
74
|
+
CREATE OR REPLACE FUNCTION public.app_config() RETURNS jsonb
|
|
75
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
76
|
+
SET search_path TO ''
|
|
77
|
+
AS $$
|
|
78
|
+
DECLARE
|
|
79
|
+
v_tenant uuid := app.current_tenant_id();
|
|
80
|
+
BEGIN
|
|
81
|
+
IF v_tenant IS NULL THEN
|
|
82
|
+
-- Fail closed and legibly: a caller with no resolvable tenant gets an empty
|
|
83
|
+
-- configuration, not somebody else's.
|
|
84
|
+
RETURN jsonb_build_object('tenant_id', NULL, 'plugins', '[]'::jsonb, 'nav', '[]'::jsonb, 'apps', '[]'::jsonb);
|
|
85
|
+
END IF;
|
|
86
|
+
|
|
87
|
+
RETURN jsonb_build_object(
|
|
88
|
+
'tenant_id', v_tenant,
|
|
89
|
+
'branding', public.tenant_branding(),
|
|
90
|
+
'settings', public.tenant_settings(),
|
|
91
|
+
'plugins', coalesce((
|
|
92
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
93
|
+
'plugin', t.plugin_id,
|
|
94
|
+
'facet', nullif(t.facet, ''),
|
|
95
|
+
'status', t.status,
|
|
96
|
+
'source', t.source,
|
|
97
|
+
'config', t.config,
|
|
98
|
+
'expires_at', t.expires_at
|
|
99
|
+
) ORDER BY t.plugin_id, t.facet)
|
|
100
|
+
FROM app.tenant_plugins t
|
|
101
|
+
WHERE t.tenant_id = v_tenant
|
|
102
|
+
AND t.status = 'active'
|
|
103
|
+
AND (t.expires_at IS NULL OR t.expires_at > now())
|
|
104
|
+
), '[]'::jsonb),
|
|
105
|
+
-- The union across the caller's accounts. One row per app, however many of
|
|
106
|
+
-- the person's accounts run it.
|
|
107
|
+
'apps', coalesce((
|
|
108
|
+
SELECT jsonb_agg(x.obj ORDER BY x.sort_name)
|
|
109
|
+
FROM (
|
|
110
|
+
SELECT a.name AS sort_name,
|
|
111
|
+
jsonb_build_object(
|
|
112
|
+
'id', a.id,
|
|
113
|
+
'name', a.name,
|
|
114
|
+
'icon', a.icon,
|
|
115
|
+
'accent_color', a.accent_color,
|
|
116
|
+
'url', a.url,
|
|
117
|
+
-- Where clicking lands. Staying in the active account wins;
|
|
118
|
+
-- otherwise the first that runs it, ordered by name so the
|
|
119
|
+
-- answer does not move between two identical calls.
|
|
120
|
+
'tenant_id', (array_agg(ta.tenant_id
|
|
121
|
+
ORDER BY (ta.tenant_id = v_tenant) DESC, tn.name))[1]
|
|
122
|
+
) AS obj
|
|
123
|
+
FROM app.tenant_apps ta
|
|
124
|
+
JOIN app.apps a ON a.id = ta.app_id
|
|
125
|
+
JOIN public.tenants tn ON tn.id = ta.tenant_id
|
|
126
|
+
WHERE ta.status = 'active'
|
|
127
|
+
AND a.active
|
|
128
|
+
AND ta.tenant_id IN (
|
|
129
|
+
SELECT m.tenant_id FROM app.memberships m
|
|
130
|
+
WHERE m.user_id = auth.uid() AND m.active)
|
|
131
|
+
GROUP BY a.id, a.name, a.icon, a.accent_color, a.url
|
|
132
|
+
) x
|
|
133
|
+
), '[]'::jsonb),
|
|
134
|
+
'nav', coalesce((
|
|
135
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
136
|
+
'key', n.entry_key, 'hidden', n.hidden, 'label', n.label,
|
|
137
|
+
'section', n.section, 'position', n.position
|
|
138
|
+
) ORDER BY coalesce(n.position, 999), n.entry_key)
|
|
139
|
+
FROM app.tenant_nav n
|
|
140
|
+
WHERE n.tenant_id = v_tenant
|
|
141
|
+
), '[]'::jsonb),
|
|
142
|
+
'permissions', coalesce((
|
|
143
|
+
SELECT jsonb_agg(DISTINCT rp.permission)
|
|
144
|
+
FROM app.memberships m
|
|
145
|
+
JOIN app.role_permissions rp ON rp.role_id = m.role_id AND rp.tenant_id = m.tenant_id
|
|
146
|
+
WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.active
|
|
147
|
+
), '[]'::jsonb),
|
|
148
|
+
'generated_at', now()
|
|
149
|
+
);
|
|
150
|
+
END $$;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 009_the_compat_aliases_nobody_adopted — four of 141's shims leave (#352).
|
|
3
|
+
--
|
|
4
|
+
-- 141 renamed a set of core tables out of the `plg_` prefix and left a VIEW
|
|
5
|
+
-- answering to the old name, under one contract: "callers move to the real
|
|
6
|
+
-- name; this view is dropped when the last one has." The first half ran. The
|
|
7
|
+
-- second half never did, so the shims have been sitting in the schema ever
|
|
8
|
+
-- since — and a name in the schema is not inert, it is an instruction. The
|
|
9
|
+
-- measured cost of leaving them: `plg_connections` has 13 callers and
|
|
10
|
+
-- `connections` has none. The alias won. That is what this file starts undoing.
|
|
11
|
+
--
|
|
12
|
+
-- WHAT LEAVES HERE, AND WHY ONLY THESE FOUR. A shim is droppable when nothing
|
|
13
|
+
-- reads it — in TypeScript, in an edge function, or in SQL inside an applied
|
|
14
|
+
-- baseline, which is the one that bites, because the chain force-replays every
|
|
15
|
+
-- file and an applied migration cannot be edited to stop reading a name.
|
|
16
|
+
--
|
|
17
|
+
-- plg_analytics_read_models no reader of any kind
|
|
18
|
+
-- plg_connection_secrets no reader of any kind
|
|
19
|
+
-- plg_entity_fields packages/core/src/fields/store.ts now names
|
|
20
|
+
-- public.entity_fields
|
|
21
|
+
-- plg_event_log packages/core/src/events/log.ts now names
|
|
22
|
+
-- public.event_log
|
|
23
|
+
--
|
|
24
|
+
-- The other six 141 shims stay for now, each for a reason that is a task and
|
|
25
|
+
-- not an opinion: `plg_connector_schedules` and `plg_sync_schedule` are WRITTEN
|
|
26
|
+
-- THROUGH by packages/shop/migrations/000_baseline.sql (an `UPDATE ... FROM` at
|
|
27
|
+
-- 4875), which runs after the spine, so dropping them here breaks the shop's
|
|
28
|
+
-- replay rather than the shop's code; `fayz_search_sources` is read by a spine
|
|
29
|
+
-- function; `fayz_projects` is joined by a view in plugin-marketing's baseline,
|
|
30
|
+
-- which makes its drop CROSS-PACKAGE and therefore not a spine file at all.
|
|
31
|
+
-- Those move with their readers, in #352 wave 3.
|
|
32
|
+
--
|
|
33
|
+
-- Dropping the view does not touch the table it pointed at, and every one of
|
|
34
|
+
-- these had `security_invoker` semantics only by accident of ownership — the
|
|
35
|
+
-- canonical tables carry their own RLS and policies, which is what the callers
|
|
36
|
+
-- now reach.
|
|
37
|
+
--
|
|
38
|
+
-- ── Numbered 009, and why this file is the one that moved ─────────────────
|
|
39
|
+
-- It was 001. #355 also has a 001 in this directory, and the chain runner sorts
|
|
40
|
+
-- by FILENAME, so two files on the same number run in whatever order the words
|
|
41
|
+
-- after it happen to give — an order nobody chose (#301).
|
|
42
|
+
--
|
|
43
|
+
-- The other file could not move. `001_the_tenant_has_apps` creates app.apps and
|
|
44
|
+
-- app.tenant_apps, and `008_the_switchers_see_across_accounts` builds a VIEW on
|
|
45
|
+
-- both — a hard dependency, so it has to stay below 008, and 002-007 are taken.
|
|
46
|
+
-- This file drops four views nothing reads: no migration before it needs them
|
|
47
|
+
-- gone, none after it needs them present. Its position is the free one, so it
|
|
48
|
+
-- is the one that gives way.
|
|
49
|
+
--
|
|
50
|
+
-- Idempotent and replay-safe by construction: IF EXISTS, and a view that is
|
|
51
|
+
-- already gone is not an error.
|
|
52
|
+
-- ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
DROP VIEW IF EXISTS public.plg_analytics_read_models;
|
|
55
|
+
DROP VIEW IF EXISTS public.plg_connection_secrets;
|
|
56
|
+
DROP VIEW IF EXISTS public.plg_entity_fields;
|
|
57
|
+
DROP VIEW IF EXISTS public.plg_event_log;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
-- 010_the_new_tenant_is_born_with_its_trade.sql
|
|
2
|
+
--
|
|
3
|
+
-- Um tenant criado pela UI nascia sem plugin e sem app. Quem criava conseguia
|
|
4
|
+
-- lançar — a RLS de INSERT só olha tenant, unidade e permissão, e essas estavam
|
|
5
|
+
-- certas — e perdia tudo no reload, porque a casca monta o que `app_config()`
|
|
6
|
+
-- devolve em `plugins`, e ali não havia nada.
|
|
7
|
+
--
|
|
8
|
+
-- Três defeitos somados, no mesmo caminho:
|
|
9
|
+
--
|
|
10
|
+
-- 1. `create_tenant()` cria tenant, papéis, unidade HQ e a membership do dono,
|
|
11
|
+
-- e PARA. Nunca semeou nem `app.tenant_plugins` nem `app.tenant_apps`.
|
|
12
|
+
--
|
|
13
|
+
-- 2. `app.vertical_defaults` semeia FACETS e nenhuma linha do plugin em si. Mas
|
|
14
|
+
-- o contrato é "o plugin é a facet de nome vazio" (PluginFacet), e a casca
|
|
15
|
+
-- filtra `status === 'active' && !facet`. Ligar só facets acende zero plugin.
|
|
16
|
+
--
|
|
17
|
+
-- 3. Nada nunca escreveu `app.tenant_apps`, que é o que o switcher lê — então
|
|
18
|
+
-- mesmo com plugin aceso o tenant novo não tinha app nenhum.
|
|
19
|
+
--
|
|
20
|
+
-- A semeadura passa a ter DUAS fontes, e nenhuma substitui a outra:
|
|
21
|
+
--
|
|
22
|
+
-- `app.vertical_defaults` continua decidindo as FACETS do ramo — é onde
|
|
23
|
+
-- "um salão agenda, e não vende online" está escrito, no grão em que foi
|
|
24
|
+
-- escrito. Este arquivo não toca nela.
|
|
25
|
+
--
|
|
26
|
+
-- `app.apps` passa a decidir os APPS e os plugins que eles exigem, que é o
|
|
27
|
+
-- grão que faltava: `requires` já lista os plugins, e `tenant_apps` é o que o
|
|
28
|
+
-- switcher lê. O ramo que cada app atende vira uma coluna DELE em vez de uma
|
|
29
|
+
-- tabela de-para separada.
|
|
30
|
+
--
|
|
31
|
+
-- E toda facet acesa garante a linha sem facet do seu plugin: acender
|
|
32
|
+
-- `agenda.scheduling` sem acender `agenda` é a forma exata do defeito 2.
|
|
33
|
+
|
|
34
|
+
-- ── o app declara o ramo que atende ─────────────────────────────────────────
|
|
35
|
+
ALTER TABLE app.apps ADD COLUMN IF NOT EXISTS serves_verticals text[] NOT NULL DEFAULT '{}';
|
|
36
|
+
|
|
37
|
+
UPDATE app.apps SET serves_verticals = ARRAY['beauty','salon','clinic','health'] WHERE id = 'beauty';
|
|
38
|
+
UPDATE app.apps SET serves_verticals = ARRAY['food','restaurant'] WHERE id = 'resto';
|
|
39
|
+
UPDATE app.apps SET serves_verticals = ARRAY['retail','commerce','ecommerce'] WHERE id = 'shop';
|
|
40
|
+
UPDATE app.apps SET serves_verticals = ARRAY['education','school'] WHERE id = 'school';
|
|
41
|
+
|
|
42
|
+
COMMENT ON COLUMN app.apps.serves_verticals IS
|
|
43
|
+
'Os ramos que este app atende. O onboarding manda um vertical; é por aqui que ele vira app, e o app diz em requires quais plugins acender.';
|
|
44
|
+
|
|
45
|
+
-- ── as facets do ramo, mais os apps e os plugins que eles exigem ────────────
|
|
46
|
+
CREATE OR REPLACE FUNCTION public.tenant_apply_vertical_defaults(p_tenant uuid, p_vertical text DEFAULT NULL::text)
|
|
47
|
+
RETURNS jsonb
|
|
48
|
+
LANGUAGE plpgsql
|
|
49
|
+
SECURITY DEFINER
|
|
50
|
+
SET search_path TO ''
|
|
51
|
+
AS $function$
|
|
52
|
+
DECLARE
|
|
53
|
+
v_vertical text;
|
|
54
|
+
v_row record;
|
|
55
|
+
v_app record;
|
|
56
|
+
v_plugin text;
|
|
57
|
+
v_on integer := 0;
|
|
58
|
+
v_apps text[] := ARRAY[]::text[];
|
|
59
|
+
v_skipped text[] := ARRAY[]::text[];
|
|
60
|
+
BEGIN
|
|
61
|
+
IF p_tenant IS NULL THEN
|
|
62
|
+
RAISE EXCEPTION 'tenant_apply_vertical_defaults: no tenant' USING ERRCODE = '22023';
|
|
63
|
+
END IF;
|
|
64
|
+
|
|
65
|
+
v_vertical := coalesce(p_vertical, (SELECT t.vertical_id FROM public.tenants t WHERE t.id = p_tenant));
|
|
66
|
+
IF v_vertical IS NULL THEN
|
|
67
|
+
RETURN jsonb_build_object('tenant_id', p_tenant, 'vertical', NULL, 'enabled', 0, 'apps', '[]'::jsonb,
|
|
68
|
+
'note', 'no vertical on this tenant - nothing to seed');
|
|
69
|
+
END IF;
|
|
70
|
+
|
|
71
|
+
-- ── as facets do ramo (160), inalteradas ─────────────────────────────────
|
|
72
|
+
FOR v_row IN
|
|
73
|
+
SELECT d.plugin_id, d.facet FROM app.vertical_defaults d WHERE d.vertical_id = v_vertical
|
|
74
|
+
LOOP
|
|
75
|
+
-- O teto vence o default, sempre: um plano que não permite significa que o
|
|
76
|
+
-- tenant não recebe aquilo aceso — não que a semeadura falhe.
|
|
77
|
+
IF NOT public.plan_permits(v_row.plugin_id, v_row.facet, p_tenant) THEN
|
|
78
|
+
v_skipped := v_skipped || (v_row.plugin_id || coalesce(nullif('.' || v_row.facet, '.'), ''));
|
|
79
|
+
CONTINUE;
|
|
80
|
+
END IF;
|
|
81
|
+
|
|
82
|
+
INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
|
|
83
|
+
VALUES (p_tenant, v_row.plugin_id, v_row.facet, 'active', 'default')
|
|
84
|
+
-- Nunca sobrescreve decisão que alguém já tomou.
|
|
85
|
+
ON CONFLICT (tenant_id, plugin_id, facet) DO NOTHING;
|
|
86
|
+
IF FOUND THEN v_on := v_on + 1; END IF;
|
|
87
|
+
|
|
88
|
+
-- A facet acesa sem a linha do plugin é o defeito 2: a casca conta
|
|
89
|
+
-- `status = 'active' AND facet = ''`, então `agenda.scheduling` sozinha
|
|
90
|
+
-- acende zero agenda. Só quando o plano também permite o plugin inteiro.
|
|
91
|
+
IF v_row.facet <> '' AND public.plan_permits(v_row.plugin_id, '', p_tenant) THEN
|
|
92
|
+
INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
|
|
93
|
+
VALUES (p_tenant, v_row.plugin_id, '', 'active', 'default')
|
|
94
|
+
ON CONFLICT (tenant_id, plugin_id, facet) DO NOTHING;
|
|
95
|
+
END IF;
|
|
96
|
+
END LOOP;
|
|
97
|
+
|
|
98
|
+
-- ── os apps do ramo, e os plugins que cada um exige ──────────────────────
|
|
99
|
+
FOR v_app IN
|
|
100
|
+
SELECT a.id, a.requires FROM app.apps a
|
|
101
|
+
WHERE a.active AND v_vertical = ANY (a.serves_verticals)
|
|
102
|
+
LOOP
|
|
103
|
+
-- O app entra no diretório do tenant: é o que o switcher lê.
|
|
104
|
+
INSERT INTO app.tenant_apps (tenant_id, app_id, status, source)
|
|
105
|
+
VALUES (p_tenant, v_app.id, 'active', 'default')
|
|
106
|
+
ON CONFLICT (tenant_id, app_id) DO NOTHING;
|
|
107
|
+
v_apps := v_apps || v_app.id;
|
|
108
|
+
|
|
109
|
+
FOREACH v_plugin IN ARRAY coalesce(v_app.requires, ARRAY[]::text[]) LOOP
|
|
110
|
+
IF NOT public.plan_permits(v_plugin, '', p_tenant) THEN
|
|
111
|
+
v_skipped := v_skipped || v_plugin;
|
|
112
|
+
CONTINUE;
|
|
113
|
+
END IF;
|
|
114
|
+
INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
|
|
115
|
+
VALUES (p_tenant, v_plugin, '', 'active', 'default')
|
|
116
|
+
ON CONFLICT (tenant_id, plugin_id, facet) DO NOTHING;
|
|
117
|
+
IF FOUND THEN v_on := v_on + 1; END IF;
|
|
118
|
+
END LOOP;
|
|
119
|
+
END LOOP;
|
|
120
|
+
|
|
121
|
+
RETURN jsonb_build_object('tenant_id', p_tenant, 'vertical', v_vertical,
|
|
122
|
+
'apps', to_jsonb(v_apps), 'enabled', v_on,
|
|
123
|
+
'not_permitted_by_plan', to_jsonb(v_skipped));
|
|
124
|
+
END $function$;
|
|
125
|
+
|
|
126
|
+
-- ── 1. o fluxo de criação passa a semear ───────────────────────────────────
|
|
127
|
+
CREATE OR REPLACE FUNCTION public.create_tenant(p_name text, p_slug text, p_kind text DEFAULT 'business'::text, p_vertical_id text DEFAULT NULL::text, p_template text DEFAULT 'generic'::text, p_settings jsonb DEFAULT '{}'::jsonb, p_hq_name text DEFAULT NULL::text)
|
|
128
|
+
RETURNS jsonb
|
|
129
|
+
LANGUAGE plpgsql
|
|
130
|
+
SECURITY DEFINER
|
|
131
|
+
SET search_path TO ''
|
|
132
|
+
AS $function$
|
|
133
|
+
DECLARE
|
|
134
|
+
v_uid uuid := auth.uid();
|
|
135
|
+
v_tenant uuid;
|
|
136
|
+
v_unit uuid;
|
|
137
|
+
v_membership uuid;
|
|
138
|
+
v_owner_role uuid;
|
|
139
|
+
v_template text;
|
|
140
|
+
v_slug text;
|
|
141
|
+
v_name text;
|
|
142
|
+
v_seeded jsonb;
|
|
143
|
+
BEGIN
|
|
144
|
+
IF v_uid IS NULL THEN
|
|
145
|
+
RAISE EXCEPTION 'create_tenant: the actor is derived from the session and there is no session'
|
|
146
|
+
USING ERRCODE = '42501';
|
|
147
|
+
END IF;
|
|
148
|
+
IF p_kind IS NULL OR p_kind NOT IN ('network', 'business') THEN
|
|
149
|
+
RAISE EXCEPTION 'create_tenant: kind must be network or business (got %)', coalesce(p_kind, '<null>')
|
|
150
|
+
USING ERRCODE = '22023';
|
|
151
|
+
END IF;
|
|
152
|
+
v_name := nullif(btrim(coalesce(p_name, '')), '');
|
|
153
|
+
IF v_name IS NULL THEN
|
|
154
|
+
RAISE EXCEPTION 'create_tenant: name is required' USING ERRCODE = '22023';
|
|
155
|
+
END IF;
|
|
156
|
+
v_slug := lower(btrim(coalesce(p_slug, '')));
|
|
157
|
+
IF v_slug !~ '^[a-z0-9]+([._-][a-z0-9]+)*$' OR length(v_slug) > 100 THEN
|
|
158
|
+
RAISE EXCEPTION 'create_tenant: slug must be lowercase letters and digits separated by single . _ - (got %)', coalesce(nullif(v_slug, ''), '<empty>')
|
|
159
|
+
USING ERRCODE = '22023';
|
|
160
|
+
END IF;
|
|
161
|
+
IF EXISTS (SELECT 1 FROM public.tenants t WHERE t.slug = v_slug) THEN
|
|
162
|
+
RAISE EXCEPTION 'create_tenant: slug % is already taken', v_slug USING ERRCODE = '23505';
|
|
163
|
+
END IF;
|
|
164
|
+
|
|
165
|
+
-- Template: explicit, else the vertical's own template when one exists, else generic.
|
|
166
|
+
v_template := coalesce(
|
|
167
|
+
p_template,
|
|
168
|
+
CASE WHEN p_vertical_id IS NOT NULL AND EXISTS (SELECT 1 FROM app.role_templates rt WHERE rt.template = p_vertical_id)
|
|
169
|
+
THEN p_vertical_id ELSE 'generic' END);
|
|
170
|
+
IF NOT EXISTS (SELECT 1 FROM app.role_templates rt WHERE rt.template = v_template) THEN
|
|
171
|
+
RAISE EXCEPTION 'create_tenant: unknown role template %', v_template USING ERRCODE = '22023';
|
|
172
|
+
END IF;
|
|
173
|
+
|
|
174
|
+
INSERT INTO public.tenants (name, slug, plan, vertical_id, kind, settings, role_template)
|
|
175
|
+
VALUES (v_name, v_slug, 'free', p_vertical_id, p_kind, coalesce(p_settings, '{}'::jsonb), v_template)
|
|
176
|
+
RETURNING id INTO v_tenant;
|
|
177
|
+
|
|
178
|
+
v_owner_role := app.seed_role_template(v_tenant, v_template);
|
|
179
|
+
|
|
180
|
+
INSERT INTO app.units (tenant_id, kind, name, slug, active)
|
|
181
|
+
VALUES (v_tenant, 'hq', coalesce(nullif(btrim(coalesce(p_hq_name, '')), ''), v_name), 'hq', true)
|
|
182
|
+
RETURNING id INTO v_unit;
|
|
183
|
+
|
|
184
|
+
-- The owner: role from the template (holds the whole catalog), every unit.
|
|
185
|
+
INSERT INTO app.memberships (tenant_id, user_id, role_id, all_units, active)
|
|
186
|
+
VALUES (v_tenant, v_uid, v_owner_role, true, true)
|
|
187
|
+
RETURNING id INTO v_membership;
|
|
188
|
+
|
|
189
|
+
-- Os plugins do ramo. Mesma transação de propósito: um tenant que existe sem
|
|
190
|
+
-- plugin nenhum abre uma casca vazia, e quem cria acha que perdeu o que
|
|
191
|
+
-- digitou. Sem vertical (ou vertical desconhecido) devolve enabled 0 e segue
|
|
192
|
+
-- — nascer sem ramo é legítimo, nascer pela metade não.
|
|
193
|
+
v_seeded := public.tenant_apply_vertical_defaults(v_tenant, p_vertical_id);
|
|
194
|
+
|
|
195
|
+
RETURN jsonb_build_object(
|
|
196
|
+
'tenant', (SELECT to_jsonb(t) FROM public.tenants t WHERE t.id = v_tenant),
|
|
197
|
+
'hq_unit', (SELECT to_jsonb(u) FROM app.units u WHERE u.id = v_unit),
|
|
198
|
+
'membership', (SELECT to_jsonb(m) FROM app.memberships m WHERE m.id = v_membership),
|
|
199
|
+
'plugins', v_seeded
|
|
200
|
+
);
|
|
201
|
+
END $function$;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 011_leadcontrol_joins_the_family.sql — the sales app gets a row.
|
|
3
|
+
--
|
|
4
|
+
-- The registry has held four products since 001, and every one of them is a
|
|
5
|
+
-- TRADE: a kitchen, a shop, a studio, a school. LeadControl is the first that
|
|
6
|
+
-- is not. Nobody's business is "sales" — every one of those four sells, and
|
|
7
|
+
-- until now the place they did it was a tab inside the app they run their trade
|
|
8
|
+
-- from (the CRM row in StudioControl, the leads the school never had a screen
|
|
9
|
+
-- for). That is why this row exists: the pipeline outgrew being a tab.
|
|
10
|
+
--
|
|
11
|
+
-- Two consequences follow from that, and they are the whole content of this
|
|
12
|
+
-- file beyond the INSERT.
|
|
13
|
+
--
|
|
14
|
+
-- ── 1. `serves_verticals` stays EMPTY, deliberately ─────────────────────────
|
|
15
|
+
--
|
|
16
|
+
-- 010 made that column the trigger for automatic seeding: a tenant declaring a
|
|
17
|
+
-- vertical gets every app that serves it. Listing all seven verticals here
|
|
18
|
+
-- would be true — everybody sells — and would also mean every restaurant
|
|
19
|
+
-- created from tomorrow silently grows a second product in its switcher that
|
|
20
|
+
-- nobody asked for and nobody set up. An app that arrives uninvited is not a
|
|
21
|
+
-- feature, it is a support ticket about an empty inbox.
|
|
22
|
+
--
|
|
23
|
+
-- So this one is opt-in: `tenant_app_set('crm', ...)`, called by a person or by
|
|
24
|
+
-- onboarding when the account says it wants it. §3 below does exactly that for
|
|
25
|
+
-- the accounts where the evidence is already in the database.
|
|
26
|
+
--
|
|
27
|
+
-- ── 2. `requires` names four plugins, not one ───────────────────────────────
|
|
28
|
+
--
|
|
29
|
+
-- The other rows require the plugins that draw their trade. This one requires
|
|
30
|
+
-- the four that make a lead worth capturing: `crm` holds the lead and the deal,
|
|
31
|
+
-- `conversations` is where the lead first speaks, `marketing` says which
|
|
32
|
+
-- channel it came from, `automations` is what answers while nobody is looking.
|
|
33
|
+
-- A LeadControl with only `crm` on is a list of names.
|
|
34
|
+
--
|
|
35
|
+
-- Note what is NOT there: `financial`. A won deal in this product hands off to
|
|
36
|
+
-- whichever app owns the money — that is the point of one tenant running
|
|
37
|
+
-- several apps, and duplicating the ledger here would give the same invoice two
|
|
38
|
+
-- front doors.
|
|
39
|
+
-- ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
-- ── §1 the registry row ───────────────────────────────────────────────────
|
|
42
|
+
--
|
|
43
|
+
-- The name is the OUTWARD name (ADR: "Control" is the family, not the app).
|
|
44
|
+
-- The rail inside says only "Lead"; the switcher's list is outward, so it
|
|
45
|
+
-- carries the whole thing.
|
|
46
|
+
--
|
|
47
|
+
-- `#2563EB` and not another blue: the tile IS the product in a launcher, and
|
|
48
|
+
-- the four colours already spoken for are red (Chef), green (Store), violet
|
|
49
|
+
-- (Studio) and amber (Escola).
|
|
50
|
+
--
|
|
51
|
+
-- `url` is the dev origin, as it is for every other row. A cluster that
|
|
52
|
+
-- publishes these apps elsewhere updates the row; nothing in code hardcodes a
|
|
53
|
+
-- port.
|
|
54
|
+
INSERT INTO app.apps (id, name, icon, accent_color, url, requires, serves_verticals) VALUES
|
|
55
|
+
('crm', 'LeadControl', 'Target', '#2563EB', 'http://localhost:5307',
|
|
56
|
+
ARRAY['crm', 'conversations', 'marketing', 'automations'],
|
|
57
|
+
ARRAY[]::text[])
|
|
58
|
+
ON CONFLICT (id) DO UPDATE
|
|
59
|
+
SET name = EXCLUDED.name, icon = EXCLUDED.icon, accent_color = EXCLUDED.accent_color,
|
|
60
|
+
url = EXCLUDED.url, requires = EXCLUDED.requires;
|
|
61
|
+
|
|
62
|
+
COMMENT ON TABLE app.apps IS
|
|
63
|
+
'Which applications exist on this cluster (001, 011). The registry the app switcher reads. An app is a LENS over a tenant''s rows — it never scopes them; two apps on one tenant share public.people by design. `requires` names the plugins the app cannot work without; `url` doubles as the allow-list for the session handoff. `serves_verticals` empty means opt-in: the app is never seeded by a tenant''s trade, only by someone asking for it.';
|
|
64
|
+
|
|
65
|
+
-- ── §2 the plugins it stands on must be activatable ───────────────────────
|
|
66
|
+
--
|
|
67
|
+
-- `requires` is honoured by tenant_app_set() and by tenant_apply_vertical_defaults(),
|
|
68
|
+
-- both of which walk the array and INSERT into app.tenant_plugins. Nothing here
|
|
69
|
+
-- needs to change for that to work — this comment exists because the first
|
|
70
|
+
-- reading of "requires" is "a dependency check", and it is not one: it is a
|
|
71
|
+
-- SEEDING list. Removing the app never removes them, because another app on the
|
|
72
|
+
-- same tenant may be standing on the same plugin (StudioControl requires `crm`
|
|
73
|
+
-- too, and always has).
|
|
74
|
+
|
|
75
|
+
-- ── §3 who gets it now ────────────────────────────────────────────────────
|
|
76
|
+
--
|
|
77
|
+
-- Every tenant that already has the CRM plugin active. That is the honest
|
|
78
|
+
-- evidence in the database that this account is already doing the work this app
|
|
79
|
+
-- is for — they have been doing it inside another product's tab, which is the
|
|
80
|
+
-- situation the row in §1 exists to end.
|
|
81
|
+
--
|
|
82
|
+
-- `source = 'default'` and not 'manual': nobody clicked. It marks these rows as
|
|
83
|
+
-- something the platform decided, so a later screen can tell them apart from
|
|
84
|
+
-- the ones an administrator chose.
|
|
85
|
+
--
|
|
86
|
+
-- Deliberately NOT a trigger, for the reason 001 §7 gives: vertical_id applied
|
|
87
|
+
-- once at INSERT and never revisited is the defect that file exists to end, and
|
|
88
|
+
-- a trigger here would reproduce it one table over.
|
|
89
|
+
INSERT INTO app.tenant_apps (tenant_id, app_id, status, source)
|
|
90
|
+
SELECT DISTINCT tp.tenant_id, 'crm', 'active', 'default'
|
|
91
|
+
FROM app.tenant_plugins tp
|
|
92
|
+
WHERE tp.plugin_id = 'crm'
|
|
93
|
+
AND tp.facet = ''
|
|
94
|
+
AND tp.status = 'active'
|
|
95
|
+
ON CONFLICT (tenant_id, app_id) DO NOTHING;
|
|
96
|
+
|
|
97
|
+
-- And the plugins LeadControl needs, for exactly those tenants — otherwise the
|
|
98
|
+
-- app opens on a rail with a Dashboard and nothing else, because the shell
|
|
99
|
+
-- builds the rail from what app_config() returns in `plugins`.
|
|
100
|
+
--
|
|
101
|
+
-- DISTINCT, because a tenant reaching this from two directions (the CRM row
|
|
102
|
+
-- above and an app that already required the same plugin) would make
|
|
103
|
+
-- ON CONFLICT DO UPDATE refuse to touch the row twice. Here it is DO NOTHING,
|
|
104
|
+
-- so the hazard is only latent — but the next person to change the conflict
|
|
105
|
+
-- action should not have to rediscover it.
|
|
106
|
+
INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
|
|
107
|
+
SELECT DISTINCT ta.tenant_id, r.plugin_id, '', 'active', 'default'
|
|
108
|
+
FROM app.tenant_apps ta
|
|
109
|
+
JOIN app.apps a ON a.id = ta.app_id
|
|
110
|
+
CROSS JOIN LATERAL unnest(a.requires) AS r(plugin_id)
|
|
111
|
+
WHERE ta.app_id = 'crm'
|
|
112
|
+
AND ta.status = 'active'
|
|
113
|
+
-- The plan ceiling wins over a default, always: an account whose plan does
|
|
114
|
+
-- not include the plugin does not get it lit — the seeding does not fail.
|
|
115
|
+
AND public.plan_permits(r.plugin_id, '', ta.tenant_id)
|
|
116
|
+
ON CONFLICT (tenant_id, plugin_id, facet) DO NOTHING;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 012_chefcontrol_wears_its_own_mark.sql — the launcher stops drawing a
|
|
3
|
+
-- stand-in for a product that has a brand.
|
|
4
|
+
--
|
|
5
|
+
-- 001 seeded ChefControl with lucide's `ChefHat` on red, because in 001 the
|
|
6
|
+
-- product had no mark of its own. It has one now (brand book v4.0, §3.0), and
|
|
7
|
+
-- the switcher is the ONE screen where the four products stand side by side —
|
|
8
|
+
-- so it is also the one screen where a stand-in glyph is legible as "this app
|
|
9
|
+
-- was never branded". The mark ships in @fayz-ai/ui's icon registry
|
|
10
|
+
-- (packages/ui/src/brand-marks.tsx) under the name this row now carries, so
|
|
11
|
+
-- every sibling app draws the Chef row the same way, from StoreControl's window
|
|
12
|
+
-- as from Chef's own.
|
|
13
|
+
--
|
|
14
|
+
-- The colour moves with it: #FBAC00 is the family's gold, the only colour the
|
|
15
|
+
-- brand book gives ChefControl, and the red it replaces was a placeholder
|
|
16
|
+
-- picked to be distinguishable from green and violet rather than to be the
|
|
17
|
+
-- product. The tile's ink is no longer hardcoded white on the client — a gold
|
|
18
|
+
-- tile is a LIGHT ground and takes charcoal — so the row keeps its contrast
|
|
19
|
+
-- without a second row of colour data to maintain here.
|
|
20
|
+
-- ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
UPDATE app.apps
|
|
23
|
+
SET icon = 'ChefControlMark',
|
|
24
|
+
accent_color = '#FBAC00'
|
|
25
|
+
WHERE id = 'resto';
|
|
26
|
+
|
|
27
|
+
COMMENT ON COLUMN app.apps.icon IS
|
|
28
|
+
'The name the client resolves this app''s glyph by (001, 012). Usually a lucide name; for a product with a mark of its own it is that mark''s name in @fayz-ai/ui''s registry (e.g. ChefControlMark). A name the registry does not know falls back to the app''s initials, never to a broken tile.';
|