@fayz-ai/db 0.16.0 → 0.18.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.
@@ -0,0 +1,250 @@
1
+ -- A configuração também se pergunta por unidade.
2
+ --
3
+ -- O registro de configuração do spine já é o mecanismo certo: uma chave nova é
4
+ -- um `app.seed_setting_keys`, que é DADO — não migration, não coluna, não
5
+ -- tabela de duas linhas. 17 chaves vivem assim hoje, de core e de plugin.
6
+ --
7
+ -- Só que ele para no tenant. E o hábito de abrir tabela para configuração
8
+ -- nasceu exatamente aí: quem precisou de valor POR FILIAL não teve como pedir.
9
+ -- É a origem de `plg_tables_settings`, `plg_reservations_settings` e
10
+ -- `plg_shop_shipping_settings` — as três têm `unit_id` e, tirando isso, não
11
+ -- fazem nada que o registro não faria.
12
+ --
13
+ -- ── A porta já estava aberta ────────────────────────────────────────────────
14
+ --
15
+ -- Isto não é ideia nova: é a metade que faltava. `tenant_setting_keys` já
16
+ -- declara `scope` com CHECK `('tenant','unit')`, e `tenant_setting_set` já
17
+ -- recusa o que não é do tenant:
18
+ --
19
+ -- IF k.scope <> 'tenant' THEN
20
+ -- RAISE EXCEPTION 'tenant_setting_set: key % is unit-scoped', p_key
21
+ --
22
+ -- Quem escreveu aquilo previu este arquivo. As 17 chaves de hoje dizem
23
+ -- `scope = 'tenant'` porque o outro lado nunca existiu.
24
+ --
25
+ -- ── Onde o valor da unidade mora ────────────────────────────────────────────
26
+ --
27
+ -- Em `app.units.settings`, irmã de `tenants.settings`. A linha da unidade já
28
+ -- existe, já tem RLS por unidade, e o resolvedor vira um coalesce de três.
29
+ -- Nenhuma tabela nova — que é o ponto: se a resposta para "configuração por
30
+ -- filial" fosse mais uma tabela, este arquivo não teria razão de existir.
31
+ --
32
+ -- ── A cascata, e por que o tenant continua podendo escrever ────────────────
33
+ --
34
+ -- unidade → tenant → default_value do registro
35
+ --
36
+ -- Uma chave `scope='unit'` gravada no TENANT é o padrão da casa; gravada na
37
+ -- UNIDADE é a exceção daquela filial. Por isso a trava do `tenant_setting_set`
38
+ -- afrouxa aqui em vez de endurecer: sem o nível do tenant, ligar uma opção para
39
+ -- uma rede de 127 unidades seria 127 escritas, e a 128ª filial nasceria com o
40
+ -- padrão errado.
41
+ --
42
+ -- Escrever `null` na unidade APAGA a exceção e devolve a filial ao padrão da
43
+ -- casa — `app.jsonb_set_path` já trata null como remoção. É o único jeito de
44
+ -- desfazer uma exceção sem inventar um terceiro estado.
45
+ --
46
+ -- Idempotente.
47
+
48
+ -- ═══════════════════════════════════════════════════════════════════════════
49
+ -- 1. A unidade ganha onde guardar
50
+ -- ═══════════════════════════════════════════════════════════════════════════
51
+
52
+ ALTER TABLE app.units
53
+ ADD COLUMN IF NOT EXISTS settings jsonb DEFAULT '{}'::jsonb NOT NULL;
54
+
55
+ COMMENT ON COLUMN app.units.settings IS
56
+ 'A exceção desta filial sobre o padrão da casa. Irmã de tenants.settings; lida pela cascata em app.unit_setting_value. Só chave com scope=''unit'' desce até aqui.';
57
+
58
+ -- ═══════════════════════════════════════════════════════════════════════════
59
+ -- 2. O resolvedor com cascata
60
+ -- ═══════════════════════════════════════════════════════════════════════════
61
+ --
62
+ -- `app.tenant_setting_value(tenant, key)` fica como está: 17 chaves e várias
63
+ -- telas dependem dela, e o nível do tenant continua sendo o que ela responde.
64
+ -- A cascata é uma função NOVA, e quem quer filial pede filial.
65
+
66
+ CREATE OR REPLACE FUNCTION app.unit_setting_value(p_tenant uuid, p_unit uuid, p_key text)
67
+ RETURNS jsonb
68
+ LANGUAGE sql
69
+ STABLE SECURITY DEFINER
70
+ SET search_path TO ''
71
+ AS $function$
72
+ SELECT coalesce(
73
+ -- a exceção da filial
74
+ (SELECT u.settings #> string_to_array(p_key, '.')
75
+ FROM app.units u
76
+ WHERE u.id = p_unit AND u.tenant_id = p_tenant),
77
+ -- o padrão da casa
78
+ (SELECT t.settings #> string_to_array(p_key, '.')
79
+ FROM public.tenants t
80
+ WHERE t.id = p_tenant),
81
+ -- o padrão declarado no registro
82
+ (SELECT k.default_value FROM app.tenant_setting_keys k WHERE k.key = p_key)
83
+ );
84
+ $function$;
85
+
86
+ COMMENT ON FUNCTION app.unit_setting_value(uuid, uuid, text) IS
87
+ 'unidade → tenant → default_value. p_unit nulo devolve o nível do tenant.';
88
+
89
+ -- ═══════════════════════════════════════════════════════════════════════════
90
+ -- 3. Ler
91
+ -- ═══════════════════════════════════════════════════════════════════════════
92
+ --
93
+ -- `p_unit` nulo = a unidade da sessão. Quem não está em unidade nenhuma lê o
94
+ -- nível do tenant, que é o padrão da casa — e não um erro.
95
+
96
+ CREATE OR REPLACE FUNCTION public.unit_setting(p_key text, p_unit uuid DEFAULT NULL)
97
+ RETURNS jsonb
98
+ LANGUAGE plpgsql
99
+ STABLE SECURITY DEFINER
100
+ SET search_path TO ''
101
+ AS $function$
102
+ DECLARE
103
+ v_tenant uuid := app.current_tenant_id();
104
+ v_unit uuid := coalesce(p_unit, app.current_unit_id());
105
+ BEGIN
106
+ IF v_tenant IS NULL THEN
107
+ RAISE EXCEPTION 'unit_setting: no active tenant' USING ERRCODE = '42501';
108
+ END IF;
109
+ IF NOT EXISTS (SELECT 1 FROM app.tenant_setting_keys k WHERE k.key = p_key) THEN
110
+ RAISE EXCEPTION 'unit_setting: unknown key %', coalesce(p_key, '<null>') USING ERRCODE = '22023';
111
+ END IF;
112
+ -- Ler a configuração de uma filial que a pessoa não alcança seria vazar o
113
+ -- recorte da franquia por um caminho lateral.
114
+ IF v_unit IS NOT NULL AND NOT app.has_unit(v_unit) THEN
115
+ RAISE EXCEPTION 'unit_setting: unit is out of reach' USING ERRCODE = '42501';
116
+ END IF;
117
+ RETURN app.unit_setting_value(v_tenant, v_unit, p_key);
118
+ END $function$;
119
+
120
+ -- Todas as chaves resolvidas para uma filial, de uma vez — o que a tela de
121
+ -- /settings lê quando abre. Mesma forma do `tenant_settings()`.
122
+ CREATE OR REPLACE FUNCTION public.unit_settings(p_unit uuid DEFAULT NULL)
123
+ RETURNS jsonb
124
+ LANGUAGE plpgsql
125
+ STABLE SECURITY DEFINER
126
+ SET search_path TO ''
127
+ AS $function$
128
+ DECLARE
129
+ v_tenant uuid := app.current_tenant_id();
130
+ v_unit uuid := coalesce(p_unit, app.current_unit_id());
131
+ BEGIN
132
+ IF v_tenant IS NULL THEN
133
+ RAISE EXCEPTION 'unit_settings: no active tenant' USING ERRCODE = '42501';
134
+ END IF;
135
+ IF v_unit IS NOT NULL AND NOT app.has_unit(v_unit) THEN
136
+ RAISE EXCEPTION 'unit_settings: unit is out of reach' USING ERRCODE = '42501';
137
+ END IF;
138
+ RETURN coalesce(
139
+ (SELECT jsonb_object_agg(k.key, app.unit_setting_value(v_tenant, v_unit, k.key))
140
+ FROM app.tenant_setting_keys k),
141
+ '{}'::jsonb);
142
+ END $function$;
143
+
144
+ -- ═══════════════════════════════════════════════════════════════════════════
145
+ -- 4. Escrever a exceção da filial
146
+ -- ═══════════════════════════════════════════════════════════════════════════
147
+
148
+ CREATE OR REPLACE FUNCTION public.unit_setting_set(p_key text, p_value jsonb, p_unit uuid DEFAULT NULL)
149
+ RETURNS jsonb
150
+ LANGUAGE plpgsql
151
+ SECURITY DEFINER
152
+ SET search_path TO ''
153
+ AS $function$
154
+ DECLARE
155
+ v_tenant uuid := app.current_tenant_id();
156
+ v_unit uuid := coalesce(p_unit, app.current_unit_id());
157
+ k record;
158
+ BEGIN
159
+ IF v_tenant IS NULL OR auth.uid() IS NULL THEN
160
+ RAISE EXCEPTION 'unit_setting_set: no session or no active tenant' USING ERRCODE = '42501';
161
+ END IF;
162
+ IF v_unit IS NULL THEN
163
+ RAISE EXCEPTION 'unit_setting_set: no unit — use tenant_setting_set for the house default' USING ERRCODE = '22023';
164
+ END IF;
165
+ -- A permissão se pergunta NA UNIDADE: quem administra uma filial não
166
+ -- administra as outras.
167
+ IF NOT app.has_permission('settings.manage', v_unit) THEN
168
+ RAISE EXCEPTION 'unit_setting_set: settings.manage is required on this unit' USING ERRCODE = '42501';
169
+ END IF;
170
+ SELECT * INTO k FROM app.tenant_setting_keys x WHERE x.key = p_key;
171
+ IF NOT FOUND THEN
172
+ RAISE EXCEPTION 'unit_setting_set: unknown key %', coalesce(p_key, '<null>') USING ERRCODE = '22023';
173
+ END IF;
174
+ -- O espelho da trava que o tenant_setting_set já tinha: chave de tenant não
175
+ -- se excepciona por filial, senão o mesmo valor passa a ter dois donos.
176
+ IF k.scope <> 'unit' THEN
177
+ RAISE EXCEPTION 'unit_setting_set: key % is tenant-scoped', p_key USING ERRCODE = '22023';
178
+ END IF;
179
+ IF NOT app.setting_type_ok(k.type, p_value) THEN
180
+ RAISE EXCEPTION 'unit_setting_set: key % expects %, got %', p_key, k.type, coalesce(jsonb_typeof(p_value), 'null') USING ERRCODE = '22023';
181
+ END IF;
182
+ -- Valor nulo REMOVE a exceção e devolve a filial ao padrão da casa —
183
+ -- app.jsonb_set_path já trata isso como remoção do caminho.
184
+ UPDATE app.units u
185
+ SET settings = app.jsonb_set_path(coalesce(u.settings, '{}'::jsonb), string_to_array(p_key, '.'), p_value)
186
+ WHERE u.id = v_unit AND u.tenant_id = v_tenant;
187
+ IF NOT FOUND THEN
188
+ RAISE EXCEPTION 'unit_setting_set: unit does not belong to this tenant' USING ERRCODE = '42501';
189
+ END IF;
190
+ RETURN app.unit_setting_value(v_tenant, v_unit, p_key);
191
+ END $function$;
192
+
193
+ -- ═══════════════════════════════════════════════════════════════════════════
194
+ -- 5. O tenant volta a poder escrever chave de unidade — como PADRÃO
195
+ -- ═══════════════════════════════════════════════════════════════════════════
196
+ --
197
+ -- A trava original estava certa enquanto não havia cascata: sem ela, gravar uma
198
+ -- chave de unidade no tenant era gravar num lugar que ninguém lia. Com a
199
+ -- cascata o nível do tenant passa a significar "o padrão da casa", e recusá-lo
200
+ -- obrigaria a escrever unidade por unidade — e deixaria a filial nova nascendo
201
+ -- sem valor nenhum.
202
+ --
203
+ -- O resto da função é idêntico ao que já estava aplicado.
204
+
205
+ CREATE OR REPLACE FUNCTION public.tenant_setting_set(p_key text, p_value jsonb)
206
+ RETURNS jsonb
207
+ LANGUAGE plpgsql
208
+ SECURITY DEFINER
209
+ SET search_path TO ''
210
+ AS $function$
211
+ DECLARE
212
+ v_tenant uuid := app.current_tenant_id();
213
+ k record;
214
+ BEGIN
215
+ IF v_tenant IS NULL OR auth.uid() IS NULL THEN
216
+ RAISE EXCEPTION 'tenant_setting_set: no session or no active tenant' USING ERRCODE = '42501';
217
+ END IF;
218
+ IF NOT app.has_permission('settings.manage') THEN
219
+ RAISE EXCEPTION 'tenant_setting_set: settings.manage is required' USING ERRCODE = '42501';
220
+ END IF;
221
+ SELECT * INTO k FROM app.tenant_setting_keys x WHERE x.key = p_key;
222
+ IF NOT FOUND THEN
223
+ RAISE EXCEPTION 'tenant_setting_set: unknown key %', coalesce(p_key, '<null>') USING ERRCODE = '22023';
224
+ END IF;
225
+ IF NOT app.setting_type_ok(k.type, p_value) THEN
226
+ RAISE EXCEPTION 'tenant_setting_set: key % expects %, got %', p_key, k.type, coalesce(jsonb_typeof(p_value), 'null') USING ERRCODE = '22023';
227
+ END IF;
228
+ UPDATE public.tenants t
229
+ SET settings = app.jsonb_set_path(coalesce(t.settings, '{}'::jsonb), string_to_array(p_key, '.'), p_value)
230
+ WHERE t.id = v_tenant;
231
+ RETURN app.tenant_setting_value(v_tenant, p_key);
232
+ END $function$;
233
+
234
+ -- ═══════════════════════════════════════════════════════════════════════════
235
+ -- 6. Quem pode chamar
236
+ -- ═══════════════════════════════════════════════════════════════════════════
237
+
238
+ -- A cascata interna não é superfície de ninguém: sem este REVOKE ela fica com o
239
+ -- EXECUTE que o PostgreSQL dá a PUBLIC por padrão — e PUBLIC inclui `anon`. Os
240
+ -- três invólucros públicos abaixo são SECURITY DEFINER e a chamam como dono, de
241
+ -- modo que ninguém perde acesso ao que deveria ter. O portão N15c do pgTAP
242
+ -- ("anon não executa nada em `app`") foi quem pegou.
243
+ REVOKE ALL ON FUNCTION app.unit_setting_value(uuid, uuid, text) FROM public, anon;
244
+
245
+ REVOKE ALL ON FUNCTION public.unit_setting(text, uuid) FROM public;
246
+ REVOKE ALL ON FUNCTION public.unit_settings(uuid) FROM public;
247
+ REVOKE ALL ON FUNCTION public.unit_setting_set(text, jsonb, uuid) FROM public;
248
+ GRANT EXECUTE ON FUNCTION public.unit_setting(text, uuid) TO authenticated, service_role;
249
+ GRANT EXECUTE ON FUNCTION public.unit_settings(uuid) TO authenticated, service_role;
250
+ GRANT EXECUTE ON FUNCTION public.unit_setting_set(text, jsonb, uuid) TO authenticated, service_role;
@@ -0,0 +1,97 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 128_a_biblioteca_de_midia_tambem_precisa_de_porta.sql
3
+ -- ---------------------------------------------------------------------------
4
+ -- Quatro buckets de mídia existem desde sempre e NENHUM tem política.
5
+ --
6
+ -- `shop-images`, `inventory-images`, `mkt-media` e `course-media` são públicos,
7
+ -- então a URL de leitura funciona — e foi por isso que o buraco ficou invisível
8
+ -- por tanto tempo: a foto do produto aparece na vitrine, o catálogo parece
9
+ -- inteiro, e ninguém suspeita.
10
+ --
11
+ -- O que NÃO funciona é tudo que passa por `storage.objects` com o JWT do
12
+ -- usuário: `storage.list` e o upload. A tela Conteúdo do StoreControl lista a
13
+ -- biblioteca com `bucket.list(...)`, e sem política de SELECT a lista volta
14
+ -- VAZIA — não com erro. Medido na Zedek Pizzas em 2026-09-22: sete banners
15
+ -- gravados no bucket, "0 arquivos | Nenhum arquivo ainda" na tela. O botão
16
+ -- "Enviar arquivos" está do outro lado do mesmo buraco: sem política de INSERT
17
+ -- ele recusa todo envio.
18
+ --
19
+ -- Isto vale para TODO tenant da frota, não para esta conta.
20
+ --
21
+ -- ── O recorte ──────────────────────────────────────────────────────────────
22
+ -- O mesmo de `core_attachments_*` (063) e da foto de ponto: a pasta do topo é
23
+ -- o tenant, e é ela que decide. Um bucket público já é legível por URL, então
24
+ -- a política de SELECT não abre nada que não estivesse aberto — ela só ensina
25
+ -- o `list` a enxergar o que o dono já pode ver.
26
+ --
27
+ -- DELETE entra aqui, ao contrário dos anexos: uma imagem de produto trocada é
28
+ -- rotina de catálogo, e a tela oferece o botão. O que ela não pode é sair do
29
+ -- pé da própria conta.
30
+ -- ---------------------------------------------------------------------------
31
+
32
+ DO $$
33
+ DECLARE
34
+ v_bucket text;
35
+ BEGIN
36
+ FOREACH v_bucket IN ARRAY ARRAY['shop-images', 'inventory-images', 'mkt-media', 'course-media'] LOOP
37
+ -- O nome da política carrega o bucket: quatro conjuntos independentes, e
38
+ -- desligar um não mexe nos outros.
39
+ EXECUTE format('DROP POLICY IF EXISTS %I ON storage.objects', 'core_media_' || replace(v_bucket, '-', '_') || '_read');
40
+ EXECUTE format('DROP POLICY IF EXISTS %I ON storage.objects', 'core_media_' || replace(v_bucket, '-', '_') || '_insert');
41
+ EXECUTE format('DROP POLICY IF EXISTS %I ON storage.objects', 'core_media_' || replace(v_bucket, '-', '_') || '_update');
42
+ EXECUTE format('DROP POLICY IF EXISTS %I ON storage.objects', 'core_media_' || replace(v_bucket, '-', '_') || '_delete');
43
+
44
+ EXECUTE format($f$
45
+ CREATE POLICY %I ON storage.objects
46
+ FOR SELECT TO authenticated
47
+ USING (
48
+ bucket_id = %L
49
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
50
+ )
51
+ $f$, 'core_media_' || replace(v_bucket, '-', '_') || '_read', v_bucket);
52
+
53
+ EXECUTE format($f$
54
+ CREATE POLICY %I ON storage.objects
55
+ FOR INSERT TO authenticated
56
+ WITH CHECK (
57
+ bucket_id = %L
58
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
59
+ )
60
+ $f$, 'core_media_' || replace(v_bucket, '-', '_') || '_insert', v_bucket);
61
+
62
+ EXECUTE format($f$
63
+ CREATE POLICY %I ON storage.objects
64
+ FOR UPDATE TO authenticated
65
+ USING (
66
+ bucket_id = %L
67
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
68
+ )
69
+ WITH CHECK (
70
+ bucket_id = %L
71
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
72
+ )
73
+ $f$, 'core_media_' || replace(v_bucket, '-', '_') || '_update', v_bucket, v_bucket);
74
+
75
+ EXECUTE format($f$
76
+ CREATE POLICY %I ON storage.objects
77
+ FOR DELETE TO authenticated
78
+ USING (
79
+ bucket_id = %L
80
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
81
+ )
82
+ $f$, 'core_media_' || replace(v_bucket, '-', '_') || '_delete', v_bucket);
83
+ END LOOP;
84
+ END $$;
85
+
86
+ -- A vistoria: dezesseis políticas, quatro por bucket. Zero significa que o
87
+ -- laço acima não rodou, e aí a tela Conteúdo continua vazia em silêncio — que
88
+ -- é exatamente o defeito que esta migration existe para fechar.
89
+ DO $$
90
+ DECLARE v_n int;
91
+ BEGIN
92
+ SELECT count(*) INTO v_n FROM pg_policy
93
+ WHERE polrelid = 'storage.objects'::regclass AND polname LIKE 'core_media_%';
94
+ IF v_n <> 16 THEN
95
+ RAISE EXCEPTION '128: esperava 16 políticas core_media_*, encontrei %', v_n;
96
+ END IF;
97
+ END $$;
@@ -0,0 +1,83 @@
1
+ -- ============================================================================
2
+ -- O login diz de QUEM ele é
3
+ --
4
+ -- SINTOMA: a régua do atendimento do V1 só é montada para quem está VINCULADO A
5
+ -- UM PROFISSIONAL — quem entra como admin da licença vê a ficha do cliente sem
6
+ -- botão nenhum. O V2 não conseguia nem fazer a pergunta: `public.tenant_members`
7
+ -- devolve (id, tenant_id, user_id, role, active, created_at, updated_at) e mais
8
+ -- nada.
9
+ --
10
+ -- E o dado ESTÁ LÁ. `app.memberships.person_id` existe e está preenchido: 4 de 4
11
+ -- no Espaço Facial, 88 no cluster. O que falta é a porta — a vista pública, que
12
+ -- é por onde todo o TypeScript lê, não publica a coluna. O contrato em
13
+ -- `packages/core/src/types/org.ts` fala dela ("um `tenant_members` ligado por
14
+ -- `person_id`"), e ninguém nunca leu nada porque ela não atravessa.
15
+ --
16
+ -- Não é migração de dado: é uma vista alargada e a pergunta virando função.
17
+ --
18
+ -- ⚠ Uma vista alargada volta a estreitar se uma migration mais antiga for
19
+ -- reaplicada fora de ordem — foi o que já aconteceu neste repositório. Se
20
+ -- `person_id` sumir da vista de novo, procure quem recriou `tenant_members`
21
+ -- depois desta.
22
+ -- ============================================================================
23
+
24
+ -- DUAS FORMAS, UM CONTRATO.
25
+ --
26
+ -- Em pool novo `public.tenant_members` é TABELA, criada pelo baseline já com
27
+ -- `person_id`: não há o que alargar. No cluster que veio do ETL ela é VISTA
28
+ -- sobre `app.memberships`, e é lá que a coluna não atravessa. `CREATE OR
29
+ -- REPLACE VIEW` sobre a tabela não falha por acaso — falha com "tenant_members
30
+ -- is not a view", e derruba a corrente inteira num banco do zero.
31
+ DO $$
32
+ BEGIN
33
+ IF (SELECT c.relkind FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
34
+ WHERE n.nspname = 'public' AND c.relname = 'tenant_members') = 'v' THEN
35
+ EXECUTE $v$
36
+ CREATE OR REPLACE VIEW public.tenant_members AS
37
+ SELECT m.id,
38
+ m.tenant_id,
39
+ m.user_id,
40
+ COALESCE(r.key, 'staff'::text) AS role,
41
+ m.active,
42
+ m.created_at,
43
+ m.updated_at,
44
+ -- A pessoa que É este login. Nulo é legítimo: o dono que criou o
45
+ -- espaço nunca ganha ficha, e metade de uma equipe de salão nunca
46
+ -- faz login.
47
+ --
48
+ -- POR ÚLTIMO, e não ao lado de `role`, onde ela faria sentido de
49
+ -- ler: `CREATE OR REPLACE VIEW` só aceita ACRESCENTAR coluna no
50
+ -- fim. Movê-la exigiria derrubar a vista, e derrubar leva junto os
51
+ -- grants e tudo que depende dela.
52
+ m.person_id
53
+ FROM app.memberships m
54
+ LEFT JOIN app.roles r ON r.id = m.role_id
55
+ $v$;
56
+ END IF;
57
+ END $$;
58
+
59
+ -- A pergunta que a tela faz — "eu sou profissional aqui?" — numa função só,
60
+ -- para não ser remontada com um `join` diferente em cada lugar.
61
+ CREATE OR REPLACE FUNCTION public.current_person_id()
62
+ RETURNS uuid LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public', 'app'
63
+ AS $fn$
64
+ DECLARE
65
+ v uuid;
66
+ BEGIN
67
+ -- `EXECUTE` e não SQL direto: o corpo de uma função `LANGUAGE sql` é
68
+ -- validado na CRIAÇÃO, e `app.memberships` não existe num pool novo — a
69
+ -- migration morreria ali, num banco onde a pergunta nem precisa dessa tabela.
70
+ -- Onde ela existe (o cluster que veio do ETL) é a fonte; onde não existe, a
71
+ -- própria `public.tenant_members`, que lá é tabela e já tem a coluna.
72
+ IF to_regclass('app.memberships') IS NOT NULL THEN
73
+ EXECUTE 'SELECT m.person_id FROM app.memberships m
74
+ WHERE m.user_id = auth.uid() AND m.tenant_id = app.current_tenant_id()
75
+ AND coalesce(m.active, true) LIMIT 1' INTO v;
76
+ ELSE
77
+ EXECUTE 'SELECT tm.person_id FROM public.tenant_members tm
78
+ WHERE tm.user_id = auth.uid() AND tm.tenant_id = app.current_tenant_id() LIMIT 1' INTO v;
79
+ END IF;
80
+ RETURN v;
81
+ END $fn$;
82
+
83
+ GRANT EXECUTE ON FUNCTION public.current_person_id() TO authenticated;
@@ -0,0 +1,31 @@
1
+ -- ============================================================================
2
+ -- O aviso leva a ALGUM LUGAR
3
+ --
4
+ -- `public.notifications` guarda título, corpo, tipo e lido — e mais nada. Um
5
+ -- aviso que diz "a conta de Fulana está aberta" e não leva até ela obriga a
6
+ -- recepção a procurar o nome numa lista, que é o caminho que o aviso existia
7
+ -- para evitar.
8
+ --
9
+ -- Duas colunas: para ONDE ir, e SOBRE O QUE é (o par polimórfico que o resto do
10
+ -- schema já usa em `documents.subject_type/subject_id`). Com o assunto dá para
11
+ -- não avisar duas vezes sobre o mesmo atendimento.
12
+ -- ============================================================================
13
+
14
+ ALTER TABLE public.notifications
15
+ ADD COLUMN IF NOT EXISTS link text,
16
+ ADD COLUMN IF NOT EXISTS subject_type text,
17
+ ADD COLUMN IF NOT EXISTS subject_id uuid;
18
+
19
+ COMMENT ON COLUMN public.notifications.link IS
20
+ 'Rota do app para onde o aviso leva (ex.: #/checkin). Sem isto o aviso informa e abandona.';
21
+ COMMENT ON COLUMN public.notifications.subject_type IS
22
+ 'Sobre o que é o aviso (appointment, order…), no mesmo par polimórfico de documents.';
23
+
24
+ -- Um aviso por assunto e por pessoa: o mesmo atendimento finalizado duas vezes
25
+ -- (reabrir e fechar de novo) não enche o sino.
26
+ CREATE UNIQUE INDEX IF NOT EXISTS notifications_subject_user_uidx
27
+ ON public.notifications (user_id, subject_type, subject_id, type)
28
+ WHERE subject_id IS NOT NULL;
29
+
30
+ CREATE INDEX IF NOT EXISTS notifications_user_unread_idx
31
+ ON public.notifications (user_id, created_at DESC) WHERE NOT read;
@@ -0,0 +1,81 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 117_o_salao_fala_o_vocabulario_do_v1.sql — o rail do StudioControl passa a
3
+ -- dizer o que o BeautySoft dizia.
4
+ -- ---------------------------------------------------------------------------
5
+ -- Estes clientes usam o V1 ha anos e vao receber o V2 com a mesma equipe, sem
6
+ -- treinamento. Um rail que espalha Confirmacoes e Check-in como linhas soltas
7
+ -- onde la eles sao filhos de Agenda, e que deixa Fornecedores fora de Cadastros
8
+ -- tendo Cadastros logo abaixo, faz cada pessoa reaprender um menu que ela ja
9
+ -- sabe de cor. Esse custo nao aparece em teste nenhum; aparece no chamado.
10
+ --
11
+ -- -- O que foi medido, dos dois lados, em 21/09/2026 --------------------------
12
+ --
13
+ -- V1 (Espaco Facial, unidade Treinamento, perfil dono):
14
+ --
15
+ -- Dashboard . Agenda v . Clientes v . Estoque . CRM . Financeiro .
16
+ -- Cadastros v . Configuracoes . Relatorios
17
+ --
18
+ -- Agenda v Agendamentos . Confirmacoes . Check-in . Comunicacao
19
+ -- Cadastros v Contatos . Contas . Equipamentos . Fornecedores .
20
+ -- Funcionarios . Locais Operacionais . Origens . Parcerias .
21
+ -- Servicos . Profissionais . Recursos Humanos
22
+ --
23
+ -- V2 (`beauty`, lido do proprio rail):
24
+ --
25
+ -- / . /agenda . /agenda/confirmations . /checkin . /sales . /customers .
26
+ -- /suppliers . /financial . /inventory . /marketing . /analytics .
27
+ -- /clients . /registry . /settings
28
+ --
29
+ -- /registry v services . categories . locations . location-groups .
30
+ -- staff . contacts . suppliers . partnerships . equipment
31
+ --
32
+ -- Tres diferencas de FORMA saltam dessa comparacao, e sao o que este arquivo
33
+ -- conserta:
34
+ --
35
+ -- 1. "Clientes" sai DUAS vezes -- `/customers` e `/clients` sao a mesma
36
+ -- linha do ponto de vista de quem usa. O ofício fica com `/clients`, que
37
+ -- e a ficha que o salao abre; `/customers` some do rail.
38
+ -- 2. "Fornecedores" sai DUAS vezes -- solto em `/suppliers` e dentro de
39
+ -- Cadastros em `/registry/suppliers`. No V1 ele mora em Cadastros e so.
40
+ -- 3. Agenda, Confirmacoes e Check-in sao tres linhas soltas onde o V1 tem
41
+ -- uma gaveta.
42
+ --
43
+ -- -- O que o preset pode e o que ele nao pode --------------------------------
44
+ -- Ele AGRUPA e ORDENA; quem NOMEIA e o plugin (083/101). Entao este arquivo
45
+ -- nao escreve "Agendamentos" nem "Funcionarios" em lugar nenhum: diz quais
46
+ -- PREFIXOS DE ROTA moram sob cada rubrica, e os rotulos continuam sendo os que
47
+ -- cada plugin declara. Foi essa separacao que parou a briga entre a arvore de
48
+ -- SQL e o catalogo, e ela nao vai ser reaberta por causa de um rotulo.
49
+ --
50
+ -- Fica, por isso, uma divergencia de VOCABULARIO que so o app resolve, anotada
51
+ -- aqui para nao se perder: o V1 chama "Funcionarios" o que `/registry/staff`
52
+ -- chama "Equipe", "Locais Operacionais" o que `/registry/locations` chama
53
+ -- "Locais de Atendimento", e "CRM" o que `/sales` chama "Vendas".
54
+ --
55
+ -- `/resources` ja entra no `under` de Cadastros: a tabela de recursos (salas,
56
+ -- macas, cadeiras) esta sendo criada agora, e no V1 esse cadastro mora em
57
+ -- Cadastros. Prefixo que ainda nao serve rota nenhuma nao custa nada -- a
58
+ -- rubrica simplesmente nao o recolhe ate a tela existir.
59
+ --
60
+ -- `hide` tira do rail o vocabulario que o V1 nao tem. NAO desliga modulo
61
+ -- nenhum: as telas continuam existindo, roteadas e alcancaveis -- o que sai e
62
+ -- a LINHA.
63
+ -- ---------------------------------------------------------------------------
64
+
65
+ UPDATE app.apps SET preset = (preset - 'rail' - 'hide') || $$
66
+ {
67
+ "rail": [
68
+ "dashboard",
69
+ { "label": "Agenda", "icon": "Calendar",
70
+ "under": ["/agenda", "/checkin", "/conversations"] },
71
+ { "label": "Clientes", "icon": "Users", "under": ["/clients"] },
72
+ "inventory",
73
+ "crm",
74
+ "financial",
75
+ { "label": "Cadastros", "icon": "ClipboardList",
76
+ "under": ["/registry", "/resources", "/company", "/staff"] },
77
+ "reports"
78
+ ],
79
+ "hide": ["/customers", "/suppliers", "/marketing", "/analytics", "/tasks"]
80
+ }
81
+ $$::jsonb WHERE id = 'beauty';
@@ -0,0 +1,91 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 118_o_cadastro_do_salao_fala_como_o_v1.sql — o Cadastros do Espaço Facial.
3
+ -- ---------------------------------------------------------------------------
4
+ -- A 117 pôs as telas de cadastro sob UMA rubrica, e foi metade do caminho. A
5
+ -- outra metade é o que a recepcionista lê quando abre o menu: ela vem de sete
6
+ -- anos de BeautySoft, e lá a lista se chama assim, nesta ordem —
7
+ --
8
+ -- Contatos · Contas · Equipamentos · Fornecedores · Funcionários ·
9
+ -- Locais Operacionais · Origens · Parcerias · Serviços ·
10
+ -- Profissionais · Recursos Humanos
11
+ --
12
+ -- Três coisas faltavam para dizer isso, e as três agora são dado:
13
+ --
14
+ -- ORDEM `under` passa a ser lida como sequência, não como conjunto. O
15
+ -- V1 abre em Contatos e fecha em Serviços; a ordem de POSIÇÃO dos
16
+ -- plugins não tem como saber disso. Um prefixo largo no fim
17
+ -- (`/registry`) continua sendo o pega-tudo.
18
+ --
19
+ -- NOME `labels` é um dicionário rota → nome. Não é a árvore paralela
20
+ -- que a 083 matou: não há rota, ícone nem posição aqui, então não
21
+ -- há como uma entrada desenhar uma linha que não existe. É só o
22
+ -- ofício dizendo que na casa DELE `/registry/staff` se chama
23
+ -- "Funcionários" — renomear isso no plugin renomearia a mesma tela
24
+ -- no restaurante e na loja.
25
+ --
26
+ -- ORIGENS a rota existe desde sempre, com rótulo vazio, e por isso nunca
27
+ -- desenhou: uma linha sem nome não tem como aparecer. Nomeada pelo
28
+ -- preset, ela volta ao menu sem o aplicativo mudar uma linha.
29
+ --
30
+ -- ── O que continua faltando, e por que não está aqui ───────────────────────
31
+ -- Contas existe DENTRO do Financeiro, como aba do módulo, não
32
+ -- como rota própria. Puxá-la para cá a tiraria de lá.
33
+ -- Profissionais o V1 separa quem TRABALHA na casa (Funcionários) de
34
+ -- quem ATENDE (Profissionais). O V2 tem uma pessoa só.
35
+ -- Eixo não modelado — não se inventa rubrica para ele.
36
+ -- Recursos Humanos é o plugin `workforce`, que este aplicativo não monta.
37
+ --
38
+ -- `modules` ganha `registry` junto: quem NÃO é oferecido não entra, mesmo
39
+ -- instalado — e uma rubrica sobre telas que não existem some em silêncio, que
40
+ -- foi exatamente o que aconteceu no aplicativo central.
41
+ --
42
+ -- Idempotente: reescreve `rail`, `hide` e `labels` por inteiro.
43
+ -- ---------------------------------------------------------------------------
44
+
45
+ UPDATE app.apps
46
+ SET preset = (preset - 'rail' - 'hide' - 'labels') || $$
47
+ {
48
+ "rail": [
49
+ "dashboard",
50
+ { "label": "Agenda", "icon": "Calendar",
51
+ "under": ["/agenda", "/checkin", "/conversations"] },
52
+ { "label": "Clientes", "icon": "Users", "under": ["/clients"] },
53
+ "inventory",
54
+ "crm",
55
+ "financial",
56
+ { "label": "Cadastros", "icon": "ClipboardList",
57
+ "under": ["/registry/contacts",
58
+ "/registry/equipment",
59
+ "/registry/suppliers",
60
+ "/registry/staff",
61
+ "/registry/locations",
62
+ "/registry/location-groups",
63
+ "/registry/origins",
64
+ "/registry/partnerships",
65
+ "/registry/services",
66
+ "/registry/categories",
67
+ "/registry", "/resources", "/company", "/staff"] },
68
+ "reports"
69
+ ],
70
+ "hide": ["/customers", "/suppliers", "/marketing", "/analytics", "/tasks"],
71
+ "labels": {
72
+ "/": "Dashboard",
73
+ "/sales": "CRM",
74
+ "/registry/staff": "Funcionários",
75
+ "/registry/locations": "Locais Operacionais",
76
+ "/registry/origins": "Origens",
77
+ "/registry/services": "Serviços",
78
+ "/registry/categories": "Categorias"
79
+ }
80
+ }
81
+ $$::jsonb WHERE id = 'beauty';
82
+
83
+ -- O ofício passa a OFERECER os Cadastros. Sem isto o plugin fica instalado e
84
+ -- calado: `resolvePluginRuntime` só liga por padrão quem está na oferta.
85
+ UPDATE app.apps
86
+ SET preset = jsonb_set(
87
+ preset, '{modules}',
88
+ (SELECT jsonb_agg(DISTINCT m)
89
+ FROM jsonb_array_elements(
90
+ COALESCE(preset->'modules', '[]'::jsonb) || '["registry"]'::jsonb) AS m))
91
+ WHERE id = 'beauty';