# Correção de unicidade login/CPF em Profissional — Plano de Implementação

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Permitir múltiplos profissionais sem login, permitir recadastro de login/CPF após soft-delete, e garantir que qualquer colisão de índice único vire mensagem no formulário em vez de tela de erro 500.

**Architecture:** No MySQL, um índice `UNIQUE` aceita múltiplos `NULL` mas não múltiplos `''`. Normalizamos login vazio para `NULL` (`beforeValidate`), liberamos login e CPF no soft-delete (`delete()` seta ambos para `NULL`), tornamos `cpf` nullable via migration, e adicionamos um `try/catch` de `IntegrityException` no controller como rede de segurança.

**Tech Stack:** PHP 8.2, Yii2, MySQL 8, Codeception (suítes `Unit` e `Functional`). Tudo roda dentro do container `php`.

**Spec:** `docs/superpowers/specs/2026-07-14-fix-profissional-login-cpf-unique-design.md`

---

## Estrutura de arquivos

- **Criar:** `migrations/m260714_000001_fix_profissional_login_cpf_unique.php` — torna `cpf` nullable e converte `login = ''` legado para `NULL`.
- **Modificar:** `models/Profissional.php` — adiciona `beforeValidate()` (normaliza login vazio) e `delete()` (libera login/cpf).
- **Modificar:** `controllers/ProfissionalController.php` — `try/catch` de `IntegrityException` em `salvar()`.
- **Modificar (testes):** `tests/Unit/Models/ProfissionalTest.php` — normalização e reuso após delete.
- **Modificar (testes):** `tests/functional/ProfissionalCest.php` — colisão de índice único vira mensagem, não 500.

**Convenção de comandos:** todo comando roda no container. Prefixe com `docker compose exec php`. Se estiver sem Docker, use um PHP ≥8.2 explícito (ex.: `/opt/homebrew/opt/php/bin/php`), nunca o `php` do PATH (é 7.4).

---

## Task 1: Migration — cpf nullable + limpeza de login vazio

**Files:**
- Create: `migrations/m260714_000001_fix_profissional_login_cpf_unique.php`

- [ ] **Step 1: Criar a migration**

Criar `migrations/m260714_000001_fix_profissional_login_cpf_unique.php`:

```php
<?php

use yii\db\Migration;

/**
 * Torna cpf nullable (para liberá-lo no soft-delete) e converte registros
 * legados com login '' em NULL, evitando colisão no índice único uq-profissional-login.
 */
class m260714_000001_fix_profissional_login_cpf_unique extends Migration
{
    public function safeUp()
    {
        $this->alterColumn('{{%profissional}}', 'cpf', $this->string(14)->null());
        $this->update('{{%profissional}}', ['login' => null], ['login' => '']);
    }

    public function safeDown()
    {
        // Reverter para NOT NULL só funciona se não houver cpf NULL (registros excluídos).
        $this->alterColumn('{{%profissional}}', 'cpf', $this->string(14)->notNull());
    }
}
```

- [ ] **Step 2: Aplicar a migration no banco de desenvolvimento**

Run: `docker compose exec php ./yii migrate --interactive=0`
Expected: `1 migration was applied` e saída mencionando `m260714_000001_fix_profissional_login_cpf_unique`.

- [ ] **Step 3: Aplicar a migration no banco de teste (`citrus_test`)**

Run: `docker compose exec php tests/bin/yii migrate --interactive=0`
Expected: `1 migration was applied` (mesmo nome). Necessário porque as suítes rodam contra `citrus_test`.

- [ ] **Step 4: Verificar que cpf ficou nullable**

Run: `docker compose exec php ./yii migrate/history --limit=1`
Expected: a saída lista `m260714_000001_fix_profissional_login_cpf_unique` como a última migration aplicada.

- [ ] **Step 5: Commit**

```bash
git add migrations/m260714_000001_fix_profissional_login_cpf_unique.php
git commit -m "feat(profissional): migration torna cpf nullable e limpa login vazio"
```

---

## Task 2: Normalizar login vazio → NULL

**Files:**
- Modify: `models/Profissional.php` (adiciona `beforeValidate()` após `moneyAttributes()`, por volta da linha 69)
- Test: `tests/Unit/Models/ProfissionalTest.php`

- [ ] **Step 1: Escrever os testes que falham**

Adicionar ao final de `tests/Unit/Models/ProfissionalTest.php`, antes do `}` final da classe:

```php
    public function testLoginVazioViraNull()
    {
        $p = $this->base(['login' => '   ']); // perfil profissional não exige login
        $p->validate();
        $this->assertNull($p->login);
    }

    public function testDoisProfissionaisSemLoginSalvam()
    {
        // login '' é o que o formulário envia quando o campo vem em branco — é
        // esse valor que colide no índice único antes da normalização.
        $p1 = $this->base(['cpf' => '222.222.222-22', 'login' => '']);
        $this->assertTrue($p1->save(), implode(',', array_keys($p1->errors)));
        $p2 = $this->base(['cpf' => '333.333.333-33', 'login' => '']);
        $this->assertTrue($p2->save(), implode(',', array_keys($p2->errors)));
        $this->assertNull($p1->login);
        $this->assertNull($p2->login);
    }
```

- [ ] **Step 2: Rodar os testes e confirmar que falham**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/ProfissionalTest.php -v`
Expected: FAIL. `testLoginVazioViraNull` falha (`login` continua `'   '`, não `null`); `testDoisProfissionaisSemLoginSalvam` falha ao salvar o segundo (colisão `''` no índice único lança `IntegrityException`).

- [ ] **Step 3: Implementar `beforeValidate()` no model**

Em `models/Profissional.php`, logo após o método `moneyAttributes()` (que termina por volta da linha 69):

```php
    public function beforeValidate()
    {
        if (!parent::beforeValidate()) { // mantém a normalização monetária do BaseActiveRecord
            return false;
        }
        if (is_string($this->login) && trim($this->login) === '') {
            $this->login = null;
        }
        return true;
    }
```

- [ ] **Step 4: Rodar os testes e confirmar que passam**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/ProfissionalTest.php -v`
Expected: PASS em todos os testes do arquivo (inclusive os pré-existentes).

- [ ] **Step 5: Commit**

```bash
git add models/Profissional.php tests/Unit/Models/ProfissionalTest.php
git commit -m "fix(profissional): normaliza login vazio para NULL no beforeValidate"
```

---

## Task 3: Liberar login e CPF no soft-delete

**Files:**
- Modify: `models/Profissional.php` (adiciona `delete()` após `beforeSave()`, por volta da linha 104)
- Test: `tests/Unit/Models/ProfissionalTest.php`

- [ ] **Step 1: Escrever o teste que falha**

Adicionar ao final de `tests/Unit/Models/ProfissionalTest.php`, antes do `}` final da classe:

```php
    public function testDeleteLiberaLoginECpfParaReuso()
    {
        $p = $this->base(['cpf' => '555.555.555-55', 'perfis' => 'administrativo',
            'login' => 'reuso', 'senha' => 'segredo123']);
        $this->assertTrue($p->save(), implode(',', array_keys($p->errors)));
        $id = $p->id;

        $this->assertEquals(1, $p->delete());

        $excluido = Profissional::find()->comExcluidos()->where(['id' => $id])->one();
        $this->assertNotNull($excluido->deleted_at);
        $this->assertNull($excluido->login);
        $this->assertNull($excluido->cpf);

        // recadastro com o mesmo login e CPF → sucesso (índice único liberado)
        $novo = $this->base(['cpf' => '555.555.555-55', 'perfis' => 'administrativo',
            'login' => 'reuso', 'senha' => 'segredo123']);
        $this->assertTrue($novo->save(), implode(',', array_keys($novo->errors)));
    }
```

- [ ] **Step 2: Rodar o teste e confirmar que falha**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/ProfissionalTest.php:testDeleteLiberaLoginECpfParaReuso -v`
Expected: FAIL. Após `delete()`, `login`/`cpf` do registro excluído continuam preenchidos, e o recadastro colide no índice único (`IntegrityException`).

- [ ] **Step 3: Implementar `delete()` no model**

Em `models/Profissional.php`, logo após o método `beforeSave()` (que termina por volta da linha 104):

```php
    public function delete()
    {
        // Libera login e CPF do índice único: registro excluído não deve reservar
        // esses valores, permitindo recadastro. O histórico fica na auditoria.
        $this->deleted_at = time();
        $this->login = null;
        $this->cpf = null;
        return $this->save(false, ['deleted_at', 'updated_at', 'login', 'cpf']) ? 1 : false;
    }
```

- [ ] **Step 4: Rodar o teste e confirmar que passa**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/ProfissionalTest.php:testDeleteLiberaLoginECpfParaReuso -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add models/Profissional.php tests/Unit/Models/ProfissionalTest.php
git commit -m "fix(profissional): libera login e cpf no soft-delete para permitir reuso"
```

---

## Task 4: Defesa em profundidade no controller

**Files:**
- Modify: `controllers/ProfissionalController.php` (import na linha ~16; método `salvar()` linhas 52-64)
- Test: `tests/functional/ProfissionalCest.php`

- [ ] **Step 1: Escrever o teste funcional que falha**

Adicionar ao topo de `tests/functional/ProfissionalCest.php`, logo após `use app\tests\Support\FunctionalTester;`:

```php
use Yii;
```

Adicionar ao final da classe `ProfissionalCest`, antes do `}` final:

```php
    public function colisaoDeLoginMostraErroAmigavel(FunctionalTester $I)
    {
        // Registro legado: excluído mas ainda reservando 'legado' no índice único
        // (inserido via SQL cru para não passar pelo delete() que zeraria o login).
        Yii::$app->db->createCommand()->insert('{{%profissional}}', [
            'nome' => 'Antigo', 'cpf' => '888.888.888-88', 'data_nascimento' => '1990-01-01',
            'endereco' => 'Rua X', 'whatsapp' => '(11) 90000-0001', 'funcao_id' => 10,
            'valor_diaria' => 0, 'perfis' => 'administrativo', 'login' => 'legado',
            'password_hash' => 'x', 'auth_key' => 'kx', 'status' => 'ativo',
            'failed_attempts' => 0, 'created_at' => 1700000000, 'updated_at' => 1700000000,
            'deleted_at' => 1700000001,
        ])->execute();

        $I->amOnRoute('profissional/create');
        $I->submitForm('#profissional-form', [
            'Profissional[nome]' => 'Novo Admin',
            'Profissional[cpf]' => '777.777.777-77',
            'Profissional[data_nascimento]' => '1992-02-02',
            'Profissional[endereco]' => 'Rua Y',
            'Profissional[whatsapp]' => '(11) 94444-4444',
            'Profissional[funcao_id]' => '10',
            'Profissional[valor_diaria]' => '150',
            'Profissional[perfisArray]' => ['administrativo'],
            'Profissional[login]' => 'legado',
            'Profissional[senha]' => 'segredo123',
            'Profissional[status]' => 'ativo',
        ]);

        $I->seeResponseCodeIs(200);      // não estourou a tela de erro 500
        $I->see('Login já cadastrado');  // virou mensagem no formulário
    }
```

- [ ] **Step 2: Rodar o teste e confirmar que falha**

Run: `docker compose exec php vendor/bin/codecept run Functional ProfissionalCest.php:colisaoDeLoginMostraErroAmigavel -v`
Expected: FAIL. O validador `unique` usa `find()` (filtra excluídos), então a validação passa; o `INSERT` colide no índice único e a `IntegrityException` não é tratada → resposta 500 (`seeResponseCodeIs(200)` falha).

- [ ] **Step 3: Adicionar o import de IntegrityException**

Em `controllers/ProfissionalController.php`, junto aos demais `use` (por volta da linha 16, após `use yii\filters\AccessControl;`):

```php
use yii\db\IntegrityException;
```

- [ ] **Step 4: Envolver o save() em try/catch no método `salvar()`**

Substituir o corpo atual de `salvar()` (linhas 52-64) por:

```php
    private function salvar(Profissional $model)
    {
        if ($model->load(Yii::$app->request->post())) {
            $perfis = Yii::$app->request->post('Profissional')['perfisArray'] ?? [];
            $model->perfis = is_array($perfis) ? implode(',', $perfis) : '';
            try {
                if ($model->save()) {
                    Yii::$app->session->setFlash('success', 'Profissional salvo.');
                    return $this->redirect(['index']);
                }
            } catch (IntegrityException $e) {
                // Rede de segurança: colisão residual no índice único (corrida de
                // concorrência ou registro excluído legado) vira mensagem no input.
                $msg = $e->getMessage();
                if (stripos($msg, 'login') !== false) {
                    $model->addError('login', 'Login já cadastrado.');
                } elseif (stripos($msg, 'cpf') !== false) {
                    $model->addError('cpf', 'CPF já cadastrado.');
                } else {
                    $model->addError('cpf', 'Registro já cadastrado.');
                }
            }
        }
        $model->perfisArray = $model->getPerfisLista();
        return $this->render('_form', ['model' => $model, 'funcoes' => Funcao::ativas()]);
    }
```

- [ ] **Step 5: Rodar o teste e confirmar que passa**

Run: `docker compose exec php vendor/bin/codecept run Functional ProfissionalCest.php:colisaoDeLoginMostraErroAmigavel -v`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add controllers/ProfissionalController.php tests/functional/ProfissionalCest.php
git commit -m "fix(profissional): traduz IntegrityException em erro no formulario"
```

---

## Task 5: Suíte completa e verificação final

**Files:** nenhuma alteração — só validação de que nada regrediu.

- [ ] **Step 1: Rodar a suíte Unit inteira**

Run: `docker compose exec php vendor/bin/codecept run Unit`
Expected: PASS (0 failures, 0 errors), incluindo os testes pré-existentes de `ProfissionalTest`, `SoftDeleteTest` e `AuditBehaviorTest`.

- [ ] **Step 2: Rodar a suíte Functional inteira**

Run: `docker compose exec php vendor/bin/codecept run Functional`
Expected: PASS (0 failures, 0 errors), incluindo `ProfissionalCest` (`criaProfissionalSemAcesso`, `liderSemLoginFalha`, `colisaoDeLoginMostraErroAmigavel`).

- [ ] **Step 3: Smoke test manual (opcional, recomendado)**

No app (http://localhost:8082), cadastrar dois profissionais com perfil "profissional" (sem login) → ambos salvam. Excluir um líder e recadastrar outro com o mesmo login → salva. Confirma o comportamento fim-a-fim descrito na spec.

---

## Notas de fragilidade (para revisão)

- A identificação do constraint no `catch` (Task 4) usa `stripos` na mensagem da `IntegrityException`, que contém o nome do índice (`uq-profissional-login` / `uq-profissional-cpf`). Se esses índices forem renomeados no futuro, o mapeamento de erro para o campo certo precisa ser revisto (cai no fallback genérico "Registro já cadastrado.").
- `safeDown()` da migration falhará se já existir profissional excluído com `cpf = NULL` — efeito esperado do modelo de soft-delete, documentado na spec.
```
