@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.
Files changed (84) hide show
  1. package/canonical-schema.json +132 -0
  2. package/dist/index.cjs +4 -4
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/schema/spine.d.ts +42 -37
  9. package/dist/schema/spine.d.ts.map +1 -1
  10. package/migrations/000_baseline.sql +29667 -0
  11. package/migrations/001_the_tenant_has_apps.sql +314 -0
  12. package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
  13. package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
  14. package/migrations/004_the_projections_get_a_successor.sql +312 -0
  15. package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
  16. package/migrations/006_the_helpers_read_the_source.sql +153 -0
  17. package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
  18. package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
  19. package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
  20. package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
  21. package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
  22. package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
  23. package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
  24. package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
  25. package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
  26. package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
  27. package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
  28. package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
  29. package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
  30. package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
  31. package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
  32. package/package.json +3 -2
  33. package/migrations/0000_legacy_quarantine.sql +0 -86
  34. package/migrations/0000b_straggler_sweep.sql +0 -37
  35. package/migrations/000_core_v1_convert.sql +0 -343
  36. package/migrations/001_core.sql +0 -372
  37. package/migrations/002_rls_user_tenant_ids.sql +0 -64
  38. package/migrations/003_invitations.sql +0 -17
  39. package/migrations/004_archetypes.sql +0 -343
  40. package/migrations/005_locations_archetype.sql +0 -18
  41. package/migrations/006_archetype_rls.sql +0 -74
  42. package/migrations/007_archetype_grants.sql +0 -4
  43. package/migrations/008_grants.sql +0 -5
  44. package/migrations/009_anon_hardening.sql +0 -19
  45. package/migrations/010_migration_ledger.sql +0 -36
  46. package/migrations/011_anon_write_revoke.sql +0 -12
  47. package/migrations/012_rename_migration_ledger.sql +0 -61
  48. package/migrations/013_sequences_rls.sql +0 -22
  49. package/migrations/014_plan_entitlements.sql +0 -28
  50. package/migrations/015_agent_guard.sql +0 -108
  51. package/migrations/016_agent_guard_actor.sql +0 -102
  52. package/migrations/017_core_addresses.sql +0 -115
  53. package/migrations/018_global_search.sql +0 -482
  54. package/migrations/019_tenant_members_person_id.sql +0 -20
  55. package/migrations/020_fayz_projects.sql +0 -63
  56. package/migrations/021_tenant_roles.sql +0 -74
  57. package/migrations/022_invite_acceptance.sql +0 -91
  58. package/migrations/023_team_visibility.sql +0 -75
  59. package/migrations/024_analytics_engine.sql +0 -317
  60. package/migrations/025_created_by.sql +0 -75
  61. package/migrations/026_audit_trail.sql +0 -73
  62. package/migrations/027_domain_events.sql +0 -266
  63. package/migrations/028_tenant_scoped_token.sql +0 -127
  64. package/migrations/029_connections.sql +0 -186
  65. package/migrations/030_effect_idempotency.sql +0 -159
  66. package/migrations/031_sync_run_message.sql +0 -39
  67. package/migrations/032_connection_secrets.sql +0 -227
  68. package/migrations/033_sync_schedule.sql +0 -651
  69. package/migrations/034_custom_fields.sql +0 -55
  70. package/migrations/035_field_registry.sql +0 -148
  71. package/migrations/036_analytics_run_batch.sql +0 -84
  72. package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
  73. package/migrations/038_onboarding_responses.sql +0 -103
  74. package/migrations/039_unit_tree.sql +0 -270
  75. package/migrations/040_resource_grants.sql +0 -474
  76. package/migrations/041_scoped_columns.sql +0 -192
  77. package/migrations/042_unit_scope_policies.sql +0 -145
  78. package/migrations/043_view_invoker.sql +0 -81
  79. package/migrations/044_unit_member_facts.sql +0 -47
  80. package/migrations/045_unit_entry.sql +0 -236
  81. package/migrations/046_membership_visible_to_members.sql +0 -85
  82. package/migrations/047_tasks.sql +0 -266
  83. package/migrations/048_every_login_is_a_person.sql +0 -190
  84. 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);