@fayz-ai/db 0.9.0 → 0.11.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,317 @@
1
+ -- ============================================================================
2
+ -- The analytics engine — one aggregate function for every domain
3
+ -- ----------------------------------------------------------------------------
4
+ -- Until now every number in the product was computed twice and differently: a
5
+ -- dashboard KPI pulled `listOrders({limit: 200})` and summed in JavaScript,
6
+ -- while the report engine ran a flat SELECT over a `rep_*` view. Two engines,
7
+ -- two answers, and the KPI silently wrong the moment a tenant passes 200 rows.
8
+ --
9
+ -- This is the half that belongs to NO domain: an allowlist of queryable read
10
+ -- models, an identifier guard, and analytics_run() — which groups, buckets and
11
+ -- aggregates any registered read model from parameters. "Orders over time" and
12
+ -- "sales by product" are the same call with different dimensions, so adding a
13
+ -- report stops being a database migration.
14
+ --
15
+ -- Each domain ships its own read models and registers them (see
16
+ -- packages/shop/migrations/0039_analytics_read_models.sql). A salon, a school
17
+ -- and a store all reuse this function over completely different views.
18
+ --
19
+ -- Because a KPI and the report behind it are literally the same query with and
20
+ -- without the GROUP BY, they cannot disagree.
21
+ --
22
+ -- SECURITY. This function builds dynamic SQL from caller-supplied strings, so:
23
+ -- * the source must be registered in plg_analytics_read_models — an allowlist,
24
+ -- not a prefix convention a future view could accidentally satisfy;
25
+ -- * every column name is verified to exist on that source via
26
+ -- information_schema before it reaches the statement;
27
+ -- * aggregates and time grains are matched against fixed enums;
28
+ -- * identifiers go through %I and literals through %L, never concatenation;
29
+ -- * the function is SECURITY INVOKER and read models are security_invoker=true,
30
+ -- so the underlying RLS still decides which rows exist. The explicit tenant
31
+ -- filter is defence in depth, not the only lock.
32
+ --
33
+ -- Idempotent.
34
+ -- ============================================================================
35
+
36
+ -- ----------------------------------------------------------------------------
37
+ -- 1. The allowlist. A read model that is not in here cannot be queried, full
38
+ -- stop. Registering is a migration-time act, never a runtime one.
39
+ -- ----------------------------------------------------------------------------
40
+ CREATE TABLE IF NOT EXISTS public.plg_analytics_read_models (
41
+ name text PRIMARY KEY,
42
+ -- The time spine. Taken from HERE and not from the client: the read model is
43
+ -- the authority on which of its columns is "when this happened".
44
+ date_column text NOT NULL DEFAULT 'created_at',
45
+ tenant_column text NOT NULL DEFAULT 'tenant_id',
46
+ description text
47
+ );
48
+
49
+ ALTER TABLE public.plg_analytics_read_models ENABLE ROW LEVEL SECURITY;
50
+
51
+ DROP POLICY IF EXISTS plg_analytics_read_models_read ON public.plg_analytics_read_models;
52
+ CREATE POLICY plg_analytics_read_models_read ON public.plg_analytics_read_models
53
+ FOR SELECT TO authenticated USING (true);
54
+
55
+ GRANT SELECT ON public.plg_analytics_read_models TO authenticated;
56
+ GRANT ALL ON public.plg_analytics_read_models TO service_role;
57
+
58
+ -- ----------------------------------------------------------------------------
59
+ -- 7. Identifier guard. Every column name from the client passes through here
60
+ -- before it can reach a statement. Raises rather than returning false so a
61
+ -- typo in a card definition fails loudly at the call instead of silently
62
+ -- dropping a dimension and returning a wrong-shaped answer.
63
+ -- ----------------------------------------------------------------------------
64
+ CREATE OR REPLACE FUNCTION public.analytics_assert_column(p_source text, p_column text)
65
+ RETURNS text
66
+ LANGUAGE plpgsql
67
+ STABLE
68
+ AS $$
69
+ BEGIN
70
+ IF p_column IS NULL OR p_column !~ '^[a-z_][a-z0-9_]*$' THEN
71
+ RAISE EXCEPTION 'analytics: malformed column name %', p_column USING ERRCODE = '22023';
72
+ END IF;
73
+
74
+ IF NOT EXISTS (
75
+ SELECT 1 FROM information_schema.columns
76
+ WHERE table_schema = 'public' AND table_name = p_source AND column_name = p_column
77
+ ) THEN
78
+ RAISE EXCEPTION 'analytics: column % does not exist on %', p_column, p_source USING ERRCODE = '42703';
79
+ END IF;
80
+
81
+ RETURN p_column;
82
+ END;
83
+ $$;
84
+
85
+ -- ----------------------------------------------------------------------------
86
+ -- 8. analytics_run — the whole engine.
87
+ --
88
+ -- p_dimensions [{"key":"created_at","grain":"day"}, {"key":"product_name"}]
89
+ -- p_measures [{"key":"net_sales","agg":"sum","column":"net_sales"}]
90
+ -- p_filters {"financial_status":"paid","category_id":["uuid","uuid"]}
91
+ --
92
+ -- Returns {"rows":[...], "total":n, "summary":{...}} where `summary` is the
93
+ -- same measures rolled up over the WHOLE range with no group-by. The summary
94
+ -- is what a KPI card reads and what a report's total row shows — computed
95
+ -- once, from the same filters, so they cannot disagree.
96
+ --
97
+ -- SECURITY INVOKER (the default, stated for the reader): RLS on the base
98
+ -- tables decides visibility; the tenant predicate below is belt and braces.
99
+ -- ----------------------------------------------------------------------------
100
+ CREATE OR REPLACE FUNCTION public.analytics_run(
101
+ p_source text,
102
+ p_tenant_id uuid DEFAULT NULL,
103
+ p_from timestamptz DEFAULT NULL,
104
+ p_to timestamptz DEFAULT NULL,
105
+ p_dimensions jsonb DEFAULT '[]'::jsonb,
106
+ p_measures jsonb DEFAULT '[]'::jsonb,
107
+ p_filters jsonb DEFAULT '{}'::jsonb,
108
+ p_search text DEFAULT NULL,
109
+ p_search_columns text[] DEFAULT NULL,
110
+ p_sort text DEFAULT NULL,
111
+ p_dir text DEFAULT 'desc',
112
+ p_limit int DEFAULT 500,
113
+ p_offset int DEFAULT 0
114
+ )
115
+ RETURNS jsonb
116
+ LANGUAGE plpgsql
117
+ STABLE
118
+ AS $$
119
+ DECLARE
120
+ v_model public.plg_analytics_read_models%ROWTYPE;
121
+ v_where text[] := ARRAY[]::text[];
122
+ v_where_sql text;
123
+ v_select text[] := ARRAY[]::text[];
124
+ v_group text[] := ARRAY[]::text[];
125
+ v_agg_only text[] := ARRAY[]::text[];
126
+ v_dim jsonb;
127
+ v_meas jsonb;
128
+ v_key text;
129
+ v_col text;
130
+ v_grain text;
131
+ v_agg text;
132
+ v_expr text;
133
+ v_filter_key text;
134
+ v_filter_val jsonb;
135
+ v_search_ors text[] := ARRAY[]::text[];
136
+ v_sort_sql text := '';
137
+ v_rows jsonb;
138
+ v_summary jsonb;
139
+ v_total bigint;
140
+ v_limit int := LEAST(GREATEST(COALESCE(p_limit, 500), 1), 5000);
141
+ v_offset int := GREATEST(COALESCE(p_offset, 0), 0);
142
+ BEGIN
143
+ -- 8.1 The source must be registered. Not "look like a read model" — registered.
144
+ SELECT * INTO v_model FROM public.plg_analytics_read_models WHERE name = p_source;
145
+ IF NOT FOUND THEN
146
+ RAISE EXCEPTION 'analytics: % is not a registered read model', p_source USING ERRCODE = '42P01';
147
+ END IF;
148
+
149
+ -- 8.2 Tenant + date window. The date column comes from the registry, so a
150
+ -- caller cannot redirect the range onto some other timestamp.
151
+ IF p_tenant_id IS NOT NULL THEN
152
+ v_where := v_where || format('%I = %L', v_model.tenant_column, p_tenant_id);
153
+ END IF;
154
+ IF p_from IS NOT NULL THEN
155
+ v_where := v_where || format('%I >= %L', v_model.date_column, p_from);
156
+ END IF;
157
+ IF p_to IS NOT NULL THEN
158
+ v_where := v_where || format('%I <= %L', v_model.date_column, p_to);
159
+ END IF;
160
+
161
+ -- 8.3 Equality / IN filters.
162
+ FOR v_filter_key, v_filter_val IN SELECT key, value FROM jsonb_each(COALESCE(p_filters, '{}'::jsonb))
163
+ LOOP
164
+ CONTINUE WHEN v_filter_val IS NULL OR jsonb_typeof(v_filter_val) = 'null';
165
+ v_col := public.analytics_assert_column(p_source, v_filter_key);
166
+
167
+ IF jsonb_typeof(v_filter_val) = 'array' THEN
168
+ CONTINUE WHEN jsonb_array_length(v_filter_val) = 0;
169
+ v_where := v_where || format(
170
+ '%I::text = ANY (SELECT jsonb_array_elements_text(%L::jsonb))', v_col, v_filter_val);
171
+ ELSE
172
+ v_where := v_where || format('%I::text = %L', v_col, v_filter_val #>> '{}');
173
+ END IF;
174
+ END LOOP;
175
+
176
+ -- 8.4 Free-text search across the caller's declared text columns.
177
+ IF p_search IS NOT NULL AND length(btrim(p_search)) > 0 AND p_search_columns IS NOT NULL THEN
178
+ FOREACH v_col IN ARRAY p_search_columns LOOP
179
+ v_col := public.analytics_assert_column(p_source, v_col);
180
+ v_search_ors := v_search_ors || format('%I::text ILIKE %L', v_col, '%' || btrim(p_search) || '%');
181
+ END LOOP;
182
+ IF array_length(v_search_ors, 1) > 0 THEN
183
+ v_where := v_where || ('(' || array_to_string(v_search_ors, ' OR ') || ')');
184
+ END IF;
185
+ END IF;
186
+
187
+ v_where_sql := CASE WHEN array_length(v_where, 1) > 0
188
+ THEN ' WHERE ' || array_to_string(v_where, ' AND ')
189
+ ELSE '' END;
190
+
191
+ -- 8.5 Dimensions. A grain turns a timestamp into a bucket; without one the
192
+ -- raw column value is the group.
193
+ FOR v_dim IN SELECT * FROM jsonb_array_elements(COALESCE(p_dimensions, '[]'::jsonb))
194
+ LOOP
195
+ v_key := v_dim ->> 'key';
196
+ v_col := public.analytics_assert_column(p_source, v_key);
197
+ v_grain := v_dim ->> 'grain';
198
+
199
+ IF v_grain IS NULL THEN
200
+ v_expr := format('%I', v_col);
201
+ ELSIF v_grain IN ('hour','day','week','month','quarter','year') THEN
202
+ v_expr := format('date_trunc(%L, %I)', v_grain, v_col);
203
+ ELSE
204
+ RAISE EXCEPTION 'analytics: unsupported time grain %', v_grain USING ERRCODE = '22023';
205
+ END IF;
206
+
207
+ v_select := v_select || format('%s AS %I', v_expr, v_key);
208
+ v_group := v_group || v_expr;
209
+ END LOOP;
210
+
211
+ -- 8.6 Measures. Fixed aggregate vocabulary — anything else is rejected rather
212
+ -- than passed through, which is the difference between a parameter and an
213
+ -- injection point. 'ratio' is deliberately absent: it is derived on the
214
+ -- client from two real measures so ratio-of-sums never degrades into
215
+ -- avg-of-ratios.
216
+ FOR v_meas IN SELECT * FROM jsonb_array_elements(COALESCE(p_measures, '[]'::jsonb))
217
+ LOOP
218
+ v_key := v_meas ->> 'key';
219
+ IF v_key IS NULL OR v_key !~ '^[a-z_][a-z0-9_]*$' THEN
220
+ RAISE EXCEPTION 'analytics: malformed measure key %', v_key USING ERRCODE = '22023';
221
+ END IF;
222
+ v_agg := lower(COALESCE(v_meas ->> 'agg', 'sum'));
223
+
224
+ IF v_agg = 'count' THEN
225
+ v_expr := 'count(*)';
226
+ ELSE
227
+ v_col := public.analytics_assert_column(p_source, COALESCE(v_meas ->> 'column', v_key));
228
+ v_expr := CASE v_agg
229
+ WHEN 'sum' THEN format('sum(%I)', v_col)
230
+ WHEN 'avg' THEN format('avg(%I)', v_col)
231
+ WHEN 'min' THEN format('min(%I)', v_col)
232
+ WHEN 'max' THEN format('max(%I)', v_col)
233
+ WHEN 'count_distinct' THEN format('count(DISTINCT %I)', v_col)
234
+ ELSE NULL
235
+ END;
236
+ IF v_expr IS NULL THEN
237
+ RAISE EXCEPTION 'analytics: unsupported aggregate %', v_agg USING ERRCODE = '22023';
238
+ END IF;
239
+ END IF;
240
+
241
+ v_select := v_select || format('%s AS %I', v_expr, v_key);
242
+ v_agg_only := v_agg_only || format('%s AS %I', v_expr, v_key);
243
+ END LOOP;
244
+
245
+ -- 8.7 No measures ⇒ a raw detail listing (the classic tabular report).
246
+ IF array_length(v_agg_only, 1) IS NULL AND array_length(v_group, 1) IS NULL THEN
247
+ v_select := ARRAY['*'];
248
+ END IF;
249
+
250
+ -- 8.8 Sort. Must be one of the things actually selected — an alias, not an
251
+ -- arbitrary expression.
252
+ IF p_sort IS NOT NULL AND p_sort ~ '^[a-z_][a-z0-9_]*$' THEN
253
+ IF EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(p_measures,'[]'::jsonb)) m WHERE m->>'key' = p_sort)
254
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(p_dimensions,'[]'::jsonb)) d WHERE d->>'key' = p_sort)
255
+ OR (array_length(v_agg_only,1) IS NULL AND array_length(v_group,1) IS NULL)
256
+ THEN
257
+ IF array_length(v_agg_only,1) IS NULL AND array_length(v_group,1) IS NULL THEN
258
+ PERFORM public.analytics_assert_column(p_source, p_sort);
259
+ END IF;
260
+ v_sort_sql := format(' ORDER BY %I %s NULLS LAST',
261
+ p_sort,
262
+ CASE WHEN lower(COALESCE(p_dir,'desc')) = 'asc' THEN 'ASC' ELSE 'DESC' END);
263
+ END IF;
264
+ END IF;
265
+
266
+ -- 8.9 Page of rows.
267
+ EXECUTE format(
268
+ 'SELECT COALESCE(jsonb_agg(t), ''[]''::jsonb) FROM (SELECT %s FROM public.%I%s%s%s LIMIT %s OFFSET %s) t',
269
+ array_to_string(v_select, ', '),
270
+ p_source,
271
+ v_where_sql,
272
+ CASE WHEN array_length(v_group, 1) > 0
273
+ THEN ' GROUP BY ' || array_to_string(v_group, ', ') ELSE '' END,
274
+ v_sort_sql,
275
+ v_limit,
276
+ v_offset
277
+ ) INTO v_rows;
278
+
279
+ -- 8.10 Row count for pagination. This must count what the MAIN query returns,
280
+ -- not what it reads: grouping ⇒ number of groups; aggregating with no
281
+ -- dimensions ⇒ exactly one row, however many rows fed it; otherwise the
282
+ -- underlying rows. Counting reads here made a 1-row KPI claim two pages.
283
+ IF array_length(v_group, 1) > 0 THEN
284
+ EXECUTE format(
285
+ 'SELECT count(*) FROM (SELECT %s FROM public.%I%s GROUP BY %s) c',
286
+ array_to_string(v_group, ', '), p_source, v_where_sql, array_to_string(v_group, ', ')
287
+ ) INTO v_total;
288
+ ELSIF array_length(v_agg_only, 1) > 0 THEN
289
+ v_total := 1;
290
+ ELSE
291
+ EXECUTE format('SELECT count(*) FROM public.%I%s', p_source, v_where_sql) INTO v_total;
292
+ END IF;
293
+
294
+ -- 8.11 The summary: identical filters, identical measures, no group-by. This
295
+ -- is the KPI headline AND the report's total row — one number, one place.
296
+ IF array_length(v_agg_only, 1) > 0 THEN
297
+ EXECUTE format(
298
+ 'SELECT to_jsonb(s) FROM (SELECT %s FROM public.%I%s) s',
299
+ array_to_string(v_agg_only, ', '),
300
+ p_source,
301
+ v_where_sql
302
+ ) INTO v_summary;
303
+ END IF;
304
+
305
+ RETURN jsonb_build_object(
306
+ 'rows', COALESCE(v_rows, '[]'::jsonb),
307
+ 'total', COALESCE(v_total, 0),
308
+ 'summary', COALESCE(v_summary, '{}'::jsonb)
309
+ );
310
+ END;
311
+ $$;
312
+
313
+ REVOKE ALL ON FUNCTION public.analytics_run(text, uuid, timestamptz, timestamptz, jsonb, jsonb, jsonb, text, text[], text, text, int, int) FROM public;
314
+ GRANT EXECUTE ON FUNCTION public.analytics_run(text, uuid, timestamptz, timestamptz, jsonb, jsonb, jsonb, text, text[], text, text, int, int) TO authenticated, service_role;
315
+
316
+ REVOKE ALL ON FUNCTION public.analytics_assert_column(text, text) FROM public;
317
+ GRANT EXECUTE ON FUNCTION public.analytics_assert_column(text, text) TO authenticated, service_role;
@@ -0,0 +1,75 @@
1
+ -- ============================================================================
2
+ -- 025_created_by.sql — quem cadastrou.
3
+ --
4
+ -- Toda tabela do core já nasceu com `created_at` e `updated_at` (001_core,
5
+ -- 004_archetypes) e um gatilho que mantém o segundo em dia. Nenhuma nasceu com
6
+ -- QUEM. A ficha de um cliente sabia dizer o dia em que foi criada e não sabia
7
+ -- dizer por quem — a pergunta que sempre vem depois de "esse cadastro está
8
+ -- errado" é "quem cadastrou", e ela não tinha resposta em lugar nenhum: o
9
+ -- `audit_logs` de 001_core existe como tabela e nunca recebeu uma linha.
10
+ --
11
+ -- A coluna é NULÁVEL e fica assim de propósito:
12
+ --
13
+ -- • as linhas que já existem não têm autor, e nunca terão — inventar um
14
+ -- (o dono do tenant, o primeiro membro) seria fabricar procedência, que é
15
+ -- exatamente o oposto do que a coluna serve para dar. Elas ficam em branco
16
+ -- e a interface omite a frase.
17
+ --
18
+ -- • nem toda escrita tem gente atrás. Importação de CSV, webhook, seed,
19
+ -- job do agente: `auth.uid()` é NULL nesses caminhos, e NULL aqui quer
20
+ -- dizer "o sistema", que é a verdade.
21
+ --
22
+ -- O DEFAULT vem por gatilho e não por `DEFAULT auth.uid()` na coluna porque o
23
+ -- `service_role` (edge functions, backfills) precisa poder gravar um autor
24
+ -- explícito; com o default na coluna, escrever NULL de propósito e "não
25
+ -- escrever nada" viram a mesma coisa e o COALESCE não teria onde morar.
26
+ --
27
+ -- Aditivo e idempotente: ADD COLUMN IF NOT EXISTS de coluna nulável não
28
+ -- reescreve a tabela (PG 11+), e os gatilhos são recriados por DROP IF EXISTS.
29
+ -- ============================================================================
30
+
31
+ CREATE OR REPLACE FUNCTION public.handle_created_by()
32
+ RETURNS trigger
33
+ LANGUAGE plpgsql
34
+ SECURITY DEFINER
35
+ SET search_path = public
36
+ AS $$
37
+ BEGIN
38
+ -- COALESCE e não atribuição direta: uma migração de dados ou uma função de
39
+ -- servidor que JÁ sabe o autor (importou em nome de alguém) manda o valor
40
+ -- certo, e o gatilho não o sobrescreve com o NULL de um contexto sem sessão.
41
+ NEW.created_by := COALESCE(NEW.created_by, auth.uid());
42
+ RETURN NEW;
43
+ END;
44
+ $$;
45
+
46
+ REVOKE ALL ON FUNCTION public.handle_created_by() FROM public;
47
+ REVOKE ALL ON FUNCTION public.handle_created_by() FROM anon;
48
+
49
+ DO $$
50
+ DECLARE
51
+ t text;
52
+ -- As bases de arquétipo (004_archetypes) mais as tabelas de core que um CRUD
53
+ -- desenha como ficha. Tabelas de junção, ledger e evento ficam de fora: elas
54
+ -- já carregam o ator no próprio corpo ou não têm ficha para mostrá-lo.
55
+ tables text[] := ARRAY[
56
+ 'people', 'categories', 'products', 'services', 'locations',
57
+ 'orders', 'transactions', 'schedules', 'appointments'
58
+ ];
59
+ BEGIN
60
+ FOREACH t IN ARRAY tables LOOP
61
+ IF to_regclass('public.' || t) IS NULL THEN
62
+ CONTINUE;
63
+ END IF;
64
+
65
+ EXECUTE format(
66
+ 'ALTER TABLE public.%I ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL',
67
+ t
68
+ );
69
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', t || '_created_by', t);
70
+ EXECUTE format(
71
+ 'CREATE TRIGGER %I BEFORE INSERT ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_created_by()',
72
+ t || '_created_by', t
73
+ );
74
+ END LOOP;
75
+ END $$;
@@ -0,0 +1,73 @@
1
+ -- ============================================================================
2
+ -- 026_audit_trail.sql — audit_logs vira leitura, não só depósito.
3
+ --
4
+ -- A tabela existe desde o 001_core e hoje recebe linhas de um lugar só: os RPCs
5
+ -- do agente (`agent_forms_upsert_template` e irmãos). Tudo que uma PESSOA faz
6
+ -- pela tela — criar cliente, corrigir um telefone, excluir um serviço — não
7
+ -- deixava rastro nenhum. O resultado é que "quem mudou isso, e quando" não tinha
8
+ -- resposta em lugar nenhum do produto.
9
+ --
10
+ -- Esta migração NÃO põe gatilho de auditoria em tabela nenhuma. A escrita passa
11
+ -- a sair do único ponto por onde todo cadastro do app já passa — o store de CRUD
12
+ -- (packages/admin/src/stores/createCrudStore.ts), que hoje já emite os eventos
13
+ -- de domínio de created/updated/deleted. Um gatilho por tabela pegaria também as
14
+ -- escritas fora do app, mas custaria uma migração a cada cadastro novo, e
15
+ -- "auditoria funciona pra qualquer cadastro" viraria "funciona nos cadastros de
16
+ -- que alguém lembrou". No chokepoint, um cadastro novo já nasce auditado.
17
+ --
18
+ -- Aqui só fica o que é do banco: a permissão para gravar, quem gravou, e os
19
+ -- índices que fazem essa tabela ser lida sem varredura.
20
+ -- ============================================================================
21
+
22
+ -- ── Quem gravou ─────────────────────────────────────────────────────────────
23
+ -- Mesma escolha do 025: por gatilho, e não `DEFAULT auth.uid()` na coluna,
24
+ -- para o service_role (edge function, backfill) poder gravar um autor explícito
25
+ -- sem que "escrever NULL de propósito" e "não escrever nada" virem a mesma coisa.
26
+ CREATE OR REPLACE FUNCTION public.handle_audit_actor()
27
+ RETURNS trigger
28
+ LANGUAGE plpgsql
29
+ SECURITY DEFINER
30
+ SET search_path = public
31
+ AS $fn$
32
+ BEGIN
33
+ NEW.user_id := COALESCE(NEW.user_id, auth.uid());
34
+ RETURN NEW;
35
+ END;
36
+ $fn$;
37
+
38
+ REVOKE ALL ON FUNCTION public.handle_audit_actor() FROM public;
39
+ REVOKE ALL ON FUNCTION public.handle_audit_actor() FROM anon;
40
+
41
+ DROP TRIGGER IF EXISTS audit_logs_actor ON public.audit_logs;
42
+ CREATE TRIGGER audit_logs_actor BEFORE INSERT ON public.audit_logs
43
+ FOR EACH ROW EXECUTE FUNCTION public.handle_audit_actor();
44
+
45
+ -- ── Permissão de escrita ────────────────────────────────────────────────────
46
+ -- A política de INSERT estava em `WITH CHECK (true)`: qualquer usuário
47
+ -- autenticado podia gravar uma linha de auditoria em QUALQUER tenant do pool —
48
+ -- inclusive forjando ação em nome de outro. Numa tabela cujo propósito é ser
49
+ -- prova do que aconteceu, isso é o defeito mais caro possível.
50
+ --
51
+ -- `user_id` fica de fora da checagem de propósito: o gatilho acima o preenche
52
+ -- DEPOIS que a política roda, e o RPC do agente grava em nome do ator que o
53
+ -- broker injetou.
54
+ DROP POLICY IF EXISTS "audit_insert" ON public.audit_logs;
55
+ CREATE POLICY "audit_insert" ON public.audit_logs
56
+ FOR INSERT TO authenticated
57
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
58
+
59
+ -- Auditoria não se corrige e não se apaga pela aplicação. Sem política de
60
+ -- UPDATE/DELETE para `authenticated`, a RLS nega — que é o comportamento certo:
61
+ -- um registro de auditoria editável não prova nada. Correção e expurgo por
62
+ -- retenção são trabalho de service_role.
63
+
64
+ -- ── Leitura ─────────────────────────────────────────────────────────────────
65
+ -- A linha do tempo de uma ficha pergunta sempre a mesma coisa: "os eventos
66
+ -- DESTE registro, do mais novo para o mais velho". Sem este índice é varredura
67
+ -- na tabela inteira do tenant a cada abertura de ficha.
68
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_entity
69
+ ON public.audit_logs (tenant_id, entity_type, entity_id, created_at DESC);
70
+
71
+ -- E o feed geral ("o que andou acontecendo"), que ordena só por data.
72
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_recent
73
+ ON public.audit_logs (tenant_id, created_at DESC);