diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index 72998b24f..687fea251 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -9,15 +9,17 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un ## Contexts -| Context | Path | Description | -| ------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| Moderation | `app-modules/moderation/` | Content moderation pipeline — classification, routing, enforcement, appeals | -| Bot Discord | `app-modules/bot-discord/` | Discord bot runtime (Laracord websocket, slash commands, event handlers) | -| Integration Discord | `app-modules/integration-discord/` | Discord platform transport (REST API via Saloon), OAuth, ETL | -| Identity | `app-modules/identity/` | Users, tenants, external identities, authentication | -| Panel Admin | `app-modules/panel-admin/` | Filament admin panel — dashboards, resources, moderation UI, marketing | -| Integration Twitch | `app-modules/integration-twitch/` | Twitch platform transport (Helix API via Saloon), OAuth, EventSub webhooks | -| Integration GitHub | `app-modules/integration-github/` | GitHub transport (REST via Saloon), OAuth, community contribution ingestion (backfill + webhooks) + event lake | +| Context | Path | Description | +| ------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Moderation | `app-modules/moderation/` | Content moderation pipeline — classification, routing, enforcement, appeals | +| Bot Discord | `app-modules/bot-discord/` | Discord bot runtime (Laracord websocket, slash commands, event handlers) | +| Integration Discord | `app-modules/integration-discord/` | Discord platform transport (REST API via Saloon), OAuth, ETL | +| Identity | `app-modules/identity/` | Users, tenants, external identities, authentication | +| Panel Admin | `app-modules/panel-admin/` | Filament admin panel — dashboards, resources, moderation UI, marketing | +| Integration Twitch | `app-modules/integration-twitch/` | Twitch platform transport (Helix API via Saloon), OAuth, EventSub webhooks | +| Integration GitHub | `app-modules/integration-github/` | GitHub transport (REST via Saloon), OAuth, community contribution ingestion (backfill + webhooks) + event lake | +| Onboarding | `app-modules/onboarding/` | Universal, mandatory entry layer — polymorphic onboarding state machines by type; owns the per-type completion gate (APTO) | +| Squads | `app-modules/squads/` | Squad lifecycle, membership and governance (captain/sub-captain, elections, removal, reallocation) | ## Relationships @@ -49,3 +51,5 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un - **Integration Twitch** depends on Identity (OAuth user resolution, ExternalIdentity for tenant linking). It never imports from Moderation, Integration Discord, or Bot Discord. - **Integration GitHub** depends on Identity (OAuth user resolution; future `Character` seam via `ExternalIdentity`). It never imports from Activity, Economy, Moderation or any Bot/runtime module — it only emits the `GithubContributionRecorded` domain event. The community presentation (in `portal`) and the allowlist admin UI (in `panel-admin`) depend on it, never the reverse. - **Identity** has no upstream dependencies on other contexts listed here. +- **Onboarding** depends on Identity (User, tenant scoping, GitHub `ExternalIdentity` link) and listens to `integration-github`'s `GithubPullRequestApproved` domain event (reads the `challenge` repos in the allowlist). It never imports from `squads` — `squads` is a consumer of its completion gate, never the reverse. +- **Squads** depends on Onboarding (reads the `Squads`-completion gate, "APTO") and Identity (users/tenants). It never imports from presentation; the panels depend on it. diff --git a/app-modules/onboarding/CONTEXT.md b/app-modules/onboarding/CONTEXT.md new file mode 100644 index 000000000..1b017dc82 --- /dev/null +++ b/app-modules/onboarding/CONTEXT.md @@ -0,0 +1,75 @@ +# Onboarding Context + +The universal, mandatory entry layer of the ecosystem. Owns the polymorphic onboarding state +machines that a person walks through, and the per-type **completion** status that other contexts +consume as an access gate. Today it powers community entry (`Welcome`) and squad entry (`Squads`), +but it is designed so new onboarding types are added without touching consumers. + +## Glossary + +| Term | Definition | Not to be confused with | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| **Onboarding** | One person's journey through one typed flow, scoped to a tenant. One row per `(tenant, user, type)`. Holds lifecycle `status`, not the per-step detail. | The UI wizard (presentation) — the Onboarding is the persisted state machine | +| **OnboardingType** | The discriminator enum (`Welcome`, `Squads`, …). Resolves the polymorphic behaviour via `handler(): OnboardingFlow` — same idiom as `IdentityProvider::getClient`. | A step — a type _has_ steps | +| **OnboardingFlow** | The per-type handler (a class behind the `OnboardingFlow` contract). Declares `steps()`, `prerequisites()`, `advance()`, `isComplete()`. All type-specific rules live here. | The model — the Flow is stateless behaviour; the Onboarding is the state | +| **OnboardingStep** | One auditable stage of a flow, one row in `onboarding_steps`. Carries its own `status` + `data` (jsonb) + `completed_at`. Enables pause/resume and history. | A prerequisite (another _type_ that must be complete first) | +| **Prerequisite** | Another `OnboardingType` that must be **completed** before this one can start (e.g. `Squads` requires `Welcome`). Declared by the flow, enforced on start. | A step (intra-type) — a prerequisite is inter-type | +| **APTO** | Domain shorthand for "completed the `Squads` onboarding". The condition that unlocks squad candidacy and squad creation. It is _not_ a global flag — it is `Squads` completion. | A generic "active member" — APTO is specifically squad-eligible | +| **Challenge** | The Git step of the `Squads` flow: open a PR (with the mandatory template) on a `challenge` repo; a human reviewer approves it on GitHub. Curation happens entirely on GitHub. | A contribution (the gamification record) — challenge repos do **not** award XP | +| **Gate** | A pre-condition checked at a transition without being a step of its own. The `Squads` flow gates `git_challenge` on having a linked GitHub `ExternalIdentity`. | A step — a gate produces no `onboarding_steps` row | + +## State machine (shape) + +`status` is a generic lifecycle: `in_progress` · `paused` · `completed` · `rejected`. The _steps_ are +type-specific and defined by the flow. The `Squads` flow: + +``` +[prereq: Welcome completed?] + │ yes + ▼ + step: form ──(submit, auto-advance, no curation)──► [gate: GitHub linked?] + │ no -> blocked + CTA "link GitHub" + │ yes + ▼ + step: git_challenge ──(PR approved on challenge repo)──► completed = APTO +``` + +Pause/resume is orthogonal: `status = paused` + `paused_at`, resumable from the current step. + +## Structure (proposed) + +``` +src/ +├── Models/ ← Onboarding · OnboardingStep +├── Enums/ ← OnboardingType · OnboardingStatus · OnboardingStepStatus +├── Contracts/ ← OnboardingFlow +├── Flows/ ← WelcomeOnboardingFlow · SquadsOnboardingFlow +├── Actions/ ← StartOnboarding · AdvanceStep · PauseOnboarding · ResumeOnboarding +├── DTOs/ ← per-step payload contracts (validated by the flow) +└── Listeners/ ← GithubPullRequestApproved -> advance the challenge step +``` + +## Module Boundaries + +### This module owns: + +- The onboarding state machines (one polymorphic model + steps) and their lifecycle. +- The per-type **completion** status other modules read as a gate (`Onboarding::isCompleted(user, tenant, type)`). +- The inter-type prerequisite chain. + +### This module does NOT own: + +- Squad lifecycle, membership, governance — belongs to `squads` (a consumer of the gate). +- Any HTTP communication with GitHub or the raw event lake — belongs to `integration-github`. This + module **listens to** `GithubPullRequestApproved` and reads `GithubRepository` (`purpose = challenge`). +- GitHub account linking — belongs to `identity` (`ExternalIdentity`, provider `github`). + +## Dependencies + +- **Identity** — `User`, tenant scoping, and the GitHub `ExternalIdentity` link (the `git_challenge` gate). +- **Integration GitHub** — consumes the `GithubPullRequestApproved` domain event and the `challenge` + repo allowlist. Never the reverse. +- Presentation (`panel-app`) drives the UI and calls the module's Actions. + +See `docs/adr/0001-onboarding-polimorfico-por-tipo.md` and +`docs/adr/0002-sinal-de-pr-aprovado-via-evento-de-dominio.md`. diff --git a/app-modules/onboarding/composer.json b/app-modules/onboarding/composer.json new file mode 100644 index 000000000..dc3cc0266 --- /dev/null +++ b/app-modules/onboarding/composer.json @@ -0,0 +1,27 @@ +{ + "name": "he4rt/onboarding", + "description": "", + "type": "library", + "version": "1.0.0", + "license": "proprietary", + "autoload": { + "psr-4": { + "He4rt\\Onboarding\\": "src/", + "He4rt\\Onboarding\\Database\\Factories\\": "database/factories/", + "He4rt\\Onboarding\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "He4rt\\Onboarding\\Tests\\": "tests/" + } + }, + "minimum-stability": "stable", + "extra": { + "laravel": { + "providers": [ + "He4rt\\Onboarding\\Providers\\OnboardingServiceProvider" + ] + } + } +} diff --git a/app-modules/onboarding/database/factories/.gitkeep b/app-modules/onboarding/database/factories/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app-modules/onboarding/database/migrations/.gitkeep b/app-modules/onboarding/database/migrations/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app-modules/onboarding/database/seeders/.gitkeep b/app-modules/onboarding/database/seeders/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app-modules/onboarding/docs/adr/0001-onboarding-polimorfico-por-tipo.md b/app-modules/onboarding/docs/adr/0001-onboarding-polimorfico-por-tipo.md new file mode 100644 index 000000000..f6f5af3b8 --- /dev/null +++ b/app-modules/onboarding/docs/adr/0001-onboarding-polimorfico-por-tipo.md @@ -0,0 +1,110 @@ +# ADR-0001: Onboarding polimórfico por tipo, com etapas auditáveis + +**Status:** Accepted +**Date:** 2026-06-15 +**Deciders:** danielhe4rt + +## Contexto + +Os Squads da He4rt rodam hoje no informal (grupos de WhatsApp, sem liderança formal). A primeira +entrega de software ataca **governança** (capitão/subcapitão, eleição, etc.) e, antes dela, uma +camada de **Entrada** que filtra quem realmente quer integrar a comunidade e contribuir. + +Essa Entrada — chamada no documento da P.O. de "pré-triagem" — é **universal e obrigatória**: +ninguém se candidata a um squad nem propõe um squad novo sem concluí-la. Duas perguntas de fronteira +apareceram: + +1. **Esse onboarding é específico de um squad ou global da comunidade?** A pré-triagem é sobre pertencer à comunidade, não a um squad específico, e reusa + pesado o `identity` (vínculo GitHub via `ExternalIdentity`). Colocá-la dentro de `squads` + amarraria um conceito universal a um consumidor específico. +2. **Quantos formatos?** O time já enxerga **mais de um tipo de entrada** — `Welcome` (entrada na + comunidade) e `Squads` (entrada no programa) — e quer outros no futuro, cada um com seu próprio + contrato de payload e processamento. + +Modelar a pré-triagem como uma máquina de estados fixa (form → desafio) resolveria o `Squads` de +hoje, mas não comportaria novos tipos sem refator. + +## Decisão + +**Criar um módulo de domínio novo, `onboarding`, dono de máquinas de onboarding polimórficas por +tipo.** `squads` (e futuros consumidores) apenas leem o gate de conclusão. + +### Polimorfismo (enum → handler) + +- `OnboardingType` (enum) discrimina o tipo e resolve o comportamento via `handler(): OnboardingFlow` + — mesmo idioma que `IdentityProvider::getClient()` já usa no `identity`. +- `OnboardingFlow` (contrato) declara `steps()`, `prerequisites()`, `advance()`, `isComplete()`. + Toda regra específica do tipo vive no handler; nenhum consumidor conhece os tipos concretos. + +### Persistência (modelo + etapas) + +Modelo único discriminado por `type` + tabela de etapas (opção "C" avaliada): + +`onboardings` — uma linha por `(tenant_id, user_id, type)`: + +| Coluna | Tipo | Notas | +| -------------- | ---------------------------- | --------------------------------------------------------------------- | +| `id` | uuid (PK) | `HasUuids` | +| `tenant_id` | uuid (FK) | tenant-scoped (convenção do repo, multi-tenant-ready) | +| `user_id` | uuid (FK) | | +| `type` | string | `OnboardingType` (`welcome` \| `squads` \| …) | +| `status` | string | ciclo de vida genérico: `in_progress`/`paused`/`completed`/`rejected` | +| `completed_at` | timestamptz? | | +| `paused_at` | timestamptz? | | +| timestamps | tz | | +| UNIQUE | `(tenant_id, user_id, type)` | | + +`onboarding_steps` — uma linha por etapa do fluxo: + +| Coluna | Tipo | Notas | +| --------------- | --------------------------- | ------------------------------------------------- | +| `id` | uuid (PK) | | +| `onboarding_id` | uuid (FK) | | +| `step_key` | string | semântica do handler (`form`, `git_challenge`, …) | +| `status` | string | `pending`/`done`/… | +| `data` | jsonb | payload da etapa, validado pelo DTO do tipo | +| `completed_at` | timestamptz? | | +| timestamps | tz | | +| UNIQUE | `(onboarding_id, step_key)` | | + +A tabela de etapas (em vez de só um `payload` JSON no modelo) foi escolhida por dar **auditoria e +histórico por etapa de graça** — relevante pro desafio Git, que tem reenvio com evolução e +pausa/retoma. + +### Cadeia entre tipos + +`prerequisites()` declara dependências **inter-tipo**: `Squads` exige `Welcome` concluído para poder +iniciar. O gate que o `squads` consome é `Onboarding::isCompleted(user, tenant, Squads)` — apelidado +de **APTO** no domínio. + +## Alternativas consideradas + +- **Pré-triagem dentro de `identity`** (membership): conceitualmente limpo, mas mistura onboarding + evolutivo com o núcleo de autenticação e força `identity` a depender de `integration-github`. +- **Pré-triagem dentro de `squads`**: entrega rápida, mas amarra um conceito universal a um consumidor + e exigiria migração quando outro módulo (eventos, etc.) quiser o mesmo gate. +- **STI / modelo por tipo**: Laravel não tem STI nativo (exige pacote/boilerplate), foge do idioma + `enum→resolve` do repo e incha o schema com colunas nuláveis por tipo. Descartado. +- **Modelo único só com `payload` JSON (sem tabela de etapas)**: mais simples, mas perde auditoria + por etapa. É o passo anterior natural; promovido para a tabela de etapas por causa do desafio Git. + +## Consequências + +### Positivas + +- Somar um tipo novo = +1 case no enum + 1 classe `Flow`. Consumidores intactos. +- Auditoria etapa-a-etapa nativa (início/fim de cada etapa, tentativas de reenvio). +- Pausa/retoma natural (estado vive na etapa + `status=paused`). +- Núcleo do jogo (futuro) pode virar mais um tipo, ou consumir o gate, sem acoplar. + +### Negativas / diferidas + +- `status`/`step_key` são strings genéricas — a disciplina de transição fica no handler, não no banco. +- Dois lugares de verdade (`onboardings` + `onboarding_steps`); o `isComplete()` precisa ser a única + fonte que decide conclusão para não divergirem. +- Validação do payload é só de aplicação (DTO), não de schema. + +## Review trigger + +Revisitar quando (a) o Núcleo do jogo for refinado e a gente decidir se ele é um `OnboardingType` ou +um consumidor do gate; ou (b) surgir um tipo cujo estado de etapa não caiba no par modelo+steps. diff --git a/app-modules/onboarding/docs/adr/0002-sinal-de-pr-aprovado-via-evento-de-dominio.md b/app-modules/onboarding/docs/adr/0002-sinal-de-pr-aprovado-via-evento-de-dominio.md new file mode 100644 index 000000000..8ab15d34e --- /dev/null +++ b/app-modules/onboarding/docs/adr/0002-sinal-de-pr-aprovado-via-evento-de-dominio.md @@ -0,0 +1,87 @@ +# ADR-0002: Sinal de "PR aprovado" via evento de domínio do integration-github + +**Status:** Accepted +**Date:** 2026-06-15 +**Deciders:** danielhe4rt +**Relates to:** [ADR-0001](0001-onboarding-polimorfico-por-tipo.md); `integration-github` + +## Contexto + +O step `git_challenge` do `SquadsOnboarding` conclui quando um **revisor humano aprova, no GitHub, o +PR do candidato** num repo de desafio. A plataforma precisa reagir a essa aprovação. + +O `integration-github` já é o **único** ponto que fala com o GitHub: `GithubWebhookController` recebe +todos os webhooks, grava no `GithubEventLog` (lake, dedup por `delivery_id`), e o `ProjectGithubEvent` +projeta eventos de repos da allowlist (`GithubRepository`) em `github_contributions`, emitindo +`GithubContributionRecorded`. A regra de fronteira do módulo é: ele **só emite eventos de domínio e +nunca importa de outros módulos**. + +Duas decisões de acoplamento precisavam ser tomadas: + +1. Como o sinal de aprovação chega ao `onboarding` sem duplicar a infra de webhook/HMAC/dedup nem + acoplar os módulos. +2. Como distinguir um "repo de desafio" de um repo de contribuições — sem que o `git_challenge` vire + XP de gamification. + +## Decisão + +### Sinal por evento de domínio + +O `integration-github` passa a emitir um **segundo evento de domínio**, +`GithubPullRequestApproved` (`author_login`, `repo`, `pr_number`, `approved_at`), ao observar +`pull_request_review` com `state = approved`. O `onboarding` registra um **listener** que resolve o +`author_login` para um `User` (via `ExternalIdentity` github) e avança o step `git_challenge`. + +O transporte continua centralizado num lugar só; HMAC e dedup são reusados; nenhum módulo passa a +falar com o GitHub além do `integration-github`. É a mesma costura do `GithubContributionRecorded`. + +### Repo de desafio via `purpose` na allowlist + +`GithubRepository` ganha um campo **`purpose`** (`contributions` | `challenge`): + +- Repos `challenge` **não** geram `GithubContributionRecorded` (fazer o desafio não vira XP). +- O `onboarding` resolve o repo de desafio lendo `GithubRepository::query()->where('purpose', 'challenge')` + (tenant-scoped), e ignora aprovações de repos que não sejam de desafio. + +O `purpose` é uma **categoria de projeção** legítima do próprio `integration-github` (ele já decide o +que projetar). O `integration-github` nunca precisa conhecer a palavra "onboarding". + +### Vínculo do GitHub é gate, não há reconciliação + +O vínculo da conta GitHub (`ExternalIdentity` provider `github`) é **pré-requisito** (gate) para o +step `git_challenge`. Logo o `author_login` do webhook **sempre** casa um `User`, e o cenário +"aprovação de conta não vinculada → retém e reconcilia" **deixa de existir** — sem buffer, sem tabela +de pendências, sem reconciliação. Isso diverge do BDD original da P.O., que previa reconciliação. + +## Alternativas consideradas + +- **Webhook próprio do `onboarding`** só pro repo de desafio: desacoplaria 100%, mas duplicaria + HMAC/dedup e criaria um segundo ponto que fala com o GitHub. Descartado. +- **`onboarding` relendo o lake (`GithubEventLog`)**: inverteria a dependência (`onboarding` → + consulta interna do `integration-github`) e furaria o encapsulamento do lake. Descartado. +- **Config de repo de desafio dentro do `onboarding`** (em vez do `purpose` na allowlist): manteria o + `integration-github` sem nenhuma categoria nova, mas duplicaria o cadastro de repos e perderia o + benefício de excluir o repo de desafio da projeção de contribuições num lugar só. Trade-off aceito + a favor do `purpose`. + +## Consequências + +### Positivas + +- Um único ponto de integração com o GitHub; HMAC/dedup reusados. +- Repos de desafio ficam fora da gamification por construção (`purpose`). +- Sem reconciliação: a máquina de estados fica drasticamente mais simples. + +### Negativas / diferidas + +- `integration-github` ganha um rótulo (`purpose=challenge`) que existe por causa de um consumidor — + acoplamento mínimo e consciente, mitigado por ser categoria de projeção, não lógica de onboarding. +- O repo de desafio precisa ter o webhook do GitHub instalado apontando pro endpoint do + `integration-github` (setup de infra, não de código). +- Diverge do BDD original (reconciliação removida) — o documento da P.O. precisa ser atualizado. + +## Review trigger + +Revisitar se algum dia um onboarding precisar reagir a aprovações de conta ainda não vinculada +(reintroduziria o buffer/reconciliação), ou se mais de um repo de desafio por tenant exigir +roteamento por tipo de desafio. diff --git a/app-modules/onboarding/phpstan.ignore.neon b/app-modules/onboarding/phpstan.ignore.neon new file mode 100644 index 000000000..f51e71c3f --- /dev/null +++ b/app-modules/onboarding/phpstan.ignore.neon @@ -0,0 +1,2 @@ +parameters: + ignoreErrors: [] diff --git a/app-modules/onboarding/phpstan.neon b/app-modules/onboarding/phpstan.neon new file mode 100644 index 000000000..b577d0f29 --- /dev/null +++ b/app-modules/onboarding/phpstan.neon @@ -0,0 +1,6 @@ +includes: + - phpstan.ignore.neon + +parameters: + paths: + - src/ diff --git a/app-modules/onboarding/src/Providers/OnboardingServiceProvider.php b/app-modules/onboarding/src/Providers/OnboardingServiceProvider.php new file mode 100644 index 000000000..1558f42d5 --- /dev/null +++ b/app-modules/onboarding/src/Providers/OnboardingServiceProvider.php @@ -0,0 +1,14 @@ +=1", "he4rt/integration-whatsapp": ">=1", "he4rt/moderation": "^1.0", + "he4rt/onboarding": "*", "he4rt/panel-admin": ">=1", "he4rt/panel-app": ">=1", "he4rt/portal": ">=1", "he4rt/profile": ">=1", + "he4rt/squads": "*", "internachi/modular": "^3.0.2", "laracord/framework": "dev-next#e7b64d6", "laravel/framework": "^13.15.0",