ganbatte-os 0.2.20 → 0.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gos/README.md +67 -0
- package/.gos/docs/slack-notifications.md +219 -0
- package/.gos/plans/tasks/T-084-api-diagnostico-negocio.md +405 -0
- package/.gos/plans/tasks/T-085-api-diagnostico-individual.md +291 -0
- package/.gos/plans/tasks/T-086-api-ativacao-manual-batch.md +287 -0
- package/.gos/plans/tasks/T-104-hook-storybook-branch-context.md +218 -0
- package/.gos/plans/tasks/T-110-drift-typescript-pre-commit-hook.md +355 -0
- package/.gos/scripts/tools/text-sanitize.js +236 -0
- package/.gos/skills/registry.json +2 -1
- package/.gos/skills/slack-review/SKILL.md +91 -0
- package/.gos/skills/weekly-update/CHANGELOG.md +84 -0
- package/.gos/tests/hooks/storybook-branch-check.test.sh +42 -0
- package/package.json +1 -1
|
@@ -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
|
+
```
|