ganbatte-os 0.2.20 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ # T-085 — API Diagnóstico Individual (participante responde)
2
+
3
+ **Sprint:** S07 · **Due:** 2026-04-19 · **Prioridade:** URGENT · **Pts:** 5
4
+ **ClickUp:** https://app.clickup.com/t/86agn0217
5
+ **Assignee:** Douglas Oliveira
6
+
7
+ ---
8
+
9
+ ## 🚫 [BLOCKED-PRD 2026-04-17] — Aguarda T-084 redesenhada + ajuste de status
10
+
11
+ **Status:** BLOQUEADA.
12
+
13
+ **Motivo:**
14
+ 1. Depende da T-084 redesenhada (tokens individuais gerados em novo fluxo).
15
+ 2. Status `selecionado` não existe mais no enum do PRD vigente. Transição correta é `pré-selecionado → cadastrado` (M2 em [impacto-tasks-clickup.md](../../../packages/fractus/docs/regras-de-negocio/impacto-tasks-clickup.md)).
16
+ 3. BR-PRT-003 precisa ser reescrita: `pré-selecionado → cadastrado` automático ao responder.
17
+ 4. PRD define **resposta como somente leitura após envio** (M7) — ajustar idempotência para rejeitar qualquer re-submissão, não apenas via UNIQUE.
18
+
19
+ **Ações pendentes:**
20
+ 1. Substituir `status = 'selecionado'` por `status = 'cadastrado'` na RPC e no schema Zod (`statusParticipanteEnum`).
21
+ 2. Atualizar AC1 e AC2 com nomenclatura nova.
22
+ 3. Aguardar fechamento da T-084 redesenhada (tokens).
23
+ 4. Confirmar que auto-save de rascunho está fora do escopo (M8 — fora do MVP).
24
+
25
+ **Referências:**
26
+ - [packages/fractus/docs/prd.md](../../../packages/fractus/docs/prd.md) — §Tipos de status, §Respostas
27
+ - [packages/fractus/docs/regras-de-negocio/impacto-tasks-clickup.md](../../../packages/fractus/docs/regras-de-negocio/impacto-tasks-clickup.md) — M1, M2, M4, M7, M8
28
+
29
+ ---
30
+
31
+ ## ⚠️ Pré-requisito bloqueante
32
+
33
+ **T-084 DEVE estar fechada.** Este plano consome os `token_diagnostico` gerados pela RPC `submit_diagnostico_negocio`. Sem eles, não há lookup de participante por token.
34
+
35
+ Validar: `psql -c "SELECT COUNT(*) FROM participantes WHERE token_diagnostico IS NOT NULL;"` retorna > 0.
36
+
37
+ ## Contexto
38
+
39
+ Após T-084, cada participante pré-selecionado recebe um link individual:
40
+ `/diagnostico/individual/[token]`. Ao clicar, vê formulário com as perguntas do projeto, responde e submete.
41
+
42
+ **Fluxo:**
43
+ 1. Lookup participante por `token_diagnostico` (status deve ser `pre_selecionado`).
44
+ 2. Persistir respostas em `respostas_pesquisa` (uma linha por participante×instancia).
45
+ 3. Transição `participantes.status = 'selecionado'` (BR-PRT-003).
46
+ 4. Invalidar cache do projeto e do negócio.
47
+
48
+ **Regras:**
49
+ - BR-PRT-003: `pre_selecionado → selecionado` automática ao responder.
50
+ - **Idempotência:** segundo submit com mesmo participante deve retornar 422 (evitar sobrescrita acidental).
51
+ - Status ≠ `pre_selecionado` → 422 com código `INVALID_STATE`.
52
+
53
+ ## Arquivos afetados
54
+
55
+ | Arquivo | Ação |
56
+ |---|---|
57
+ | `packages/fractus/src/lib/validations/diagnostico.ts` | [EDITAR] adicionar `submitIndividualSchema` |
58
+ | `packages/fractus/src/app/actions/diagnostico/submit-individual.ts` | [NOVO] server action |
59
+ | `packages/fractus/src/app/actions/diagnostico/submit-individual.test.ts` | [NOVO] testes |
60
+ | `packages/fractus/src/app/diagnostico/individual/[token]/page.tsx` | [NOVO] página pública |
61
+ | `packages/fractus/src/app/diagnostico/individual/[token]/form-client.tsx` | [NOVO] form |
62
+ | `packages/fractus/supabase/migrations/2026041X_unique_resposta.sql` | [NOVO] UNIQUE (participante_id, instancia_id) |
63
+
64
+ ## Passo a passo
65
+
66
+ ### 0. Validar pré-requisito
67
+
68
+ ```bash
69
+ cd packages/fractus
70
+ psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM participantes WHERE token_diagnostico IS NOT NULL AND status='pre_selecionado';"
71
+ # Deve retornar > 0
72
+ ```
73
+
74
+ ### 1. Garantir idempotência no schema
75
+
76
+ Migration `2026041X_unique_resposta.sql`:
77
+ ```sql
78
+ ALTER TABLE respostas_pesquisa
79
+ ADD CONSTRAINT uniq_participante_instancia UNIQUE (participante_id, instancia_id);
80
+ ```
81
+
82
+ ### 2. Schema Zod
83
+
84
+ Adicionar em `src/lib/validations/diagnostico.ts`:
85
+
86
+ ```ts
87
+ export const submitIndividualSchema = z.object({
88
+ token: z.string().min(16),
89
+ instancia_id: z.string().uuid(),
90
+ respostas: z.array(z.object({
91
+ pergunta_id: z.string().uuid(),
92
+ valor: z.union([z.string(), z.number(), z.array(z.string())]),
93
+ })).min(1),
94
+ })
95
+ export type SubmitIndividualInput = z.infer<typeof submitIndividualSchema>
96
+ ```
97
+
98
+ ### 3. Server Action
99
+
100
+ `src/app/actions/diagnostico/submit-individual.ts`:
101
+
102
+ ```ts
103
+ 'use server'
104
+
105
+ import { headers } from 'next/headers'
106
+ import { revalidatePath } from 'next/cache'
107
+ import { createClient } from '@/lib/supabase/server'
108
+ import { checkRateLimit } from '@/lib/rate-limit'
109
+ import { submitIndividualSchema } from '@/lib/validations/diagnostico'
110
+
111
+ type Result =
112
+ | { ok: true; participanteId: string }
113
+ | { ok: false; code: 'VALIDATION'|'NOT_FOUND'|'INVALID_STATE'|'ALREADY_SUBMITTED'|'RATE_LIMIT'|'ERROR'; message: string; fieldErrors?: Record<string,string[]> }
114
+
115
+ export async function submitDiagnosticoIndividual(input: unknown): Promise<Result> {
116
+ const hdrs = await headers()
117
+ const ip = hdrs.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
118
+ const rl = checkRateLimit(ip)
119
+ if (!rl.allowed) return { ok: false, code: 'RATE_LIMIT', message: `Tente em ${rl.retryAfterSeconds}s` }
120
+
121
+ const parsed = submitIndividualSchema.safeParse(input)
122
+ if (!parsed.success) {
123
+ return { ok: false, code: 'VALIDATION', message: 'Dados inválidos', fieldErrors: parsed.error.flatten().fieldErrors }
124
+ }
125
+
126
+ const supabase = await createClient()
127
+ const { token, instancia_id, respostas } = parsed.data
128
+
129
+ // 1. Lookup participante
130
+ const { data: p, error: pErr } = await supabase
131
+ .from('participantes')
132
+ .select('id, status, negocio_id, negocio:negocios(projeto_id)')
133
+ .eq('token_diagnostico', token)
134
+ .single()
135
+
136
+ if (pErr || !p) return { ok: false, code: 'NOT_FOUND', message: 'Link inválido' }
137
+ if (p.status !== 'pre_selecionado') {
138
+ return { ok: false, code: 'INVALID_STATE', message: 'Diagnóstico já respondido ou participante em estado inválido' }
139
+ }
140
+
141
+ // 2. RPC atômico: insert respostas + update status
142
+ const { error } = await supabase.rpc('submit_diagnostico_individual', {
143
+ p_participante_id: p.id,
144
+ p_instancia_id: instancia_id,
145
+ p_respostas: respostas,
146
+ })
147
+
148
+ if (error) {
149
+ if (error.code === '23505') {
150
+ return { ok: false, code: 'ALREADY_SUBMITTED', message: 'Diagnóstico já enviado' }
151
+ }
152
+ return { ok: false, code: 'ERROR', message: error.message }
153
+ }
154
+
155
+ revalidatePath(`/dashboard/projetos/${(p.negocio as any)?.projeto_id}`)
156
+ return { ok: true, participanteId: p.id }
157
+ }
158
+ ```
159
+
160
+ ### 4. RPC Postgres
161
+
162
+ ```sql
163
+ CREATE OR REPLACE FUNCTION submit_diagnostico_individual(
164
+ p_participante_id uuid,
165
+ p_instancia_id uuid,
166
+ p_respostas jsonb
167
+ ) RETURNS void
168
+ LANGUAGE plpgsql SECURITY DEFINER AS $$
169
+ DECLARE
170
+ v_r jsonb;
171
+ BEGIN
172
+ -- Guardar snapshot das respostas
173
+ INSERT INTO respostas_pesquisa (participante_id, instancia_id, payload, respondido_em)
174
+ VALUES (p_participante_id, p_instancia_id, p_respostas, now());
175
+
176
+ UPDATE participantes SET status = 'selecionado' WHERE id = p_participante_id AND status = 'pre_selecionado';
177
+ END;
178
+ $$;
179
+ ```
180
+
181
+ ### 5. Página pública
182
+
183
+ `src/app/diagnostico/individual/[token]/page.tsx`:
184
+
185
+ ```tsx
186
+ import { notFound } from 'next/navigation'
187
+ import { createClient } from '@/lib/supabase/server'
188
+ import { FormClient } from './form-client'
189
+
190
+ export default async function Page({ params }: { params: Promise<{ token: string }> }) {
191
+ const { token } = await params
192
+ const supabase = await createClient()
193
+ const { data } = await supabase
194
+ .from('participantes')
195
+ .select('id, nome, status, negocio:negocios(nome, projeto:projetos(id, nome, instancia_diagnostico_id))')
196
+ .eq('token_diagnostico', token)
197
+ .single()
198
+
199
+ if (!data) notFound()
200
+ if (data.status !== 'pre_selecionado') {
201
+ return <p className="p-8">Este diagnóstico já foi respondido.</p>
202
+ }
203
+
204
+ return <FormClient token={token} participante={data} />
205
+ }
206
+ ```
207
+
208
+ ### 6. Testes
209
+
210
+ - Token inexistente → `NOT_FOUND`
211
+ - Status `ativo` → `INVALID_STATE`
212
+ - Segundo submit com mesmo participante → `ALREADY_SUBMITTED` (UNIQUE)
213
+ - Payload válido em primeiro submit → `ok: true` e status muda no banco
214
+
215
+ ## Critérios de aceite (literais)
216
+
217
+ - **AC1:** Status muda para `selecionado`.
218
+ - **AC2:** Participante com status diferente de `pre_selecionado` rejeitado.
219
+
220
+ ## Verificação end-to-end
221
+
222
+ - [ ] `pnpm typecheck && pnpm lint && pnpm build` OK
223
+ - [ ] Testes unitários verdes
224
+ - [ ] Curl válido → `{ok:true}`, banco mostra status `selecionado`
225
+ - [ ] Curl duplicado → `ALREADY_SUBMITTED`
226
+ - [ ] Curl com participante `ativo` → `INVALID_STATE`
227
+ - [ ] `pnpm dev` + navegar `/diagnostico/individual/<token>` com token válido → form
228
+
229
+ ## Entrega ao Douglas (**NÃO commitar, NÃO dar push**)
230
+
231
+ > ⚠️ Dev NÃO commita.
232
+
233
+ ### Mensagem de commit sugerida
234
+
235
+ ```
236
+ feat(diagnostico): API submit diagnóstico individual (idempotente)
237
+
238
+ Refs T-085
239
+
240
+ - AC1: status pre_selecionado → selecionado (RPC submit_diagnostico_individual)
241
+ - AC2: status ≠ pre_selecionado retorna INVALID_STATE
242
+ - UNIQUE (participante_id, instancia_id) em respostas_pesquisa (idempotência)
243
+ - Rate limit 60/60s
244
+ ```
245
+
246
+ ## Rollback
247
+
248
+ ```bash
249
+ git restore packages/fractus/src/lib/validations/diagnostico.ts
250
+ rm -rf packages/fractus/src/app/actions/diagnostico/submit-individual*
251
+ rm -rf packages/fractus/src/app/diagnostico/individual
252
+ psql -c "DROP FUNCTION IF EXISTS submit_diagnostico_individual; ALTER TABLE respostas_pesquisa DROP CONSTRAINT IF EXISTS uniq_participante_instancia;"
253
+ ```
254
+
255
+ ## Dependências e bloqueios
256
+
257
+ - **Upstream:** T-084 fechada (tokens gerados).
258
+ - **Downstream:** T-086 (ativação manual depende de status `selecionado`).
259
+ - **Risco:** tabela `instancias_pesquisa` precisa estar populada com perguntas. Se vazio, form não renderiza.
260
+
261
+ ## Checklist de entrega
262
+
263
+ - [ ] T-084 confirmada fechada
264
+ - [ ] AC1-AC2 validados
265
+ - [ ] typecheck + lint + build OK
266
+ - [ ] Testes unitários verdes
267
+ - [ ] 3 cenários curl testados
268
+ - [ ] `git status` + `git diff --stat` colados
269
+ - [ ] **NÃO fiz commit nem push**
270
+ - [ ] Douglas notificado
271
+
272
+ ## Checklist do Douglas
273
+
274
+ - [ ] Revisar diff
275
+ - [ ] Migration aplicada em staging
276
+ - [ ] Smoke manual
277
+ - [ ] Commit+push
278
+ - [ ] ClickUp T-085 → `complete`
279
+
280
+ ## Evidência de entrega
281
+
282
+ ```
283
+ # git status + diff --stat
284
+ (output)
285
+
286
+ # pnpm test
287
+ (output)
288
+
289
+ # 3 curls
290
+ (output)
291
+ ```
@@ -0,0 +1,287 @@
1
+ # T-086 — API Ativação Manual (gestor ativa participante em lote)
2
+
3
+ **Sprint:** S07 · **Due:** 2026-04-19 · **Prioridade:** URGENT · **Pts:** 3
4
+ **ClickUp:** https://app.clickup.com/t/86agn021d
5
+ **Assignee:** Douglas Oliveira
6
+
7
+ ---
8
+
9
+ ## 🚫 [BLOCKED-PRD 2026-04-17] — Escopo invertido: ativação primária é automática
10
+
11
+ **Status:** BLOQUEADA. Reavaliar escopo antes de executar.
12
+
13
+ **Motivo:** O PRD vigente ([prd.md](../../../packages/fractus/docs/prd.md) §Tipos de status) determina que a **ativação é automática via presença em ≥1 oficina** — a ativação manual em lote deixa de ser fluxo principal e vira **exceção auditada** (BR-PRT-004 reinterpretada).
14
+
15
+ Além disso, a transição correta é `cadastrado → ativo` (não `selecionado → ativo` — status `selecionado` foi removido, ver M2).
16
+
17
+ **Ações pendentes:**
18
+ 1. Reduzir escopo: manter endpoint mas documentá-lo como exceção (não como feature principal).
19
+ 2. Trocar todas as refs a `status = 'selecionado'` por `status = 'cadastrado'` (AC1, AC2, RPC, testes).
20
+ 3. Adicionar auditoria obrigatória (log de quem ativou manualmente e quando) — PRD exige.
21
+ 4. Priorizar primeiro a task de ativação automática por presença (que ainda não existe em `.gos/plans/tasks/`).
22
+
23
+ **Referências:**
24
+ - [packages/fractus/docs/prd.md](../../../packages/fractus/docs/prd.md) — §Tipos de status, §Oficinas
25
+ - [packages/fractus/docs/regras-de-negocio/impacto-tasks-clickup.md](../../../packages/fractus/docs/regras-de-negocio/impacto-tasks-clickup.md) — M2
26
+
27
+ ---
28
+
29
+ ## ⚠️ Pré-requisito bloqueante
30
+
31
+ **T-085 DEVE estar fechada.** Este plano só faz sentido quando há participantes no status `selecionado` (output da T-085).
32
+
33
+ Validar: `psql -c "SELECT COUNT(*) FROM participantes WHERE status='selecionado';"` retorna > 0.
34
+
35
+ ## Contexto
36
+
37
+ Após o participante responder o diagnóstico (T-085), ele fica em `selecionado`. O **gestor do projeto** revisa os leads e decide quais promover para `ativo` (começam formalmente no programa). Essa ação é **manual e autenticada** — BR-PRT-004.
38
+
39
+ **Fluxo:**
40
+ 1. Gestor autenticado abre `/dashboard/projetos/[id]/participantes`.
41
+ 2. Seleciona N participantes com checkbox (UI multi-select).
42
+ 3. Clica "Ativar selecionados" → chama server action `activateParticipants({ ids })`.
43
+ 4. Backend atualiza `status = 'ativo'` **apenas** onde `status = 'selecionado'` (idempotente).
44
+ 5. Retorna `{ activated, rejected }` — rejected = participantes em outros status.
45
+
46
+ **Regras:**
47
+ - BR-PRT-004: Transição para `ativo` requer ação manual.
48
+ - Transição permitida: `selecionado → ativo`. Qualquer outro status → 422 por item.
49
+ - Batch parcial é aceitável (UI mostra quais falharam).
50
+ - Concorrência: dois gestores clicando simultaneamente — UPDATE idempotente via `WHERE status='selecionado'` resolve.
51
+
52
+ ## Arquivos afetados
53
+
54
+ | Arquivo | Ação |
55
+ |---|---|
56
+ | `packages/fractus/src/app/actions/participants/activate.ts` | [NOVO] server action |
57
+ | `packages/fractus/src/app/actions/participants/activate.test.ts` | [NOVO] testes |
58
+ | `packages/fractus/src/components/domain/participants/activate-batch-button.tsx` | [NOVO] botão + confirm dialog |
59
+ | `packages/fractus/src/components/domain/participants/participants-table.tsx` | [EDITAR] adicionar checkbox multi-select + botão |
60
+ | `packages/fractus/src/lib/validations/participant.ts` | [EDITAR] adicionar `activateBatchSchema` |
61
+
62
+ ## Passo a passo
63
+
64
+ ### 0. Validar pré-requisito
65
+
66
+ ```bash
67
+ psql "$DATABASE_URL" -c "SELECT status, COUNT(*) FROM participantes GROUP BY status;"
68
+ # Deve ter pelo menos alguns 'selecionado' para testar
69
+ ```
70
+
71
+ ### 1. Schema Zod
72
+
73
+ Em `src/lib/validations/participant.ts`:
74
+
75
+ ```ts
76
+ export const activateBatchSchema = z.object({
77
+ participantIds: z.array(z.string().uuid()).min(1).max(100),
78
+ })
79
+ export type ActivateBatchInput = z.infer<typeof activateBatchSchema>
80
+ ```
81
+
82
+ ### 2. Server Action
83
+
84
+ `src/app/actions/participants/activate.ts`:
85
+
86
+ ```ts
87
+ 'use server'
88
+
89
+ import { revalidatePath } from 'next/cache'
90
+ import { createClient } from '@/lib/supabase/server'
91
+ import { activateBatchSchema } from '@/lib/validations/participant'
92
+
93
+ type ItemResult = { id: string; ok: true } | { id: string; ok: false; code: 'INVALID_STATE'|'NOT_FOUND'; currentStatus?: string }
94
+
95
+ type Result =
96
+ | { ok: true; activated: number; rejected: ItemResult[] }
97
+ | { ok: false; code: 'UNAUTHORIZED'|'VALIDATION'|'ERROR'; message: string; fieldErrors?: Record<string,string[]> }
98
+
99
+ export async function activateParticipants(input: unknown): Promise<Result> {
100
+ // 1. Autenticação
101
+ const supabase = await createClient()
102
+ const { data: { user } } = await supabase.auth.getUser()
103
+ if (!user) return { ok: false, code: 'UNAUTHORIZED', message: 'Login obrigatório' }
104
+
105
+ // 2. Validação
106
+ const parsed = activateBatchSchema.safeParse(input)
107
+ if (!parsed.success) {
108
+ return { ok: false, code: 'VALIDATION', message: 'IDs inválidos', fieldErrors: parsed.error.flatten().fieldErrors }
109
+ }
110
+ const { participantIds } = parsed.data
111
+
112
+ // 3. Ler status atual de todos
113
+ const { data: current, error: readErr } = await supabase
114
+ .from('participantes')
115
+ .select('id, status, negocio:negocios(projeto_id)')
116
+ .in('id', participantIds)
117
+
118
+ if (readErr) return { ok: false, code: 'ERROR', message: readErr.message }
119
+
120
+ const currentMap = new Map(current?.map(p => [p.id, p]) ?? [])
121
+ const rejected: ItemResult[] = []
122
+ const eligible: string[] = []
123
+
124
+ for (const id of participantIds) {
125
+ const p = currentMap.get(id)
126
+ if (!p) { rejected.push({ id, ok: false, code: 'NOT_FOUND' }); continue }
127
+ if (p.status !== 'selecionado') {
128
+ rejected.push({ id, ok: false, code: 'INVALID_STATE', currentStatus: p.status })
129
+ continue
130
+ }
131
+ eligible.push(id)
132
+ }
133
+
134
+ // 4. Update idempotente (WHERE status='selecionado' previne race)
135
+ if (eligible.length > 0) {
136
+ const { error: updErr, count } = await supabase
137
+ .from('participantes')
138
+ .update({ status: 'ativo' }, { count: 'exact' })
139
+ .in('id', eligible)
140
+ .eq('status', 'selecionado')
141
+
142
+ if (updErr) return { ok: false, code: 'ERROR', message: updErr.message }
143
+
144
+ // 5. Revalidate paths dos projetos afetados
145
+ const projetoIds = [...new Set(current?.map(p => (p.negocio as any)?.projeto_id).filter(Boolean))]
146
+ projetoIds.forEach(pid => revalidatePath(`/dashboard/projetos/${pid}`))
147
+
148
+ return { ok: true, activated: count ?? 0, rejected }
149
+ }
150
+
151
+ return { ok: true, activated: 0, rejected }
152
+ }
153
+ ```
154
+
155
+ ### 3. UI — Botão batch
156
+
157
+ `src/components/domain/participants/activate-batch-button.tsx`:
158
+
159
+ ```tsx
160
+ 'use client'
161
+ import { useState, useTransition } from 'react'
162
+ import { Button } from '@/components/ui/button'
163
+ import { ConfirmDialog } from '@/components/ui/confirm-dialog'
164
+ import { toast } from 'sonner'
165
+ import { activateParticipants } from '@/app/actions/participants/activate'
166
+
167
+ export function ActivateBatchButton({ selectedIds, onDone }: { selectedIds: string[]; onDone: () => void }) {
168
+ const [open, setOpen] = useState(false)
169
+ const [pending, start] = useTransition()
170
+
171
+ const run = () => start(async () => {
172
+ const res = await activateParticipants({ participantIds: selectedIds })
173
+ if (!res.ok) { toast.error(res.message); return }
174
+ toast.success(`${res.activated} ativados${res.rejected.length ? `, ${res.rejected.length} rejeitados` : ''}`)
175
+ if (res.rejected.length) {
176
+ res.rejected.forEach(r => toast.warning(`${r.id}: ${r.code}${'currentStatus' in r ? ` (${r.currentStatus})` : ''}`))
177
+ }
178
+ setOpen(false); onDone()
179
+ })
180
+
181
+ return (
182
+ <>
183
+ <Button disabled={!selectedIds.length || pending} onClick={() => setOpen(true)}>
184
+ Ativar {selectedIds.length} selecionado{selectedIds.length !== 1 ? 's' : ''}
185
+ </Button>
186
+ <ConfirmDialog open={open} onOpenChange={setOpen} onConfirm={run}
187
+ title="Ativar participantes?"
188
+ description={`${selectedIds.length} serão promovidos para ativo. Apenas os em status 'selecionado' serão afetados.`} />
189
+ </>
190
+ )
191
+ }
192
+ ```
193
+
194
+ ### 4. Integrar no ParticipantsTable
195
+
196
+ Em `participants-table.tsx`:
197
+ - Adicionar coluna checkbox (controlada via `selectedIds` state).
198
+ - Header com "select all" para status `selecionado`.
199
+ - Colocar `<ActivateBatchButton selectedIds={...} onDone={() => setSelectedIds([])} />` acima da tabela.
200
+
201
+ ### 5. Testes
202
+
203
+ `activate.test.ts`:
204
+ - Sem auth → `UNAUTHORIZED`
205
+ - IDs válidos, todos `selecionado` → `activated = N, rejected = []`
206
+ - Mix: 2 `selecionado` + 1 `pre_selecionado` + 1 `ativo` → `activated = 2, rejected.length = 2`
207
+ - ID inexistente → rejected com `NOT_FOUND`
208
+ - Payload vazio → `VALIDATION`
209
+
210
+ ## Critérios de aceite (literais)
211
+
212
+ - **AC1:** Gestor ativa de `selecionado` para `ativo`.
213
+ - **AC2:** Status diferente de `selecionado` retorna 422 (no contexto de server action: `rejected[].code = 'INVALID_STATE'`).
214
+ - **AC3:** Ativação em lote funcional.
215
+
216
+ ## Verificação end-to-end
217
+
218
+ - [ ] `pnpm typecheck && pnpm lint && pnpm build` OK
219
+ - [ ] Testes unitários verdes (5 cenários)
220
+ - [ ] Smoke manual: logar como gestor, ir em `/dashboard/projetos/[id]/participantes`, selecionar 3 (mix de status), clicar ativar, ver toast com `activated: 2, rejected: 1` (ajustar conforme fixtures)
221
+ - [ ] Banco: participantes `selecionado` viraram `ativo`; outros intocados
222
+ - [ ] Toast mostra falhas por item
223
+
224
+ ## Entrega ao Douglas (**NÃO commitar, NÃO dar push**)
225
+
226
+ > ⚠️ Dev NÃO commita.
227
+
228
+ ### Mensagem de commit sugerida
229
+
230
+ ```
231
+ feat(participants): ativação manual em lote (selecionado → ativo)
232
+
233
+ Refs T-086
234
+
235
+ - AC1: gestor ativa via server action activateParticipants
236
+ - AC2: status ≠ selecionado rejected com INVALID_STATE + currentStatus
237
+ - AC3: batch com UI multi-select + ConfirmDialog
238
+ - Auth obrigatória (supabase.auth.getUser)
239
+ - Update idempotente (WHERE status='selecionado') protege contra race
240
+ ```
241
+
242
+ ## Rollback
243
+
244
+ ```bash
245
+ rm -f packages/fractus/src/app/actions/participants/activate*
246
+ rm -f packages/fractus/src/components/domain/participants/activate-batch-button.tsx
247
+ git restore packages/fractus/src/components/domain/participants/participants-table.tsx
248
+ git restore packages/fractus/src/lib/validations/participant.ts
249
+ ```
250
+
251
+ ## Dependências e bloqueios
252
+
253
+ - **Upstream:** T-085 fechada (participantes em `selecionado`).
254
+ - **Downstream:** fluxo de presença (T-053 futura) pode filtrar só `ativo`.
255
+ - **Risco:** se `ConfirmDialog` ainda não existir (T-014 em andamento), fazer fallback com `window.confirm` temporariamente e documentar no plano.
256
+
257
+ ## Checklist de entrega
258
+
259
+ - [ ] T-085 confirmada fechada
260
+ - [ ] AC1-AC3 validados
261
+ - [ ] typecheck + lint + build OK
262
+ - [ ] 5 cenários de teste verdes
263
+ - [ ] Smoke manual no dashboard
264
+ - [ ] `git status` + `git diff --stat` colados
265
+ - [ ] **NÃO fiz commit nem push**
266
+ - [ ] Douglas notificado
267
+
268
+ ## Checklist do Douglas
269
+
270
+ - [ ] Revisar diff (UI + action)
271
+ - [ ] Smoke manual com usuário real autenticado
272
+ - [ ] Commit+push
273
+ - [ ] ClickUp T-086 → `complete`
274
+ - [ ] Sprint S07 fechada se esta for a última
275
+
276
+ ## Evidência de entrega
277
+
278
+ ```
279
+ # git status + diff --stat
280
+ (output)
281
+
282
+ # pnpm test activate
283
+ (output)
284
+
285
+ # smoke manual (screenshot ou texto)
286
+ (output)
287
+ ```