@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,266 @@
1
+ -- ============================================================================
2
+ -- 047_tasks.sql — public.tasks: the one table admin work lives in.
3
+ --
4
+ -- WHY THIS IS SPINE AND NOT A PLUGIN TABLE. plugin-tasks has carried
5
+ -- plg_tasks_tasks since its first release, with almost exactly these columns.
6
+ -- But only three of the shipped apps install that plugin, and the shell itself
7
+ -- now writes tasks: the workspace setup wizard turns its "getting started"
8
+ -- checklist into rows, assigned to whoever ran it. A checklist that silently
9
+ -- writes nothing on the apps without the plugin is not a feature. So the table
10
+ -- moves to the spine, where every app has it, and plugin-tasks becomes one of
11
+ -- several UIs over it (plugin migration 002 carries its rows across).
12
+ --
13
+ -- WHY `source` / `source_key` AND NOT A LABEL. plg_tasks_tasks marks a
14
+ -- machine-made task by pushing a string into `labels` (the notes bridge writes
15
+ -- 'note:<id>'). That works only because nothing renders it: TaskLabelBadge
16
+ -- returns null for any id that is not a real label row. A column that says who
17
+ -- put the row here is filterable, indexable and visible, and the unique index
18
+ -- below is what makes seeding idempotent — a wizard run twice writes the same
19
+ -- N rows, not 2N.
20
+ --
21
+ -- Idempotent.
22
+ -- ============================================================================
23
+
24
+ CREATE TABLE IF NOT EXISTS public.tasks (
25
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
26
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
27
+
28
+ title text NOT NULL,
29
+ description text,
30
+
31
+ status text NOT NULL DEFAULT 'todo'
32
+ CHECK (status IN ('todo', 'in_progress', 'done', 'cancelled')),
33
+ priority text NOT NULL DEFAULT 'medium'
34
+ CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
35
+
36
+ due_date date,
37
+ -- When it was closed, as distinct from `status`. The onboarding checklist
38
+ -- reads it as a latch: a step whose sensor once fired is never re-probed, so
39
+ -- reopening a task by hand actually sticks.
40
+ completed_at timestamptz,
41
+
42
+ -- Who answers for it. Kept alongside a denormalised name because the panel
43
+ -- draws initials for people the reader may not be allowed to look up.
44
+ assigned_to_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
45
+ assigned_to_name text,
46
+
47
+ parent_id uuid REFERENCES public.tasks(id) ON DELETE CASCADE,
48
+ labels jsonb NOT NULL DEFAULT '[]'::jsonb,
49
+ position integer NOT NULL DEFAULT 0,
50
+
51
+ -- Which feature put the row here ('onboarding', 'agent', …) and its stable
52
+ -- key within that feature. Both NULL when a person typed the task.
53
+ source text,
54
+ source_key text,
55
+
56
+ created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
57
+ created_by_name text,
58
+
59
+ -- The 041 signature. See the scoped_resources block at the foot of this file.
60
+ unit_id uuid REFERENCES public.locations(id) ON DELETE SET NULL,
61
+ owner_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
62
+
63
+ created_at timestamptz NOT NULL DEFAULT now(),
64
+ updated_at timestamptz NOT NULL DEFAULT now()
65
+ );
66
+
67
+ CREATE INDEX IF NOT EXISTS tasks_tenant_status_idx ON public.tasks (tenant_id, status);
68
+ CREATE INDEX IF NOT EXISTS tasks_parent_idx ON public.tasks (parent_id);
69
+ CREATE INDEX IF NOT EXISTS tasks_assignee_idx ON public.tasks (tenant_id, assigned_to_id);
70
+ CREATE INDEX IF NOT EXISTS tasks_due_idx ON public.tasks (tenant_id, due_date) WHERE due_date IS NOT NULL;
71
+ CREATE INDEX IF NOT EXISTS tasks_unit_idx ON public.tasks (tenant_id, unit_id);
72
+ CREATE INDEX IF NOT EXISTS tasks_owner_idx ON public.tasks (tenant_id, owner_id) WHERE owner_id IS NOT NULL;
73
+
74
+ -- DELIBERATELY NOT PARTIAL. PostgREST's upsert emits a bare `ON CONFLICT
75
+ -- (tenant_id, source, source_key)` with no index predicate, and a partial
76
+ -- unique index cannot be inferred from that — the seed would fail at runtime
77
+ -- and every wizard run would duplicate the checklist. NULLs are distinct in a
78
+ -- unique index, so hand-typed tasks (source IS NULL) never collide.
79
+ CREATE UNIQUE INDEX IF NOT EXISTS tasks_source_uniq
80
+ ON public.tasks (tenant_id, source, source_key);
81
+
82
+ -- ── Stamps ─────────────────────────────────────────────────────────────────
83
+ DROP TRIGGER IF EXISTS tasks_updated_at ON public.tasks;
84
+ CREATE TRIGGER tasks_updated_at BEFORE UPDATE ON public.tasks
85
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
86
+
87
+ -- COALESCEs, so the seed's explicit author survives (025).
88
+ --
89
+ -- Checked rather than assumed, for the same reason 041 checks for `created_by`:
90
+ -- a pool assembled by hand may never have run 025, and a migration that fails
91
+ -- on a drifted pool is a migration nobody can apply. Without the trigger a
92
+ -- hand-typed task simply has no author until 025 lands — the seeded rows carry
93
+ -- theirs explicitly, and stamp_scope_tasks below falls back to auth.uid().
94
+ DROP TRIGGER IF EXISTS tasks_created_by ON public.tasks;
95
+ DO $$
96
+ BEGIN
97
+ IF to_regprocedure('public.handle_created_by()') IS NOT NULL THEN
98
+ CREATE TRIGGER tasks_created_by BEFORE INSERT ON public.tasks
99
+ FOR EACH ROW EXECUTE FUNCTION public.handle_created_by();
100
+ ELSE
101
+ RAISE NOTICE '047: public.handle_created_by() is missing (025 not applied) — tasks.created_by will not be stamped';
102
+ END IF;
103
+ END $$;
104
+
105
+ -- 041 generates one of these per table it knows about, but it is applied and
106
+ -- checksum-frozen: it only ever iterates the list it shipped with. A table
107
+ -- added afterwards brings its own copy. Keep this body in step with the
108
+ -- generator in 041_scoped_columns.sql — including the fact that IT MUST NEVER
109
+ -- RAISE (SECURITY DEFINER RPCs insert here and a complaining BEFORE INSERT
110
+ -- trigger would take them down; validation is 042's WITH CHECK).
111
+ CREATE OR REPLACE FUNCTION public.stamp_scope_tasks() RETURNS trigger
112
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $body$
113
+ BEGIN
114
+ IF NEW.unit_id IS NULL THEN
115
+ NEW.unit_id := public.requested_unit();
116
+ END IF;
117
+ IF NEW.owner_id IS NULL THEN
118
+ NEW.owner_id := COALESCE(NEW.created_by, auth.uid());
119
+ END IF;
120
+ RETURN NEW;
121
+ EXCEPTION WHEN OTHERS THEN
122
+ RETURN NEW;
123
+ END $body$;
124
+
125
+ REVOKE ALL ON FUNCTION public.stamp_scope_tasks() FROM public, anon;
126
+
127
+ DROP TRIGGER IF EXISTS tasks_scope_stamp ON public.tasks;
128
+ -- AFTER tasks_created_by alphabetically is not a guarantee — Postgres fires
129
+ -- BEFORE triggers in name order, and 'tasks_created_by' < 'tasks_scope_stamp',
130
+ -- which is what lets the COALESCE above see the author it just stamped.
131
+ CREATE TRIGGER tasks_scope_stamp BEFORE INSERT ON public.tasks
132
+ FOR EACH ROW EXECUTE FUNCTION public.stamp_scope_tasks();
133
+
134
+ -- ── RLS ─────────────────────────────────────────────────────────────────────
135
+ -- 011 closed `anon` on new public tables but not `authenticated`, which is born
136
+ -- with the full grant. Stripped first, granted back deliberately. The blanket
137
+ -- tenant_isolation sweep in 002 ran long before this table existed, so the
138
+ -- policies below are written out rather than inherited.
139
+ REVOKE ALL ON public.tasks FROM anon, authenticated;
140
+
141
+ ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY;
142
+
143
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.tasks TO authenticated;
144
+
145
+ DROP POLICY IF EXISTS tasks_member_read ON public.tasks;
146
+ CREATE POLICY tasks_member_read ON public.tasks
147
+ FOR SELECT TO authenticated
148
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
149
+
150
+ DROP POLICY IF EXISTS tasks_member_insert ON public.tasks;
151
+ CREATE POLICY tasks_member_insert ON public.tasks
152
+ FOR INSERT TO authenticated
153
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
154
+
155
+ -- USING and WITH CHECK both: without the second, a member could move a task
156
+ -- into a tenant they do not belong to.
157
+ DROP POLICY IF EXISTS tasks_member_update ON public.tasks;
158
+ CREATE POLICY tasks_member_update ON public.tasks
159
+ FOR UPDATE TO authenticated
160
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
161
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
162
+
163
+ DROP POLICY IF EXISTS tasks_member_delete ON public.tasks;
164
+ CREATE POLICY tasks_member_delete ON public.tasks
165
+ FOR DELETE TO authenticated
166
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
167
+
168
+ -- ── Unit scoping ────────────────────────────────────────────────────────────
169
+ -- The registry row 042 loops over and the settings screen reads.
170
+ --
171
+ -- 'tenant' visibility, not 'unit': a task is assigned to a person, and a
172
+ -- checklist the owner is working through has to stay visible to the team that
173
+ -- is helping. owner_id is still stamped, so a tenant that switches tasks to
174
+ -- 'owner' in the visibility settings gets private lists without a migration.
175
+ -- Guarded on the registry existing at all: this pool proved drift is real and
176
+ -- not in file order (it had 039-042 but never ran 025), so "the spine applies
177
+ -- in sequence" is not something to bet the migration on.
178
+ DO $$
179
+ BEGIN
180
+ IF to_regclass('public.scoped_resources') IS NULL THEN
181
+ RAISE NOTICE '047: scoped_resources is missing (040 not applied) — tasks will not be unit-scoped';
182
+ RETURN;
183
+ END IF;
184
+
185
+ INSERT INTO public.scoped_resources
186
+ (resource_table, unit_column, owner_column, shareable, default_visibility, label)
187
+ VALUES
188
+ ('tasks', 'unit_id', 'owner_id', true, 'tenant', 'Tarefas')
189
+ ON CONFLICT (resource_table) DO UPDATE
190
+ SET unit_column = EXCLUDED.unit_column,
191
+ owner_column = EXCLUDED.owner_column,
192
+ shareable = EXCLUDED.shareable,
193
+ label = EXCLUDED.label;
194
+ -- default_visibility is NOT updated on conflict — same reason as 041: it is
195
+ -- the factory setting, and re-running must not walk back an operator's choice.
196
+ END $$;
197
+
198
+ -- The restrictive policy, from 042's generator. Same reason as the trigger
199
+ -- above: 042 is frozen and only saw the registry as it stood then. The predicate
200
+ -- text is read straight out of the registry so the two stay identical in shape;
201
+ -- if 042 is ever superseded, this block must be revisited with it.
202
+ DO $$
203
+ DECLARE
204
+ r record;
205
+ v_read text;
206
+ v_write text;
207
+ BEGIN
208
+ IF to_regclass('public.scoped_resources') IS NULL THEN
209
+ RETURN; -- no registry, no unit scoping — see the block above
210
+ END IF;
211
+
212
+ FOR r IN SELECT resource_table, unit_column FROM public.scoped_resources
213
+ WHERE resource_table = 'tasks' LOOP
214
+ CONTINUE WHEN to_regclass('public.' || r.resource_table) IS NULL;
215
+
216
+ v_read := format($p$
217
+ NOT (SELECT public.unit_scoping_active())
218
+ OR tenant_id NOT IN (SELECT public.unit_scoped_tenants())
219
+ OR tenant_id NOT IN (SELECT public.user_tenant_ids())
220
+ OR tenant_id IN (SELECT public.user_admin_tenant_ids())
221
+ OR %3$I = (SELECT auth.uid())
222
+ OR id IN (SELECT public.granted_record_ids(%1$L))
223
+ OR (
224
+ tenant_id IN (SELECT public.resource_mode_tenants(%1$L, 'tenant'))
225
+ AND id NOT IN (SELECT public.restricted_record_ids(%1$L))
226
+ )
227
+ OR (
228
+ (
229
+ tenant_id IN (SELECT public.resource_mode_tenants(%1$L, 'unit'))
230
+ OR (%3$I IS NULL AND tenant_id IN (SELECT public.resource_mode_tenants(%1$L, 'owner')))
231
+ )
232
+ AND (
233
+ %2$I IN (SELECT public.user_unit_ids())
234
+ OR (%2$I IS NULL AND id NOT IN (SELECT public.restricted_record_ids(%1$L)))
235
+ )
236
+ )
237
+ $p$, r.resource_table, r.unit_column, 'owner_id');
238
+
239
+ v_write := format($p$
240
+ NOT (SELECT public.unit_scoping_active())
241
+ OR tenant_id NOT IN (SELECT public.unit_scoped_tenants())
242
+ OR tenant_id NOT IN (SELECT public.user_tenant_ids())
243
+ OR tenant_id IN (SELECT public.user_admin_tenant_ids())
244
+ OR (
245
+ (%2$I IS NULL OR %2$I IN (SELECT public.user_unit_ids()))
246
+ AND (%3$I IS NULL OR %3$I = (SELECT auth.uid()))
247
+ )
248
+ $p$, r.resource_table, r.unit_column, 'owner_id');
249
+
250
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', r.resource_table);
251
+ EXECUTE format('DROP POLICY IF EXISTS unit_scope ON public.%I', r.resource_table);
252
+ -- `AS RESTRICTIVE` is the word this whole block depends on: without it the
253
+ -- policy ORs with the member policies above and grants MORE access, not
254
+ -- less. negative-control.sh deletes exactly this line and demands red.
255
+ EXECUTE format(
256
+ 'CREATE POLICY unit_scope ON public.%I '
257
+ || ' AS RESTRICTIVE '
258
+ || ' FOR ALL TO authenticated USING (%s) WITH CHECK (%s)',
259
+ r.resource_table, v_read, v_write);
260
+ END LOOP;
261
+ END $$;
262
+
263
+ COMMENT ON TABLE public.tasks IS
264
+ 'Admin tasks: assignments, status, due date. Spine-owned — the shell''s '
265
+ 'getting-started checklist seeds rows here with source = ''onboarding''. '
266
+ 'plugin-tasks renders this table; it no longer owns one.';
@@ -0,0 +1,190 @@
1
+ -- ============================================================================
2
+ -- 048_every_login_is_a_person.sql — quem entra no sistema é uma pessoa.
3
+ --
4
+ -- A 019 deixou o vínculo opcional nos dois sentidos, e documentou o porquê:
5
+ -- "login-only account (e.g. the owner who signed up) -> person_id NULL". Na
6
+ -- prática isso virou o estado NORMAL, não a exceção — em pool vivo, ZERO
7
+ -- memberships tinham ficha. E a consequência não é cosmética:
8
+ --
9
+ -- * a mesma pessoa existe duas vezes, uma como login e outra como ficha, e
10
+ -- nada no sistema diz que são a mesma;
11
+ -- * a tela de equipe lista as duas como se fossem duas pessoas;
12
+ -- * o perfil de quem entra não tem escala, comissão, documento nem endereço,
13
+ -- porque essas coisas moram na ficha e o login não alcança nenhuma.
14
+ --
15
+ -- O lado opcional continua verdadeiro e é legítimo: metade da equipe de um
16
+ -- salão nunca faz login, e essas fichas seguem sem membership. O que deixa de
17
+ -- ser opcional é o outro lado — **todo login tem ficha**.
18
+ --
19
+ -- POR QUE UM GATILHO, E NÃO CONSERTAR CADA CAMINHO. Membership nasce em pelo
20
+ -- menos três lugares: a criação da conta, a aceitação de convite (022, que
21
+ -- insere sem person_id) e o que cada app fizer por fora. Consertar os três
22
+ -- deixa o quarto para depois. BEFORE INSERT vale para todos, inclusive para o
23
+ -- SQL que alguém rodar à mão daqui a um ano.
24
+ --
25
+ -- POR QUE O GATILHO NUNCA LEVANTA EXCEÇÃO. Ele roda dentro da aceitação de
26
+ -- convite. Uma falha aqui derrubaria a entrada de alguém que acabou de aceitar
27
+ -- um convite legítimo — e ficar sem ficha é chato, ficar sem acesso é o
28
+ -- chamado de sábado. Falhou, a linha entra sem ficha e o backfill pega depois.
29
+ --
30
+ -- POR QUE `person_id` NÃO VIROU NOT NULL. A coluna é `ON DELETE SET NULL`: com
31
+ -- NOT NULL, apagar uma ficha passaria a falhar com erro de chave estrangeira em
32
+ -- cima de quem tem login, e a tela de cadastros não tem o que fazer com isso.
33
+ -- Trocar para CASCADE seria pior — apagar a ficha revogaria o acesso em
34
+ -- silêncio. A garantia fica onde ela é barata e completa: na criação.
35
+ --
36
+ -- Aditiva e idempotente.
37
+ -- ============================================================================
38
+
39
+ -- Qual `kind` uma ficha criada automaticamente recebe.
40
+ --
41
+ -- Configurável por conta porque cada vertical chama a própria equipe de um
42
+ -- jeito, e uma ficha com kind que o app não lista não aparece no cadastro dele.
43
+ -- 'staff' como padrão: é o que a frota usa. Um palpite adaptativo (olhar o kind
44
+ -- mais comum da conta) foi descartado — esperteza dentro de gatilho falha em
45
+ -- silêncio, e o custo de errar aqui é uma ficha no lugar errado.
46
+ CREATE OR REPLACE FUNCTION public.team_person_kind(p_tenant_id uuid)
47
+ RETURNS text LANGUAGE sql STABLE SECURITY DEFINER
48
+ SET search_path = public, pg_temp AS $$
49
+ SELECT COALESCE(
50
+ NULLIF(trim((SELECT t.settings ->> 'team_person_kind' FROM public.tenants t WHERE t.id = p_tenant_id)), ''),
51
+ 'staff'
52
+ );
53
+ $$;
54
+
55
+ COMMENT ON FUNCTION public.team_person_kind(uuid) IS
56
+ 'O kind que uma ficha criada para um login recebe. `settings.team_person_kind`, ou staff.';
57
+
58
+ -- ── A ficha de quem entra ───────────────────────────────────────────────────
59
+ CREATE OR REPLACE FUNCTION public.ensure_member_person()
60
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER
61
+ SET search_path = public, pg_temp AS $$
62
+ DECLARE
63
+ v_email text;
64
+ v_name text;
65
+ v_kind text;
66
+ v_id uuid;
67
+ BEGIN
68
+ IF NEW.person_id IS NOT NULL THEN
69
+ RETURN NEW;
70
+ END IF;
71
+
72
+ SELECT u.email INTO v_email FROM auth.users u WHERE u.id = NEW.user_id;
73
+ SELECT p.full_name INTO v_name FROM public.profiles p WHERE p.id = NEW.user_id;
74
+
75
+ -- O nome que a ficha nasce com. Sem nome no perfil, o pedaço antes do @ é
76
+ -- melhor do que "Sem nome": ele é reconhecível e a pessoa corrige em um
77
+ -- clique no próprio perfil.
78
+ v_name := COALESCE(NULLIF(trim(v_name), ''), NULLIF(split_part(COALESCE(v_email, ''), '@', 1), ''), 'Sem nome');
79
+ v_kind := public.team_person_kind(NEW.tenant_id);
80
+
81
+ -- Primeiro procura: a ficha da pessoa provavelmente já existe, cadastrada
82
+ -- por quem montou a equipe antes de ela ganhar login. Criar uma segunda seria
83
+ -- fabricar exatamente a duplicata que este arquivo existe para acabar.
84
+ --
85
+ -- `NOT EXISTS` contra memberships porque uma ficha só pertence a um login:
86
+ -- o índice único de 019 recusaria a segunda, e aí o INSERT inteiro cairia.
87
+ IF v_email IS NOT NULL AND v_email <> '' THEN
88
+ SELECT pe.id INTO v_id
89
+ FROM public.people pe
90
+ WHERE pe.tenant_id = NEW.tenant_id
91
+ AND lower(pe.email) = lower(v_email)
92
+ AND pe.kind = v_kind
93
+ AND NOT EXISTS (
94
+ SELECT 1 FROM public.tenant_members tm
95
+ WHERE tm.tenant_id = NEW.tenant_id AND tm.person_id = pe.id
96
+ )
97
+ ORDER BY pe.created_at
98
+ LIMIT 1;
99
+ END IF;
100
+
101
+ IF v_id IS NULL THEN
102
+ INSERT INTO public.people (tenant_id, kind, name, email)
103
+ VALUES (NEW.tenant_id, v_kind, v_name, v_email)
104
+ RETURNING id INTO v_id;
105
+ END IF;
106
+
107
+ NEW.person_id := v_id;
108
+ RETURN NEW;
109
+ EXCEPTION WHEN OTHERS THEN
110
+ -- Ver o cabeçalho: entrar sem ficha é recuperável, não entrar não é.
111
+ RETURN NEW;
112
+ END $$;
113
+
114
+ DROP TRIGGER IF EXISTS tenant_members_ensure_person ON public.tenant_members;
115
+ CREATE TRIGGER tenant_members_ensure_person
116
+ BEFORE INSERT ON public.tenant_members
117
+ FOR EACH ROW EXECUTE FUNCTION public.ensure_member_person();
118
+
119
+ -- ── E quem já estava dentro ─────────────────────────────────────────────────
120
+ -- O gatilho só alcança o futuro. Sem isto, todo mundo que já usa o sistema
121
+ -- continuaria sendo duas pessoas — que é o estado que motivou o arquivo.
122
+ --
123
+ -- Em função, e não solto, para poder ser chamado de novo num pool que receber
124
+ -- membership por fora depois. Devolve quantos ligou.
125
+ CREATE OR REPLACE FUNCTION public.backfill_member_persons()
126
+ RETURNS integer LANGUAGE plpgsql SECURITY DEFINER
127
+ SET search_path = public, pg_temp AS $$
128
+ DECLARE
129
+ r record;
130
+ v_email text;
131
+ v_name text;
132
+ v_kind text;
133
+ v_id uuid;
134
+ v_count integer := 0;
135
+ BEGIN
136
+ FOR r IN
137
+ SELECT tm.id, tm.tenant_id, tm.user_id
138
+ FROM public.tenant_members tm
139
+ WHERE tm.person_id IS NULL
140
+ ORDER BY tm.created_at
141
+ LOOP
142
+ BEGIN
143
+ SELECT u.email INTO v_email FROM auth.users u WHERE u.id = r.user_id;
144
+ SELECT p.full_name INTO v_name FROM public.profiles p WHERE p.id = r.user_id;
145
+ v_name := COALESCE(NULLIF(trim(v_name), ''), NULLIF(split_part(COALESCE(v_email, ''), '@', 1), ''), 'Sem nome');
146
+ v_kind := public.team_person_kind(r.tenant_id);
147
+ v_id := NULL;
148
+
149
+ IF v_email IS NOT NULL AND v_email <> '' THEN
150
+ SELECT pe.id INTO v_id
151
+ FROM public.people pe
152
+ WHERE pe.tenant_id = r.tenant_id
153
+ AND lower(pe.email) = lower(v_email)
154
+ AND pe.kind = v_kind
155
+ AND NOT EXISTS (
156
+ SELECT 1 FROM public.tenant_members tm2
157
+ WHERE tm2.tenant_id = r.tenant_id AND tm2.person_id = pe.id
158
+ )
159
+ ORDER BY pe.created_at
160
+ LIMIT 1;
161
+ END IF;
162
+
163
+ IF v_id IS NULL THEN
164
+ INSERT INTO public.people (tenant_id, kind, name, email)
165
+ VALUES (r.tenant_id, v_kind, v_name, v_email)
166
+ RETURNING id INTO v_id;
167
+ END IF;
168
+
169
+ UPDATE public.tenant_members SET person_id = v_id WHERE id = r.id;
170
+ v_count := v_count + 1;
171
+ EXCEPTION WHEN OTHERS THEN
172
+ -- Uma linha que não dá para ligar não pode impedir as outras.
173
+ CONTINUE;
174
+ END;
175
+ END LOOP;
176
+ RETURN v_count;
177
+ END $$;
178
+
179
+ SELECT public.backfill_member_persons();
180
+
181
+ -- Quem sobrou. Uma conta com número diferente de zero aqui tem gente que a tela
182
+ -- de equipe ainda mostra duas vezes, e é isso que o doctor deve olhar.
183
+ CREATE OR REPLACE FUNCTION public.unlinked_member_count()
184
+ RETURNS integer LANGUAGE sql STABLE SECURITY DEFINER
185
+ SET search_path = public, pg_temp AS $$
186
+ SELECT count(*)::integer FROM public.tenant_members WHERE person_id IS NULL;
187
+ $$;
188
+
189
+ GRANT EXECUTE ON FUNCTION public.team_person_kind(uuid) TO authenticated;
190
+ GRANT EXECUTE ON FUNCTION public.unlinked_member_count() TO authenticated;
@@ -0,0 +1,126 @@
1
+ -- ============================================================================
2
+ -- 049_bookable_people.sql — trabalhar aqui não é atender cliente.
3
+ --
4
+ -- A 048 passou a criar uma ficha para todo login, e a agenda lista como
5
+ -- profissional toda pessoa de `kind = staff` que esteja ativa. O resultado
6
+ -- apareceu na hora: o contador e o administrador viraram colunas na grade
7
+ -- semanal, com horário livre para alguém marcar em cima.
8
+ --
9
+ -- O sinal que faltava não é o cargo — é uma pergunta só, e ela é binária:
10
+ -- **esta pessoa atende cliente?** Um "tipo de staff" (recepção, profissional,
11
+ -- administrativo) responderia isso e mais coisas, mas exigiria uma taxonomia
12
+ -- por vertical — o que a clínica chama de recepção a escola chama de
13
+ -- secretaria — e uma taxonomia errada é mais difícil de desfazer do que um
14
+ -- campo a mais. Quando o tipo existir, ele deriva esta coluna; enquanto não
15
+ -- existe, esta coluna já responde o que a agenda precisa saber.
16
+ --
17
+ -- O PADRÃO SEGUE COMO A FICHA NASCEU, e é isso que evita as duas regressões
18
+ -- opostas:
19
+ --
20
+ -- * `DEFAULT true` para tudo que já existe — ninguém que já atende some da
21
+ -- agenda quando esta migration sobe;
22
+ -- * `false` para a ficha que a 048 cria — quem só entrou no sistema não vira
23
+ -- profissional por ter feito login.
24
+ --
25
+ -- Aditiva e idempotente.
26
+ -- ============================================================================
27
+
28
+ ALTER TABLE public.people
29
+ ADD COLUMN IF NOT EXISTS is_bookable boolean NOT NULL DEFAULT true;
30
+
31
+ COMMENT ON COLUMN public.people.is_bookable IS
32
+ 'Esta pessoa atende cliente e aparece na agenda. Ficha criada a partir de um login nasce false.';
33
+
34
+ -- ── A 048, agora sabendo a diferença ────────────────────────────────────────
35
+ -- Reescrita inteira em vez de um ALTER: a função é a mesma da 048 com uma
36
+ -- coluna a mais no INSERT, e duas versões parciais do mesmo corpo espalhadas em
37
+ -- dois arquivos é como uma delas fica para trás.
38
+ CREATE OR REPLACE FUNCTION public.ensure_member_person()
39
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER
40
+ SET search_path = public, pg_temp AS $$
41
+ DECLARE
42
+ v_email text; v_name text; v_kind text; v_id uuid;
43
+ BEGIN
44
+ IF NEW.person_id IS NOT NULL THEN RETURN NEW; END IF;
45
+
46
+ SELECT u.email INTO v_email FROM auth.users u WHERE u.id = NEW.user_id;
47
+ SELECT p.full_name INTO v_name FROM public.profiles p WHERE p.id = NEW.user_id;
48
+ v_name := COALESCE(NULLIF(trim(v_name), ''), NULLIF(split_part(COALESCE(v_email, ''), '@', 1), ''), 'Sem nome');
49
+ v_kind := public.team_person_kind(NEW.tenant_id);
50
+
51
+ IF v_email IS NOT NULL AND v_email <> '' THEN
52
+ SELECT pe.id INTO v_id FROM public.people pe
53
+ WHERE pe.tenant_id = NEW.tenant_id AND lower(pe.email) = lower(v_email) AND pe.kind = v_kind
54
+ AND NOT EXISTS (SELECT 1 FROM public.tenant_members tm
55
+ WHERE tm.tenant_id = NEW.tenant_id AND tm.person_id = pe.id)
56
+ ORDER BY pe.created_at LIMIT 1;
57
+ END IF;
58
+
59
+ IF v_id IS NULL THEN
60
+ -- `false` aqui é a linha inteira desta migration: entrar no sistema não é
61
+ -- passar a atender. Quem de fato atende é marcado por quem sabe.
62
+ INSERT INTO public.people (tenant_id, kind, name, email, is_bookable)
63
+ VALUES (NEW.tenant_id, v_kind, v_name, v_email, false)
64
+ RETURNING id INTO v_id;
65
+ END IF;
66
+
67
+ NEW.person_id := v_id;
68
+ RETURN NEW;
69
+ EXCEPTION WHEN OTHERS THEN
70
+ RETURN NEW;
71
+ END $$;
72
+
73
+ CREATE OR REPLACE FUNCTION public.backfill_member_persons()
74
+ RETURNS integer LANGUAGE plpgsql SECURITY DEFINER
75
+ SET search_path = public, pg_temp AS $$
76
+ DECLARE
77
+ r record; v_email text; v_name text; v_kind text; v_id uuid; v_count integer := 0;
78
+ BEGIN
79
+ FOR r IN SELECT tm.id, tm.tenant_id, tm.user_id FROM public.tenant_members tm
80
+ WHERE tm.person_id IS NULL ORDER BY tm.created_at
81
+ LOOP
82
+ BEGIN
83
+ SELECT u.email INTO v_email FROM auth.users u WHERE u.id = r.user_id;
84
+ SELECT p.full_name INTO v_name FROM public.profiles p WHERE p.id = r.user_id;
85
+ v_name := COALESCE(NULLIF(trim(v_name), ''), NULLIF(split_part(COALESCE(v_email, ''), '@', 1), ''), 'Sem nome');
86
+ v_kind := public.team_person_kind(r.tenant_id);
87
+ v_id := NULL;
88
+
89
+ IF v_email IS NOT NULL AND v_email <> '' THEN
90
+ SELECT pe.id INTO v_id FROM public.people pe
91
+ WHERE pe.tenant_id = r.tenant_id AND lower(pe.email) = lower(v_email) AND pe.kind = v_kind
92
+ AND NOT EXISTS (SELECT 1 FROM public.tenant_members tm2
93
+ WHERE tm2.tenant_id = r.tenant_id AND tm2.person_id = pe.id)
94
+ ORDER BY pe.created_at LIMIT 1;
95
+ END IF;
96
+
97
+ IF v_id IS NULL THEN
98
+ INSERT INTO public.people (tenant_id, kind, name, email, is_bookable)
99
+ VALUES (r.tenant_id, v_kind, v_name, v_email, false) RETURNING id INTO v_id;
100
+ END IF;
101
+
102
+ UPDATE public.tenant_members SET person_id = v_id WHERE id = r.id;
103
+ v_count := v_count + 1;
104
+ EXCEPTION WHEN OTHERS THEN CONTINUE;
105
+ END;
106
+ END LOOP;
107
+ RETURN v_count;
108
+ END $$;
109
+
110
+ -- ── E as fichas que a 048 já criou antes desta coluna existir ───────────────
111
+ -- Elas nasceram com o DEFAULT true e estão na agenda agora. A correção precisa
112
+ -- alcançar ESSAS e nenhuma outra, então ela não pergunta "quem tem login" — a
113
+ -- profissional dona da clínica também tem — e sim "quem tem login E nunca
114
+ -- apareceu em atendimento nenhum": sem agendamento, sem escala e sem pedido.
115
+ --
116
+ -- Uma profissional de verdade recém-contratada, ainda sem histórico, também cai
117
+ -- aqui e é desmarcada. É o lado certo para errar: ela some de uma grade onde
118
+ -- ainda não tinha nada, e volta com um clique — enquanto o contador marcado
119
+ -- como profissional recebe cliente.
120
+ UPDATE public.people pe
121
+ SET is_bookable = false
122
+ WHERE pe.is_bookable
123
+ AND EXISTS (SELECT 1 FROM public.tenant_members tm WHERE tm.person_id = pe.id)
124
+ AND NOT EXISTS (SELECT 1 FROM public.appointments a WHERE a.assignee_id = pe.id)
125
+ AND NOT EXISTS (SELECT 1 FROM public.schedules s WHERE s.assignee_id = pe.id)
126
+ AND NOT EXISTS (SELECT 1 FROM public.orders o WHERE o.assignee_id = pe.id);
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.10.0",
6
+ "version": "0.12.0",
7
7
  "description": "Fayz SDK database layer — Drizzle schema primitives, spine references, and migration helpers shared across plugins.",
8
8
  "type": "module",
9
9
  "sideEffects": false,
@@ -38,9 +38,12 @@
38
38
  ],
39
39
  "peerDependencies": {},
40
40
  "scripts": {
41
- "build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
41
+ "build": "tsup && tsc -b --force",
42
42
  "dev": "tsup --watch",
43
- "typecheck": "tsc --noEmit",
44
- "clean": "rm -rf dist"
43
+ "typecheck": "tsc -b",
44
+ "test": "bash test/run-migrations.sh",
45
+ "test:migrations": "bash test/run-migrations.sh",
46
+ "test:negative-control": "bash test/negative-control.sh",
47
+ "clean": "rm -rf dist .tsbuildinfo"
45
48
  }
46
49
  }