{
  "id": "migration-04-affiliations",
  "title": "Migration 04 — affiliations",
  "description": "Fundação dos vínculos institucionais e do contexto de acesso.",
  "type": "migration-reference",
  "status": "implemented",
  "visibility": "public",
  "tags": [
    "sge/migrations",
    "sge/banco-de-dados",
    "sge/autorizacao"
  ],
  "related": [
    "migration-03-campuses",
    "migration-10-course-id-em-affiliations",
    "enum-affiliationtype",
    "migration-09-courses",
    "modelo-de-dados-acesso",
    "fase-02-conta-e-contexto"
  ],
  "sourceRefs": [
    "https://github.com/sge-suite/sge/blob/master/database/migrations/2026_09_22_105745_create_affiliations_table.php",
    "https://github.com/sge-suite/sge/blob/master/app/Models/Affiliation.php",
    "https://github.com/sge-suite/sge/blob/master/app/Concerns/AffiliationValidationRules.php",
    "https://github.com/sge-suite/sge/blob/master/database/factories/AffiliationFactory.php",
    "https://github.com/sge-suite/sge/blob/master/tests/Feature/AffiliationTest.php",
    "https://github.com/sge-suite/sge/blob/master/tests/Feature/AuditInterfaceTest.php",
    "https://github.com/sge-suite/sge/blob/master/tests/Feature/EmailLogInterfaceTest.php"
  ],
  "authors": [],
  "updated": null,
  "diagram": null,
  "body": "> [!success] Estado\n> Migration, Model, validação, factory, relações e Activity Log estão implementados. A migration depende de `users` e [`campuses`](doc:migration-03-campuses). O contrato foi verificado no PostgreSQL por Sail. A resolução do vínculo após o login, a sessão, a tela de seleção e a troca pelo menu do perfil estão implementadas na [Fase 02](doc:fase-02-conta-e-contexto).\n\n## Schema PostgreSQL\n\n| Campo | Tipo e regra |\n| --- | --- |\n| `id` | `bigint`, chave primária. |\n| `user_id` | `bigint` obrigatório, FK para `users.id` com `ON DELETE RESTRICT`. |\n| `campus_id` | `bigint` nullable, FK para `campuses.id` com `ON DELETE RESTRICT`. Nulo somente em vínculo de `SystemAdministrator`. |\n| `type` | `varchar(255)`, convertido pelo cast PHP [`AffiliationType`](doc:enum-affiliationtype) e validado antes de persistir. |\n| `registration_number` | `varchar(255)` nullable; obrigatório nos tipos diferentes de `Supervisor`. |\n| `email` | `varchar(255)` obrigatório; e-mail de contato do contexto. |\n| `deactivated_at` | `timestamp(0)` nullable; nulo significa vínculo ativo. |\n| `last_used_at` | `timestamp(0)` nullable; último vínculo explicitamente selecionado. |\n| `created_at`, `updated_at` | timestamps convencionais do Laravel. |\n\nEsta migration inicial não contém `course_id` nem `deleted_at`. A ausência de curso aqui é intencional: a [Migration 10](doc:migration-10-course-id-em-affiliations), já implementada, adiciona a FK após a criação de `courses` na Migration 09. O schema final exige curso para vínculos de discente por validação do Model. `Affiliation` não usa `SoftDeletes`: o ciclo de vida do vínculo usa `deactivated_at`.\n\n### FKs e índices\n\n- A exclusão física de um usuário ou campus referenciado é restringida. A exclusão lógica de `Campus` não remove nem altera a FK.\n- A migration cria somente a chave primária e as FKs; índices secundários ficam para quando as consultas reais indicarem necessidade.\n- As regras de tipo, campus obrigatório, matrícula e unicidade de matrícula discente são validadas pelo model em PHP. O cast de `AffiliationType` converte os valores para o enum e rejeita valores desconhecidos ao acessar o atributo.\n- Matrículas de servidores podem se repetir entre funções. Não há unicidade global de e-mail, matrícula de servidor ou combinação usuário/tipo/campus.\n\n## Model, relações e validação\n\n`Affiliation` usa o cast `AffiliationType` e casts datetime para `deactivated_at` e `last_used_at`. As relações iniciais são `Affiliation → User`, `Affiliation → Campus`, `User → affiliations` e `Campus → affiliations`. A Migration 10 acrescenta `Affiliation → Course` e a relação inversa de discentes; a Migration 09 acrescenta as relações dos cursos com seus vínculos coordenadores.\n\n`AffiliationValidationRules` é executado ao salvar e centraliza:\n\n- enum válido, usuário existente, e-mail obrigatório válido e datas opcionais válidas;\n- campus obrigatório por tipo, campus existente e campus ativo/não excluído ao criar, trocar campus ou reativar;\n- preservação de vínculos existentes quando o campus é posteriormente desativado;\n- matrícula obrigatória para tipos diferentes de supervisor, proibida para supervisor e única somente para discente.\n\nUma pessoa pode ter vários vínculos, inclusive de tipos ou campi distintos. Na gestão administrativa, a criação e a reativação bloqueiam outro vínculo ativo da mesma pessoa, tipo e campus; a regra é aplicada na transação, sem limpar duplicidades históricas. O campus é um atributo do vínculo e não muda por seleção de contexto.\n\nO scope `active()` filtra `deactivated_at IS NULL`. `orderByLastUsedAt()` ordena por `last_used_at DESC NULLS LAST` e desempata por `id ASC`, evitando a ordenação padrão de nulos do PostgreSQL.\n\n## Último contexto usado\n\n`last_used_at` é memória operacional do último vínculo selecionado ou usado numa troca explícita de contexto. `markAsUsed()` atualiza o timestamp somente quando chamado para um vínculo persistido e ativo. Leituras e requisições comuns não o atualizam.\n\nO campo não é auditado pelo Activity Log e não representa login, logout ou trilha de autenticação. `ActiveAffiliationContext` restaura o vínculo ativo mais recentemente usado; vínculo desativado nunca é elegível. Se houver um único vínculo ativo, ele é selecionado automaticamente. Com múltiplos vínculos e nenhum uso anterior, a pessoa escolhe em `affiliations/select`, sem seleção baseada no desempate técnico. Somente a seleção ou troca explícita atualiza `last_used_at`.\n\n## Factory e auditoria\n\n`AffiliationFactory` fornece os estados `global()`, `onCampus()`, `server()`, `student()`, `supervisor()`, `deactivated()` e `recentlyUsed()`. Após a Migration 10, `student()` também cria ou recebe um curso do mesmo campus.\n\nO Spatie Activity Log registra criação e alterações relevantes nos dados fillable, incluindo tipo, campus, matrícula, e-mail e desativação/reativação. `last_used_at` não é fillable nem auditado; uma alteração isolada não cria atividade.\n\nNa consulta administrativa, o Administrador do Sistema pode ver atividades de vínculos dos tipos Administrador do Sistema e Administrador do Campus, além dos vínculos administrativos associados às contas elegíveis. A seleção ou troca de vínculo atualiza apenas a memória operacional de `last_used_at` e não é registrada como atividade. O histórico de e-mails mantém um snapshot separado do contexto afetado em `email_delivery_attempts.scope_context`; esse campo não guarda a seleção de vínculo.\n\n## Testes verificados\n\n`tests/Feature/AffiliationTest.php` cobre schema, rollback/reaplicação em ordem de dependência, FKs, relações, validação PHP e cast enum, curso obrigatório para discente, factory, ativação, ordenação e Activity Log. As alterações das Migrations 09 e 10 foram verificadas no PostgreSQL por Sail junto com `tests/Feature/CourseTest.php`. A tela de seleção, sessão e middleware são cobertos separadamente por `ActiveAffiliationContextTest`, `DashboardTest` e os testes da Fase 02.\n\n## Dependências\n\n- [`AffiliationType`](doc:enum-affiliationtype)\n- [`campuses`](doc:migration-03-campuses)\n- [`courses`](doc:migration-09-courses)\n- [`course_id` em affiliations](doc:migration-10-course-id-em-affiliations)\n- [Modelo de acesso](doc:modelo-de-dados-acesso)",
  "sections": [
    {
      "id": "schema-postgresql",
      "level": 2,
      "title": "Schema PostgreSQL",
      "text": "| Campo | Tipo e regra | | --- | --- | | `id` | `bigint`, chave primária. | | `user_id` | `bigint` obrigatório, FK para `users.id` com `ON DELETE RESTRICT`. | | `campus_id` | `bigint` nullable, FK para `campuses.id` com `ON DELETE RESTRICT`. Nulo somente em vínculo de `SystemAdministrator`. | | `type` | `varchar(255)`, convertido pelo cast PHP [`AffiliationType`](doc:enum-affiliationtype) e validado antes de persistir. | | `registration_number` | `varchar(255)` nullable; obrigatório nos tipos diferentes de `Supervisor`. | | `email` | `varchar(255)` obrigatório; e-mail de contato do contexto. | | `deactivated_at` | `timestamp(0)` nullable; nulo significa vínculo ativo. | | `last_used_at` | `timestamp(0)` nullable; último vínculo explicitamente selecionado. | | `created_at`, `updated_at` | timestamps convencionais do Laravel. |  Esta migration inicial não contém `course_id` nem `deleted_at`. A ausência de curso aqui é intencional: a [Migration 10](doc:migration-10-course-id-em-affiliations), já implementada, adiciona a FK após a criação de `courses` na Migration 09. O schema final exige curso para vínculos de discente por validação do Model. `Affiliation` não usa `SoftDeletes`: o ciclo de vida do vínculo usa `deactivated_at`.",
      "line": 4
    },
    {
      "id": "fks-e-indices",
      "level": 3,
      "title": "FKs e índices",
      "text": "- A exclusão física de um usuário ou campus referenciado é restringida. A exclusão lógica de `Campus` não remove nem altera a FK. - A migration cria somente a chave primária e as FKs; índices secundários ficam para quando as consultas reais indicarem necessidade. - As regras de tipo, campus obrigatório, matrícula e unicidade de matrícula discente são validadas pelo model em PHP. O cast de `AffiliationType` converte os valores para o enum e rejeita valores desconhecidos ao acessar o atributo. - Matrículas de servidores podem se repetir entre funções. Não há unicidade global de e-mail, matrícula de servidor ou combinação usuário/tipo/campus.",
      "line": 20
    },
    {
      "id": "model-relacoes-e-validacao",
      "level": 2,
      "title": "Model, relações e validação",
      "text": "`Affiliation` usa o cast `AffiliationType` e casts datetime para `deactivated_at` e `last_used_at`. As relações iniciais são `Affiliation → User`, `Affiliation → Campus`, `User → affiliations` e `Campus → affiliations`. A Migration 10 acrescenta `Affiliation → Course` e a relação inversa de discentes; a Migration 09 acrescenta as relações dos cursos com seus vínculos coordenadores.  `AffiliationValidationRules` é executado ao salvar e centraliza:  - enum válido, usuário existente, e-mail obrigatório válido e datas opcionais válidas; - campus obrigatório por tipo, campus existente e campus ativo/não excluído ao criar, trocar campus ou reativar; - preservação de vínculos existentes quando o campus é posteriormente desativado; - matrícula obrigatória para tipos diferentes de supervisor, proibida para supervisor e única somente para discente.  Uma pessoa pode ter vários vínculos, inclusive de tipos ou campi distintos. Na gestão administrativa, a criação e a reativação bloqueiam outro vínculo ativo da mesma pessoa, tipo e campus; a regra é aplicada na transação, sem limpar duplicidades históricas. O campus é um atributo do vínculo e não muda por seleção de contexto.  O scope `active()` filtra `deactivated_at IS NULL`. `orderByLastUsedAt()` ordena por `last_used_at DESC NULLS LAST` e desempata por `id ASC`, evitando a ordenação padrão de nulos do PostgreSQL.",
      "line": 27
    },
    {
      "id": "ultimo-contexto-usado",
      "level": 2,
      "title": "Último contexto usado",
      "text": "`last_used_at` é memória operacional do último vínculo selecionado ou usado numa troca explícita de contexto. `markAsUsed()` atualiza o timestamp somente quando chamado para um vínculo persistido e ativo. Leituras e requisições comuns não o atualizam.  O campo não é auditado pelo Activity Log e não representa login, logout ou trilha de autenticação. `ActiveAffiliationContext` restaura o vínculo ativo mais recentemente usado; vínculo desativado nunca é elegível. Se houver um único vínculo ativo, ele é selecionado automaticamente. Com múltiplos vínculos e nenhum uso anterior, a pessoa escolhe em `affiliations/select`, sem seleção baseada no desempate técnico. Somente a seleção ou troca explícita atualiza `last_used_at`.",
      "line": 42
    },
    {
      "id": "factory-e-auditoria",
      "level": 2,
      "title": "Factory e auditoria",
      "text": "`AffiliationFactory` fornece os estados `global()`, `onCampus()`, `server()`, `student()`, `supervisor()`, `deactivated()` e `recentlyUsed()`. Após a Migration 10, `student()` também cria ou recebe um curso do mesmo campus.  O Spatie Activity Log registra criação e alterações relevantes nos dados fillable, incluindo tipo, campus, matrícula, e-mail e desativação/reativação. `last_used_at` não é fillable nem auditado; uma alteração isolada não cria atividade.  Na consulta administrativa, o Administrador do Sistema pode ver atividades de vínculos dos tipos Administrador do Sistema e Administrador do Campus, além dos vínculos administrativos associados às contas elegíveis. A seleção ou troca de vínculo atualiza apenas a memória operacional de `last_used_at` e não é registrada como atividade. O histórico de e-mails mantém um snapshot separado do contexto afetado em `email_delivery_attempts.scope_context`; esse campo não guarda a seleção de vínculo.",
      "line": 48
    },
    {
      "id": "testes-verificados",
      "level": 2,
      "title": "Testes verificados",
      "text": "`tests/Feature/AffiliationTest.php` cobre schema, rollback/reaplicação em ordem de dependência, FKs, relações, validação PHP e cast enum, curso obrigatório para discente, factory, ativação, ordenação e Activity Log. As alterações das Migrations 09 e 10 foram verificadas no PostgreSQL por Sail junto com `tests/Feature/CourseTest.php`. A tela de seleção, sessão e middleware são cobertos separadamente por `ActiveAffiliationContextTest`, `DashboardTest` e os testes da Fase 02.",
      "line": 56
    },
    {
      "id": "dependencias",
      "level": 2,
      "title": "Dependências",
      "text": "- [`AffiliationType`](doc:enum-affiliationtype) - [`campuses`](doc:migration-03-campuses) - [`courses`](doc:migration-09-courses) - [`course_id` em affiliations](doc:migration-10-course-id-em-affiliations) - [Modelo de acesso](doc:modelo-de-dados-acesso)",
      "line": 60
    }
  ],
  "sourcePath": "content/migration-04-affiliations.md",
  "visuals": [],
  "apiVersion": 1
}
