@fayz-ai/db 0.10.0 → 0.12.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 (34) hide show
  1. package/dist/index.cjs +39 -0
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +39 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/schema/spine.d.ts +553 -0
  8. package/dist/schema/spine.d.ts.map +1 -1
  9. package/migrations/025_created_by.sql +75 -0
  10. package/migrations/026_audit_trail.sql +73 -0
  11. package/migrations/027_domain_events.sql +266 -0
  12. package/migrations/028_tenant_scoped_token.sql +127 -0
  13. package/migrations/029_connections.sql +186 -0
  14. package/migrations/030_effect_idempotency.sql +159 -0
  15. package/migrations/031_sync_run_message.sql +39 -0
  16. package/migrations/032_connection_secrets.sql +227 -0
  17. package/migrations/033_sync_schedule.sql +651 -0
  18. package/migrations/034_custom_fields.sql +55 -0
  19. package/migrations/035_field_registry.sql +148 -0
  20. package/migrations/036_analytics_run_batch.sql +84 -0
  21. package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
  22. package/migrations/038_onboarding_responses.sql +103 -0
  23. package/migrations/039_unit_tree.sql +270 -0
  24. package/migrations/040_resource_grants.sql +474 -0
  25. package/migrations/041_scoped_columns.sql +192 -0
  26. package/migrations/042_unit_scope_policies.sql +145 -0
  27. package/migrations/043_view_invoker.sql +81 -0
  28. package/migrations/044_unit_member_facts.sql +47 -0
  29. package/migrations/045_unit_entry.sql +236 -0
  30. package/migrations/046_membership_visible_to_members.sql +85 -0
  31. package/migrations/047_tasks.sql +266 -0
  32. package/migrations/048_every_login_is_a_person.sql +190 -0
  33. package/migrations/049_bookable_people.sql +126 -0
  34. package/package.json +7 -4
@@ -0,0 +1,145 @@
1
+ -- ============================================================================
2
+ -- 042_unit_scope_policies.sql — the only file here that changes an answer.
3
+ --
4
+ -- WHY A RESTRICTIVE POLICY AND NOT A REWRITE. The obvious move is to extend the
5
+ -- canonical predicate — `tenant_id IN (SELECT public.user_tenant_ids()) AND …`
6
+ -- — on every table. That would mean re-emitting ~321 policy definitions across
7
+ -- 148 tables in db, shop, courses and eight plugin trees, and the migrations
8
+ -- that created them CANNOT be edited: a checksum change on an applied file is a
9
+ -- MigrationDriftError, a hard stop in cli/src/lib/ledger.ts.
10
+ --
11
+ -- Postgres already has the operator for this. Restrictive policies are ANDed
12
+ -- with the OR of every permissive policy on the table. So one policy per table,
13
+ -- added here, composes with everything that already exists — no plugin SQL
14
+ -- changes, no CI-gate contortion, and `DROP POLICY unit_scope ON …` reverts the
15
+ -- entire feature.
16
+ --
17
+ -- It also closes a hole a rewrite would have left open. `storefront_public_read`
18
+ -- on plg_shop_products is `TO anon, authenticated`; because RLS ORs permissive
19
+ -- policies, a signed-in staff member reads the whole catalogue through that
20
+ -- door — which is exactly the "Botox in 2 of 15 units" case, defeated. A
21
+ -- restrictive policy ANDs on top and shuts it.
22
+ --
23
+ -- WHY THE PREDICATE IS LITERAL TEXT AND NOT A FUNCTION. A helper taking the
24
+ -- row's own columns — record_visible(id, unit_id, owner_id) — is CORRELATED.
25
+ -- RLS predicates are injected into RangeTblEntry.securityQuals, which
26
+ -- pull_up_sublinks never sees, so a sublink there is never turned into a
27
+ -- semijoin; a correlated one becomes a SubPlan re-executed once PER ROW. Worse,
28
+ -- such a helper must be SECURITY DEFINER with SET search_path, and either of
29
+ -- those alone makes it non-inlinable. Every disjunct below is UNCORRELATED: one
30
+ -- InitPlan per query, hashed, then a cheap probe per row.
31
+ --
32
+ -- WHAT IT COSTS A TENANT THAT DOES NOT USE UNITS. The first disjunct is a
33
+ -- zero-argument STABLE function wrapped in `(SELECT …)`, so it is one InitPlan
34
+ -- per query. On a pool where nobody has flipped the switch it returns false,
35
+ -- `NOT false` is true, and OR does not evaluate its remaining arms — one
36
+ -- boolean test per row, no hash built, plan shape unchanged.
37
+ --
38
+ -- Idempotent: every policy is dropped by name before being created.
39
+ -- ============================================================================
40
+
41
+ DO $$
42
+ DECLARE
43
+ r record;
44
+ v_read text;
45
+ v_write text;
46
+ BEGIN
47
+ FOR r IN SELECT resource_table, unit_column FROM public.scoped_resources ORDER BY resource_table LOOP
48
+ CONTINUE WHEN to_regclass('public.' || r.resource_table) IS NULL;
49
+
50
+ -- %1$s is the table name as a STRING LITERAL for the helper arguments, and
51
+ -- %2$I / %3$I are the identifiers. Baking the name in as a constant is what
52
+ -- keeps granted_record_ids() uncorrelated.
53
+ v_read := format($p$
54
+ NOT (SELECT public.unit_scoping_active())
55
+ OR tenant_id NOT IN (SELECT public.unit_scoped_tenants())
56
+ OR tenant_id NOT IN (SELECT public.user_tenant_ids())
57
+ OR tenant_id IN (SELECT public.user_admin_tenant_ids())
58
+ OR %3$I = (SELECT auth.uid())
59
+ OR id IN (SELECT public.granted_record_ids(%1$L))
60
+ OR (
61
+ tenant_id IN (SELECT public.resource_mode_tenants(%1$L, 'tenant'))
62
+ AND id NOT IN (SELECT public.restricted_record_ids(%1$L))
63
+ )
64
+ OR (
65
+ (
66
+ tenant_id IN (SELECT public.resource_mode_tenants(%1$L, 'unit'))
67
+ OR (%3$I IS NULL AND tenant_id IN (SELECT public.resource_mode_tenants(%1$L, 'owner')))
68
+ )
69
+ AND (
70
+ %2$I IN (SELECT public.user_unit_ids())
71
+ OR (%2$I IS NULL AND id NOT IN (SELECT public.restricted_record_ids(%1$L)))
72
+ )
73
+ )
74
+ $p$, r.resource_table, r.unit_column, 'owner_id');
75
+
76
+ v_write := format($p$
77
+ NOT (SELECT public.unit_scoping_active())
78
+ OR tenant_id NOT IN (SELECT public.unit_scoped_tenants())
79
+ OR tenant_id NOT IN (SELECT public.user_tenant_ids())
80
+ OR tenant_id IN (SELECT public.user_admin_tenant_ids())
81
+ OR (
82
+ (%2$I IS NULL OR %2$I IN (SELECT public.user_unit_ids()))
83
+ AND (%3$I IS NULL OR %3$I = (SELECT auth.uid()))
84
+ )
85
+ $p$, r.resource_table, r.unit_column, 'owner_id');
86
+
87
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', r.resource_table);
88
+ EXECUTE format('DROP POLICY IF EXISTS unit_scope ON public.%I', r.resource_table);
89
+ -- `AS RESTRICTIVE` sits on its own concatenated line because it is the
90
+ -- single word this whole file depends on: without it the policy becomes
91
+ -- permissive, ORs with everything else on the table and grants MORE access
92
+ -- than there was before. negative-control.sh deletes exactly this line and
93
+ -- demands the bench go red — see the case list there.
94
+ EXECUTE format(
95
+ 'CREATE POLICY unit_scope ON public.%I '
96
+ || ' AS RESTRICTIVE '
97
+ || ' FOR ALL TO authenticated USING (%s) WITH CHECK (%s)',
98
+ r.resource_table, v_read, v_write);
99
+ END LOOP;
100
+ END $$;
101
+
102
+ -- ── What the predicate says, in words ───────────────────────────────────────
103
+ --
104
+ -- READ. A row is visible when ANY of these holds, checked left to right:
105
+ --
106
+ -- 1. no tenant in this pool uses units → the whole feature is off
107
+ -- 2. this row's tenant does not use units → that tenant is unchanged
108
+ -- 3. the row is not mine to begin with → a shopper browsing a
109
+ -- storefront is governed by
110
+ -- whatever let them in, not
111
+ -- by this
112
+ -- 4. I am owner/admin of the tenant → the franchisor sees the brand
113
+ -- 5. I own the row
114
+ -- 6. the row was explicitly shared with me, my team or a unit I reach
115
+ -- 7. this record type is org-wide for this tenant, AND nobody has named the
116
+ -- units it belongs to → the ordinary case
117
+ -- 8. this record type is unit-scoped (or is owner-scoped and has no owner at
118
+ -- all), AND the row's unit is one I reach, or it has no unit and nobody
119
+ -- named units for it
120
+ --
121
+ -- Rule 7's second half and rule 8's are the same idea, and it is what makes
122
+ -- "Botox in 2 of 15" work with no extra table: a record with NO unit grant
123
+ -- belongs to the whole brand; the moment somebody grants it to two units, the
124
+ -- org-wide pass stops applying and only those two units (rule 6) see it.
125
+ --
126
+ -- An ownerless row can never be private (rule 8's parenthesis). Records that
127
+ -- predate 025 have no author and therefore no owner, and a tenant switching a
128
+ -- record type to "only the owner" must not make its whole history vanish.
129
+ --
130
+ -- WRITE. You may stamp a row with no unit, or with a unit you actually reach,
131
+ -- and you may not hand a record to somebody else. Reassigning a record AWAY
132
+ -- from yourself is allowed — that is a legitimate handoff, and losing your own
133
+ -- access is the correct consequence. Claiming someone else's is blocked by the
134
+ -- USING clause, which the pre-image must satisfy before WITH CHECK ever runs.
135
+ --
136
+ -- Writing a row with NO unit is deliberately permitted: product decision — a
137
+ -- NULL unit is a legitimate, permanent state ("belongs to the whole
138
+ -- organization"), and forbidding it would break every insert path the day a
139
+ -- tenant flips the switch.
140
+ --
141
+ -- WHERE THIS GUARANTEE STOPS. `service_role` has BYPASSRLS, so every edge
142
+ -- function reads across units by construction; any edge function returning rows
143
+ -- to a person must filter for itself. The sharpest edge is public.agent_guard
144
+ -- (015), which has no unit dimension yet — an agent RPC acting for a
145
+ -- unit-restricted user is not restricted. That is tracked, not solved here.
@@ -0,0 +1,81 @@
1
+ -- ============================================================================
2
+ -- 043_view_invoker.sql — the views that were never anybody's tenant.
3
+ --
4
+ -- THIS IS NOT PART OF UNIT SCOPING. It is a cross-tenant hole that predates it,
5
+ -- found while auditing what 042's predicate would and would not reach, and it
6
+ -- has to close first or "the guarantee is the API's" is false on the day it
7
+ -- ships.
8
+ --
9
+ -- A Postgres view runs with the privileges of its OWNER unless it is created
10
+ -- `WITH (security_invoker = true)`. Migrations here are applied as `postgres`,
11
+ -- who owns the tables, and a table owner is exempt from that table's RLS unless
12
+ -- the table is set FORCE ROW LEVEL SECURITY — which nothing in this tree does.
13
+ -- Meanwhile 001_core and 008_grants hand `authenticated` a blanket SELECT.
14
+ --
15
+ -- Put together, on a shared industry pool — one Supabase project carrying
16
+ -- several clinics — any signed-in user of ANY tenant could
17
+ --
18
+ -- SELECT * FROM public.v_documents;
19
+ --
20
+ -- and read every tenant's patient documents. Same for invoice balances and the
21
+ -- agenda's five report views. Twenty-one views in the tree already carry
22
+ -- `security_invoker`; these did not, and nothing distinguishes them except who
23
+ -- wrote them.
24
+ --
25
+ -- WHY A CORE SWEEP AND NOT A FIX IN EACH PLUGIN. Fixing it where it lives means
26
+ -- a new migration in plugin-forms, plugin-financial, plugin-agenda and courses,
27
+ -- each of which only reaches a pool when that plugin is next applied. The hole
28
+ -- is open now, on live pools, and one file that closes it everywhere is worth
29
+ -- the ownership smudge. The plugins should still carry the fix forward in their
30
+ -- own next migration; this file is idempotent and will simply agree with them.
31
+ --
32
+ -- WHAT IS DELIBERATELY LEFT ALONE — the three anon-facing views:
33
+ --
34
+ -- public.plg_shop_catalog the storefront's product list
35
+ -- public.v_public_services public booking
36
+ -- public.v_public_blog_posts the public blog
37
+ --
38
+ -- Those are MEANT to answer without a session, and flipping them to invoker
39
+ -- makes them resolve as `anon`, which returns rows only if every table they
40
+ -- touch carries an anon policy. Some do (shop 0020's storefront_public_read),
41
+ -- some are unverified. Turning a storefront dark is not an acceptable way to
42
+ -- fix a leak in a report screen, so they are handled separately, against a live
43
+ -- pool, with the storefront actually open in a browser.
44
+ --
45
+ -- ALTER VIEW ... SET is additive and idempotent. Every statement is guarded, so
46
+ -- a pool without a given plugin simply skips it.
47
+ -- ============================================================================
48
+
49
+ DO $$
50
+ DECLARE
51
+ v text;
52
+ -- Named one by one rather than swept, so this file can never silently reach a
53
+ -- view somebody adds later for anon.
54
+ leaky text[] := ARRAY[
55
+ -- plugin-forms: the patient record, and the one that joins it to people
56
+ 'v_documents',
57
+ 'v_frm_documents',
58
+ -- plugin-financial: what is still owed, per invoice
59
+ 'v_invoice_balances',
60
+ -- plugin-agenda: the five report read-models
61
+ 'rep_appointments_by_period',
62
+ 'rep_confirmation_queue',
63
+ 'rep_new_clients',
64
+ 'rep_revenue_by_professional',
65
+ 'rep_revenue_by_service',
66
+ -- courses
67
+ 'plg_courses_rep_revenue'
68
+ ];
69
+ BEGIN
70
+ FOREACH v IN ARRAY leaky LOOP
71
+ -- to_regclass and not information_schema: a pool has whichever plugins it
72
+ -- has, and the ledger is not a reliable account of that.
73
+ CONTINUE WHEN to_regclass('public.' || v) IS NULL;
74
+ EXECUTE format('ALTER VIEW public.%I SET (security_invoker = true)', v);
75
+ END LOOP;
76
+ END $$;
77
+
78
+ -- What changes on screen: a report that was quietly summing other tenants' rows
79
+ -- now sums only its own. That is a smaller number and it is the correct one —
80
+ -- but it IS a visible change, which is why this lives in its own file and can
81
+ -- be rolled back on its own.
@@ -0,0 +1,47 @@
1
+ -- ============================================================================
2
+ -- 044_unit_member_facts.sql — the two facts a unit roster is asked for.
3
+ --
4
+ -- "Quem trabalha aqui" is never only a list of names. The two questions that
5
+ -- follow it are always the same: since when, and are they actually using it.
6
+ --
7
+ -- since when → location_members.created_at, which 001 never gave it.
8
+ -- last access → auth.users.last_sign_in_at, which no client role can read,
9
+ -- and should not be able to: that table also holds password
10
+ -- hashes and recovery tokens.
11
+ --
12
+ -- So the second one comes through a function that returns ONLY the two harmless
13
+ -- columns, only for members of a tenant the caller administers. An admin can
14
+ -- already see every member's name and e-mail on the team screen; when they last
15
+ -- signed in is strictly less than that, and it is the difference between "we
16
+ -- gave Ana access" and "Ana has been using it".
17
+ --
18
+ -- Additive and idempotent.
19
+ -- ============================================================================
20
+
21
+ ALTER TABLE public.location_members
22
+ ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
23
+
24
+ COMMENT ON COLUMN public.location_members.created_at IS
25
+ 'When this person was given access to this unit.';
26
+
27
+ -- Rows that predate the column all default to now(), which would claim everyone
28
+ -- was bound today. Left as-is deliberately: a wrong date is worse than a vague
29
+ -- one, and the screen shows nothing older than the column itself is.
30
+
31
+ CREATE OR REPLACE FUNCTION public.tenant_member_activity(p_tenant_id uuid)
32
+ RETURNS TABLE (user_id uuid, last_sign_in_at timestamptz, signed_up_at timestamptz)
33
+ LANGUAGE sql STABLE SECURITY DEFINER
34
+ SET search_path = public, pg_temp
35
+ AS $$
36
+ SELECT u.id, u.last_sign_in_at, u.created_at
37
+ FROM auth.users u
38
+ JOIN public.tenant_members tm ON tm.user_id = u.id
39
+ WHERE tm.tenant_id = p_tenant_id
40
+ -- The gate, and the only one: an admin of THIS tenant, and nobody else.
41
+ -- Without it the function is a directory of every last_sign_in_at in the
42
+ -- pool, reachable by anyone with an anon key.
43
+ AND p_tenant_id IN (SELECT public.user_admin_tenant_ids());
44
+ $$;
45
+
46
+ REVOKE ALL ON FUNCTION public.tenant_member_activity(uuid) FROM public, anon;
47
+ GRANT EXECUTE ON FUNCTION public.tenant_member_activity(uuid) TO authenticated, service_role;
@@ -0,0 +1,236 @@
1
+ -- ============================================================================
2
+ -- 045_unit_entry.sql — entrar no sistema já na unidade certa.
3
+ --
4
+ -- A fronteira de unidade existe desde a 039. O que faltava era a pergunta que
5
+ -- todo funcionário de rede responde toda manhã: "em qual loja eu estou?".
6
+ --
7
+ -- Responder isso com um seletor vazio é empurrar trabalho para quem só queria
8
+ -- abrir a agenda. Três fatos resolvem quase todos os casos, e nenhum deles
9
+ -- precisa de permissão do navegador:
10
+ --
11
+ -- 1. o que ESTA MÁQUINA abriu por último → a recepcionista do balcão da
12
+ -- Ipanema usa sempre o mesmo computador, e ele fica na Ipanema;
13
+ -- 2. o que ESTA PESSOA abriu por último em qualquer lugar → ela trocou de
14
+ -- máquina, mas não de hábito;
15
+ -- 3. só existe uma unidade ao alcance dela → não há escolha a fazer.
16
+ --
17
+ -- Só o que sobra depois disso justifica pedir a localização do navegador, e por
18
+ -- isso as coordenadas aqui são OPCIONAIS e a tela só oferece o botão quando
19
+ -- elas existem. Ninguém leva um pedido de permissão de GPS por ter feito login.
20
+ --
21
+ -- NADA AQUI CONCEDE ACESSO. O pino é preferência, não autorização: a unidade
22
+ -- ativa só ESTREITA o que o RLS já deixou passar (ver 042). Um pino apontando
23
+ -- para uma unidade que a pessoa não alcança não vaza nada — é ignorado na
24
+ -- leitura e recusado na escrita, e as duas coisas estão abaixo.
25
+ --
26
+ -- Aditiva e idempotente.
27
+ -- ============================================================================
28
+
29
+ -- ── Coordenadas da unidade (opcionais) ──────────────────────────────────────
30
+ -- Sem serviço de geocodificação: quem cadastra a unidade informa, ou não
31
+ -- informa. Nulo é o estado normal, e "achar a mais perto" simplesmente não é
32
+ -- oferecido para uma rede que nunca preencheu isso.
33
+ ALTER TABLE public.locations
34
+ ADD COLUMN IF NOT EXISTS latitude numeric(9,6),
35
+ ADD COLUMN IF NOT EXISTS longitude numeric(9,6);
36
+
37
+ DO $$
38
+ BEGIN
39
+ IF NOT EXISTS (
40
+ SELECT 1 FROM pg_constraint WHERE conname = 'locations_coords_range'
41
+ ) THEN
42
+ ALTER TABLE public.locations
43
+ ADD CONSTRAINT locations_coords_range CHECK (
44
+ (latitude IS NULL OR (latitude BETWEEN -90 AND 90)) AND
45
+ (longitude IS NULL OR (longitude BETWEEN -180 AND 180))
46
+ ) NOT VALID;
47
+ END IF;
48
+ END $$;
49
+
50
+ COMMENT ON COLUMN public.locations.latitude IS
51
+ 'Opcional. Só serve para oferecer "a unidade mais perto de mim" na entrada.';
52
+
53
+ -- ── O pino, por pessoa, por conta, por máquina ──────────────────────────────
54
+ -- A chave inclui o dispositivo porque a máquina é um proxy melhor de "onde eu
55
+ -- estou fisicamente" do que a conta: o mesmo gerente abre o Leblon no celular
56
+ -- de casa à noite e a Ipanema no balcão de manhã, e nenhuma das duas escolhas
57
+ -- deveria estragar a outra.
58
+ --
59
+ -- Não existe linha "da conta": a preferência da conta é a linha mais recente
60
+ -- entre os dispositivos. Um estado derivado não pode divergir do outro.
61
+ CREATE TABLE IF NOT EXISTS public.user_unit_prefs (
62
+ user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
63
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
64
+ -- Opaco e sorteado no navegador, nunca uma impressão digital do aparelho:
65
+ -- ele só precisa distinguir "este computador" de "aquele", e um identificador
66
+ -- que não descreve nada não pode ser usado para mais nada.
67
+ device_id text NOT NULL,
68
+ -- Nulo = a pessoa escolheu "todas as unidades" explicitamente, que é uma
69
+ -- escolha e não uma ausência de escolha.
70
+ unit_id uuid REFERENCES public.locations(id) ON DELETE CASCADE,
71
+ updated_at timestamptz NOT NULL DEFAULT now(),
72
+ PRIMARY KEY (user_id, tenant_id, device_id)
73
+ );
74
+
75
+ CREATE INDEX IF NOT EXISTS user_unit_prefs_recent_idx
76
+ ON public.user_unit_prefs (user_id, tenant_id, updated_at DESC);
77
+
78
+ COMMENT ON TABLE public.user_unit_prefs IS
79
+ 'Onde cada pessoa estava por último, por máquina. Preferência de entrada — nunca autorização.';
80
+
81
+ ALTER TABLE public.user_unit_prefs ENABLE ROW LEVEL SECURITY;
82
+
83
+ -- Permissivas: eu leio e escrevo o meu pino, e só o meu.
84
+ DROP POLICY IF EXISTS "user_unit_prefs_own" ON public.user_unit_prefs;
85
+ CREATE POLICY "user_unit_prefs_own" ON public.user_unit_prefs
86
+ FOR ALL TO authenticated
87
+ USING (user_id = (SELECT auth.uid()))
88
+ WITH CHECK (user_id = (SELECT auth.uid()));
89
+
90
+ -- Restritivas, pelo mesmo motivo da 040: esta tabela tem `tenant_id` e não está
91
+ -- na lista de tabelas do núcleo, então a varredura da 002 a redescobre a cada
92
+ -- reaplicação e acrescenta uma policy PERMISSIVA por conta inteira. Permissivas
93
+ -- se somam com OU — sem o par restritivo abaixo, um colega da mesma conta
94
+ -- passaria a ler (e a escrever) em que unidade eu estava.
95
+ DROP POLICY IF EXISTS "user_unit_prefs_select_guard" ON public.user_unit_prefs;
96
+ CREATE POLICY "user_unit_prefs_select_guard" ON public.user_unit_prefs
97
+ AS RESTRICTIVE FOR SELECT TO authenticated
98
+ USING (user_id = (SELECT auth.uid()));
99
+
100
+ DROP POLICY IF EXISTS "user_unit_prefs_insert_guard" ON public.user_unit_prefs;
101
+ CREATE POLICY "user_unit_prefs_insert_guard" ON public.user_unit_prefs
102
+ AS RESTRICTIVE FOR INSERT TO authenticated
103
+ WITH CHECK (user_id = (SELECT auth.uid()));
104
+
105
+ DROP POLICY IF EXISTS "user_unit_prefs_update_guard" ON public.user_unit_prefs;
106
+ CREATE POLICY "user_unit_prefs_update_guard" ON public.user_unit_prefs
107
+ AS RESTRICTIVE FOR UPDATE TO authenticated
108
+ USING (user_id = (SELECT auth.uid()))
109
+ WITH CHECK (user_id = (SELECT auth.uid()));
110
+
111
+ DROP POLICY IF EXISTS "user_unit_prefs_delete_guard" ON public.user_unit_prefs;
112
+ CREATE POLICY "user_unit_prefs_delete_guard" ON public.user_unit_prefs
113
+ AS RESTRICTIVE FOR DELETE TO authenticated
114
+ USING (user_id = (SELECT auth.uid()));
115
+
116
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.user_unit_prefs TO authenticated;
117
+ GRANT ALL ON public.user_unit_prefs TO service_role;
118
+
119
+ -- Um navegador novo a cada limpeza de cookies gera um device_id novo, e sem
120
+ -- teto a tabela acumularia uma linha por sessão para sempre. Cinco é mais do
121
+ -- que qualquer pessoa usa e menos do que qualquer coisa que incomode.
122
+ CREATE OR REPLACE FUNCTION public.trim_user_unit_prefs()
123
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER
124
+ SET search_path = public, pg_temp AS $$
125
+ BEGIN
126
+ DELETE FROM public.user_unit_prefs p
127
+ WHERE p.user_id = NEW.user_id
128
+ AND p.tenant_id = NEW.tenant_id
129
+ AND p.device_id NOT IN (
130
+ SELECT device_id FROM public.user_unit_prefs
131
+ WHERE user_id = NEW.user_id AND tenant_id = NEW.tenant_id
132
+ ORDER BY updated_at DESC
133
+ LIMIT 5
134
+ );
135
+ RETURN NULL;
136
+ END $$;
137
+
138
+ DROP TRIGGER IF EXISTS user_unit_prefs_trim ON public.user_unit_prefs;
139
+ CREATE TRIGGER user_unit_prefs_trim
140
+ AFTER INSERT OR UPDATE ON public.user_unit_prefs
141
+ FOR EACH ROW EXECUTE FUNCTION public.trim_user_unit_prefs();
142
+
143
+ -- ── Gravar o pino ───────────────────────────────────────────────────────────
144
+ -- Por função e não por upsert direto para poder recusar um pino impossível. Um
145
+ -- pino inválido não vazaria nada (ele só estreita), mas uma tabela de
146
+ -- preferências que guarda unidades inalcançáveis mente para quem a lê depois.
147
+ CREATE OR REPLACE FUNCTION public.set_entry_unit(
148
+ p_tenant_id uuid,
149
+ p_unit_id uuid,
150
+ p_device_id text
151
+ )
152
+ RETURNS void LANGUAGE plpgsql SECURITY INVOKER
153
+ SET search_path = public, pg_temp AS $$
154
+ BEGIN
155
+ IF p_device_id IS NULL OR length(trim(p_device_id)) = 0 THEN
156
+ RAISE EXCEPTION 'device_id obrigatório';
157
+ END IF;
158
+
159
+ -- "Todas as unidades" (nulo) é sempre válido. Uma unidade só é aceita se a
160
+ -- pessoa a alcança de verdade — a mesma função que o RLS usa, não uma cópia.
161
+ IF p_unit_id IS NOT NULL AND p_unit_id NOT IN (SELECT public.user_unit_ids()) THEN
162
+ RAISE EXCEPTION 'unidade fora do alcance';
163
+ END IF;
164
+
165
+ INSERT INTO public.user_unit_prefs (user_id, tenant_id, device_id, unit_id, updated_at)
166
+ VALUES ((SELECT auth.uid()), p_tenant_id, p_device_id, p_unit_id, now())
167
+ ON CONFLICT (user_id, tenant_id, device_id)
168
+ DO UPDATE SET unit_id = EXCLUDED.unit_id, updated_at = now();
169
+ END $$;
170
+
171
+ GRANT EXECUTE ON FUNCTION public.set_entry_unit(uuid, uuid, text) TO authenticated;
172
+
173
+ -- ── Tudo que a entrada precisa, numa viagem ─────────────────────────────────
174
+ -- A tela pagava cinco idas ao servidor antes de saber em que unidade estava:
175
+ -- chave da conta, árvore, vínculos, registro de tabelas escopadas e pinos. Em
176
+ -- rede móvel isso é o intervalo inteiro entre o login e a primeira lista — e
177
+ -- durante ele a lista já pintou, sem unidade, mostrando linhas que o seletor
178
+ -- diz não estar mostrando.
179
+ --
180
+ -- SECURITY INVOKER de propósito: tudo aqui dentro passa pelo RLS de quem
181
+ -- chamou. Esta função junta viagens, não privilégios.
182
+ CREATE OR REPLACE FUNCTION public.unit_bootstrap(
183
+ p_tenant_id uuid,
184
+ p_device_id text DEFAULT NULL
185
+ )
186
+ RETURNS jsonb LANGUAGE sql STABLE SECURITY INVOKER
187
+ SET search_path = public, pg_temp AS $$
188
+ SELECT jsonb_build_object(
189
+ 'mode', COALESCE((
190
+ SELECT t.unit_scoping_enabled FROM public.tenants t WHERE t.id = p_tenant_id
191
+ ), false),
192
+ 'units', COALESCE((
193
+ SELECT jsonb_agg(to_jsonb(l) ORDER BY l.is_headquarters DESC NULLS LAST, l.name)
194
+ FROM public.locations l WHERE l.tenant_id = p_tenant_id
195
+ ), '[]'::jsonb),
196
+ 'bindings', COALESCE((
197
+ SELECT jsonb_agg(jsonb_build_object('location_id', lm.location_id, 'role', lm.role))
198
+ FROM public.location_members lm WHERE lm.user_id = (SELECT auth.uid())
199
+ ), '[]'::jsonb),
200
+ 'scoped', COALESCE((
201
+ SELECT jsonb_object_agg(sr.resource_table, jsonb_build_object(
202
+ 'unit_column', sr.unit_column, 'shareable', sr.shareable))
203
+ FROM public.scoped_resources sr
204
+ ), '{}'::jsonb),
205
+ -- O pino desta máquina ganha do pino da conta quando os dois existem: a
206
+ -- máquina fica na loja, a conta viaja com a pessoa.
207
+ 'device_pin', (
208
+ SELECT p.unit_id FROM public.user_unit_prefs p
209
+ WHERE p.user_id = (SELECT auth.uid())
210
+ AND p.tenant_id = p_tenant_id
211
+ AND p.device_id = p_device_id
212
+ ),
213
+ 'device_pin_set', EXISTS (
214
+ SELECT 1 FROM public.user_unit_prefs p
215
+ WHERE p.user_id = (SELECT auth.uid())
216
+ AND p.tenant_id = p_tenant_id
217
+ AND p.device_id = p_device_id
218
+ ),
219
+ 'account_pin', (
220
+ SELECT p.unit_id FROM public.user_unit_prefs p
221
+ WHERE p.user_id = (SELECT auth.uid())
222
+ AND p.tenant_id = p_tenant_id
223
+ ORDER BY p.updated_at DESC
224
+ LIMIT 1
225
+ ),
226
+ 'account_pin_set', EXISTS (
227
+ SELECT 1 FROM public.user_unit_prefs p
228
+ WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id
229
+ )
230
+ );
231
+ $$;
232
+
233
+ GRANT EXECUTE ON FUNCTION public.unit_bootstrap(uuid, text) TO authenticated;
234
+
235
+ COMMENT ON FUNCTION public.unit_bootstrap(uuid, text) IS
236
+ 'Uma viagem para tudo que a entrada precisa. Invoker: agrupa requisições, não privilégios.';
@@ -0,0 +1,85 @@
1
+ -- ============================================================================
2
+ -- 046_membership_visible_to_members.sql — a tela de equipe consegue ler a
3
+ -- própria equipe.
4
+ --
5
+ -- `members_select` da 001 é `user_id = auth.uid()`: cada pessoa enxerga **só a
6
+ -- própria linha** de `tenant_members`. A consequência não é sutil.
7
+ --
8
+ -- A tela de Equipe monta a lista a partir de `people` e usa `tenant_members`
9
+ -- como camada de acesso por cima. Com a policy de hoje essa camada tem uma
10
+ -- linha. Na prática:
11
+ --
12
+ -- * a coluna "função" só está certa para quem está olhando;
13
+ -- * quem tem login e NÃO tem cadastro de pessoa (o dono que criou o espaço, o
14
+ -- contador, o sócio) simplesmente **não aparece** — a tela de "quem tem
15
+ -- acesso" omite justamente quem tem acesso;
16
+ -- * a mesma cegueira atinge o vínculo `person_id`, então o sistema não
17
+ -- consegue dizer que a Dra. Silvania do cadastro e o login dela são a mesma
18
+ -- pessoa, mesmo tendo a coluna para isso desde a 019.
19
+ --
20
+ -- Verificado num pool vivo antes de mexer: numa conta com três membros, um
21
+ -- administrador logado lia **um**.
22
+ --
23
+ -- Função de colega não é segredo em lugar nenhum — é o que toda ferramenta de
24
+ -- trabalho mostra na lista de gente. E é a diferença entre uma tela de acesso e
25
+ -- uma tela que finge ser uma.
26
+ --
27
+ -- ESCREVER continua sendo de administrador. `members_update` e `members_delete`
28
+ -- não são tocados: passar a enxergar quem está na equipe não é passar a poder
29
+ -- mudar o papel de ninguém.
30
+ --
31
+ -- `tenant_members` está na lista de tabelas do núcleo da 002, então a varredura
32
+ -- não a alcança e não existe policy permissiva chegando por trás — não precisa
33
+ -- de par restritivo, ao contrário das tabelas novas da 040 e da 045.
34
+ --
35
+ -- Aditiva e idempotente.
36
+ -- ============================================================================
37
+
38
+ -- Uma policy numa tabela sem RLS ligado é uma mentira: ela existe no catálogo e
39
+ -- não filtra nada. A 001 já liga, então aqui é no-op em qualquer pool de
40
+ -- verdade — e é o que faz este arquivo bastar por si em qualquer outro.
41
+ ALTER TABLE public.tenant_members ENABLE ROW LEVEL SECURITY;
42
+
43
+ DROP POLICY IF EXISTS "members_select" ON public.tenant_members;
44
+ CREATE POLICY "members_select" ON public.tenant_members
45
+ FOR SELECT
46
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
47
+
48
+ -- ── E o nome de quem está na lista ──────────────────────────────────────────
49
+ -- Só ampliar `tenant_members` deixaria a tela pior, não melhor: as linhas dos
50
+ -- colegas passariam a aparecer **em branco**. Quem tem login e não tem ficha é
51
+ -- nomeado a partir de `profiles`, e `profiles_select` da 001 é
52
+ -- `id = auth.uid()` — cada um só lê o próprio nome.
53
+ --
54
+ -- `profiles` tem exatamente id, e-mail, nome e foto. Nome e foto são a razão de
55
+ -- a tabela existir, e o e-mail do colega a tela de equipe já mostra pela ficha
56
+ -- e pelo convite. O que não pode acontecer é ler gente de outra empresa, e é
57
+ -- por isso que o segundo braço passa por `tenant_members` (que acabou de ficar
58
+ -- limitado às minhas contas) em vez de liberar geral.
59
+ --
60
+ -- O braço é uma subconsulta NÃO correlacionada: um InitPlan por consulta, não
61
+ -- uma busca por linha. É a mesma disciplina do predicado de unidade da 042.
62
+ DROP POLICY IF EXISTS "profiles_select" ON public.profiles;
63
+ CREATE POLICY "profiles_select" ON public.profiles
64
+ FOR SELECT
65
+ USING (
66
+ id = (SELECT auth.uid())
67
+ OR id IN (
68
+ SELECT tm.user_id FROM public.tenant_members tm
69
+ WHERE tm.tenant_id IN (SELECT public.user_tenant_ids())
70
+ )
71
+ );
72
+
73
+ -- O ledger não é confiável em todo pool (há tabela aplicada à mão fora dele),
74
+ -- então a coluna é conferida em vez de presumida.
75
+ DO $$
76
+ BEGIN
77
+ IF EXISTS (
78
+ SELECT 1 FROM information_schema.columns
79
+ WHERE table_schema = 'public' AND table_name = 'tenant_members'
80
+ AND column_name = 'person_id'
81
+ ) THEN
82
+ EXECUTE 'COMMENT ON COLUMN public.tenant_members.person_id IS '
83
+ || quote_literal('O cadastro de pessoa que É este login. Nulo é legítimo: o dono que criou o espaço nunca ganha ficha, e metade de uma equipe de salão nunca faz login.');
84
+ END IF;
85
+ END $$;