@fayz-ai/db 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/canonical-schema.json +132 -0
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/schema/spine.d.ts +42 -37
- package/dist/schema/spine.d.ts.map +1 -1
- package/migrations/000_baseline.sql +29667 -0
- package/migrations/001_the_tenant_has_apps.sql +314 -0
- package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
- package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
- package/migrations/004_the_projections_get_a_successor.sql +312 -0
- package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
- package/migrations/006_the_helpers_read_the_source.sql +153 -0
- package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
- package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
- package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
- package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
- package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
- package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
- package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
- package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
- package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
- package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
- package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
- package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
- package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
- package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
- package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
- package/package.json +3 -2
- package/migrations/0000_legacy_quarantine.sql +0 -86
- package/migrations/0000b_straggler_sweep.sql +0 -37
- package/migrations/000_core_v1_convert.sql +0 -343
- package/migrations/001_core.sql +0 -372
- package/migrations/002_rls_user_tenant_ids.sql +0 -64
- package/migrations/003_invitations.sql +0 -17
- package/migrations/004_archetypes.sql +0 -343
- package/migrations/005_locations_archetype.sql +0 -18
- package/migrations/006_archetype_rls.sql +0 -74
- package/migrations/007_archetype_grants.sql +0 -4
- package/migrations/008_grants.sql +0 -5
- package/migrations/009_anon_hardening.sql +0 -19
- package/migrations/010_migration_ledger.sql +0 -36
- package/migrations/011_anon_write_revoke.sql +0 -12
- package/migrations/012_rename_migration_ledger.sql +0 -61
- package/migrations/013_sequences_rls.sql +0 -22
- package/migrations/014_plan_entitlements.sql +0 -28
- package/migrations/015_agent_guard.sql +0 -108
- package/migrations/016_agent_guard_actor.sql +0 -102
- package/migrations/017_core_addresses.sql +0 -115
- package/migrations/018_global_search.sql +0 -482
- package/migrations/019_tenant_members_person_id.sql +0 -20
- package/migrations/020_fayz_projects.sql +0 -63
- package/migrations/021_tenant_roles.sql +0 -74
- package/migrations/022_invite_acceptance.sql +0 -91
- package/migrations/023_team_visibility.sql +0 -75
- package/migrations/024_analytics_engine.sql +0 -317
- package/migrations/025_created_by.sql +0 -75
- package/migrations/026_audit_trail.sql +0 -73
- package/migrations/027_domain_events.sql +0 -266
- package/migrations/028_tenant_scoped_token.sql +0 -127
- package/migrations/029_connections.sql +0 -186
- package/migrations/030_effect_idempotency.sql +0 -159
- package/migrations/031_sync_run_message.sql +0 -39
- package/migrations/032_connection_secrets.sql +0 -227
- package/migrations/033_sync_schedule.sql +0 -651
- package/migrations/034_custom_fields.sql +0 -55
- package/migrations/035_field_registry.sql +0 -148
- package/migrations/036_analytics_run_batch.sql +0 -84
- package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
- package/migrations/038_onboarding_responses.sql +0 -103
- package/migrations/039_unit_tree.sql +0 -270
- package/migrations/040_resource_grants.sql +0 -474
- package/migrations/041_scoped_columns.sql +0 -192
- package/migrations/042_unit_scope_policies.sql +0 -145
- package/migrations/043_view_invoker.sql +0 -81
- package/migrations/044_unit_member_facts.sql +0 -47
- package/migrations/045_unit_entry.sql +0 -236
- package/migrations/046_membership_visible_to_members.sql +0 -85
- package/migrations/047_tasks.sql +0 -266
- package/migrations/048_every_login_is_a_person.sql +0 -190
- package/migrations/049_bookable_people.sql +0 -126
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 022_invite_acceptance.sql — o convite finalmente vira membro da org.
|
|
3
|
-
--
|
|
4
|
-
-- `createInvite` sempre gravou a linha em `invitations` e mandou o magic link,
|
|
5
|
-
-- mas NADA nunca converteu essa linha em `tenant_members`. O convidado entrava,
|
|
6
|
-
-- `listUserOrgs` voltava vazio, e o autoCreate do OrgProvider (ligado por
|
|
7
|
-
-- padrão) criava uma "My Organization" nova com role `owner` — a org que
|
|
8
|
-
-- convidou caía no chão em silêncio.
|
|
9
|
-
--
|
|
10
|
-
-- Duas mudanças:
|
|
11
|
-
--
|
|
12
|
-
-- 1. public.accept_pending_invitations() — o resgate que faltava. O client
|
|
13
|
-
-- chama logo depois do sign-in, antes de listar as orgs.
|
|
14
|
-
--
|
|
15
|
-
-- A fonte é a linha de `invitations`, NÃO `auth.users.raw_user_meta_data`.
|
|
16
|
-
-- O metadata também carrega tenant_id/role, mas vem de um
|
|
17
|
-
-- `signInWithOtp({ data })` do browser — qualquer um com a anon key se
|
|
18
|
-
-- carimbaria dentro de qualquer tenant. `invitations` é RLS-gated a admin
|
|
19
|
-
-- do tenant, então é o único sinal confiável.
|
|
20
|
-
--
|
|
21
|
-
-- 2. `invites_select` deixa de ser `USING (true)`. Num pool de indústria
|
|
22
|
-
-- compartilhado isso deixava qualquer usuário autenticado ler a lista de
|
|
23
|
-
-- convites de todos os tenants — e-mails inclusive. Agora: os seus
|
|
24
|
-
-- tenants, mais os convites endereçados a você (que o resgate precisa ver).
|
|
25
|
-
--
|
|
26
|
-
-- Idempotente: seguro re-rodar, no-op quando já aplicada.
|
|
27
|
-
-- ============================================================================
|
|
28
|
-
|
|
29
|
-
CREATE OR REPLACE FUNCTION public.accept_pending_invitations()
|
|
30
|
-
RETURNS integer
|
|
31
|
-
LANGUAGE plpgsql
|
|
32
|
-
SECURITY DEFINER
|
|
33
|
-
SET search_path = public
|
|
34
|
-
AS $$
|
|
35
|
-
DECLARE
|
|
36
|
-
v_uid uuid := auth.uid();
|
|
37
|
-
v_email text;
|
|
38
|
-
v_count integer := 0;
|
|
39
|
-
r record;
|
|
40
|
-
BEGIN
|
|
41
|
-
IF v_uid IS NULL THEN
|
|
42
|
-
RETURN 0;
|
|
43
|
-
END IF;
|
|
44
|
-
|
|
45
|
-
-- Lido de auth.users, não do JWT: sobrevive a token sem claim de e-mail.
|
|
46
|
-
SELECT u.email INTO v_email FROM auth.users u WHERE u.id = v_uid;
|
|
47
|
-
IF v_email IS NULL OR v_email = '' THEN
|
|
48
|
-
RETURN 0;
|
|
49
|
-
END IF;
|
|
50
|
-
|
|
51
|
-
FOR r IN
|
|
52
|
-
SELECT i.id, i.tenant_id, i.role, i.location_ids
|
|
53
|
-
FROM public.invitations i
|
|
54
|
-
WHERE lower(i.email) = lower(v_email)
|
|
55
|
-
AND i.status = 'pending'
|
|
56
|
-
AND i.expires_at > now()
|
|
57
|
-
ORDER BY i.created_at
|
|
58
|
-
LOOP
|
|
59
|
-
-- DO NOTHING e não DO UPDATE: um convite nunca troca o papel de quem já é membro.
|
|
60
|
-
INSERT INTO public.tenant_members (tenant_id, user_id, role)
|
|
61
|
-
VALUES (r.tenant_id, v_uid, r.role)
|
|
62
|
-
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
|
63
|
-
|
|
64
|
-
IF r.location_ids IS NOT NULL AND array_length(r.location_ids, 1) > 0 THEN
|
|
65
|
-
INSERT INTO public.location_members (location_id, user_id, role)
|
|
66
|
-
SELECT l.id, v_uid, r.role
|
|
67
|
-
FROM public.locations l
|
|
68
|
-
WHERE l.id = ANY(r.location_ids) AND l.tenant_id = r.tenant_id
|
|
69
|
-
ON CONFLICT (location_id, user_id) DO NOTHING;
|
|
70
|
-
END IF;
|
|
71
|
-
|
|
72
|
-
UPDATE public.invitations
|
|
73
|
-
SET status = 'accepted', accepted_at = now()
|
|
74
|
-
WHERE id = r.id;
|
|
75
|
-
|
|
76
|
-
v_count := v_count + 1;
|
|
77
|
-
END LOOP;
|
|
78
|
-
|
|
79
|
-
RETURN v_count;
|
|
80
|
-
END; $$;
|
|
81
|
-
|
|
82
|
-
REVOKE ALL ON FUNCTION public.accept_pending_invitations() FROM public;
|
|
83
|
-
REVOKE ALL ON FUNCTION public.accept_pending_invitations() FROM anon;
|
|
84
|
-
GRANT EXECUTE ON FUNCTION public.accept_pending_invitations() TO authenticated, service_role;
|
|
85
|
-
|
|
86
|
-
DROP POLICY IF EXISTS "invites_select" ON public.invitations;
|
|
87
|
-
CREATE POLICY "invites_select" ON public.invitations FOR SELECT TO authenticated
|
|
88
|
-
USING (
|
|
89
|
-
tenant_id IN (SELECT public.user_tenant_ids())
|
|
90
|
-
OR lower(email) = lower(coalesce(auth.jwt() ->> 'email', ''))
|
|
91
|
-
);
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 023_team_visibility.sql — a equipe finalmente enxerga a equipe.
|
|
3
|
-
--
|
|
4
|
-
-- `001_core` fechou dois SELECTs no próprio usuário:
|
|
5
|
-
--
|
|
6
|
-
-- profiles_select USING (id = auth.uid()) -- "own profile only"
|
|
7
|
-
-- members_select USING (user_id = auth.uid()) -- "see own"
|
|
8
|
-
--
|
|
9
|
-
-- Com isso a tela de Equipe é impossível por construção: nenhum usuário, em
|
|
10
|
-
-- nenhum tenant, jamais vê mais de um membro. O sintoma muda com o modo da
|
|
11
|
-
-- TeamTab, mas a causa é a mesma:
|
|
12
|
-
--
|
|
13
|
-
-- • modo legado (lista vem de `listMembers` → tenant_members): "1 membro
|
|
14
|
-
-- nesta organização", sempre — só a própria linha atravessa a RLS.
|
|
15
|
-
--
|
|
16
|
-
-- • person-mode (lista vem de `people`, com o vínculo sobreposto a partir de
|
|
17
|
-
-- tenant_members): as pessoas aparecem, mas `p.membership` volta undefined
|
|
18
|
-
-- para todo mundo menos você → `hasAccess = false` → a linha exibe
|
|
19
|
-
-- "sem acesso" + botão Convidar MESMO para quem já tem conta e papel.
|
|
20
|
-
-- Quem tem acesso e quem não tem ficam indistinguíveis na tela.
|
|
21
|
-
--
|
|
22
|
-
-- Não é decisão de projeto que valha manter: o resto do próprio 001_core já
|
|
23
|
-
-- usa o padrão tenant-wide (`subs_select`, `invoices_select`,
|
|
24
|
-
-- `loc_members_select` todos fazem tenant_id IN (... WHERE user_id = auth.uid())).
|
|
25
|
-
-- Só members/profiles ficaram no `= auth.uid()`. Esta migration alinha os dois
|
|
26
|
-
-- com o padrão que o core já adota — não inventa política nova.
|
|
27
|
-
--
|
|
28
|
-
-- O alcance é deliberadamente o mínimo que a tela precisa: quem divide tenant
|
|
29
|
-
-- com você passa a ver seu nome, e-mail e avatar. Nada atravessa tenant.
|
|
30
|
-
--
|
|
31
|
-
-- EFEITO COLATERAL ESPERADO — seat cap. O limite 'users'
|
|
32
|
-
-- (limits-registry.ts → { key: 'users', table: 'tenant_members' }) conta linhas
|
|
33
|
-
-- de tenant_members, e hoje essa contagem volta 1 para todo mundo: o cap de
|
|
34
|
-
-- assentos nunca foi de fato aplicado. Depois desta migration ele passa a
|
|
35
|
-
-- contar certo, e tenants acima do plano vão bater no UpgradeModal ao convidar.
|
|
36
|
-
-- É a regra funcionando pela primeira vez, não uma regressão — mas conferir os
|
|
37
|
-
-- planos antes de aplicar em produção evita susto.
|
|
38
|
-
--
|
|
39
|
-
-- Idempotente: seguro re-rodar, no-op quando já aplicada.
|
|
40
|
-
-- ============================================================================
|
|
41
|
-
|
|
42
|
-
-- Os membros dos SEUS tenants, não só você.
|
|
43
|
-
--
|
|
44
|
-
-- `user_tenant_ids()` (002) é SECURITY DEFINER, então roda por fora da RLS e a
|
|
45
|
-
-- policy de tenant_members pode consultar tenant_members sem recursão. Trocar
|
|
46
|
-
-- por um EXISTS inline sobre a mesma tabela reintroduz o loop — não simplifique.
|
|
47
|
-
DROP POLICY IF EXISTS "members_select" ON public.tenant_members;
|
|
48
|
-
CREATE POLICY "members_select" ON public.tenant_members FOR SELECT TO authenticated
|
|
49
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
50
|
-
|
|
51
|
-
-- O profile de quem divide tenant com você. Mesmo motivo do SECURITY DEFINER
|
|
52
|
-
-- acima: a checagem lê tenant_members, que agora tem policy própria.
|
|
53
|
-
CREATE OR REPLACE FUNCTION public.shares_tenant_with(p_user_id uuid)
|
|
54
|
-
RETURNS boolean
|
|
55
|
-
LANGUAGE sql
|
|
56
|
-
STABLE
|
|
57
|
-
SECURITY DEFINER
|
|
58
|
-
SET search_path = public
|
|
59
|
-
AS $$
|
|
60
|
-
SELECT EXISTS (
|
|
61
|
-
SELECT 1
|
|
62
|
-
FROM public.tenant_members mine
|
|
63
|
-
JOIN public.tenant_members theirs ON theirs.tenant_id = mine.tenant_id
|
|
64
|
-
WHERE mine.user_id = auth.uid()
|
|
65
|
-
AND theirs.user_id = p_user_id
|
|
66
|
-
);
|
|
67
|
-
$$;
|
|
68
|
-
|
|
69
|
-
REVOKE ALL ON FUNCTION public.shares_tenant_with(uuid) FROM public;
|
|
70
|
-
REVOKE ALL ON FUNCTION public.shares_tenant_with(uuid) FROM anon;
|
|
71
|
-
GRANT EXECUTE ON FUNCTION public.shares_tenant_with(uuid) TO authenticated, service_role;
|
|
72
|
-
|
|
73
|
-
DROP POLICY IF EXISTS "profiles_select" ON public.profiles;
|
|
74
|
-
CREATE POLICY "profiles_select" ON public.profiles FOR SELECT TO authenticated
|
|
75
|
-
USING (id = auth.uid() OR public.shares_tenant_with(id));
|
|
@@ -1,317 +0,0 @@
|
|
|
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;
|
|
@@ -1,75 +0,0 @@
|
|
|
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 $$;
|
|
@@ -1,73 +0,0 @@
|
|
|
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);
|