@fayz-ai/db 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/120_o_servico_diz_se_ele_se_agenda.sql +76 -0
- package/migrations/121_a_liberacao_pergunta_uma_vez_e_nao_cento_e_vinte_mil.sql +109 -0
- package/migrations/122_o_email_da_pessoa_e_um_email_nao_uma_lista.sql +80 -0
- package/migrations/123_um_writer_para_a_tabela_que_ja_tem_a_forma_certa.sql +197 -0
- package/migrations/124_o_updated_at_so_pode_ser_atribuido_uma_vez.sql +155 -0
- package/migrations/125_a_data_que_fecha_nao_e_a_data_que_so_lembra.sql +160 -0
- package/migrations/126_o_telefone_procurado_nao_tem_parenteses.sql +62 -0
- package/migrations/127_a_configuracao_tambem_se_pergunta_por_unidade.sql +250 -0
- package/migrations/128_a_biblioteca_de_midia_tambem_precisa_de_porta.sql +97 -0
- package/migrations/129_o_login_diz_de_quem_ele_e.sql +83 -0
- package/migrations/130_o_aviso_leva_a_algum_lugar.sql +31 -0
- package/migrations/131_o_salao_fala_o_vocabulario_do_v1.sql +81 -0
- package/migrations/132_o_cadastro_do_salao_fala_como_o_v1.sql +91 -0
- package/migrations/133_a_agenda_do_salao_perde_o_que_nao_e_dela.sql +69 -0
- package/migrations/134_todo_oficio_agrupa_os_seus_cadastros.sql +123 -0
- package/package.json +1 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 125 — A DATA QUE FECHA NÃO É A DATA QUE SÓ LEMBRA.
|
|
3
|
+
--
|
|
4
|
+
-- O V1 guarda as duas coisas na mesma tabela e as separa por UMA coluna,
|
|
5
|
+
-- `holidays.is_holiday`, que o formulário chama de *Tipo*:
|
|
6
|
+
--
|
|
7
|
+
-- Feriado "Afeta agenda e funcionamento"
|
|
8
|
+
-- Data Comemorativa "Usada no módulo de marketing"
|
|
9
|
+
--
|
|
10
|
+
-- O V2 herdou a tabela e PERDEU a coluna: o ETL trouxe 179 linhas para o Espaço
|
|
11
|
+
-- Facial sem nenhum traço de `is_holiday` (o `metadata.migration` guarda só o
|
|
12
|
+
-- batch e a origem). Sem ela, "Dia do Sorvete" e "Independência do Brasil" são
|
|
13
|
+
-- a mesma coisa para quem lê a tabela — e uma agenda que tratasse as duas
|
|
14
|
+
-- igualmente fecharia a casa no Dia do Sorvete ou abriria no dia 7 de setembro.
|
|
15
|
+
--
|
|
16
|
+
-- `kind` NÃO resolve isso: national|state|city|company é ALCANCE GEOGRÁFICO, e
|
|
17
|
+
-- de fato as 177 linhas importadas estão todas em 'national', comemorativas
|
|
18
|
+
-- inclusive. São eixos ortogonais.
|
|
19
|
+
--
|
|
20
|
+
-- ── O backfill, e o que ele deliberadamente não decide ──────────────────────
|
|
21
|
+
--
|
|
22
|
+
-- Marca como fechamento APENAS o que é feriado nacional por lei (Lei 662/1949,
|
|
23
|
+
-- 6.802/1980 e 14.759/2024, mais a Sexta-feira Santa). Tudo o mais nasce
|
|
24
|
+
-- comemorativo.
|
|
25
|
+
--
|
|
26
|
+
-- Carnaval, Quarta-feira de Cinzas e Corpus Christi ficam de FORA de propósito:
|
|
27
|
+
-- são ponto facultativo, não feriado — quem fecha nesses dias é a casa, e o V1
|
|
28
|
+
-- deixava isso por linha. Adivinhar aqui fecharia três dias do ano de 179
|
|
29
|
+
-- tenants sem ninguém ter pedido. A tela permite virar a chave numa linha.
|
|
30
|
+
--
|
|
31
|
+
-- Idempotente: o UPDATE só toca linhas que ainda estão no padrão.
|
|
32
|
+
-- ============================================================================
|
|
33
|
+
|
|
34
|
+
ALTER TABLE public.holidays
|
|
35
|
+
ADD COLUMN IF NOT EXISTS closes boolean NOT NULL DEFAULT true;
|
|
36
|
+
|
|
37
|
+
COMMENT ON COLUMN public.holidays.closes IS
|
|
38
|
+
'true = fecha/altera o funcionamento (o "Feriado" do V1); false = data comemorativa, só marketing.';
|
|
39
|
+
|
|
40
|
+
-- Os nomes exatos com que o calendário oficial do V1 escreve os feriados
|
|
41
|
+
-- nacionais. Comparação sem acento e sem caixa: a mesma data chega escrita
|
|
42
|
+
-- "Nossa Senhora Aparecida" e "Nossa Senhora da Aparecida" no mesmo tenant.
|
|
43
|
+
DO $$
|
|
44
|
+
DECLARE
|
|
45
|
+
v_fechamentos text[] := ARRAY[
|
|
46
|
+
'confraternizacao universal',
|
|
47
|
+
'tiradentes',
|
|
48
|
+
'dia do trabalho',
|
|
49
|
+
'dia do trabalhador',
|
|
50
|
+
'sexta-feira santa',
|
|
51
|
+
'independencia do brasil',
|
|
52
|
+
'nossa senhora aparecida',
|
|
53
|
+
'nossa senhora da aparecida',
|
|
54
|
+
'finados',
|
|
55
|
+
'proclamacao da republica',
|
|
56
|
+
'consciencia negra',
|
|
57
|
+
'natal'
|
|
58
|
+
];
|
|
59
|
+
BEGIN
|
|
60
|
+
-- `translate` e não `unaccent`: a extensão não está garantida em todo pool, e
|
|
61
|
+
-- um corpo com search_path vazio só estoura na PRIMEIRA chamada.
|
|
62
|
+
UPDATE public.holidays h
|
|
63
|
+
SET closes = false
|
|
64
|
+
WHERE h.closes -- só o que ainda está no padrão
|
|
65
|
+
AND translate(lower(btrim(h.name)), 'áàâãéêíóôõúüç', 'aaaaeeiooouuc') <> ALL (v_fechamentos);
|
|
66
|
+
END $$;
|
|
67
|
+
|
|
68
|
+
-- ---------------------------------------------------------------------------
|
|
69
|
+
-- A porta por onde a agenda lê. Sem `closes` aqui, o eixo existiria na tabela e
|
|
70
|
+
-- não chegaria a tela nenhuma.
|
|
71
|
+
-- ---------------------------------------------------------------------------
|
|
72
|
+
-- DROP antes: `CREATE OR REPLACE` não muda a forma do RETURNS TABLE, e uma
|
|
73
|
+
-- coluna nova é forma nova.
|
|
74
|
+
DROP FUNCTION IF EXISTS public.holidays_for_unit(uuid, date, date);
|
|
75
|
+
CREATE FUNCTION public.holidays_for_unit(p_unit uuid, p_from date, p_to date)
|
|
76
|
+
RETURNS TABLE (id uuid, occurs_on date, name text, kind text, unit_id uuid,
|
|
77
|
+
recurring boolean, description text, closes boolean)
|
|
78
|
+
LANGUAGE sql STABLE SET search_path = ''
|
|
79
|
+
AS $$
|
|
80
|
+
WITH years AS (
|
|
81
|
+
SELECT generate_series(extract(year FROM p_from)::int, extract(year FROM p_to)::int) AS y
|
|
82
|
+
),
|
|
83
|
+
scoped AS (
|
|
84
|
+
SELECT h.* FROM public.holidays h
|
|
85
|
+
WHERE h.tenant_id = app.current_tenant_id()
|
|
86
|
+
AND h.is_active
|
|
87
|
+
AND (h.unit_id IS NULL OR h.unit_id = p_unit)
|
|
88
|
+
AND (p_unit IS NULL OR EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = h.tenant_id AND u.id = p_unit))
|
|
89
|
+
)
|
|
90
|
+
SELECT s.id, s.date AS occurs_on, s.name, s.kind, s.unit_id, s.recurring, s.description, s.closes
|
|
91
|
+
FROM scoped s
|
|
92
|
+
WHERE NOT s.recurring AND s.date BETWEEN p_from AND p_to
|
|
93
|
+
UNION ALL
|
|
94
|
+
SELECT s.id, (s.date + make_interval(years => (y.y - extract(year FROM s.date)::int)))::date,
|
|
95
|
+
s.name, s.kind, s.unit_id, s.recurring, s.description, s.closes
|
|
96
|
+
FROM scoped s CROSS JOIN years y
|
|
97
|
+
WHERE s.recurring
|
|
98
|
+
AND (s.date + make_interval(years => (y.y - extract(year FROM s.date)::int)))::date BETWEEN p_from AND p_to
|
|
99
|
+
ORDER BY 2, 3;
|
|
100
|
+
$$;
|
|
101
|
+
|
|
102
|
+
-- O DROP leva as concessões junto, e o padrão do Postgres é EXECUTE para
|
|
103
|
+
-- PUBLIC — o que abriria o calendário da casa ao anon.
|
|
104
|
+
REVOKE ALL ON FUNCTION public.holidays_for_unit(uuid, date, date) FROM PUBLIC;
|
|
105
|
+
REVOKE ALL ON FUNCTION public.holidays_for_unit(uuid, date, date) FROM anon;
|
|
106
|
+
GRANT EXECUTE ON FUNCTION public.holidays_for_unit(uuid, date, date) TO authenticated, service_role;
|
|
107
|
+
|
|
108
|
+
-- ---------------------------------------------------------------------------
|
|
109
|
+
-- E o writer do ETL, para que a próxima carga traga o eixo em vez de perdê-lo
|
|
110
|
+
-- de novo. `closes` ausente no payload mantém o que está gravado: uma casa que
|
|
111
|
+
-- virou a chave à mão não a perde na reimportação.
|
|
112
|
+
-- ---------------------------------------------------------------------------
|
|
113
|
+
CREATE OR REPLACE FUNCTION migration.upsert_holiday(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
|
|
114
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
115
|
+
AS $$
|
|
116
|
+
DECLARE
|
|
117
|
+
v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
|
|
118
|
+
v_kind text := coalesce(p_args ->> 'kind', 'company');
|
|
119
|
+
v_date date;
|
|
120
|
+
v_name text := nullif(btrim(coalesce(p_row ->> 'name', '')), '');
|
|
121
|
+
v_existing_tenant uuid;
|
|
122
|
+
v_created boolean := false;
|
|
123
|
+
v_standalone boolean := migration._standalone();
|
|
124
|
+
BEGIN
|
|
125
|
+
IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
|
|
126
|
+
BEGIN
|
|
127
|
+
v_date := (p_row ->> 'date')::date;
|
|
128
|
+
EXCEPTION WHEN OTHERS THEN
|
|
129
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('%L is not a date', p_row ->> 'date'));
|
|
130
|
+
END;
|
|
131
|
+
IF p_id IS NULL OR v_date IS NULL OR v_name IS NULL THEN
|
|
132
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a holiday needs an id, a date and a name');
|
|
133
|
+
END IF;
|
|
134
|
+
IF v_kind NOT IN ('national', 'state', 'city', 'company') THEN
|
|
135
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown holiday kind %L', v_kind));
|
|
136
|
+
END IF;
|
|
137
|
+
SELECT tenant_id INTO v_existing_tenant FROM public.holidays WHERE id = p_id;
|
|
138
|
+
IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
|
|
139
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('holiday %s already exists in another tenant', p_id));
|
|
140
|
+
END IF;
|
|
141
|
+
IF v_existing_tenant IS NULL THEN
|
|
142
|
+
INSERT INTO public.holidays (id, tenant_id, unit_id, date, name, kind, recurring, description, is_active, closes, metadata)
|
|
143
|
+
VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_date, v_name, v_kind,
|
|
144
|
+
coalesce((p_row ->> 'recurring')::boolean, false), p_row ->> 'description',
|
|
145
|
+
coalesce((p_row ->> 'is_active')::boolean, true),
|
|
146
|
+
coalesce((p_row ->> 'closes')::boolean, true), v_meta);
|
|
147
|
+
v_created := true;
|
|
148
|
+
ELSE
|
|
149
|
+
UPDATE public.holidays SET
|
|
150
|
+
unit_id = CASE WHEN p_row ? 'unit_id' THEN (p_row ->> 'unit_id')::uuid ELSE unit_id END,
|
|
151
|
+
date = v_date, name = v_name, kind = v_kind,
|
|
152
|
+
recurring = CASE WHEN p_row ? 'recurring' THEN coalesce((p_row ->> 'recurring')::boolean, false) ELSE recurring END,
|
|
153
|
+
description = CASE WHEN p_row ? 'description' THEN p_row ->> 'description' ELSE description END,
|
|
154
|
+
closes = CASE WHEN p_row ? 'closes' THEN coalesce((p_row ->> 'closes')::boolean, true) ELSE closes END,
|
|
155
|
+
metadata = migration._merge_meta(metadata, v_meta)
|
|
156
|
+
WHERE id = p_id;
|
|
157
|
+
END IF;
|
|
158
|
+
IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.holidays', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
|
|
159
|
+
RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
|
|
160
|
+
END $$;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 126 — O TELEFONE PROCURADO NÃO TEM PARÊNTESES.
|
|
3
|
+
--
|
|
4
|
+
-- O V1 procura cliente por NOME, TELEFONE OU CPF ("a busca pede pelo menos 2
|
|
5
|
+
-- caracteres e procura por nome, telefone ou CPF"). O V2 procura só por nome —
|
|
6
|
+
-- e a recepção, com a cliente ao telefone, digita o número e não acha ninguém.
|
|
7
|
+
--
|
|
8
|
+
-- Por que não bastou trocar a consulta: dos 120.808 contatos do Espaço Facial,
|
|
9
|
+
-- 100.322 têm o telefone GRAVADO COM FORMATO — "(21) 99999-9999" — e 19.689 em
|
|
10
|
+
-- dígitos puros. Quem digita "21999999999" não casa com o primeiro grupo; quem
|
|
11
|
+
-- digita "(21)" não casa com o segundo. Comparar texto com texto aqui é uma
|
|
12
|
+
-- loteria sobre como o dado foi digitado anos atrás.
|
|
13
|
+
--
|
|
14
|
+
-- `phone_digits` é a mesma informação sem a pontuação, calculada pelo banco.
|
|
15
|
+
-- GERADA, não copiada: uma coluna que alguém precisa lembrar de atualizar
|
|
16
|
+
-- envelhece em silêncio, e uma busca que envelhece devolve "não encontrado"
|
|
17
|
+
-- para quem está cadastrado.
|
|
18
|
+
--
|
|
19
|
+
-- `document_number` não precisa disto — os 114.476 preenchidos já estão em
|
|
20
|
+
-- dígitos puros neste tenant.
|
|
21
|
+
--
|
|
22
|
+
-- ── Custo ──────────────────────────────────────────────────────────────────
|
|
23
|
+
--
|
|
24
|
+
-- Coluna gerada REESCREVE a tabela (565 MB, 123.483 linhas) com ACCESS
|
|
25
|
+
-- EXCLUSIVE: `public.people` fica indisponível por dezenas de segundos. É uma
|
|
26
|
+
-- janela curta, mas é uma janela — vale rodar fora do horário de balcão.
|
|
27
|
+
--
|
|
28
|
+
-- Os índices de trigrama são o outro lado: hoje CADA TECLA da busca faz varredura
|
|
29
|
+
-- sequencial em 123 mil linhas, porque `ilike '%texto%'` não usa índice comum.
|
|
30
|
+
-- `extensions.gin_trgm_ops` qualificado de propósito: a extensão não mora em
|
|
31
|
+
-- `public`, e um nome sem esquema só falha na primeira chamada.
|
|
32
|
+
-- ============================================================================
|
|
33
|
+
|
|
34
|
+
ALTER TABLE public.people
|
|
35
|
+
ADD COLUMN IF NOT EXISTS phone_digits text
|
|
36
|
+
GENERATED ALWAYS AS (nullif(regexp_replace(coalesce(phone, ''), '[^0-9]', '', 'g'), '')) STORED;
|
|
37
|
+
|
|
38
|
+
COMMENT ON COLUMN public.people.phone_digits IS
|
|
39
|
+
'O telefone só com dígitos, gerado a partir de `phone`. É por onde a busca procura.';
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS people_phone_digits_trgm
|
|
42
|
+
ON public.people USING gin (phone_digits extensions.gin_trgm_ops);
|
|
43
|
+
|
|
44
|
+
CREATE INDEX IF NOT EXISTS people_document_number_trgm
|
|
45
|
+
ON public.people USING gin (document_number extensions.gin_trgm_ops);
|
|
46
|
+
|
|
47
|
+
CREATE INDEX IF NOT EXISTS people_name_trgm
|
|
48
|
+
ON public.people USING gin (name extensions.gin_trgm_ops);
|
|
49
|
+
|
|
50
|
+
-- A VISTA MASCARADA TAMBÉM PRECISA SABER DA COLUNA NOVA.
|
|
51
|
+
--
|
|
52
|
+
-- `public.people` é a única tabela com leitura mascarada (`v_people`), e a
|
|
53
|
+
-- vista é GERADA: coluna acrescentada à tabela e ausente da vista chega ao
|
|
54
|
+
-- cliente como "não existe esse campo" — não como erro. O portão N16d do pgTAP
|
|
55
|
+
-- existe exatamente para isto, e foi ele que pegou.
|
|
56
|
+
DO $$
|
|
57
|
+
BEGIN
|
|
58
|
+
IF to_regprocedure('app.masked_view_refresh(regclass,text,text)') IS NOT NULL
|
|
59
|
+
AND EXISTS (SELECT 1 FROM app.masked_views WHERE table_name = 'people') THEN
|
|
60
|
+
PERFORM app.masked_view_refresh('public.people', 'person', 'person');
|
|
61
|
+
END IF;
|
|
62
|
+
END $$;
|
|
@@ -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;
|