From e0f7582bccf72c6f7a947d25e46e216624c8f67c Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 22 Aug 2026 18:05:51 -0300 Subject: [PATCH 01/10] =?UTF-8?q?feat(identity):=20situa=C3=A7=C3=A3o=20de?= =?UTF-8?q?rivada=20do=20usu=C3=A1rio=20e=20rela=C3=A7=C3=A3o=20com=20o=20?= =?UTF-8?q?perfil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona UserSituation, enum que resolve ativo/suspenso/banido a partir de banned_at e suspended_until. É leitura pura: quem escreve essas duas datas continua sendo o WebModerationAdapter, a partir de um caso de moderação. Expõe também User::profile(), que faltava apesar de o UserObserver já criar o perfil de todo usuário, e troca o override de newFactory() pelo atributo UseFactory exigido pela guideline de models. --- .../identity/src/User/Enums/UserSituation.php | 64 +++++++++++++++++++ app-modules/identity/src/User/Models/User.php | 32 +++++++++- .../identity/tests/Unit/UserSituationTest.php | 39 +++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 app-modules/identity/src/User/Enums/UserSituation.php create mode 100644 app-modules/identity/tests/Unit/UserSituationTest.php diff --git a/app-modules/identity/src/User/Enums/UserSituation.php b/app-modules/identity/src/User/Enums/UserSituation.php new file mode 100644 index 00000000..413c9fb5 --- /dev/null +++ b/app-modules/identity/src/User/Enums/UserSituation.php @@ -0,0 +1,64 @@ + 'Ativo', + self::Suspended => 'Suspenso', + self::Banned => 'Banido', + }; + } + + public function getColor(): string + { + return match ($this) { + self::Active => 'success', + self::Suspended => 'warning', + self::Banned => 'danger', + }; + } + + public function getDescription(): string + { + return match ($this) { + self::Active => 'Sem restrição ativa', + self::Suspended => 'Acesso bloqueado até a data de término', + self::Banned => 'Acesso revogado por decisão de moderação', + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + self::Active => Heroicon::OutlinedCheckCircle, + self::Suspended => Heroicon::OutlinedClock, + self::Banned => Heroicon::OutlinedNoSymbol, + }; + } +} diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index e91b55c8..86484f36 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -12,10 +12,13 @@ use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\Database\Factories\UserFactory; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; +use He4rt\Identity\User\Enums\UserSituation; use He4rt\Identity\User\Observers\UserObserver; +use He4rt\Profile\Models\Profile; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Attributes\Table; +use Illuminate\Database\Eloquent\Attributes\UseFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -38,8 +41,10 @@ * @property string|null $remember_token * @property CarbonInterface|null $created_at * @property CarbonInterface|null $updated_at + * @property-read UserSituation $situation */ #[ObservedBy(classes: UserObserver::class)] +#[UseFactory(factoryClass: UserFactory::class)] #[Table(name: 'users')] #[Hidden('password', 'remember_token', 'email_verified_at')] final class User extends Authenticatable implements FilamentUser, HasMedia, HasName @@ -72,6 +77,14 @@ public function character(): HasOne return $this->hasOne(Character::class); } + /** + * @return HasOne + */ + public function profile(): HasOne + { + return $this->hasOne(Profile::class); + } + public function getFilamentName(): string { return $this->username; @@ -102,9 +115,12 @@ public function getFilamentAvatarUrl(): string return sprintf('https://github.com/%s.png', $this->username); } - protected static function newFactory(): UserFactory + /** + * @return Attribute + */ + protected function situation(): Attribute { - return UserFactory::new(); + return Attribute::get($this->resolveSituation(...)); } /** @@ -136,4 +152,16 @@ protected function casts(): array 'first_login_at' => 'datetime', ]; } + + private function resolveSituation(): UserSituation + { + $isBanned = $this->banned_at !== null; + $isSuspended = $this->suspended_until !== null && $this->suspended_until->isFuture(); + + return match (true) { + $isBanned => UserSituation::Banned, + $isSuspended => UserSituation::Suspended, + default => UserSituation::Active, + }; + } } diff --git a/app-modules/identity/tests/Unit/UserSituationTest.php b/app-modules/identity/tests/Unit/UserSituationTest.php new file mode 100644 index 00000000..77e45a28 --- /dev/null +++ b/app-modules/identity/tests/Unit/UserSituationTest.php @@ -0,0 +1,39 @@ +create(['banned_at' => now()->subDay()]); + + expect($user->situation)->toBe(UserSituation::Banned); +}); + +test('suspensão vigente devolve Suspended', function (): void { + $user = User::factory()->create(['suspended_until' => now()->addWeek()]); + + expect($user->situation)->toBe(UserSituation::Suspended); +}); + +test('suspensão vencida devolve Active', function (): void { + $user = User::factory()->create(['suspended_until' => now()->subDay()]); + + expect($user->situation)->toBe(UserSituation::Active); +}); + +test('sem punição devolve Active', function (): void { + $user = User::factory()->create(); + + expect($user->situation)->toBe(UserSituation::Active); +}); + +test('banimento vence suspensão quando os dois estão preenchidos', function (): void { + $user = User::factory()->create([ + 'banned_at' => now()->subDay(), + 'suspended_until' => now()->addWeek(), + ]); + + expect($user->situation)->toBe(UserSituation::Banned); +}); From bc37082014278ba38291141cdcbff0d5dd945fb4 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 22 Aug 2026 18:06:03 -0300 Subject: [PATCH 02/10] =?UTF-8?q?fix(profile):=20WorkPreferences=20seriali?= =?UTF-8?q?z=C3=A1vel=20e=20factory=20expl=C3=ADcita=20na=20Skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkPreferences já tinha toArray(), mas não declarava Arrayable, então o Eloquent devolvia o objeto cru em attributesToArray() e o Livewire falhava ao hidratar qualquer tela que carregasse um perfil. Skill ganha o atributo UseFactory que faltava. --- app-modules/profile/src/Data/WorkPreferences.php | 15 ++++++++++++++- app-modules/profile/src/Models/Skill.php | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app-modules/profile/src/Data/WorkPreferences.php b/app-modules/profile/src/Data/WorkPreferences.php index 0041c38d..9e6e7290 100644 --- a/app-modules/profile/src/Data/WorkPreferences.php +++ b/app-modules/profile/src/Data/WorkPreferences.php @@ -5,8 +5,13 @@ namespace He4rt\Profile\Data; use He4rt\Profile\Enums\EmploymentType; +use Illuminate\Contracts\Support\Arrayable; +use JsonSerializable; -final readonly class WorkPreferences +/** + * @implements Arrayable + */ +final readonly class WorkPreferences implements Arrayable, JsonSerializable { /** * @param list $employmentTypes @@ -56,4 +61,12 @@ public function toArray(): array ), ]; } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/app-modules/profile/src/Models/Skill.php b/app-modules/profile/src/Models/Skill.php index 8240ec83..7d720456 100644 --- a/app-modules/profile/src/Models/Skill.php +++ b/app-modules/profile/src/Models/Skill.php @@ -8,6 +8,7 @@ use He4rt\Profile\Database\Factories\SkillFactory; use He4rt\Profile\Enums\SkillCategory; use Illuminate\Database\Eloquent\Attributes\Table; +use Illuminate\Database\Eloquent\Attributes\UseFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -23,6 +24,7 @@ * @property CarbonInterface|null $created_at * @property CarbonInterface|null $updated_at */ +#[UseFactory(factoryClass: SkillFactory::class)] #[Table(name: 'skills')] final class Skill extends Model { From c15f903e5bf52a715f76c015d80fa65ab033fb03 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 22 Aug 2026 18:06:21 -0300 Subject: [PATCH 03/10] =?UTF-8?q?feat(panel-admin):=20aba=20Pessoas=20com?= =?UTF-8?q?=20usu=C3=A1rios,=20perfis=20e=20skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primeira leva da reestruturação da sidebar. Introduz o enum NavigationGroup como fonte única de label e ícone dos grupos, e monta o grupo Pessoas no NavigationBuilder — com builder customizado a propriedade $navigationGroup dos Resources é ignorada pelo Filament, então quem agrupa é o provider. UserResource não cria conta: usuário nasce por OAuth, e criar à mão daria uma conta sem identidade externa, incapaz de logar. Não edita punição tampouco — banimento e suspensão são exibidos, nunca escritos. ProfileResource não cria nem apaga: o UserObserver já garante um perfil por usuário. Remuneração pretendida fica fora da tabela e do form, só no infolist, em seção fechada. SkillResource esconde a exclusão de skill em uso, que deixaria profile_skills órfão por falta de cascade. Os cinco clusters seguem intactos. --- .../docs/plans/2026-08-22-aba-pessoas.md | 1413 +++++++++++++++++ .../panel-admin/lang/en/navigation.php | 9 + .../panel-admin/lang/pt_BR/navigation.php | 9 + .../panel-admin/src/Enums/NavigationGroup.php | 37 + .../Resources/Profiles/Pages/EditProfile.php | 24 + .../Resources/Profiles/Pages/ListProfiles.php | 13 + .../Resources/Profiles/Pages/ViewProfile.php | 24 + .../Resources/Profiles/ProfileResource.php | 107 ++ .../ProfileSkillsRelationManager.php | 89 ++ .../WorkExperiencesRelationManager.php | 121 ++ .../Profiles/Schemas/ProfileForm.php | 79 + .../Profiles/Schemas/ProfileInfolist.php | 88 + .../Profiles/Tables/ProfilesTable.php | 87 + .../Resources/Skills/Pages/CreateSkill.php | 13 + .../Resources/Skills/Pages/EditSkill.php | 13 + .../Resources/Skills/Pages/ListSkills.php | 24 + .../Resources/Skills/Schemas/SkillForm.php | 48 + .../Resources/Skills/SkillResource.php | 65 + .../Resources/Skills/Tables/SkillsTable.php | 79 + .../Resources/Users/Pages/EditUser.php | 24 + .../Resources/Users/Pages/ListUsers.php | 13 + .../Resources/Users/Pages/ViewUser.php | 24 + .../ProvidersRelationManager.php | 66 + .../Resources/Users/Schemas/UserForm.php | 51 + .../Resources/Users/Schemas/UserInfolist.php | 97 ++ .../Resources/Users/Tables/UsersTable.php | 118 ++ .../Filament/Resources/Users/UserResource.php | 105 ++ .../src/PanelAdminServiceProvider.php | 38 +- .../Feature/Identity/UserResourceTest.php | 125 ++ .../tests/Feature/NavigationGroupsTest.php | 78 + .../Feature/Profile/ProfileResourceTest.php | 129 ++ .../Feature/Profile/SkillResourceTest.php | 127 ++ 32 files changed, 3327 insertions(+), 10 deletions(-) create mode 100644 app-modules/panel-admin/docs/plans/2026-08-22-aba-pessoas.md create mode 100644 app-modules/panel-admin/lang/en/navigation.php create mode 100644 app-modules/panel-admin/lang/pt_BR/navigation.php create mode 100644 app-modules/panel-admin/src/Enums/NavigationGroup.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Profiles/Pages/EditProfile.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Profiles/Pages/ListProfiles.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Profiles/Pages/ViewProfile.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Profiles/ProfileResource.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Profiles/RelationManagers/ProfileSkillsRelationManager.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Profiles/RelationManagers/WorkExperiencesRelationManager.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Profiles/Schemas/ProfileForm.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Profiles/Schemas/ProfileInfolist.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Profiles/Tables/ProfilesTable.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Skills/Pages/CreateSkill.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Skills/Pages/EditSkill.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Skills/Pages/ListSkills.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Skills/Schemas/SkillForm.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Skills/SkillResource.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Skills/Tables/SkillsTable.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/Pages/ListUsers.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/Pages/ViewUser.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/ProvidersRelationManager.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserForm.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserInfolist.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/Tables/UsersTable.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php create mode 100644 app-modules/panel-admin/tests/Feature/Identity/UserResourceTest.php create mode 100644 app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php create mode 100644 app-modules/panel-admin/tests/Feature/Profile/ProfileResourceTest.php create mode 100644 app-modules/panel-admin/tests/Feature/Profile/SkillResourceTest.php diff --git a/app-modules/panel-admin/docs/plans/2026-08-22-aba-pessoas.md b/app-modules/panel-admin/docs/plans/2026-08-22-aba-pessoas.md new file mode 100644 index 00000000..a92f7683 --- /dev/null +++ b/app-modules/panel-admin/docs/plans/2026-08-22-aba-pessoas.md @@ -0,0 +1,1413 @@ +--- +type: plan +title: 'Aba Pessoas no painel admin' +module: panel-admin +status: proposed +date: 2026-08-22 +author: danielhe4rt +--- + +# Aba Pessoas — plano de implementação + +Primeira leva da reestruturação do painel: o grupo **Pessoas**, com quatro telas +(Usuários, Identidades externas, Perfis, Skills) e o enum de grupos de navegação +que as sustenta. + +## Contexto + +O painel expõe 14 Resources para 55 models de domínio. `users` é referenciada por +19 das 69 foreign keys do schema e **não tem Resource nenhum** — a entidade mais +central do sistema é invisível no admin. `user_profiles`, `skills` e +`profile_skills` também não têm tela. + +A sidebar hoje não usa `navigationGroup` em nenhum lugar: `PanelAdminServiceProvider` +monta a navegação à mão com `NavigationBuilder` e `->items()`, deixando +`ExternalIdentityResource` e `EventResource` soltos na raiz. + +## Decisões tomadas com o autor + +| Decisão | Escolha | Consequência no plano | +| ----------------------- | ---------------------------- | ------------------------------------------------ | +| Escopo | As quatro telas da aba | 4 Resources + 2 RelationManagers | +| Punir (banir/suspender) | **Só leitura** no painel | Nenhuma escrita em `banned_at`/`suspended_until` | +| Autorização | Binária, sem Policy | `canAccessPanel()` já barra; sem `UserPolicy` | +| Navegação | Só o enum, clusters intactos | Enum novo + ajuste no `NavigationBuilder` | + +## Restrições descobertas no levantamento + +**1. `WebModerationAdapter` é o dono de banir e suspender.** +`app-modules/moderation/src/Platform/WebModerationAdapter.php:96,106` escreve +`suspended_until` e `banned_at` a partir de um `ModerationAction`. O painel de +Pessoas **não pode** escrever nessas colunas: criaria punição sem caso, sem +notificação e sem audit log. Elas são exibidas, nunca editadas. + +**2. `$navigationGroup` é ignorado quando existe `NavigationBuilder` custom.** +O painel chama `->navigation($this->buildNavigation(...))`. Nesse modo, a +propriedade `$navigationGroup` dos Resources não tem efeito — grupos só aparecem +via `$builder->groups([NavigationGroup::make()->items([...])])`. +Docs: https://filamentphp.com/docs/5.x/navigation/overview#registering-custom-navigation-groups +Por isso o enum é a fonte de label/ícone/ordem, e o builder o consome. + +**3. Todo perfil já nasce com o usuário.** +`UserObserver::created()` chama `Profile::ensureExists()`. Logo `ProfileResource` +**não tem Create nem Delete** — criar um segundo perfil viola o índice único em +`user_id`, e apagar quebra a invariante que o resto do sistema assume. + +**4. Todos os enums envolvidos já implementam os contratos Filament.** +`SeniorityLevel`, `StartAvailability`, `SkillCategory`, `SkillProficiency` +(módulo profile) e `IdentityProvider`, `IdentityType`, `CredentialsType` +(módulo identity) implementam `HasLabel`, `HasColor` e `HasIcon`. Badges e +filtros ganham label, cor e ícone sem nenhuma configuração manual. + +**5. `expected_salary_min` / `expected_salary_max` são dado sensível.** +Ficam fora da tabela e fora do form. Aparecem apenas no infolist da View page, +dentro de uma Section colapsada e iniciada fechada. + +## Arquitetura da navegação + +```text + ┌─ PanelAdminServiceProvider::defaultNavigation() ────────────────┐ + │ │ + │ $builder │ + │ ->items([ ...Dashboard::getNavigationItems() ]) ← solto │ + │ ->groups([ │ + │ NavigationGroup::make(NavGroup::People->getLabel()) │ + │ ->icon(NavGroup::People->getIcon()) │ + │ ->items([ │ + │ ...UserResource::getNavigationItems(), │ + │ ...ExternalIdentityResource::getNavigationItems(), │ + │ ...ProfileResource::getNavigationItems(), │ + │ ...SkillResource::getNavigationItems(), │ + │ ]), │ + │ ]) │ + │ ->items([ ← inalterado │ + │ ...ModerationCluster::getNavigationItems(), │ + │ ...MarketingCluster::getNavigationItems(), │ + │ ...TwitchCluster::getNavigationItems(), │ + │ ...GithubCluster::getNavigationItems(), │ + │ ...DiscordCluster::getNavigationItems(), │ + │ ...EventResource::getNavigationItems(), │ + │ ]) │ + └─────────────────────────────────────────────────────────────────┘ +``` + +Os cinco clusters e o `EventResource` continuam exatamente como estão. A única +mudança neles é que `ExternalIdentityResource` sai da lista solta e entra no +grupo Pessoas. + +## Fronteiras de módulo + +```text + ┌──────────────┐ lê ┌─────────────┐ + │ panel-admin │ ──────► │ identity │ User, ExternalIdentity + │ (presentation)│ ──────► │ profile │ Profile, Skill, ProfileSkill, + └──────┬───────┘ └─────────────┘ WorkExperience + │ lê (só URL de link) + ▼ + ┌──────────────┐ + │ moderation │ ModerationCaseResource::getUrl() + └──────────────┘ +``` + +`identity` já importa `He4rt\Profile\Models\Profile` no `UserObserver`, então a +relação `User::profile()` que este plano adiciona não cria acoplamento novo — +apenas torna explícito o que já existe. + +--- + +# 1. Commands + +Rodar nesta ordem, antes de escrever qualquer código. + +```bash +php artisan make:filament-resource User \ + --view --generate \ + --model-namespace="He4rt\Identity\User\Models" \ + --resource-namespace="He4rt\PanelAdmin\Filament\Resources" \ + --record-title-attribute=username \ + --no-interaction + +php artisan make:filament-resource Profile \ + --view --generate \ + --model-namespace="He4rt\Profile\Models" \ + --resource-namespace="He4rt\PanelAdmin\Filament\Resources" \ + --record-title-attribute=nickname \ + --no-interaction + +php artisan make:filament-resource Skill \ + --generate \ + --model-namespace="He4rt\Profile\Models" \ + --resource-namespace="He4rt\PanelAdmin\Filament\Resources" \ + --record-title-attribute=name \ + --no-interaction + +php artisan make:filament-relation-manager UserResource providers provider \ + --resource-namespace="He4rt\PanelAdmin\Filament\Resources" \ + --no-interaction + +php artisan make:filament-relation-manager ProfileResource workExperiences company_name \ + --resource-namespace="He4rt\PanelAdmin\Filament\Resources" \ + --no-interaction + +php artisan make:filament-relation-manager ProfileResource profileSkills skill.name \ + --resource-namespace="He4rt\PanelAdmin\Filament\Resources" \ + --no-interaction +``` + +Depois de gerar, **conferir o path**: os arquivos devem cair em +`app-modules/panel-admin/src/Filament/Resources/{Users,Profiles,Skills}/`. +Se o gerador escrever em `app/Filament/Resources/`, mover à mão e corrigir o +namespace para `He4rt\PanelAdmin\Filament\Resources\...`. + +Nenhuma migration é criada — todas as tabelas já existem. + +--- + +# 2. Models + +## 2.1 `He4rt\Identity\User\Models\User` — atualizar + +**Adicionar** a relação com o perfil (hoje inexistente): + +``` +Relationship: profile + Type: HasOne + Target: He4rt\Profile\Models\Profile + PHPDoc: /** @return HasOne */ +``` + +**Adicionar** um accessor de situação, usado pela coluna e pelo filtro. Ele é +derivado — não há coluna nova, nenhuma migration: + +``` +Accessor: situation + Returns: He4rt\Identity\User\Enums\UserSituation + Logic: + - banned_at is not null -> UserSituation::Banned + - suspended_until is in the future -> UserSituation::Suspended + - otherwise -> UserSituation::Active + PHPDoc: @property-read UserSituation $situation +``` + +**Corrigir desvio de guideline** (o model usa `newFactory()` em vez do atributo): + +``` +Remove: protected static function newFactory(): UserFactory +Add: #[UseFactory(factoryClass: UserFactory::class)] +Import: Illuminate\Database\Eloquent\Attributes\UseFactory +``` + +Manter o trait `HasFactory` — ele é quem fornece `factory()`. + +**Não alterar** `banned_at`, `suspended_until`, nem os casts existentes. + +## 2.2 `He4rt\Identity\User\Enums\UserSituation` — criar + +Enum novo. Pela guideline do repo (`.ai/07-enum-filament-contracts`), todo enum +novo implementa os contratos Filament no mesmo commit, e escalas ordenadas +formam rampa claro → vermelho. + +``` +Enum: He4rt\Identity\User\Enums\UserSituation + Backed by: string + Implements: Filament\Support\Contracts\HasColor, + Filament\Support\Contracts\HasDescription, + Filament\Support\Contracts\HasIcon, + Filament\Support\Contracts\HasLabel + + Cases: + Active = 'active' + Suspended = 'suspended' + Banned = 'banned' + + getLabel(): + Active -> 'Ativo' + Suspended -> 'Suspenso' + Banned -> 'Banido' + + getColor(): (escala ordenada, rampa claro -> vermelho) + Active -> 'success' + Suspended -> 'warning' + Banned -> 'danger' + + getDescription(): + Active -> 'Sem restrição ativa' + Suspended -> 'Acesso bloqueado até a data de término' + Banned -> 'Acesso revogado por decisão de moderação' + + getIcon(): + Active -> Heroicon::OutlinedCheckCircle + Suspended -> Heroicon::OutlinedClock + Banned -> Heroicon::OutlinedNoSymbol + + Imports: Filament\Support\Icons\Heroicon +``` + +Cada getter é um `match ($this)` sobre **todos** os cases, sem braço `default`. + +## 2.3 `He4rt\Profile\Models\Skill` — atualizar + +Falta o atributo de factory exigido pela guideline: + +``` +Add: #[UseFactory(factoryClass: SkillFactory::class)] +Import: Illuminate\Database\Eloquent\Attributes\UseFactory +``` + +## 2.4 Models não alterados + +`Profile`, `ProfileSkill`, `WorkExperience` e `ExternalIdentity` ficam como +estão. Já têm `#[Table]`, PHPDoc completo, casts e relações necessárias. + +--- + +# 3. Navegação + +## 3.1 `He4rt\PanelAdmin\Enums\NavigationGroup` — criar + +``` +Enum: He4rt\PanelAdmin\Enums\NavigationGroup + Pure enum (não backed) — a ordem dos cases define a ordem dos grupos + Implements: Filament\Support\Contracts\HasIcon, + Filament\Support\Contracts\HasLabel + Docs: https://filamentphp.com/docs/5.x/navigation/overview#registering-navigation-groups-with-an-enum + + Cases (nesta ordem): + People + + getLabel(): People -> __('panel-admin::navigation.groups.people') + + getIcon(): string | BackedEnum | Htmlable | null + People -> Heroicon::OutlinedUsers + + Imports: + - BackedEnum + - Filament\Support\Icons\Heroicon + - Illuminate\Contracts\Support\Htmlable +``` + +Só o case `People` nesta leva. Os demais grupos entram quando suas telas +existirem — um case sem tela renderiza um grupo vazio. + +## 3.2 Traduções — criar + +`app-modules/panel-admin/lang/pt_BR/navigation.php`: + +```php +return [ + 'groups' => [ + 'people' => 'Pessoas', + ], +]; +``` + +`app-modules/panel-admin/lang/en/navigation.php`: + +```php +return [ + 'groups' => [ + 'people' => 'People', + ], +]; +``` + +## 3.3 `PanelAdminServiceProvider` — atualizar + +**Em `register()`**, adicionar os três Resources novos ao array `->resources([...])` +que já lista `ExternalIdentityResource` e `EventResource`: + +``` +->resources([ + ExternalIdentityResource::class, + EventResource::class, + UserResource::class, ← novo + ProfileResource::class, ← novo + SkillResource::class, ← novo +]) +``` + +**Em `defaultNavigation()`**, trocar a lista plana única por itens soltos + +um grupo, conforme o diagrama da seção "Arquitetura da navegação": + +``` +Antes: + return $builder->items([ + ...Dashboard::getNavigationItems(), + ...ModerationCluster::getNavigationItems(), + ...MarketingCluster::getNavigationItems(), + ...TwitchCluster::getNavigationItems(), + ...GithubCluster::getNavigationItems(), + ...ExternalIdentityResource::getNavigationItems(), + ...EventResource::getNavigationItems(), + ...DiscordCluster::getNavigationItems(), + ]); + +Depois: + return $builder + ->items([ + ...Dashboard::getNavigationItems(), + ...ModerationCluster::getNavigationItems(), + ...MarketingCluster::getNavigationItems(), + ...TwitchCluster::getNavigationItems(), + ...GithubCluster::getNavigationItems(), + ...DiscordCluster::getNavigationItems(), + ...EventResource::getNavigationItems(), + ]) + ->groups([ + NavigationGroup::make(NavGroup::People->getLabel()) + ->icon(NavGroup::People->getIcon()) + ->items([ + ...UserResource::getNavigationItems(), + ...ExternalIdentityResource::getNavigationItems(), + ...ProfileResource::getNavigationItems(), + ...SkillResource::getNavigationItems(), + ]), + ]); + + Imports: + - Filament\Navigation\NavigationGroup + - He4rt\PanelAdmin\Enums\NavigationGroup as NavGroup (alias obrigatório: + o nome colide com a classe de navegação do Filament) +``` + +`ExternalIdentityResource` sai de `->items()` e entra no grupo. Os cinco clusters +e o `EventResource` permanecem soltos, sem alteração de comportamento. + +**Não** mexer em `buildNavigation()`, `moderationNavigation()`, +`marketingNavigation()`, `twitchNavigation()` nem `discordNavigation()`. + +--- + +# 4. Resources + +## 4.1 UserResource + +``` +Resource: UserResource + Command: ver seção 1 + Location: He4rt\PanelAdmin\Filament\Resources\Users\UserResource + Model: He4rt\Identity\User\Models\User + Docs: https://filamentphp.com/docs/5.x/resources/overview + + Slug: users + RecordTitleAttribute: username + Icon: Heroicon::OutlinedUsers + NavigationGroup: montado pelo builder (seção 3.3), não por $navigationGroup + + GloballySearchableAttributes: [username, name, email] + + getEloquentQuery(): ->withCount('providers') + + canCreate(): false + Motivo: usuário nasce por OAuth (FindOrCreateUserByProvider). Criar à mão + produziria conta sem identidade externa, que não consegue logar. + + Pages: + index → ListUsers + view → ViewUser + edit → EditUser + (sem CreateUser — remover a página e a entrada em getPages()) +``` + +### Form (Edit) + +``` +Form: + Columns: 2 + + Section: Identificação + Component: Filament\Schemas\Components\Section + Docs: https://filamentphp.com/docs/5.x/schemas/sections + ColumnSpan: full + Columns: 2 + + Field: username + Component: Filament\Forms\Components\TextInput + Docs: https://filamentphp.com/docs/5.x/forms/text-input + Validation: required, max:255, unique:users,username + Config: ->maxLength(255), ->required() + + Field: name + Component: Filament\Forms\Components\TextInput + Docs: https://filamentphp.com/docs/5.x/forms/text-input + Validation: required, max:255 + Config: ->maxLength(255), ->required() + + Field: email + Component: Filament\Forms\Components\TextInput + Docs: https://filamentphp.com/docs/5.x/forms/text-input + Validation: nullable, email, max:255, unique:users,email + Config: ->email(), ->maxLength(255) + + Section: Sinalizações + Component: Filament\Schemas\Components\Section + ColumnSpan: full + Columns: 1 + + Field: is_donator + Component: Filament\Forms\Components\Toggle + Docs: https://filamentphp.com/docs/5.x/forms/toggle + Validation: boolean + Config: ->helperText('Marca manual de apoiador. Não afeta permissões.') +``` + +**Nenhum campo de punição no form.** `banned_at` e `suspended_until` não entram — +ver restrição 1. + +### Table + +``` +Table: + DefaultSort: created_at desc + + Column: username + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->searchable(), ->sortable(), ->weight(FontWeight::Medium) + Imports: Filament\Support\Enums\FontWeight + + Column: name + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->searchable(), ->sortable(), ->description(fn (User $record): ?string => $record->email) + + Column: situation + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->badge(), ->state(fn (User $record): UserSituation => $record->situation), ->sortable(false) + Nota: cor, label e ícone vêm do enum (HasColor/HasLabel/HasIcon) — não + configurar ->color() manualmente. + + Column: suspended_until + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), + ->sortable(), ->placeholder('—'), ->toggleable(isToggledHiddenByDefault: true) + + Column: is_donator + Component: Filament\Tables\Columns\IconColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/icon + Config: ->boolean(), ->sortable() + + Column: providers_count + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->label('Identidades'), ->counts('providers'), ->numeric(0), ->sortable() + + Column: first_login_at + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), + ->sortable(), ->placeholder('Nunca'), ->toggleable(isToggledHiddenByDefault: true) + + Column: created_at + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), + ->sortable(), ->toggleable(isToggledHiddenByDefault: true) +``` + +### Filters + +``` +Filter: situation + Component: Filament\Tables\Filters\SelectFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/select + Config: ->options(UserSituation::class), + ->query(fn (Builder $query, array $data): Builder => match ($data['value'] ?? null) { + UserSituation::Banned->value => $query->whereNotNull('banned_at'), + UserSituation::Suspended->value => $query->whereNull('banned_at')->where('suspended_until', '>', now()), + UserSituation::Active->value => $query->whereNull('banned_at') + ->where(fn (Builder $inner): Builder => $inner->whereNull('suspended_until')->orWhere('suspended_until', '<=', now())), + default => $query, + }) + Imports: Illuminate\Database\Eloquent\Builder + +Filter: is_donator + Component: Filament\Tables\Filters\TernaryFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/ternary + Config: ->label('Apoiador') + +Filter: never_logged_in + Component: Filament\Tables\Filters\Filter + Docs: https://filamentphp.com/docs/5.x/tables/filters/custom + Config: ->label('Nunca logou'), + ->query(fn (Builder $query): Builder => $query->whereNull('first_login_at')) +``` + +### Actions + +``` +Action: view + Component: Filament\Actions\ViewAction + Docs: https://filamentphp.com/docs/5.x/actions/overview + Location: table row + +Action: edit + Component: Filament\Actions\EditAction + Docs: https://filamentphp.com/docs/5.x/actions/overview + Location: table row + +Action: moderationCases + Component: Filament\Actions\Action + Docs: https://filamentphp.com/docs/5.x/actions/overview + Location: table row + Icon: Heroicon::OutlinedShieldExclamation + Color: gray + Label: 'Casos de moderação' + Visibility: always + Authorization: nenhuma além do acesso ao painel + Behavior: + - Abre a listagem de casos já filtrada por este usuário como autor + - Config: ->url(fn (User $record): string => ModerationCaseResource::getUrl('index', [ + 'tableFilters' => ['author' => ['value' => $record->getKey()]], + ])), ->openUrlInNewTab(false) + Imports: He4rt\PanelAdmin\Moderation\Resources\ModerationCaseResource + Dependência: exige o filtro 'author' descrito em 4.5 — implementar os dois juntos. + +Toolbar: nenhuma bulk action. + Motivo: exclusão em massa de usuários não é operação que este painel deva + oferecer; `users` é alvo de 19 FKs e a cascata não está mapeada. +``` + +### Infolist (View page) + +``` +Infolist: + Columns: 1 + + Section: Conta + Component: Filament\Schemas\Components\Section + Docs: https://filamentphp.com/docs/5.x/schemas/sections + Columns: 2 + Icon: Heroicon::OutlinedUser + + Entry: username + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + Config: ->copyable() + + Entry: name + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + + Entry: email + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + Config: ->copyable(), ->placeholder('Sem e-mail') + + Entry: is_donator + Component: Filament\Infolists\Components\IconEntry + Docs: https://filamentphp.com/docs/5.x/infolists/icon-entry + Config: ->boolean(), ->label('Apoiador') + + Entry: first_login_at + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), ->placeholder('Nunca') + + Entry: created_at + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')) + + Section: Situação + Component: Filament\Schemas\Components\Section + Columns: 2 + Icon: Heroicon::OutlinedShieldCheck + Description: 'Somente leitura. Punições são aplicadas pelo fluxo de moderação.' + + Entry: situation + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + Config: ->badge(), ->state(fn (User $record): UserSituation => $record->situation) + + Entry: suspended_until + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), ->placeholder('—') + + Entry: banned_at + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), ->placeholder('—') + + Section: Perfil + Component: Filament\Schemas\Components\Section + Columns: 2 + Icon: Heroicon::OutlinedIdentification + Collapsible: yes + + Entry: profile.headline + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + Config: ->label('Headline'), ->placeholder('Não preenchido') + + Entry: profile.seniority_level + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + Config: ->badge(), ->label('Senioridade'), ->placeholder('—') + + Entry: profile.available_for_proposals + Component: Filament\Infolists\Components\IconEntry + Docs: https://filamentphp.com/docs/5.x/infolists/icon-entry + Config: ->boolean(), ->label('Aberto a propostas') +``` + +### RelationManager: providers + +``` +RelationManager: ProvidersRelationManager + Location: He4rt\PanelAdmin\Filament\Resources\Users\RelationManagers\ProvidersRelationManager + Relationship: providers (MorphMany ExternalIdentity) + Title attribute: provider + Can create: no + Can edit: no + Can delete: no + Motivo: vincular e desvincular identidade é responsabilidade das Actions do + módulo identity (LinkExternalIdentity / AttachProviderToUser), que + disparam eventos. Aqui é leitura. + + Table: + Column: provider + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->badge() + + Column: type + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->badge() + + Column: external_account_id + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->copyable(), ->placeholder('—') + + Column: connected_at + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), ->sortable() + + Column: disconnected_at + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), ->placeholder('Ativa') + + Record actions: + Action: view + Component: Filament\Actions\Action + Location: table row + Label: 'Abrir' + Config: ->url(fn (ExternalIdentity $record): string => ExternalIdentityResource::getUrl('edit', ['record' => $record])) +``` + +--- + +## 4.2 ExternalIdentityResource — atualizar + +Resource já existe. Apenas mudanças: + +``` +Modify: navegação + ExternalIdentityResource sai de ->items() e entra no grupo Pessoas (seção 3.3). + Nenhuma alteração dentro da classe do Resource. + +Modify: ExternalIdentitiesTable + Remove Column: metadata + Motivo: renderiza array bruto na listagem. Sem valor numa tabela. + + Modify Column: provider + Config atual: ->badge(), ->label('Provider') + Config novo: ->badge(), ->label('Provider'), ->searchable(), ->sortable() + + Modify Column: connected_at / disconnected_at + Config atual: ->date() + Config novo: ->dateTime('d/m/Y H:i'), ->timezone(config('app.display_timezone')), ->sortable() + Motivo: guideline `.ai/05-timezone-aware-dates` — datas exibidas ao usuário + usam config('app.display_timezone'). + + Add Filter: provider + Component: Filament\Tables\Filters\SelectFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/select + Config: ->options(IdentityProvider::class), ->multiple(), ->searchable() + Imports: He4rt\Identity\ExternalIdentity\Enums\IdentityProvider + + Add Filter: credentials_type + Component: Filament\Tables\Filters\SelectFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/select + Config: ->options(CredentialsType::class) + Imports: He4rt\Identity\ExternalIdentity\Enums\CredentialsType + + Add Filter: connection_state + Component: Filament\Tables\Filters\TernaryFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/ternary + Config: ->label('Conexão ativa'), + ->queries( + true: fn (Builder $query): Builder => $query->whereNull('disconnected_at'), + false: fn (Builder $query): Builder => $query->whereNotNull('disconnected_at'), + blank: fn (Builder $query): Builder => $query, + ) + + Keep Filter: trashed (TrashedFilter) — inalterado +``` + +Não mexer em `ExternalIdentityForm`, `ExternalIdentityInfolist`, +`MessagesRelationManager` nem nas Pages. + +--- + +## 4.3 ProfileResource + +``` +Resource: ProfileResource + Command: ver seção 1 + Location: He4rt\PanelAdmin\Filament\Resources\Profiles\ProfileResource + Model: He4rt\Profile\Models\Profile + Docs: https://filamentphp.com/docs/5.x/resources/overview + + Slug: profiles + RecordTitleAttribute: nickname + Icon: Heroicon::OutlinedIdentification + + GloballySearchableAttributes: [nickname, headline, user.username] + + getEloquentQuery(): ->with('user')->withCount('profileSkills') + + canCreate(): false + canDelete(): false + canDeleteAny(): false + Motivo: UserObserver::created() já cria o perfil via Profile::ensureExists(). + Criar produz violação do índice único em user_id; apagar quebra a invariante + "todo usuário tem perfil" que o resto do sistema assume. + + Pages: + index → ListProfiles + view → ViewProfile + edit → EditProfile + (sem CreateProfile) +``` + +### Form (Edit) + +``` +Form: + Columns: 2 + + Section: Apresentação + Component: Filament\Schemas\Components\Section + ColumnSpan: full + Columns: 2 + + Field: nickname + Component: Filament\Forms\Components\TextInput + Docs: https://filamentphp.com/docs/5.x/forms/text-input + Validation: nullable, max:255 + Config: ->maxLength(255) + + Field: headline + Component: Filament\Forms\Components\TextInput + Docs: https://filamentphp.com/docs/5.x/forms/text-input + Validation: nullable, max:255 + Config: ->maxLength(255) + + Field: about + Component: Filament\Forms\Components\Textarea + Docs: https://filamentphp.com/docs/5.x/forms/textarea + Validation: nullable, max:5000 + Config: ->rows(5), ->columnSpanFull() + + Field: birthdate + Component: Filament\Forms\Components\DatePicker + Docs: https://filamentphp.com/docs/5.x/forms/date-time-picker + Validation: nullable, date, before:today + Config: ->maxDate(now()), ->native(false), ->displayFormat('d/m/Y') + + Section: Carreira + Component: Filament\Schemas\Components\Section + ColumnSpan: full + Columns: 2 + + Field: seniority_level + Component: Filament\Forms\Components\Select + Docs: https://filamentphp.com/docs/5.x/forms/select + Validation: nullable + Config: ->options(SeniorityLevel::class) + Imports: He4rt\Profile\Enums\SeniorityLevel + + Field: years_experience + Component: Filament\Forms\Components\TextInput + Docs: https://filamentphp.com/docs/5.x/forms/text-input + Validation: nullable, integer, min:0, max:70 + Config: ->integer(), ->minValue(0), ->maxValue(70) + + Field: available_for_proposals + Component: Filament\Forms\Components\Toggle + Docs: https://filamentphp.com/docs/5.x/forms/toggle + Validation: boolean + Config: ->live() + Imports: Filament\Schemas\Components\Utilities\Get + + Field: start_availability + Component: Filament\Forms\Components\Select + Docs: https://filamentphp.com/docs/5.x/forms/select + Validation: nullable + Config: ->options(StartAvailability::class), + ->visible(fn (Get $get): bool => (bool) $get('available_for_proposals')) + Imports: He4rt\Profile\Enums\StartAvailability, + Filament\Schemas\Components\Utilities\Get +``` + +**Fora do form**: `expected_salary_min`, `expected_salary_max`, `social_links`, +`preferences` e `user_id`. Salário é sensível (restrição 5); `preferences` é +value object com cast próprio (`AsWorkPreferences`) e merece tela própria; +`user_id` é imutável. + +### Table + +``` +Table: + DefaultSort: created_at desc + + Column: user.username + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->label('Usuário'), ->searchable(), ->sortable(), ->weight(FontWeight::Medium) + Imports: Filament\Support\Enums\FontWeight + + Column: nickname + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->searchable(), ->placeholder('—') + + Column: headline + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->searchable(), ->limit(50), ->placeholder('Não preenchido') + + Column: seniority_level + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->badge(), ->placeholder('—'), ->sortable() + + Column: years_experience + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->numeric(0), ->sortable(), ->suffix(' anos'), ->placeholder('—') + + Column: available_for_proposals + Component: Filament\Tables\Columns\IconColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/icon + Config: ->boolean(), ->label('Aberto a propostas'), ->sortable() + + Column: profile_skills_count + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->label('Skills'), ->counts('profileSkills'), ->numeric(0), ->sortable() + +Filters: + Filter: seniority_level + Component: Filament\Tables\Filters\SelectFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/select + Config: ->options(SeniorityLevel::class), ->multiple() + + Filter: available_for_proposals + Component: Filament\Tables\Filters\TernaryFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/ternary + Config: ->label('Aberto a propostas') + + Filter: incomplete + Component: Filament\Tables\Filters\Filter + Docs: https://filamentphp.com/docs/5.x/tables/filters/custom + Config: ->label('Perfil incompleto'), + ->query(fn (Builder $query): Builder => $query->whereNull('headline')->orWhereNull('seniority_level')) + +Actions: ViewAction, EditAction (namespaces Filament\Actions\*) +Toolbar: nenhuma bulk action (canDelete é false) +``` + +### Infolist (View page) + +``` +Infolist: + Columns: 1 + + Section: Apresentação + Columns: 2 + Entry: user.username → TextEntry, ->label('Usuário') + Entry: nickname → TextEntry, ->placeholder('—') + Entry: headline → TextEntry, ->placeholder('Não preenchido') + Entry: birthdate → TextEntry, ->date('d/m/Y'), ->placeholder('—') + Entry: about → TextEntry, ->columnSpanFull(), ->placeholder('—') + (todos: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry) + + Section: Carreira + Columns: 2 + Entry: seniority_level → TextEntry, ->badge(), ->placeholder('—') + Entry: years_experience → TextEntry, ->numeric(0), ->suffix(' anos') + Entry: available_for_proposals → IconEntry, ->boolean() + Component: Filament\Infolists\Components\IconEntry + Docs: https://filamentphp.com/docs/5.x/infolists/icon-entry + Entry: start_availability → TextEntry, ->badge(), ->placeholder('—') + + Section: Remuneração pretendida + Component: Filament\Schemas\Components\Section + Columns: 2 + Icon: Heroicon::OutlinedLockClosed + Collapsible: yes + Collapsed: yes + Description: 'Informado pela pessoa. Não exibir fora do painel.' + + Entry: expected_salary_min + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + Config: ->money('BRL'), ->placeholder('—') + + Entry: expected_salary_max + Component: Filament\Infolists\Components\TextEntry + Docs: https://filamentphp.com/docs/5.x/infolists/text-entry + Config: ->money('BRL'), ->placeholder('—') +``` + +### RelationManager: profileSkills + +``` +RelationManager: ProfileSkillsRelationManager + Location: He4rt\PanelAdmin\Filament\Resources\Profiles\RelationManagers\ProfileSkillsRelationManager + Relationship: profileSkills (HasMany ProfileSkill) + Title attribute: skill.name + Can create: yes + Can edit: yes + Can delete: yes + + Form: + Field: skill_id + Component: Filament\Forms\Components\Select + Docs: https://filamentphp.com/docs/5.x/forms/select + Validation: required, exists:skills,id + Config: ->relationship('skill', 'name'), ->searchable(), + ->getSearchResultsUsing(fn (string $search): array => Skill::search($search)), + ->required() + Imports: He4rt\Profile\Models\Skill + Nota: Skill::search() já existe no model e devolve rótulos "Categoria · Nome", + com limite de 50 — usar em vez de ->preload(), que carregaria as 116 linhas. + + Field: proficiency + Component: Filament\Forms\Components\Select + Docs: https://filamentphp.com/docs/5.x/forms/select + Validation: required + Config: ->options(SkillProficiency::class), ->required() + Imports: He4rt\Profile\Enums\SkillProficiency + + Field: years_experience + Component: Filament\Forms\Components\TextInput + Docs: https://filamentphp.com/docs/5.x/forms/text-input + Validation: nullable, integer, min:0, max:70 + Config: ->integer(), ->minValue(0), ->maxValue(70) + + Table: + Column: skill.name + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->label('Skill'), ->searchable(), ->sortable() + + Column: skill.category + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->badge(), ->label('Categoria') + + Column: proficiency + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->badge(), ->sortable() + + Column: years_experience + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->numeric(0), ->suffix(' anos'), ->placeholder('—') +``` + +### RelationManager: workExperiences + +``` +RelationManager: WorkExperiencesRelationManager + Location: He4rt\PanelAdmin\Filament\Resources\Profiles\RelationManagers\WorkExperiencesRelationManager + Relationship: workExperiences (HasMany WorkExperience) + Title attribute: company_name + Can create: yes + Can edit: yes + Can delete: yes + + Form: + Columns: 2 + + Field: company_name + Component: Filament\Forms\Components\TextInput + Docs: https://filamentphp.com/docs/5.x/forms/text-input + Validation: required, max:255 + Config: ->maxLength(255), ->required() + + Field: position + Component: Filament\Forms\Components\TextInput + Docs: https://filamentphp.com/docs/5.x/forms/text-input + Validation: required, max:255 + Config: ->maxLength(255), ->required() + + Field: description + Component: Filament\Forms\Components\Textarea + Docs: https://filamentphp.com/docs/5.x/forms/textarea + Validation: required, max:5000 + Config: ->rows(4), ->columnSpanFull(), ->required() + + Field: start_date + Component: Filament\Forms\Components\DatePicker + Docs: https://filamentphp.com/docs/5.x/forms/date-time-picker + Validation: required, date, before_or_equal:today + Config: ->maxDate(now()), ->native(false), ->displayFormat('d/m/Y'), ->required() + + Field: is_currently_working_here + Component: Filament\Forms\Components\Toggle + Docs: https://filamentphp.com/docs/5.x/forms/toggle + Validation: boolean + Config: ->live(), ->afterStateUpdated(fn (Set $set, bool $state) => $state ? $set('end_date', null) : null) + Imports: Filament\Schemas\Components\Utilities\Set + + Field: end_date + Component: Filament\Forms\Components\DatePicker + Docs: https://filamentphp.com/docs/5.x/forms/date-time-picker + Validation: nullable, date, after:start_date, required_if:is_currently_working_here,false + Config: ->native(false), ->displayFormat('d/m/Y'), + ->disabled(fn (Get $get): bool => (bool) $get('is_currently_working_here')), + ->afterOrEqual('start_date') + Imports: Filament\Schemas\Components\Utilities\Get + + Table: + Column: company_name + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->searchable(), ->sortable(), ->description(fn (WorkExperience $record): string => $record->position) + + Column: start_date + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->date('m/Y'), ->sortable() + + Column: end_date + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->date('m/Y'), ->placeholder('Atual') + + Column: is_currently_working_here + Component: Filament\Tables\Columns\IconColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/icon + Config: ->boolean(), ->label('Atual') + + DefaultSort: start_date desc +``` + +--- + +## 4.4 SkillResource + +``` +Resource: SkillResource + Command: ver seção 1 + Location: He4rt\PanelAdmin\Filament\Resources\Skills\SkillResource + Model: He4rt\Profile\Models\Skill + Docs: https://filamentphp.com/docs/5.x/resources/overview + + Slug: skills + RecordTitleAttribute: name + Icon: Heroicon::OutlinedSparkles + + GloballySearchableAttributes: [name, slug] + + getEloquentQuery(): ->withCount('profileSkills') + + Sem --simple e sem --view: a listagem precisa de filtros e agrupamento, que o + modo simples (modais) não comporta, e o catálogo não tem detalhe suficiente + para justificar uma View page. + + Pages: + index → ListSkills + create → CreateSkill + edit → EditSkill +``` + +### Form + +``` +Form: + Columns: 2 + + Field: name + Component: Filament\Forms\Components\TextInput + Docs: https://filamentphp.com/docs/5.x/forms/text-input + Validation: required, max:255 + Config: ->maxLength(255), ->required(), ->live(onBlur: true), + ->afterStateUpdated(fn (Set $set, ?string $state) => $set('slug', Str::slug($state ?? ''))) + Imports: Filament\Schemas\Components\Utilities\Set, Illuminate\Support\Str + + Field: slug + Component: Filament\Forms\Components\TextInput + Docs: https://filamentphp.com/docs/5.x/forms/text-input + Validation: required, max:255, unique:skills,slug + Config: ->maxLength(255), ->required() + + Field: category + Component: Filament\Forms\Components\Select + Docs: https://filamentphp.com/docs/5.x/forms/select + Validation: required + Config: ->options(SkillCategory::class), ->required() + Imports: He4rt\Profile\Enums\SkillCategory + + Field: icon + Component: Filament\Forms\Components\TextInput + Docs: https://filamentphp.com/docs/5.x/forms/text-input + Validation: nullable, max:255 + Config: ->maxLength(255), ->helperText('Nome do ícone, ex.: devicon-php-plain') +``` + +### Table + +``` +Table: + DefaultSort: name asc + + Column: name + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->searchable(), ->sortable(), ->weight(FontWeight::Medium) + Imports: Filament\Support\Enums\FontWeight + + Column: slug + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->searchable(), ->color('gray'), ->copyable() + + Column: category + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->badge(), ->sortable() + + Column: profile_skills_count + Component: Filament\Tables\Columns\TextColumn + Docs: https://filamentphp.com/docs/5.x/tables/columns/text + Config: ->label('Perfis'), ->counts('profileSkills'), ->numeric(0), ->sortable() + +Grouping: + Default: category + Options: [category] + Collapsible: true + Docs: https://filamentphp.com/docs/5.x/tables/grouping + +Filters: + Filter: category + Component: Filament\Tables\Filters\SelectFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/select + Config: ->options(SkillCategory::class), ->multiple() + + Filter: unused + Component: Filament\Tables\Filters\Filter + Docs: https://filamentphp.com/docs/5.x/tables/filters/custom + Config: ->label('Sem nenhum perfil'), + ->query(fn (Builder $query): Builder => $query->whereDoesntHave('profileSkills')) + Imports: Illuminate\Database\Eloquent\Builder + +Actions: EditAction, DeleteAction (Filament\Actions\*) + DeleteAction Visibility: só quando profile_skills_count === 0 + Config: ->visible(fn (Skill $record): bool => $record->profileSkills()->doesntExist()) + Motivo: apagar skill em uso deixa profile_skills órfão — não há cascade. + +Toolbar: + BulkActionGroup contendo DeleteBulkAction + Component: Filament\Actions\BulkActionGroup, Filament\Actions\DeleteBulkAction + Docs: https://filamentphp.com/docs/5.x/actions/overview +``` + +--- + +## 4.5 ModerationCaseResource — alteração mínima + +Necessária para a action `moderationCases` do UserResource funcionar com filtro. + +``` +Modify: app-modules/panel-admin/src/Moderation/Resources/ModerationCaseResource.php + Add Filter: author + Component: Filament\Tables\Filters\SelectFilter + Docs: https://filamentphp.com/docs/5.x/tables/filters/select + Config: ->relationship('author', 'username'), ->searchable(), ->preload(false) + Posição: junto dos filtros existentes (status, source_platform, violation_type, severity) +``` + +`ModerationCase::author()` já existe (`BelongsTo User`), então basta o filtro. + +--- + +# 5. Authorization + +``` +Resource: UserResource + Authorization: qualquer usuário que acesse o painel admin + +Resource: ExternalIdentityResource + Authorization: qualquer usuário que acesse o painel admin + +Resource: ProfileResource + Authorization: qualquer usuário que acesse o painel admin + +Resource: SkillResource + Authorization: qualquer usuário que acesse o painel admin +``` + +Nenhuma Policy é criada. O controle já existe e é binário: +`User::canAccessPanel()` devolve `isAdmin()` em produção, que compara `username` +contra a lista em `config('he4rt.admins')`. Fora de produção, qualquer usuário +autenticado entra. + +Restrições que **não** são autorização e sim design do Resource: + +| Restrição | Onde | Motivo | +| -------------------------------- | ----------------- | ------------------------------------ | +| `canCreate(): false` | UserResource | conta nasce por OAuth | +| `canCreate(): false` | ProfileResource | UserObserver já cria | +| `canDelete(): false` | ProfileResource | invariante "todo usuário tem perfil" | +| DeleteAction condicional | SkillResource | evita `profile_skills` órfão | +| Sem campo de punição | UserResource form | WebModerationAdapter é o dono | +| Salário só no infolist colapsado | ProfileResource | dado sensível | + +--- + +# 6. Tests + +Arquivos, seguindo a convenção de `app-modules/panel-admin/tests/Feature/`: + +- `app-modules/panel-admin/tests/Feature/Identity/UserResourceTest.php` +- `app-modules/panel-admin/tests/Feature/Identity/ExternalIdentityResourceTest.php` +- `app-modules/panel-admin/tests/Feature/Profile/ProfileResourceTest.php` +- `app-modules/panel-admin/tests/Feature/Profile/SkillResourceTest.php` +- `app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php` +- `app-modules/identity/tests/Unit/UserSituationTest.php` + +Todo teste do painel precisa de um admin autenticado, como em +`AdminPanelAccessTest`: + +```php +$user = User::factory()->create(['username' => 'danielhe4rt']); +config(['he4rt.admins' => 'danielhe4rt']); +$this->actingAs($user); +``` + +Usar `use function Pest\Livewire\livewire;`. + +``` +UserSituation (unit): + - banned_at preenchido devolve UserSituation::Banned + - suspended_until no futuro e banned_at nulo devolve Suspended + - suspended_until no passado devolve Active + - suspended_until nulo e banned_at nulo devolve Active + - banned_at vence suspended_until quando os dois estão preenchidos + +UserResource: + Authorization: + - usuário não-admin não acessa a listagem em produção + - página de listagem carrega para admin + Component Config: + - a listagem não expõe página de criação (canCreate é false) + - o form de edição não contém campo banned_at + - o form de edição não contém campo suspended_until + - coluna situation existe e renderiza como badge + Filters: + - filtro situation=banned mostra só usuários com banned_at + - filtro situation=suspended mostra só suspensão vigente, não vencida + - filtro situation=active exclui banidos e suspensos vigentes + - filtro never_logged_in mostra só usuários com first_login_at nulo + - filtro is_donator separa apoiadores + Validation (use dataset pattern): + - username: required, max:255, unique + - name: required, max:255 + - email: nullable, email, max:255 + Actions: + - action moderationCases aponta para a URL de casos com o filtro author preenchido + RelationManagers: + - ProvidersRelationManager lista as identidades do usuário + - ProvidersRelationManager não oferece criar, editar nem excluir + +ExternalIdentityResource: + Filters: + - filtro provider mostra só o provider selecionado + - filtro connection_state=true mostra só disconnected_at nulo + - filtro connection_state=false mostra só desconectadas + Component Config: + - a tabela não tem mais a coluna metadata + +ProfileResource: + Component Config: + - a listagem não expõe página de criação + - a listagem não expõe ação de exclusão + - start_availability fica oculto enquanto available_for_proposals é falso + - start_availability aparece quando available_for_proposals é verdadeiro + - expected_salary_min não aparece no form de edição + - expected_salary_max não aparece no form de edição + Filters: + - filtro seniority_level filtra por senioridade + - filtro available_for_proposals separa quem está aberto a propostas + - filtro incomplete mostra perfis sem headline ou sem senioridade + Validation (use dataset pattern): + - years_experience: nullable, integer, min:0, max:70 + - birthdate: nullable, date, before today + RelationManagers: + - ProfileSkillsRelationManager cria vínculo com skill e proficiência + - WorkExperiencesRelationManager desabilita end_date quando is_currently_working_here é verdadeiro + - WorkExperiencesRelationManager rejeita end_date anterior a start_date + +SkillResource: + Component Config: + - preencher name gera o slug automaticamente + - a tabela agrupa por category por padrão + Filters: + - filtro category filtra por categoria + - filtro unused mostra só skills sem nenhum profile_skill + Actions: + - DeleteAction fica oculta para skill vinculada a algum perfil + - DeleteAction fica visível para skill sem vínculo + Validation (use dataset pattern): + - name: required, max:255 + - slug: required, unique + - category: required + +NavigationGroups: + - a navegação padrão do painel expõe um grupo com o label 'Pessoas' + - o grupo Pessoas contém os quatro itens (users, external-identities, profiles, skills) + - os cinco clusters continuam como itens de topo, fora de grupo + - ExternalIdentityResource não aparece mais como item solto de topo +``` + +Rodar: `php artisan test --compact --parallel` no módulo afetado, e +`vendor/bin/pest --parallel --update-shards` na suíte completa antes do push. + +--- + +# 7. Verificação antes de abrir PR + +- [ ] `vendor/bin/pint --dirty --format agent` +- [ ] `vendor/bin/phpstan analyse` limpo nos módulos tocados +- [ ] `php artisan test --compact --parallel` +- [ ] Nenhum arquivo novo em `app/Filament/` — tudo em `app-modules/panel-admin/src/Filament/Resources/` +- [ ] Nenhuma escrita em `banned_at` ou `suspended_until` fora de `WebModerationAdapter` +- [ ] Datas exibidas usam `config('app.display_timezone')` +- [ ] `UserSituation` implementa os quatro contratos, `match` sem `default` +- [ ] `#[UseFactory]` presente em `User` e `Skill` +- [ ] Branch `feature/*` ou `story/*`, commits `(panel-admin): ...` + +# 8. Fora de escopo, registrado + +| Item | Por quê | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Aplicar punição pelo painel | Decisão do autor: só leitura nesta leva | +| Enum de grupos para os outros 8 grupos | Requer as telas correspondentes | +| Desmontar o `NavigationBuilder` custom e os clusters | Decisão do autor: clusters intactos | +| `UserPolicy` e permissões granulares | Decisão do autor: manter autorização binária | +| Tela de `preferences` (WorkPreferences) | Value object com cast próprio, merece tela dedicada | +| `user_information` e `user_address` | Tabelas legadas, sobrepõem `user_profiles` — precisam de decisão de domínio antes de ganhar tela | diff --git a/app-modules/panel-admin/lang/en/navigation.php b/app-modules/panel-admin/lang/en/navigation.php new file mode 100644 index 00000000..23190360 --- /dev/null +++ b/app-modules/panel-admin/lang/en/navigation.php @@ -0,0 +1,9 @@ + [ + 'people' => 'People', + ], +]; diff --git a/app-modules/panel-admin/lang/pt_BR/navigation.php b/app-modules/panel-admin/lang/pt_BR/navigation.php new file mode 100644 index 00000000..e662e210 --- /dev/null +++ b/app-modules/panel-admin/lang/pt_BR/navigation.php @@ -0,0 +1,9 @@ + [ + 'people' => 'Pessoas', + ], +]; diff --git a/app-modules/panel-admin/src/Enums/NavigationGroup.php b/app-modules/panel-admin/src/Enums/NavigationGroup.php new file mode 100644 index 00000000..8d31d529 --- /dev/null +++ b/app-modules/panel-admin/src/Enums/NavigationGroup.php @@ -0,0 +1,37 @@ + __('panel-admin::navigation.groups.people'), + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + self::People => Heroicon::OutlinedUsers, + }; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Profiles/Pages/EditProfile.php b/app-modules/panel-admin/src/Filament/Resources/Profiles/Pages/EditProfile.php new file mode 100644 index 00000000..b877d117 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Profiles/Pages/EditProfile.php @@ -0,0 +1,24 @@ + + */ + protected function getHeaderActions(): array + { + return [ + ViewAction::make(), + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Profiles/Pages/ListProfiles.php b/app-modules/panel-admin/src/Filament/Resources/Profiles/Pages/ListProfiles.php new file mode 100644 index 00000000..12958359 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Profiles/Pages/ListProfiles.php @@ -0,0 +1,13 @@ + + */ + protected function getHeaderActions(): array + { + return [ + EditAction::make(), + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Profiles/ProfileResource.php b/app-modules/panel-admin/src/Filament/Resources/Profiles/ProfileResource.php new file mode 100644 index 00000000..910fb404 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Profiles/ProfileResource.php @@ -0,0 +1,107 @@ +profile`. + */ + public static function canDelete(Model $record): bool + { + return false; + } + + public static function canDeleteAny(): bool + { + return false; + } + + public static function form(Schema $schema): Schema + { + return ProfileForm::configure($schema); + } + + public static function infolist(Schema $schema): Schema + { + return ProfileInfolist::configure($schema); + } + + public static function table(Table $table): Table + { + return ProfilesTable::configure($table); + } + + public static function getRelations(): array + { + return [ + ProfileSkillsRelationManager::class, + WorkExperiencesRelationManager::class, + ]; + } + + /** + * @return array + */ + public static function getPages(): array + { + return [ + 'index' => ListProfiles::route('/'), + 'view' => ViewProfile::route('/{record}'), + 'edit' => EditProfile::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->with('user') + ->withCount('profileSkills'); + } + + /** + * @return array + */ + public static function getGloballySearchableAttributes(): array + { + return ['nickname', 'headline', 'user.username']; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Profiles/RelationManagers/ProfileSkillsRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Profiles/RelationManagers/ProfileSkillsRelationManager.php new file mode 100644 index 00000000..5999ce7b --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Profiles/RelationManagers/ProfileSkillsRelationManager.php @@ -0,0 +1,89 @@ +components([ + Select::make('skill_id') + ->label('Skill') + ->relationship('skill', 'name') + ->searchable() + ->getSearchResultsUsing(static fn (string $search): array => Skill::search($search)) + ->required(), + + Select::make('proficiency') + ->label('Proficiência') + ->options(SkillProficiency::class) + ->required(), + + TextInput::make('years_experience') + ->label('Anos de experiência') + ->integer() + ->minValue(0) + ->maxValue(70), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('skill.name') + ->columns([ + TextColumn::make('skill.name') + ->label('Skill') + ->searchable() + ->sortable(), + + TextColumn::make('skill.category') + ->label('Categoria') + ->badge(), + + TextColumn::make('proficiency') + ->label('Proficiência') + ->badge() + ->sortable(), + + TextColumn::make('years_experience') + ->label('Experiência') + ->numeric(0) + ->suffix(' anos') + ->placeholder('—'), + ]) + ->headerActions([ + CreateAction::make(), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Profiles/RelationManagers/WorkExperiencesRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Profiles/RelationManagers/WorkExperiencesRelationManager.php new file mode 100644 index 00000000..071e94da --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Profiles/RelationManagers/WorkExperiencesRelationManager.php @@ -0,0 +1,121 @@ +columns(2) + ->components([ + TextInput::make('company_name') + ->label('Empresa') + ->required() + ->maxLength(255), + + TextInput::make('position') + ->label('Cargo') + ->required() + ->maxLength(255), + + Textarea::make('description') + ->label('Descrição') + ->rows(4) + ->required() + ->maxLength(5_000) + ->columnSpanFull(), + + DatePicker::make('start_date') + ->label('Início') + ->required() + ->maxDate(now()) + ->native(condition: false) + ->displayFormat('d/m/Y'), + + Toggle::make('is_currently_working_here') + ->label('Trabalha aqui atualmente') + ->live() + ->afterStateUpdated(static function (Set $set, bool $state): void { + if ($state) { + $set('end_date', null); + } + }), + + DatePicker::make('end_date') + ->label('Término') + ->native(condition: false) + ->displayFormat('d/m/Y') + ->afterOrEqual('start_date') + ->disabled(static fn (Get $get): bool => (bool) $get('is_currently_working_here')) + // Campo desabilitado não é dehidratado por padrão; sem isto o + // término antigo sobreviveria ao marcar "trabalha aqui". + ->dehydrated() + ->required(static fn (Get $get): bool => !$get('is_currently_working_here')), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('company_name') + ->defaultSort('start_date', 'desc') + ->columns([ + TextColumn::make('company_name') + ->label('Empresa') + ->searchable() + ->sortable() + ->description(static fn (WorkExperience $record): string => $record->position), + + TextColumn::make('start_date') + ->label('Início') + ->date('m/Y') + ->sortable(), + + TextColumn::make('end_date') + ->label('Término') + ->date('m/Y') + ->placeholder('Atual'), + + IconColumn::make('is_currently_working_here') + ->label('Atual') + ->boolean(), + ]) + ->headerActions([ + CreateAction::make(), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Profiles/Schemas/ProfileForm.php b/app-modules/panel-admin/src/Filament/Resources/Profiles/Schemas/ProfileForm.php new file mode 100644 index 00000000..d6dc1f3e --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Profiles/Schemas/ProfileForm.php @@ -0,0 +1,79 @@ +columns(2) + ->components([ + Section::make('Apresentação') + ->columnSpanFull() + ->columns(2) + ->schema([ + TextInput::make('nickname') + ->label('Apelido') + ->maxLength(255), + + TextInput::make('headline') + ->label('Headline') + ->maxLength(255), + + DatePicker::make('birthdate') + ->label('Nascimento') + ->maxDate(now()) + ->native(condition: false) + ->displayFormat('d/m/Y'), + + Textarea::make('about') + ->label('Sobre') + ->rows(5) + ->maxLength(5_000) + ->columnSpanFull(), + ]), + + Section::make('Carreira') + ->columnSpanFull() + ->columns(2) + ->schema([ + Select::make('seniority_level') + ->label('Senioridade') + ->options(SeniorityLevel::class), + + TextInput::make('years_experience') + ->label('Anos de experiência') + ->integer() + ->minValue(0) + ->maxValue(70), + + Toggle::make('available_for_proposals') + ->label('Aberto a propostas') + ->live(), + + Select::make('start_availability') + ->label('Disponibilidade de início') + ->options(StartAvailability::class) + ->visible(static fn (Get $get): bool => (bool) $get('available_for_proposals')), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Profiles/Schemas/ProfileInfolist.php b/app-modules/panel-admin/src/Filament/Resources/Profiles/Schemas/ProfileInfolist.php new file mode 100644 index 00000000..3f81ef34 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Profiles/Schemas/ProfileInfolist.php @@ -0,0 +1,88 @@ +columns(1) + ->components([ + Section::make('Apresentação') + ->columns(2) + ->schema([ + TextEntry::make('user.username') + ->label('Usuário'), + + TextEntry::make('nickname') + ->label('Apelido') + ->placeholder('—'), + + TextEntry::make('headline') + ->label('Headline') + ->placeholder('Não preenchido'), + + TextEntry::make('birthdate') + ->label('Nascimento') + ->date('d/m/Y') + ->placeholder('—'), + + TextEntry::make('about') + ->label('Sobre') + ->placeholder('—') + ->columnSpanFull(), + ]), + + Section::make('Carreira') + ->columns(2) + ->schema([ + TextEntry::make('seniority_level') + ->label('Senioridade') + ->badge() + ->placeholder('—'), + + TextEntry::make('years_experience') + ->label('Experiência') + ->numeric(0) + ->suffix(' anos') + ->placeholder('—'), + + IconEntry::make('available_for_proposals') + ->label('Aberto a propostas') + ->boolean(), + + TextEntry::make('start_availability') + ->label('Disponibilidade de início') + ->badge() + ->placeholder('—'), + ]), + + Section::make('Remuneração pretendida') + ->icon(Heroicon::OutlinedLockClosed) + ->description('Informado pela pessoa. Não exibir fora do painel.') + ->collapsible() + ->collapsed() + ->columns(2) + ->schema([ + TextEntry::make('expected_salary_min') + ->label('Mínimo') + ->money('BRL') + ->placeholder('—'), + + TextEntry::make('expected_salary_max') + ->label('Máximo') + ->money('BRL') + ->placeholder('—'), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Profiles/Tables/ProfilesTable.php b/app-modules/panel-admin/src/Filament/Resources/Profiles/Tables/ProfilesTable.php new file mode 100644 index 00000000..48ae6b53 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Profiles/Tables/ProfilesTable.php @@ -0,0 +1,87 @@ +defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('user.username') + ->label('Usuário') + ->searchable() + ->sortable() + ->weight(FontWeight::Medium), + + TextColumn::make('nickname') + ->label('Apelido') + ->searchable() + ->placeholder('—'), + + TextColumn::make('headline') + ->label('Headline') + ->searchable() + ->limit(50) + ->placeholder('Não preenchido'), + + TextColumn::make('seniority_level') + ->label('Senioridade') + ->badge() + ->sortable() + ->placeholder('—'), + + TextColumn::make('years_experience') + ->label('Experiência') + ->numeric(0) + ->suffix(' anos') + ->sortable() + ->placeholder('—'), + + IconColumn::make('available_for_proposals') + ->label('Aberto a propostas') + ->boolean() + ->sortable(), + + TextColumn::make('profile_skills_count') + ->label('Skills') + ->counts('profileSkills') + ->numeric(0) + ->sortable(), + ]) + ->filters([ + SelectFilter::make('seniority_level') + ->label('Senioridade') + ->options(SeniorityLevel::class) + ->multiple(), + + TernaryFilter::make('available_for_proposals') + ->label('Aberto a propostas'), + + Filter::make('incomplete') + ->label('Perfil incompleto') + ->query(static fn (Builder $query): Builder => $query + ->whereNull('headline') + ->orWhereNull('seniority_level')), + ]) + ->recordActions([ + ViewAction::make(), + EditAction::make(), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Skills/Pages/CreateSkill.php b/app-modules/panel-admin/src/Filament/Resources/Skills/Pages/CreateSkill.php new file mode 100644 index 00000000..b5d6977a --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Skills/Pages/CreateSkill.php @@ -0,0 +1,13 @@ + + */ + protected function getHeaderActions(): array + { + return [ + CreateAction::make(), + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Skills/Schemas/SkillForm.php b/app-modules/panel-admin/src/Filament/Resources/Skills/Schemas/SkillForm.php new file mode 100644 index 00000000..72c8b63d --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Skills/Schemas/SkillForm.php @@ -0,0 +1,48 @@ +columns(2) + ->components([ + TextInput::make('name') + ->label('Nome') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(static fn (Set $set, ?string $state) => $set( + 'slug', + Str::slug($state ?? ''), + )), + + TextInput::make('slug') + ->label('Slug') + ->required() + ->maxLength(255) + ->unique(), + + Select::make('category') + ->label('Categoria') + ->options(SkillCategory::class) + ->required(), + + TextInput::make('icon') + ->label('Ícone') + ->maxLength(255) + ->helperText('Nome do ícone, ex.: devicon-php-plain'), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Skills/SkillResource.php b/app-modules/panel-admin/src/Filament/Resources/Skills/SkillResource.php new file mode 100644 index 00000000..8a778aab --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Skills/SkillResource.php @@ -0,0 +1,65 @@ + + */ + public static function getPages(): array + { + return [ + 'index' => ListSkills::route('/'), + 'create' => CreateSkill::route('/create'), + 'edit' => EditSkill::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery()->withCount('profileSkills'); + } + + /** + * @return array + */ + public static function getGloballySearchableAttributes(): array + { + return ['name', 'slug']; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Skills/Tables/SkillsTable.php b/app-modules/panel-admin/src/Filament/Resources/Skills/Tables/SkillsTable.php new file mode 100644 index 00000000..f083a6ea --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Skills/Tables/SkillsTable.php @@ -0,0 +1,79 @@ +defaultSort('name') + ->defaultGroup('category') + ->groups([ + Group::make('category') + ->label('Categoria') + ->collapsible(), + ]) + ->columns([ + TextColumn::make('name') + ->label('Nome') + ->searchable() + ->sortable() + ->weight(FontWeight::Medium), + + TextColumn::make('slug') + ->label('Slug') + ->searchable() + ->color('gray') + ->copyable(), + + TextColumn::make('category') + ->label('Categoria') + ->badge() + ->sortable(), + + TextColumn::make('profile_skills_count') + ->label('Perfis') + ->counts('profileSkills') + ->numeric(0) + ->sortable(), + ]) + ->filters([ + SelectFilter::make('category') + ->label('Categoria') + ->options(SkillCategory::class) + ->multiple(), + + Filter::make('unused') + ->label('Sem nenhum perfil') + ->query(static fn (Builder $query): Builder => $query->whereDoesntHave('profileSkills')), + ]) + ->recordActions([ + EditAction::make(), + // Apagar skill em uso deixaria `profile_skills` órfão: não há cascade. + DeleteAction::make() + ->visible(static fn (Skill $record): bool => $record->profileSkills()->doesntExist()), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php new file mode 100644 index 00000000..9c3e371c --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php @@ -0,0 +1,24 @@ + + */ + protected function getHeaderActions(): array + { + return [ + ViewAction::make(), + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/Pages/ListUsers.php b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/ListUsers.php new file mode 100644 index 00000000..ac2b7a0b --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/ListUsers.php @@ -0,0 +1,13 @@ + + */ + protected function getHeaderActions(): array + { + return [ + EditAction::make(), + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/ProvidersRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/ProvidersRelationManager.php new file mode 100644 index 00000000..19e14263 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/ProvidersRelationManager.php @@ -0,0 +1,66 @@ +recordTitleAttribute('provider') + ->defaultSort('connected_at', 'desc') + ->columns([ + TextColumn::make('provider') + ->label('Provider') + ->badge(), + + TextColumn::make('type') + ->label('Tipo') + ->badge(), + + TextColumn::make('external_account_id') + ->label('Conta externa') + ->copyable() + ->placeholder('—'), + + TextColumn::make('connected_at') + ->label('Conectada em') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->sortable(), + + TextColumn::make('disconnected_at') + ->label('Desconectada em') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->placeholder('Ativa'), + ]) + ->recordActions([ + Action::make('open') + ->label('Abrir') + ->icon(Heroicon::OutlinedArrowTopRightOnSquare) + ->url(static fn (ExternalIdentity $record): string => ExternalIdentityResource::getUrl('edit', [ + 'record' => $record, + ])), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserForm.php b/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserForm.php new file mode 100644 index 00000000..baa8fe26 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserForm.php @@ -0,0 +1,51 @@ +columns(2) + ->components([ + Section::make('Identificação') + ->columnSpanFull() + ->columns(2) + ->schema([ + TextInput::make('username') + ->label('Username') + ->required() + ->maxLength(255) + ->unique(), + + TextInput::make('name') + ->label('Nome') + ->required() + ->maxLength(255), + + TextInput::make('email') + ->label('E-mail') + ->email() + ->maxLength(255) + ->unique(), + ]), + + Section::make('Sinalizações') + ->columnSpanFull() + ->columns(1) + ->schema([ + Toggle::make('is_donator') + ->label('Apoiador') + ->helperText('Marca manual de apoiador. Não afeta permissões.'), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserInfolist.php b/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserInfolist.php new file mode 100644 index 00000000..ad94bdd7 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserInfolist.php @@ -0,0 +1,97 @@ +columns(1) + ->components([ + Section::make('Conta') + ->icon(Heroicon::OutlinedUser) + ->columns(2) + ->schema([ + TextEntry::make('username') + ->label('Username') + ->copyable(), + + TextEntry::make('name') + ->label('Nome'), + + TextEntry::make('email') + ->label('E-mail') + ->copyable() + ->placeholder('Sem e-mail'), + + IconEntry::make('is_donator') + ->label('Apoiador') + ->boolean(), + + TextEntry::make('first_login_at') + ->label('Primeiro login') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->placeholder('Nunca'), + + TextEntry::make('created_at') + ->label('Criado em') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')), + ]), + + Section::make('Situação') + ->icon(Heroicon::OutlinedShieldCheck) + ->description('Somente leitura. Punições são aplicadas pelo fluxo de moderação.') + ->columns(2) + ->schema([ + TextEntry::make('situation') + ->label('Situação') + ->badge() + ->state(fn (User $record): UserSituation => $record->situation), + + TextEntry::make('suspended_until') + ->label('Suspenso até') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->placeholder('—'), + + TextEntry::make('banned_at') + ->label('Banido em') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->placeholder('—'), + ]), + + Section::make('Perfil') + ->icon(Heroicon::OutlinedIdentification) + ->collapsible() + ->columns(2) + ->schema([ + TextEntry::make('profile.headline') + ->label('Headline') + ->placeholder('Não preenchido'), + + TextEntry::make('profile.seniority_level') + ->label('Senioridade') + ->badge() + ->placeholder('—'), + + IconEntry::make('profile.available_for_proposals') + ->label('Aberto a propostas') + ->boolean(), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/Tables/UsersTable.php b/app-modules/panel-admin/src/Filament/Resources/Users/Tables/UsersTable.php new file mode 100644 index 00000000..cb1e3b2e --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Tables/UsersTable.php @@ -0,0 +1,118 @@ +defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('username') + ->label('Username') + ->searchable() + ->sortable() + ->weight(FontWeight::Medium), + + TextColumn::make('name') + ->label('Nome') + ->searchable() + ->sortable() + ->description(fn (User $record): ?string => $record->email), + + TextColumn::make('situation') + ->label('Situação') + ->badge() + ->state(fn (User $record): UserSituation => $record->situation), + + TextColumn::make('suspended_until') + ->label('Suspenso até') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->sortable() + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + + IconColumn::make('is_donator') + ->label('Apoiador') + ->boolean() + ->sortable(), + + TextColumn::make('providers_count') + ->label('Identidades') + ->state(static fn (User $record): int => $record->providers->count()) + ->numeric(0), + + TextColumn::make('first_login_at') + ->label('Primeiro login') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->sortable() + ->placeholder('Nunca') + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('created_at') + ->label('Criado em') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('situation') + ->label('Situação') + ->options(UserSituation::class) + ->query(static fn (Builder $query, array $data): Builder => match ($data['value'] ?? null) { + UserSituation::Banned->value => $query->whereNotNull('banned_at'), + UserSituation::Suspended->value => $query + ->whereNull('banned_at') + ->where('suspended_until', '>', now()), + UserSituation::Active->value => $query + ->whereNull('banned_at') + ->where(static fn (Builder $inner): Builder => $inner + ->whereNull('suspended_until') + ->orWhere('suspended_until', '<=', now())), + default => $query, + }), + + TernaryFilter::make('is_donator') + ->label('Apoiador'), + + Filter::make('never_logged_in') + ->label('Nunca logou') + ->query(static fn (Builder $query): Builder => $query->whereNull('first_login_at')), + ]) + ->recordActions([ + ViewAction::make(), + EditAction::make(), + Action::make('moderationCases') + ->label('Casos de moderação') + ->icon(Heroicon::OutlinedShieldExclamation) + ->color('gray') + ->url(static fn (User $record): string => ModerationCaseResource::getUrl('index', [ + 'tableFilters' => [ + 'author' => ['value' => $record->getKey()], + ], + ])), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php b/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php new file mode 100644 index 00000000..b20e1cdc --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php @@ -0,0 +1,105 @@ + + */ + public static function getPages(): array + { + return [ + 'index' => ListUsers::route('/'), + 'view' => ViewUser::route('/{record}'), + 'edit' => EditUser::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + // `external_identities.model_id` é varchar e `users.id` é uuid: uma + // subquery correlacionada (withCount) falha no Postgres por falta de + // cast. O eager load compara por valor bindado e funciona. + return parent::getEloquentQuery() + ->with(['profile', 'providers']); + } + + /** + * @return array + */ + public static function getGloballySearchableAttributes(): array + { + return ['username', 'name', 'email']; + } + + /** + * @param User $record + * @return array + */ + public static function getGlobalSearchResultDetails(Model $record): array + { + return array_filter([ + 'Nome' => $record->name, + 'E-mail' => $record->email, + ]); + } +} diff --git a/app-modules/panel-admin/src/PanelAdminServiceProvider.php b/app-modules/panel-admin/src/PanelAdminServiceProvider.php index 7bb248ac..6558d344 100644 --- a/app-modules/panel-admin/src/PanelAdminServiceProvider.php +++ b/app-modules/panel-admin/src/PanelAdminServiceProvider.php @@ -5,11 +5,16 @@ namespace He4rt\PanelAdmin; use Filament\Navigation\NavigationBuilder; +use Filament\Navigation\NavigationGroup; use Filament\Navigation\NavigationItem; use Filament\Panel; use He4rt\PanelAdmin\Discord\DiscordCluster; +use He4rt\PanelAdmin\Enums\NavigationGroup as NavGroup; use He4rt\PanelAdmin\Filament\Resources\Events\EventResource; use He4rt\PanelAdmin\Filament\Resources\ExternalIdentities\ExternalIdentityResource; +use He4rt\PanelAdmin\Filament\Resources\Profiles\ProfileResource; +use He4rt\PanelAdmin\Filament\Resources\Skills\SkillResource; +use He4rt\PanelAdmin\Filament\Resources\Users\UserResource; use He4rt\PanelAdmin\Github\GithubCluster; use He4rt\PanelAdmin\Marketing\MarketingCluster; use He4rt\PanelAdmin\Moderation\Livewire\AppealQueue; @@ -44,6 +49,9 @@ public function register(): void ->resources([ ExternalIdentityResource::class, EventResource::class, + UserResource::class, + ProfileResource::class, + SkillResource::class, ]) ->discoverResources( in: __DIR__.'/Moderation/Resources', @@ -124,16 +132,26 @@ private function buildNavigation(NavigationBuilder $builder): NavigationBuilder private function defaultNavigation(NavigationBuilder $builder): NavigationBuilder { - return $builder->items([ - ...Dashboard::getNavigationItems(), - ...ModerationCluster::getNavigationItems(), - ...MarketingCluster::getNavigationItems(), - ...TwitchCluster::getNavigationItems(), - ...GithubCluster::getNavigationItems(), - ...ExternalIdentityResource::getNavigationItems(), - ...EventResource::getNavigationItems(), - ...DiscordCluster::getNavigationItems(), - ]); + return $builder + ->items([ + ...Dashboard::getNavigationItems(), + ...ModerationCluster::getNavigationItems(), + ...MarketingCluster::getNavigationItems(), + ...TwitchCluster::getNavigationItems(), + ...GithubCluster::getNavigationItems(), + ...DiscordCluster::getNavigationItems(), + ...EventResource::getNavigationItems(), + ]) + ->groups([ + NavigationGroup::make(NavGroup::People->getLabel()) + ->icon(NavGroup::People->getIcon()) + ->items([ + ...UserResource::getNavigationItems(), + ...ExternalIdentityResource::getNavigationItems(), + ...ProfileResource::getNavigationItems(), + ...SkillResource::getNavigationItems(), + ]), + ]); } private function moderationNavigation(NavigationBuilder $builder): NavigationBuilder diff --git a/app-modules/panel-admin/tests/Feature/Identity/UserResourceTest.php b/app-modules/panel-admin/tests/Feature/Identity/UserResourceTest.php new file mode 100644 index 00000000..39e5cf92 --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Identity/UserResourceTest.php @@ -0,0 +1,125 @@ + 'danielhe4rt', + 'app.display_timezone' => 'America/Sao_Paulo', + ]); + + $this->admin = User::factory()->create(['username' => 'danielhe4rt']); + + $this->actingAs($this->admin); + + Filament::setCurrentPanel(Filament::getPanel('admin')); +}); + +test('a listagem carrega para admin', function (): void { + livewire(ListUsers::class)->loadTable()->assertOk(); +}); + +test('a conta não pode ser criada pelo painel', function (): void { + expect(UserResource::canCreate())->toBeFalse() + ->and(UserResource::getPages())->not->toHaveKey('create'); +}); + +test('o form de edição não expõe campos de punição', function (): void { + livewire(EditUser::class, ['record' => $this->admin->getKey()]) + ->assertSchemaComponentDoesNotExist('banned_at') + ->assertSchemaComponentDoesNotExist('suspended_until'); +}); + +test('o form de edição expõe os campos de identificação', function (): void { + livewire(EditUser::class, ['record' => $this->admin->getKey()]) + ->assertSchemaComponentExists('username', checkComponentUsing: fn (TextInput $field): bool => $field->isRequired()) + ->assertSchemaComponentExists('name') + ->assertSchemaComponentExists('email'); +}); + +test('a coluna de situação existe na tabela', function (): void { + livewire(ListUsers::class)->loadTable()->assertTableColumnExists('situation'); +}); + +test('o filtro de situação separa banidos de ativos', function (): void { + $banned = User::factory()->create(['banned_at' => now()->subDay()]); + $active = User::factory()->create(); + + livewire(ListUsers::class) + ->loadTable() + ->filterTable('situation', 'banned') + ->assertCanSeeTableRecords([$banned]) + ->assertCanNotSeeTableRecords([$active, $this->admin]); +}); + +test('o filtro de situação mostra só suspensão vigente', function (): void { + $suspended = User::factory()->create(['suspended_until' => now()->addWeek()]); + $expired = User::factory()->create(['suspended_until' => now()->subWeek()]); + + livewire(ListUsers::class) + ->loadTable() + ->filterTable('situation', 'suspended') + ->assertCanSeeTableRecords([$suspended]) + ->assertCanNotSeeTableRecords([$expired]); +}); + +test('o filtro de situação exclui banidos e suspensos dos ativos', function (): void { + $banned = User::factory()->create(['banned_at' => now()]); + $suspended = User::factory()->create(['suspended_until' => now()->addWeek()]); + + livewire(ListUsers::class) + ->loadTable() + ->filterTable('situation', 'active') + ->assertCanSeeTableRecords([$this->admin]) + ->assertCanNotSeeTableRecords([$banned, $suspended]); +}); + +test('o filtro de quem nunca logou mostra só first_login_at nulo', function (): void { + $logged = User::factory()->create(['first_login_at' => now()]); + + livewire(ListUsers::class) + ->loadTable() + ->filterTable('never_logged_in') + ->assertCanSeeTableRecords([$this->admin]) + ->assertCanNotSeeTableRecords([$logged]); +}); + +test('valida os dados do form', function (array $data, array $errors): void { + livewire(EditUser::class, ['record' => $this->admin->getKey()]) + ->fillForm($data) + ->call('save') + ->assertHasFormErrors($errors); +})->with([ + '`username` é obrigatório' => [['username' => null], ['username' => 'required']], + '`username` tem no máximo 255' => [['username' => Str::random(256)], ['username' => 'max']], + '`name` é obrigatório' => [['name' => null], ['name' => 'required']], + '`name` tem no máximo 255' => [['name' => Str::random(256)], ['name' => 'max']], + '`email` precisa ser válido' => [['email' => 'nao-e-email'], ['email' => 'email']], +]); + +test('o relation manager de identidades lista os provedores do usuário', function (): void { + $identity = ExternalIdentity::factory()->create([ + 'model_type' => $this->admin->getMorphClass(), + 'model_id' => $this->admin->getKey(), + ]); + + livewire(ProvidersRelationManager::class, [ + 'ownerRecord' => $this->admin, + 'pageClass' => EditUser::class, + ]) + ->loadTable() + ->assertOk() + ->assertCanSeeTableRecords([$identity]); +}); diff --git a/app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php b/app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php new file mode 100644 index 00000000..ca718180 --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php @@ -0,0 +1,78 @@ + 'danielhe4rt']); + + actingAs(User::factory()->create(['username' => 'danielhe4rt'])); + + Filament::setCurrentPanel(Filament::getPanel('admin')); +}); + +/** + * Grupo da navegação padrão do painel pelo label. `null` procura o bloco sem + * label, onde ficam os itens de topo (dashboard e clusters). + */ +function adminNavigationGroup(?string $label): NavigationGroup +{ + foreach (Filament::getPanel('admin')->getNavigation() as $group) { + if ($group->getLabel() === $label) { + return $group; + } + } + + throw new RuntimeException(sprintf('Grupo de navegação [%s] não encontrado.', $label ?? 'sem label')); +} + +/** + * @return array + */ +function navigationItemLabels(NavigationGroup $group): array +{ + return array_map( + static fn (NavigationItem $item): string => $item->getLabel(), + $group->getItems(), + ); +} + +test('a navegação padrão expõe o grupo Pessoas', function (): void { + $people = adminNavigationGroup(NavGroup::People->getLabel()); + + expect($people->getLabel())->toBe(NavGroup::People->getLabel()); +}); + +test('o grupo Pessoas reúne as quatro telas da aba', function (): void { + $people = adminNavigationGroup(NavGroup::People->getLabel()); + + expect(navigationItemLabels($people))->toHaveCount(4) + ->and(navigationItemLabels($people))->toEqualCanonicalizing([ + 'Users', + 'External Identities', + 'Profiles', + 'Skills', + ]); +}); + +test('os clusters seguem como itens de topo, fora de grupo', function (): void { + $labels = navigationItemLabels(adminNavigationGroup(label: null)); + + expect($labels)->toContain('Dashboard') + ->toContain('Twitch') + ->toContain('GitHub') + ->toContain('Discord'); +}); + +test('identidades externas não aparece mais solto no topo', function (): void { + $labels = navigationItemLabels(adminNavigationGroup(label: null)); + + expect($labels)->not->toContain('External Identities'); +}); diff --git a/app-modules/panel-admin/tests/Feature/Profile/ProfileResourceTest.php b/app-modules/panel-admin/tests/Feature/Profile/ProfileResourceTest.php new file mode 100644 index 00000000..dd67aae7 --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Profile/ProfileResourceTest.php @@ -0,0 +1,129 @@ + 'danielhe4rt', + 'app.display_timezone' => 'America/Sao_Paulo', + ]); + + $this->admin = User::factory()->create(['username' => 'danielhe4rt']); + + $this->actingAs($this->admin); + + Filament::setCurrentPanel(Filament::getPanel('admin')); + + // O UserObserver já cria o perfil junto do usuário. + $this->profile = Profile::query()->where('user_id', $this->admin->getKey())->sole(); +}); + +test('a listagem carrega para admin', function (): void { + livewire(ListProfiles::class)->loadTable()->assertOk(); +}); + +test('o perfil não pode ser criado nem apagado pelo painel', function (): void { + expect(ProfileResource::canCreate())->toBeFalse() + ->and(ProfileResource::canDelete($this->profile))->toBeFalse() + ->and(ProfileResource::canDeleteAny())->toBeFalse() + ->and(ProfileResource::getPages())->not->toHaveKey('create'); +}); + +test('o form não expõe a remuneração pretendida', function (): void { + livewire(EditProfile::class, ['record' => $this->profile->getKey()]) + ->assertSchemaComponentDoesNotExist('expected_salary_min') + ->assertSchemaComponentDoesNotExist('expected_salary_max'); +}); + +test('a disponibilidade de início só aparece para quem está aberto a propostas', function (): void { + livewire(EditProfile::class, ['record' => $this->profile->getKey()]) + ->fillForm(['available_for_proposals' => false]) + ->assertSchemaComponentHidden('start_availability') + ->fillForm(['available_for_proposals' => true]) + ->assertSchemaComponentVisible('start_availability'); +}); + +test('o filtro de senioridade filtra os perfis', function (): void { + $senior = Profile::query()->where('user_id', User::factory()->create()->getKey())->sole(); + $senior->update(['seniority_level' => SeniorityLevel::Senior]); + + $junior = Profile::query()->where('user_id', User::factory()->create()->getKey())->sole(); + $junior->update(['seniority_level' => SeniorityLevel::Junior]); + + livewire(ListProfiles::class) + ->loadTable() + ->filterTable('seniority_level', [SeniorityLevel::Senior->value]) + ->assertCanSeeTableRecords([$senior]) + ->assertCanNotSeeTableRecords([$junior]); +}); + +test('o filtro de perfil incompleto mostra quem não tem headline ou senioridade', function (): void { + $complete = Profile::query()->where('user_id', User::factory()->create()->getKey())->sole(); + $complete->update([ + 'headline' => 'Backend', + 'seniority_level' => SeniorityLevel::Senior, + ]); + + livewire(ListProfiles::class) + ->loadTable() + ->filterTable('incomplete') + ->assertCanSeeTableRecords([$this->profile]) + ->assertCanNotSeeTableRecords([$complete]); +}); + +test('valida os dados do form', function (array $data, array $errors): void { + livewire(EditProfile::class, ['record' => $this->profile->getKey()]) + ->fillForm($data) + ->call('save') + ->assertHasFormErrors($errors); +})->with([ + '`years_experience` não aceita negativo' => [['years_experience' => -1], ['years_experience' => 'min']], + '`years_experience` tem teto de 70' => [['years_experience' => 71], ['years_experience' => 'max']], + '`birthdate` não pode ser no futuro' => [['birthdate' => now()->addYear()->format('Y-m-d')], ['birthdate' => 'before_or_equal']], +]); + +test('o relation manager de skills registra a proficiência', function (): void { + $skill = Skill::factory()->create(['name' => 'Rust']); + + livewire(ProfileSkillsRelationManager::class, [ + 'ownerRecord' => $this->profile, + 'pageClass' => EditProfile::class, + ]) + ->loadTable() + ->callAction(TestAction::make('create')->table(), data: [ + 'skill_id' => $skill->getKey(), + 'proficiency' => SkillProficiency::Advanced->value, + 'years_experience' => 3, + ]) + ->assertHasNoActionErrors(); + + expect($this->profile->profileSkills()->where('skill_id', $skill->getKey())->exists())->toBeTrue(); +}); + +test('o relation manager de experiências lista os registros do perfil', function (): void { + $experience = WorkExperience::factory()->create(['profile_id' => $this->profile->getKey()]); + + livewire(WorkExperiencesRelationManager::class, [ + 'ownerRecord' => $this->profile, + 'pageClass' => EditProfile::class, + ]) + ->loadTable() + ->assertOk() + ->assertCanSeeTableRecords([$experience]); +}); diff --git a/app-modules/panel-admin/tests/Feature/Profile/SkillResourceTest.php b/app-modules/panel-admin/tests/Feature/Profile/SkillResourceTest.php new file mode 100644 index 00000000..d057914b --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Profile/SkillResourceTest.php @@ -0,0 +1,127 @@ + 'danielhe4rt', + 'app.display_timezone' => 'America/Sao_Paulo', + ]); + + $this->admin = User::factory()->create(['username' => 'danielhe4rt']); + + $this->actingAs($this->admin); + + Filament::setCurrentPanel(Filament::getPanel('admin')); +}); + +test('a listagem carrega para admin', function (): void { + livewire(ListSkills::class)->loadTable()->assertOk(); +}); + +test('preencher o nome gera o slug', function (): void { + livewire(CreateSkill::class) + ->fillForm(['name' => 'Rust Lang']) + ->assertSchemaStateSet(['slug' => 'rust-lang']); +}); + +test('cria uma skill', function (): void { + // O catálogo já vem semeado pela migration de skills: usar um slug sintético. + livewire(CreateSkill::class) + ->fillForm([ + 'name' => 'Linguagem Fictícia', + 'slug' => 'linguagem-ficticia', + 'category' => SkillCategory::Language->value, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + expect(Skill::query()->where('slug', 'linguagem-ficticia')->exists())->toBeTrue(); +}); + +test('o filtro de categoria filtra as skills', function (): void { + // O catálogo semeado pela migration passa de 100 linhas e a tabela pagina: + // a busca restringe o conjunto para a asserção ser determinística. + $language = Skill::factory()->create(['name' => 'Zoltrix Lang', 'category' => SkillCategory::Language]); + $tool = Skill::factory()->create(['name' => 'Zoltrix Tool', 'category' => SkillCategory::Tool]); + + livewire(ListSkills::class) + ->loadTable() + ->searchTable('Zoltrix') + ->filterTable('category', [SkillCategory::Language->value]) + ->assertCanSeeTableRecords([$language]) + ->assertCanNotSeeTableRecords([$tool]); +}); + +test('o filtro de skills sem perfil mostra só as não usadas', function (): void { + $unused = Skill::factory()->create(['name' => 'Zoltrix Livre']); + $used = Skill::factory()->create(['name' => 'Zoltrix Usada']); + + $profile = Profile::query()->where('user_id', $this->admin->getKey())->sole(); + ProfileSkill::factory()->create([ + 'profile_id' => $profile->getKey(), + 'skill_id' => $used->getKey(), + ]); + + livewire(ListSkills::class) + ->loadTable() + ->searchTable('Zoltrix') + ->filterTable('unused') + ->assertCanSeeTableRecords([$unused]) + ->assertCanNotSeeTableRecords([$used]); +}); + +test('a exclusão fica escondida para skill em uso', function (): void { + $used = Skill::factory()->create(['name' => 'Zoltrix Usada']); + + $profile = Profile::query()->where('user_id', $this->admin->getKey())->sole(); + ProfileSkill::factory()->create([ + 'profile_id' => $profile->getKey(), + 'skill_id' => $used->getKey(), + ]); + + livewire(ListSkills::class) + ->loadTable() + ->searchTable('Zoltrix') + ->assertActionHidden(TestAction::make(DeleteAction::class)->table($used)); +}); + +test('a exclusão fica visível para skill sem vínculo', function (): void { + $unused = Skill::factory()->create(['name' => 'Zoltrix Livre']); + + livewire(ListSkills::class) + ->loadTable() + ->searchTable('Zoltrix') + ->assertActionVisible(TestAction::make(DeleteAction::class)->table($unused)); +}); + +test('valida os dados do form', function (array $data, array $errors): void { + livewire(CreateSkill::class) + ->fillForm([ + 'name' => 'Base Sintética', + 'slug' => 'base-sintetica', + 'category' => SkillCategory::Language->value, + ...$data, + ]) + ->call('create') + ->assertHasFormErrors($errors); +})->with([ + '`name` é obrigatório' => [['name' => null], ['name' => 'required']], + '`name` tem no máximo 255' => [['name' => Str::random(256)], ['name' => 'max']], + '`slug` é obrigatório' => [['slug' => null], ['slug' => 'required']], + '`category` é obrigatória' => [['category' => null], ['category' => 'required']], +]); From 17cb76f2191f930c259f5f9246e20ffa482cbc2d Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 22 Aug 2026 18:06:37 -0300 Subject: [PATCH 04/10] refactor(panel-admin): filtros e datas na tabela de identidades externas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove a coluna metadata, que renderizava array bruto na listagem, e passa as datas a usar o display_timezone conforme a guideline. Acrescenta filtros por provider, tipo de credencial e estado da conexão. Adiciona também o filtro por autor na tabela de casos de moderação, alvo do atalho 'Casos de moderação' do UserResource. --- .../Tables/ExternalIdentitiesTable.php | 36 ++++++++++-- .../Resources/ModerationCaseResource.php | 3 + .../Identity/ExternalIdentityResourceTest.php | 58 +++++++++++++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 app-modules/panel-admin/tests/Feature/Identity/ExternalIdentityResourceTest.php diff --git a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Tables/ExternalIdentitiesTable.php b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Tables/ExternalIdentitiesTable.php index 02ca3604..0e3aa867 100644 --- a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Tables/ExternalIdentitiesTable.php +++ b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Tables/ExternalIdentitiesTable.php @@ -13,8 +13,12 @@ use Filament\Actions\RestoreAction; use Filament\Actions\RestoreBulkAction; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; +use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; +use He4rt\Identity\ExternalIdentity\Enums\CredentialsType; +use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use Illuminate\Database\Eloquent\Builder; class ExternalIdentitiesTable @@ -38,7 +42,9 @@ public static function table(Table $table): Table TextColumn::make('provider') ->badge() - ->label('Provider'), + ->label('Provider') + ->searchable() + ->sortable(), TextColumn::make('external_account_id') ->label('External Account Id'), @@ -50,14 +56,16 @@ public static function table(Table $table): Table TextColumn::make('connected_at') ->label('Connected Date') - ->date(), + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->sortable(), TextColumn::make('disconnected_at') ->label('Disconnected Date') - ->date(), - - TextColumn::make('metadata') - ->label('Metadata'), + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->placeholder('Ativa') + ->sortable(), TextColumn::make('email') ->label('Email') @@ -65,6 +73,22 @@ public static function table(Table $table): Table ->sortable(), ]) ->filters([ + SelectFilter::make('provider') + ->options(IdentityProvider::class) + ->multiple() + ->searchable(), + + SelectFilter::make('credentials_type') + ->options(CredentialsType::class), + + TernaryFilter::make('connection_state') + ->label('Conexão ativa') + ->queries( + true: static fn (Builder $query): Builder => $query->whereNull('disconnected_at'), + false: static fn (Builder $query): Builder => $query->whereNotNull('disconnected_at'), + blank: static fn (Builder $query): Builder => $query, + ), + TrashedFilter::make(), ]) ->recordActions([ diff --git a/app-modules/panel-admin/src/Moderation/Resources/ModerationCaseResource.php b/app-modules/panel-admin/src/Moderation/Resources/ModerationCaseResource.php index dd56280c..00a358de 100644 --- a/app-modules/panel-admin/src/Moderation/Resources/ModerationCaseResource.php +++ b/app-modules/panel-admin/src/Moderation/Resources/ModerationCaseResource.php @@ -90,6 +90,9 @@ public static function table(Table $table): Table ->options(ViolationType::class), SelectFilter::make('severity') ->options(Severity::class), + SelectFilter::make('author') + ->relationship('author', 'username') + ->searchable(), ]); } diff --git a/app-modules/panel-admin/tests/Feature/Identity/ExternalIdentityResourceTest.php b/app-modules/panel-admin/tests/Feature/Identity/ExternalIdentityResourceTest.php new file mode 100644 index 00000000..a2452a16 --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Identity/ExternalIdentityResourceTest.php @@ -0,0 +1,58 @@ + 'danielhe4rt', + 'app.display_timezone' => 'America/Sao_Paulo', + ]); + + $this->admin = User::factory()->create(['username' => 'danielhe4rt']); + + $this->actingAs($this->admin); + + Filament::setCurrentPanel(Filament::getPanel('admin')); +}); + +test('a listagem não expõe mais a coluna de metadata', function (): void { + livewire(ListExternalIdentities::class) + ->loadTable() + ->assertTableColumnDoesNotExist('metadata'); +}); + +test('o filtro de provider restringe ao provedor escolhido', function (): void { + $discord = ExternalIdentity::factory()->create(['provider' => IdentityProvider::Discord]); + $twitch = ExternalIdentity::factory()->create(['provider' => IdentityProvider::Twitch]); + + livewire(ListExternalIdentities::class) + ->loadTable() + ->filterTable('provider', [IdentityProvider::Discord->value]) + ->assertCanSeeTableRecords([$discord]) + ->assertCanNotSeeTableRecords([$twitch]); +}); + +test('o filtro de conexão ativa separa conectadas de desconectadas', function (): void { + $active = ExternalIdentity::factory()->create(['disconnected_at' => null]); + $disconnected = ExternalIdentity::factory()->create(['disconnected_at' => now()->subDay()]); + + livewire(ListExternalIdentities::class) + ->loadTable() + ->filterTable('connection_state', true) + ->assertCanSeeTableRecords([$active]) + ->assertCanNotSeeTableRecords([$disconnected]); + + livewire(ListExternalIdentities::class) + ->loadTable() + ->filterTable('connection_state', false) + ->assertCanSeeTableRecords([$disconnected]) + ->assertCanNotSeeTableRecords([$active]); +}); From 5682757f2d9a8d3e250ea0e9de041e65522ee179 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 22 Aug 2026 18:27:16 -0300 Subject: [PATCH 05/10] =?UTF-8?q?fix(contents):=20TagList=20serializ=C3=A1?= =?UTF-8?q?vel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mesmo caso do WorkPreferences: o VO tinha toArray() mas não declarava Arrayable, então o Eloquent devolvia o objeto cru em attributesToArray() e o Livewire falhava ao hidratar qualquer tela que carregasse uma entrada do acervo. --- app-modules/contents/src/Data/TagList.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app-modules/contents/src/Data/TagList.php b/app-modules/contents/src/Data/TagList.php index 9c22a630..c5ee7ac7 100644 --- a/app-modules/contents/src/Data/TagList.php +++ b/app-modules/contents/src/Data/TagList.php @@ -4,7 +4,13 @@ namespace He4rt\Contents\Data; -final readonly class TagList +use Illuminate\Contracts\Support\Arrayable; +use JsonSerializable; + +/** + * @implements Arrayable + */ +final readonly class TagList implements Arrayable, JsonSerializable { /** @param list $tags */ public function __construct( @@ -52,4 +58,12 @@ public function contains(string $tag): bool { return in_array($tag, $this->tags, strict: true); } + + /** + * @return list + */ + public function jsonSerialize(): array + { + return $this->tags; + } } From f175df3cef282761e0bbebb5a76016062fdb7ad8 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 22 Aug 2026 18:27:18 -0300 Subject: [PATCH 06/10] =?UTF-8?q?feat(panel-admin):=20aba=20Conte=C3=BAdo?= =?UTF-8?q?=20com=20o=20acervo=20de=20artigos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Segunda leva da reestruturação da sidebar. Uma tela só enquanto Article for o único contentable_type: duas telas seriam a mesma listagem duas vezes. O form expõe apenas o vínculo de autor. UpsertArticle sobrescreve título, url, tags, métricas e corpo a cada execução, mas preserva um author_id já preenchido — qualquer outro campo editado aqui seria desfeito no próximo sync. Criar e apagar ficam desligados pelo mesmo motivo. A listagem abre pela capa e filtra por provider, artigos sem autor vinculado e métricas paradas há mais de uma semana. A ação de sincronizar enfileira o contents:sync-articles em vez de rodar na requisição, que estouraria o timeout conversando com a API do provider. --- .../panel-admin/lang/en/navigation.php | 1 + .../panel-admin/lang/pt_BR/navigation.php | 1 + .../panel-admin/src/Enums/NavigationGroup.php | 4 + .../ContentEntries/ContentEntryResource.php | 98 +++++++++++++ .../ContentEntries/Pages/EditContentEntry.php | 24 ++++ .../Pages/ListContentEntries.php | 50 +++++++ .../ContentEntries/Pages/ViewContentEntry.php | 24 ++++ .../Schemas/ContentEntryForm.php | 43 ++++++ .../Schemas/ContentEntryInfolist.php | 132 ++++++++++++++++++ .../Tables/ContentEntriesTable.php | 106 ++++++++++++++ .../src/PanelAdminServiceProvider.php | 7 + .../Contents/ContentEntryResourceTest.php | 113 +++++++++++++++ .../tests/Feature/NavigationGroupsTest.php | 6 + 13 files changed, 609 insertions(+) create mode 100644 app-modules/panel-admin/src/Filament/Resources/ContentEntries/ContentEntryResource.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/EditContentEntry.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ListContentEntries.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ViewContentEntry.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ContentEntries/Schemas/ContentEntryForm.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ContentEntries/Schemas/ContentEntryInfolist.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ContentEntries/Tables/ContentEntriesTable.php create mode 100644 app-modules/panel-admin/tests/Feature/Contents/ContentEntryResourceTest.php diff --git a/app-modules/panel-admin/lang/en/navigation.php b/app-modules/panel-admin/lang/en/navigation.php index 23190360..71e716d3 100644 --- a/app-modules/panel-admin/lang/en/navigation.php +++ b/app-modules/panel-admin/lang/en/navigation.php @@ -5,5 +5,6 @@ return [ 'groups' => [ 'people' => 'People', + 'content' => 'Content', ], ]; diff --git a/app-modules/panel-admin/lang/pt_BR/navigation.php b/app-modules/panel-admin/lang/pt_BR/navigation.php index e662e210..b9ce06b4 100644 --- a/app-modules/panel-admin/lang/pt_BR/navigation.php +++ b/app-modules/panel-admin/lang/pt_BR/navigation.php @@ -5,5 +5,6 @@ return [ 'groups' => [ 'people' => 'Pessoas', + 'content' => 'Conteúdo', ], ]; diff --git a/app-modules/panel-admin/src/Enums/NavigationGroup.php b/app-modules/panel-admin/src/Enums/NavigationGroup.php index 8d31d529..cca05412 100644 --- a/app-modules/panel-admin/src/Enums/NavigationGroup.php +++ b/app-modules/panel-admin/src/Enums/NavigationGroup.php @@ -21,10 +21,13 @@ enum NavigationGroup implements HasIcon, HasLabel { case People; + case Content; + public function getLabel(): string { return match ($this) { self::People => __('panel-admin::navigation.groups.people'), + self::Content => __('panel-admin::navigation.groups.content'), }; } @@ -32,6 +35,7 @@ public function getIcon(): Heroicon { return match ($this) { self::People => Heroicon::OutlinedUsers, + self::Content => Heroicon::OutlinedNewspaper, }; } } diff --git a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/ContentEntryResource.php b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/ContentEntryResource.php new file mode 100644 index 00000000..9cafa8aa --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/ContentEntryResource.php @@ -0,0 +1,98 @@ + + */ + public static function getPages(): array + { + return [ + 'index' => ListContentEntries::route('/'), + 'view' => ViewContentEntry::route('/{record}'), + 'edit' => EditContentEntry::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery()->with(['author', 'contentable']); + } + + /** + * @return array + */ + public static function getGloballySearchableAttributes(): array + { + return ['title', 'author_handle']; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/EditContentEntry.php b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/EditContentEntry.php new file mode 100644 index 00000000..de1f8541 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/EditContentEntry.php @@ -0,0 +1,24 @@ + + */ + protected function getHeaderActions(): array + { + return [ + ViewAction::make(), + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ListContentEntries.php b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ListContentEntries.php new file mode 100644 index 00000000..982c1afc --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ListContentEntries.php @@ -0,0 +1,50 @@ + + */ + protected function getHeaderActions(): array + { + return [ + Action::make('sync') + ->label('Sincronizar artigos') + ->icon(Heroicon::OutlinedArrowPath) + ->color('primary') + ->modalHeading('Sincronizar artigos') + ->modalDescription('A sincronização vai para a fila e conversa com a API do provider. O acervo é atualizado assim que ela termina.') + ->modalSubmitActionLabel('Enfileirar') + ->action(function (): void { + // Enfileirado, não síncrono: o comando chama a API do + // provider e paginaria além do timeout da requisição. + Artisan::queue('contents:sync-articles', [ + '--provider' => array_map( + static fn (ContentProvider $provider): string => $provider->value, + ContentProvider::cases(), + ), + ]); + + Notification::make() + ->title('Sincronização enfileirada') + ->body('O acervo é atualizado quando a fila processar o comando.') + ->success() + ->send(); + }), + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ViewContentEntry.php b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ViewContentEntry.php new file mode 100644 index 00000000..05874410 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ViewContentEntry.php @@ -0,0 +1,24 @@ + + */ + protected function getHeaderActions(): array + { + return [ + EditAction::make(), + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Schemas/ContentEntryForm.php b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Schemas/ContentEntryForm.php new file mode 100644 index 00000000..029db7d5 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Schemas/ContentEntryForm.php @@ -0,0 +1,43 @@ +author_id`). Editar + * qualquer outro campo aqui seria desfeito na próxima execução do + * `contents:sync-articles`. + */ +class ContentEntryForm +{ + public static function configure(Schema $schema): Schema + { + return $schema + ->columns(1) + ->components([ + Section::make('Autoria') + ->description('O sync resolve o autor pela identidade conectada no provider. Quando não encontra, o vínculo pode ser feito aqui — e sobrevive às próximas sincronizações.') + ->schema([ + Select::make('author_id') + ->label('Autor') + ->relationship('author', 'username') + ->searchable() + ->preload() + ->helperText(static fn (ContentEntry $record): string => sprintf( + 'Handle no provider: %s', + $record->author_handle, + )), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Schemas/ContentEntryInfolist.php b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Schemas/ContentEntryInfolist.php new file mode 100644 index 00000000..9dd45a0a --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Schemas/ContentEntryInfolist.php @@ -0,0 +1,132 @@ +columns(1) + ->components([ + Section::make('Publicação') + ->icon(Heroicon::OutlinedNewspaper) + ->columns(2) + ->schema([ + TextEntry::make('title') + ->label('Título') + ->columnSpanFull(), + + TextEntry::make('provider') + ->label('Provider') + ->badge(), + + TextEntry::make('published_at') + ->label('Publicado em') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')), + + TextEntry::make('url') + ->label('Endereço') + ->url(static fn (ContentEntry $record): string => $record->url) + ->openUrlInNewTab() + ->columnSpanFull(), + + TextEntry::make('tags') + ->label('Tags') + ->badge() + ->state(static fn (ContentEntry $record): array => $record->tags->toArray()) + ->placeholder('Sem tags') + ->columnSpanFull(), + + ImageEntry::make('thumbnail_url') + ->label('Capa') + ->placeholder('Sem capa') + ->columnSpanFull(), + ]), + + Section::make('Autoria') + ->icon(Heroicon::OutlinedUser) + ->columns(2) + ->schema([ + TextEntry::make('author.username') + ->label('Autor vinculado') + ->placeholder('Não vinculado'), + + TextEntry::make('author_handle') + ->label('Handle no provider'), + ]), + + Section::make('Engajamento') + ->icon(Heroicon::OutlinedChartBar) + ->columns(4) + ->schema([ + TextEntry::make('reactions_count') + ->label('Reações') + ->numeric(0) + ->placeholder('—'), + + TextEntry::make('comments_count') + ->label('Comentários') + ->numeric(0) + ->placeholder('—'), + + TextEntry::make('saves_count') + ->label('Salvos') + ->numeric(0) + ->placeholder('—'), + + TextEntry::make('metrics_synced_at') + ->label('Sincronizado') + ->since() + ->placeholder('Nunca'), + ]), + + Section::make('Artigo') + ->icon(Heroicon::OutlinedDocumentText) + ->collapsible() + ->columns(2) + ->schema([ + TextEntry::make('contentable.description') + ->label('Descrição') + ->placeholder('—') + ->columnSpanFull(), + + TextEntry::make('contentable.reading_time_minutes') + ->label('Tempo de leitura') + ->numeric(0) + ->suffix(' min') + ->placeholder('—'), + + TextEntry::make('contentable.canonical_url') + ->label('URL canônica') + ->placeholder('—'), + + TextEntry::make('contentable.source_edited_at') + ->label('Editado na origem') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->placeholder('—'), + + TextEntry::make('body') + ->label('Corpo') + ->markdown() + ->state(static fn (ContentEntry $record): string => $record->contentable instanceof Article + ? (string) $record->contentable->body_markdown + : '') + ->placeholder('Corpo não hidratado — o sync só busca o detalhe sob demanda.') + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Tables/ContentEntriesTable.php b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Tables/ContentEntriesTable.php new file mode 100644 index 00000000..81b1bb67 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Tables/ContentEntriesTable.php @@ -0,0 +1,106 @@ +defaultSort('published_at', 'desc') + ->columns([ + ImageColumn::make('thumbnail_url') + ->label('Capa') + ->imageHeight(40) + ->imageWidth(72) + // A capa é remota (CDN do provider): sem isto o Filament + // faria uma requisição por linha só para saber se existe. + ->checkFileExistence(condition: false), + + TextColumn::make('title') + ->label('Título') + ->searchable() + ->sortable() + ->limit(60) + ->weight(FontWeight::Medium), + + TextColumn::make('author.username') + ->label('Autor') + ->searchable() + ->placeholder('Não vinculado') + ->description(static fn (ContentEntry $record): string => $record->author_handle), + + TextColumn::make('provider') + ->label('Provider') + ->badge() + ->sortable(), + + TextColumn::make('published_at') + ->label('Publicado') + ->dateTime('d/m/Y H:i') + ->timezone(config('app.display_timezone')) + ->sortable(), + + TextColumn::make('reactions_count') + ->label('Reações') + ->numeric(0) + ->sortable() + ->placeholder('—'), + + TextColumn::make('comments_count') + ->label('Comentários') + ->numeric(0) + ->sortable() + ->placeholder('—'), + + TextColumn::make('metrics_synced_at') + ->label('Métricas de') + ->since() + ->sortable() + ->placeholder('Nunca') + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('provider') + ->label('Provider') + ->options(ContentProvider::class), + + Filter::make('unlinked_author') + ->label('Sem autor vinculado') + ->query(static fn (Builder $query): Builder => $query->whereNull('author_id')), + + Filter::make('stale_metrics') + ->label('Métricas desatualizadas') + ->query(static fn (Builder $query): Builder => $query + ->where(static fn (Builder $inner): Builder => $inner + ->whereNull('metrics_synced_at') + ->orWhere('metrics_synced_at', '<', now()->subWeek()))), + ]) + ->recordActions([ + ViewAction::make(), + EditAction::make(), + Action::make('openSource') + ->label('Abrir no provider') + ->icon(Heroicon::OutlinedArrowTopRightOnSquare) + ->color('gray') + ->url(static fn (ContentEntry $record): string => $record->url) + ->openUrlInNewTab(), + ]); + } +} diff --git a/app-modules/panel-admin/src/PanelAdminServiceProvider.php b/app-modules/panel-admin/src/PanelAdminServiceProvider.php index 6558d344..87817d6b 100644 --- a/app-modules/panel-admin/src/PanelAdminServiceProvider.php +++ b/app-modules/panel-admin/src/PanelAdminServiceProvider.php @@ -10,6 +10,7 @@ use Filament\Panel; use He4rt\PanelAdmin\Discord\DiscordCluster; use He4rt\PanelAdmin\Enums\NavigationGroup as NavGroup; +use He4rt\PanelAdmin\Filament\Resources\ContentEntries\ContentEntryResource; use He4rt\PanelAdmin\Filament\Resources\Events\EventResource; use He4rt\PanelAdmin\Filament\Resources\ExternalIdentities\ExternalIdentityResource; use He4rt\PanelAdmin\Filament\Resources\Profiles\ProfileResource; @@ -52,6 +53,7 @@ public function register(): void UserResource::class, ProfileResource::class, SkillResource::class, + ContentEntryResource::class, ]) ->discoverResources( in: __DIR__.'/Moderation/Resources', @@ -151,6 +153,11 @@ private function defaultNavigation(NavigationBuilder $builder): NavigationBuilde ...ProfileResource::getNavigationItems(), ...SkillResource::getNavigationItems(), ]), + NavigationGroup::make(NavGroup::Content->getLabel()) + ->icon(NavGroup::Content->getIcon()) + ->items([ + ...ContentEntryResource::getNavigationItems(), + ]), ]); } diff --git a/app-modules/panel-admin/tests/Feature/Contents/ContentEntryResourceTest.php b/app-modules/panel-admin/tests/Feature/Contents/ContentEntryResourceTest.php new file mode 100644 index 00000000..1921d667 --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Contents/ContentEntryResourceTest.php @@ -0,0 +1,113 @@ + 'danielhe4rt', + 'app.display_timezone' => 'America/Sao_Paulo', + ]); + + $this->admin = User::factory()->create(['username' => 'danielhe4rt']); + + $this->actingAs($this->admin); + + Filament::setCurrentPanel(Filament::getPanel('admin')); +}); + +test('a listagem carrega para admin', function (): void { + $entries = ContentEntry::factory()->count(3)->create(); + + livewire(ListContentEntries::class) + ->loadTable() + ->assertOk() + ->assertCanSeeTableRecords($entries); +}); + +test('a capa abre a listagem, antes do título', function (): void { + ContentEntry::factory()->create(['thumbnail_url' => 'https://example.test/capa.png']); + + $columns = array_keys( + livewire(ListContentEntries::class)->loadTable()->instance()->getTable()->getColumns(), + ); + + expect($columns[0])->toBe('thumbnail_url') + ->and($columns[1])->toBe('title'); +}); + +test('artigo não pode ser criado nem apagado pelo painel', function (): void { + $entry = ContentEntry::factory()->create(); + + expect(ContentEntryResource::canCreate())->toBeFalse() + ->and(ContentEntryResource::canDelete($entry))->toBeFalse() + ->and(ContentEntryResource::canDeleteAny())->toBeFalse() + ->and(ContentEntryResource::getPages())->not->toHaveKey('create'); +}); + +test('o form só permite editar o vínculo de autor', function (): void { + $entry = ContentEntry::factory()->create(); + + livewire(EditContentEntry::class, ['record' => $entry->getKey()]) + ->assertSchemaComponentExists('author_id') + ->assertSchemaComponentDoesNotExist('title') + ->assertSchemaComponentDoesNotExist('url') + ->assertSchemaComponentDoesNotExist('tags') + ->assertSchemaComponentDoesNotExist('reactions_count'); +}); + +test('vincular um autor persiste na entrada', function (): void { + $entry = ContentEntry::factory()->create(['author_id' => null]); + $author = User::factory()->create(); + + livewire(EditContentEntry::class, ['record' => $entry->getKey()]) + ->fillForm(['author_id' => $author->getKey()]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($entry->refresh()->author_id)->toBe($author->getKey()); +}); + +test('o filtro de artigos sem autor mostra só os não vinculados', function (): void { + $unlinked = ContentEntry::factory()->create(['author_id' => null]); + $linked = ContentEntry::factory()->authoredBy($this->admin)->create(); + + livewire(ListContentEntries::class) + ->loadTable() + ->filterTable('unlinked_author') + ->assertCanSeeTableRecords([$unlinked]) + ->assertCanNotSeeTableRecords([$linked]); +}); + +test('o filtro de métricas desatualizadas ignora as sincronizadas na semana', function (): void { + $fresh = ContentEntry::factory()->create(['metrics_synced_at' => now()->subDay()]); + $stale = ContentEntry::factory()->create(['metrics_synced_at' => now()->subMonth()]); + $never = ContentEntry::factory()->create(['metrics_synced_at' => null]); + + livewire(ListContentEntries::class) + ->loadTable() + ->filterTable('stale_metrics') + ->assertCanSeeTableRecords([$stale, $never]) + ->assertCanNotSeeTableRecords([$fresh]); +}); + +test('a ação de sincronizar enfileira o comando em vez de rodar na requisição', function (): void { + Queue::fake(); + + livewire(ListContentEntries::class) + ->loadTable() + ->callAction('sync') + ->assertNotified(); + + Queue::assertPushed(QueuedCommand::class); +}); diff --git a/app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php b/app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php index ca718180..dc24d65e 100644 --- a/app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php +++ b/app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php @@ -62,6 +62,12 @@ function navigationItemLabels(NavigationGroup $group): array ]); }); +test('a navegação padrão expõe o grupo Conteúdo com os artigos', function (): void { + $content = adminNavigationGroup(NavGroup::Content->getLabel()); + + expect(navigationItemLabels($content))->toBe(['Artigos']); +}); + test('os clusters seguem como itens de topo, fora de grupo', function (): void { $labels = navigationItemLabels(adminNavigationGroup(label: null)); From 86f0b12541f740d38e9b67237a5f69b55678cff2 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 22 Aug 2026 18:30:12 -0300 Subject: [PATCH 07/10] fix(identity): paleta completa na cor do provider Spotify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spotify declarava a cor como hex solto (['#1DB954']) enquanto todos os outros usam Color::X. O BadgeComponent do Filament indexa a paleta por tonalidade, então qualquer listagem que renderizasse um badge de identidade do Spotify quebrava com 'Undefined array key 50'. Passa a usar Color::hex(), que deriva as onze tonalidades. O teste percorre todos os cases do enum: com a factory sorteando provider, o defeito só aparecia quando o Spotify calhava de cair na tela. --- .../ExternalIdentity/Enums/IdentityProvider.php | 2 +- .../tests/Unit/IdentityProviderColorTest.php | 16 ++++++++++++++++ .../Identity/ExternalIdentityResourceTest.php | 10 ++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 app-modules/identity/tests/Unit/IdentityProviderColorTest.php diff --git a/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php b/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php index 47303a08..0a9c8b8c 100644 --- a/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php +++ b/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php @@ -123,7 +123,7 @@ public function getColor(): array self::Discord => Color::Blue, self::Twitch => Color::Purple, self::DevTo => Color::Gray, - self::Spotify => ['#1DB954'], + self::Spotify => Color::hex('#1DB954'), self::Steam => Color::Slate, self::GitHub => Color::Neutral, self::Xbox => Color::Green, diff --git a/app-modules/identity/tests/Unit/IdentityProviderColorTest.php b/app-modules/identity/tests/Unit/IdentityProviderColorTest.php new file mode 100644 index 00000000..ebe7233c --- /dev/null +++ b/app-modules/identity/tests/Unit/IdentityProviderColorTest.php @@ -0,0 +1,16 @@ +getColor()) + ->toBeArray() + ->toHaveKeys([50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]); +})->with(IdentityProvider::cases()); diff --git a/app-modules/panel-admin/tests/Feature/Identity/ExternalIdentityResourceTest.php b/app-modules/panel-admin/tests/Feature/Identity/ExternalIdentityResourceTest.php index a2452a16..2732bdaf 100644 --- a/app-modules/panel-admin/tests/Feature/Identity/ExternalIdentityResourceTest.php +++ b/app-modules/panel-admin/tests/Feature/Identity/ExternalIdentityResourceTest.php @@ -41,8 +41,14 @@ }); test('o filtro de conexão ativa separa conectadas de desconectadas', function (): void { - $active = ExternalIdentity::factory()->create(['disconnected_at' => null]); - $disconnected = ExternalIdentity::factory()->create(['disconnected_at' => now()->subDay()]); + $active = ExternalIdentity::factory()->create([ + 'provider' => IdentityProvider::Discord, + 'disconnected_at' => null, + ]); + $disconnected = ExternalIdentity::factory()->create([ + 'provider' => IdentityProvider::Discord, + 'disconnected_at' => now()->subDay(), + ]); livewire(ListExternalIdentities::class) ->loadTable() From 2967451073c397af3a3dd02db37f170a5c6ceb72 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 22 Aug 2026 18:44:19 -0300 Subject: [PATCH 08/10] feat(panel-admin): remodela a view do artigo e adiciona stats na listagem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A view repetia o título três vezes — breadcrumb, cabeçalho e campo — e empilhava tudo numa coluna. Passa a ter duas: leitura à esquerda, contexto à direita. Título, provider, data e tempo de leitura sobem para o cabeçalho da página; o breadcrumb vira só a trilha de navegação. Some também a repetição de conteúdo: a URL canônica só aparece quando difere do endereço, e a descrição só quando o corpo não foi hidratado — no Dev.to ela é gerada a partir do próprio início do texto. A listagem ganha quatro indicadores: tamanho do acervo com as publicações das últimas oito semanas, fila de artigos sem autor vinculado, reações acumuladas e idade da última sincronização. --- .../ContentEntries/ContentEntryResource.php | 11 ++ .../Pages/ListContentEntries.php | 11 ++ .../ContentEntries/Pages/ViewContentEntry.php | 64 ++++++- .../Schemas/ContentEntryInfolist.php | 157 ++++++++++-------- .../Widgets/ContentEntryStatsWidget.php | 135 +++++++++++++++ .../Contents/ContentEntryResourceTest.php | 91 ++++++++++ 6 files changed, 399 insertions(+), 70 deletions(-) create mode 100644 app-modules/panel-admin/src/Filament/Resources/ContentEntries/Widgets/ContentEntryStatsWidget.php diff --git a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/ContentEntryResource.php b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/ContentEntryResource.php index 9cafa8aa..b37215a2 100644 --- a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/ContentEntryResource.php +++ b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/ContentEntryResource.php @@ -17,6 +17,7 @@ use He4rt\PanelAdmin\Filament\Resources\ContentEntries\Schemas\ContentEntryForm; use He4rt\PanelAdmin\Filament\Resources\ContentEntries\Schemas\ContentEntryInfolist; use He4rt\PanelAdmin\Filament\Resources\ContentEntries\Tables\ContentEntriesTable; +use He4rt\PanelAdmin\Filament\Resources\ContentEntries\Widgets\ContentEntryStatsWidget; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -71,6 +72,16 @@ public static function table(Table $table): Table return ContentEntriesTable::configure($table); } + /** + * @return array + */ + public static function getWidgets(): array + { + return [ + ContentEntryStatsWidget::class, + ]; + } + /** * @return array */ diff --git a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ListContentEntries.php b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ListContentEntries.php index 982c1afc..931bc881 100644 --- a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ListContentEntries.php +++ b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ListContentEntries.php @@ -10,12 +10,23 @@ use Filament\Support\Icons\Heroicon; use He4rt\Contents\Enums\ContentProvider; use He4rt\PanelAdmin\Filament\Resources\ContentEntries\ContentEntryResource; +use He4rt\PanelAdmin\Filament\Resources\ContentEntries\Widgets\ContentEntryStatsWidget; use Illuminate\Support\Facades\Artisan; class ListContentEntries extends ListRecords { protected static string $resource = ContentEntryResource::class; + /** + * @return array + */ + protected function getHeaderWidgets(): array + { + return [ + ContentEntryStatsWidget::class, + ]; + } + /** * @return array */ diff --git a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ViewContentEntry.php b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ViewContentEntry.php index 05874410..bd561f3d 100644 --- a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ViewContentEntry.php +++ b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Pages/ViewContentEntry.php @@ -4,8 +4,12 @@ namespace He4rt\PanelAdmin\Filament\Resources\ContentEntries\Pages; +use Filament\Actions\Action; use Filament\Actions\EditAction; use Filament\Resources\Pages\ViewRecord; +use Filament\Support\Icons\Heroicon; +use He4rt\Contents\Articles\Models\Article; +use He4rt\Contents\Models\ContentEntry; use He4rt\PanelAdmin\Filament\Resources\ContentEntries\ContentEntryResource; class ViewContentEntry extends ViewRecord @@ -13,12 +17,70 @@ class ViewContentEntry extends ViewRecord protected static string $resource = ContentEntryResource::class; /** - * @return array + * O título do artigo, sem o prefixo "View" do Filament — ele aparecia + * junto do breadcrumb e de novo no corpo, três vezes na mesma dobra. + */ + public function getTitle(): string + { + return $this->entry()->title; + } + + /** + * O breadcrumb padrão traz o título do registro logo acima do cabeçalho + * que já o mostra. Aqui fica só a trilha de navegação. + * + * @return array + */ + public function getBreadcrumbs(): array + { + return [ + ContentEntryResource::getUrl() => 'Artigos', + 'Detalhes', + ]; + } + + public function getSubheading(): string + { + $entry = $this->entry(); + + $parts = [ + $entry->provider->getLabel(), + $entry->published_at + ->timezone(config('app.display_timezone')) + ->format('d/m/Y'), + ]; + + $article = $entry->contentable; + + if ($article instanceof Article && $article->reading_time_minutes !== null) { + $parts[] = sprintf('%d min de leitura', $article->reading_time_minutes); + } + + return implode(' · ', $parts); + } + + /** + * @return array */ protected function getHeaderActions(): array { return [ + Action::make('openSource') + ->label('Abrir no provider') + ->icon(Heroicon::OutlinedArrowTopRightOnSquare) + ->color('gray') + ->url(fn (): string => $this->entry()->url) + ->openUrlInNewTab(), + EditAction::make(), ]; } + + private function entry(): ContentEntry + { + /** @var ContentEntry $record */ + $record = $this->getRecord(); + + return $record; + } } diff --git a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Schemas/ContentEntryInfolist.php b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Schemas/ContentEntryInfolist.php index 9dd45a0a..1e9add36 100644 --- a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Schemas/ContentEntryInfolist.php +++ b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Schemas/ContentEntryInfolist.php @@ -6,40 +6,97 @@ use Filament\Infolists\Components\ImageEntry; use Filament\Infolists\Components\TextEntry; +use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; +use Filament\Support\Enums\TextSize; use Filament\Support\Icons\Heroicon; use He4rt\Contents\Articles\Models\Article; use He4rt\Contents\Models\ContentEntry; +/** + * Título, provider, data e tempo de leitura vivem no cabeçalho da página + * ({@see \He4rt\PanelAdmin\Filament\Resources\ContentEntries\Pages\ViewContentEntry}), + * não aqui — repeti-los em campo era a maior fonte de ruído desta tela. + */ class ContentEntryInfolist { public static function configure(Schema $schema): Schema { return $schema - ->columns(1) + ->columns(3) ->components([ - Section::make('Publicação') - ->icon(Heroicon::OutlinedNewspaper) - ->columns(2) - ->schema([ - TextEntry::make('title') - ->label('Título') - ->columnSpanFull(), + self::readingColumn(), + self::sidebar(), + ]); + } + + private static function readingColumn(): Section + { + return Section::make('Artigo') + ->icon(Heroicon::OutlinedDocumentText) + ->columnSpan(2) + ->schema([ + // No Dev.to a descrição é gerada a partir do início do corpo: + // com o corpo hidratado, exibir as duas é repetir o mesmo texto. + TextEntry::make('contentable.description') + ->hiddenLabel() + ->size(TextSize::Large) + ->color('gray') + ->visible(static fn (ContentEntry $record): bool => !self::hasBody($record)) + ->placeholder('Sem descrição.') + ->columnSpanFull(), + + TextEntry::make('body') + ->hiddenLabel() + ->markdown() + ->state(static fn (ContentEntry $record): string => self::body($record)) + ->placeholder('Corpo não hidratado — o sync só busca o detalhe sob demanda.') + ->columnSpanFull(), + ]); + } + + /** + * O corpo só existe quando o sync hidratou o detalhe do artigo — a + * descoberta traz apenas os metadados da listagem do provider. + */ + private static function body(ContentEntry $record): string + { + $article = $record->contentable; - TextEntry::make('provider') - ->label('Provider') - ->badge(), + return $article instanceof Article + ? (string) $article->body_markdown + : ''; + } - TextEntry::make('published_at') - ->label('Publicado em') - ->dateTime('d/m/Y H:i') - ->timezone(config('app.display_timezone')), + private static function hasBody(ContentEntry $record): bool + { + return self::body($record) !== ''; + } + + private static function sidebar(): Grid + { + return Grid::make(1) + ->columnSpan(1) + ->schema([ + Section::make() + ->schema([ + ImageEntry::make('thumbnail_url') + ->hiddenLabel() + ->imageWidth('100%') + ->checkFileExistence(condition: false) + ->extraImgAttributes(['class' => 'rounded-lg']) + ->visible(static fn (ContentEntry $record): bool => filled($record->thumbnail_url)) + ->columnSpanFull(), - TextEntry::make('url') - ->label('Endereço') - ->url(static fn (ContentEntry $record): string => $record->url) - ->openUrlInNewTab() + // Só quando difere do endereço: no Dev.to as duas + // costumam ser idênticas, e repetir a URL foi o que + // mais poluiu a versão anterior desta tela. + TextEntry::make('contentable.canonical_url') + ->label('URL canônica') + ->visible(static fn (ContentEntry $record): bool => $record->contentable instanceof Article + && filled($record->contentable->canonical_url) + && $record->contentable->canonical_url !== $record->url) ->columnSpanFull(), TextEntry::make('tags') @@ -48,28 +105,31 @@ public static function configure(Schema $schema): Schema ->state(static fn (ContentEntry $record): array => $record->tags->toArray()) ->placeholder('Sem tags') ->columnSpanFull(), - - ImageEntry::make('thumbnail_url') - ->label('Capa') - ->placeholder('Sem capa') - ->columnSpanFull(), ]), Section::make('Autoria') ->icon(Heroicon::OutlinedUser) - ->columns(2) ->schema([ TextEntry::make('author.username') ->label('Autor vinculado') - ->placeholder('Não vinculado'), + ->icon(Heroicon::OutlinedCheckBadge) + ->visible(static fn (ContentEntry $record): bool => $record->author !== null), TextEntry::make('author_handle') - ->label('Handle no provider'), + ->label(static fn (ContentEntry $record): string => $record->author !== null + ? 'Handle no provider' + : 'Handle no provider (sem vínculo)') + ->color(static fn (ContentEntry $record): string => $record->author !== null + ? 'gray' + : 'warning'), ]), Section::make('Engajamento') ->icon(Heroicon::OutlinedChartBar) - ->columns(4) + ->description(static fn (ContentEntry $record): string => $record->metrics_synced_at === null + ? 'Métricas nunca sincronizadas' + : sprintf('Sincronizado %s', $record->metrics_synced_at->diffForHumans())) + ->columns(1) ->schema([ TextEntry::make('reactions_count') ->label('Reações') @@ -85,47 +145,6 @@ public static function configure(Schema $schema): Schema ->label('Salvos') ->numeric(0) ->placeholder('—'), - - TextEntry::make('metrics_synced_at') - ->label('Sincronizado') - ->since() - ->placeholder('Nunca'), - ]), - - Section::make('Artigo') - ->icon(Heroicon::OutlinedDocumentText) - ->collapsible() - ->columns(2) - ->schema([ - TextEntry::make('contentable.description') - ->label('Descrição') - ->placeholder('—') - ->columnSpanFull(), - - TextEntry::make('contentable.reading_time_minutes') - ->label('Tempo de leitura') - ->numeric(0) - ->suffix(' min') - ->placeholder('—'), - - TextEntry::make('contentable.canonical_url') - ->label('URL canônica') - ->placeholder('—'), - - TextEntry::make('contentable.source_edited_at') - ->label('Editado na origem') - ->dateTime('d/m/Y H:i') - ->timezone(config('app.display_timezone')) - ->placeholder('—'), - - TextEntry::make('body') - ->label('Corpo') - ->markdown() - ->state(static fn (ContentEntry $record): string => $record->contentable instanceof Article - ? (string) $record->contentable->body_markdown - : '') - ->placeholder('Corpo não hidratado — o sync só busca o detalhe sob demanda.') - ->columnSpanFull(), ]), ]); } diff --git a/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Widgets/ContentEntryStatsWidget.php b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Widgets/ContentEntryStatsWidget.php new file mode 100644 index 00000000..0f33fd1d --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ContentEntries/Widgets/ContentEntryStatsWidget.php @@ -0,0 +1,135 @@ + + */ + protected function getStats(): array + { + return [ + $this->catalogue(), + $this->unlinkedAuthors(), + $this->reactions(), + $this->lastSync(), + ]; + } + + private function catalogue(): Stat + { + $total = ContentEntry::query()->count(); + + return Stat::make('Artigos no acervo', Number::format($total)) + ->description('Publicações nas últimas 8 semanas') + ->descriptionIcon(Heroicon::OutlinedCalendarDays) + ->chart($this->publicationsPerWeek()) + ->icon(Heroicon::OutlinedNewspaper) + ->color('primary'); + } + + /** + * A fila de curadoria: o sync só resolve o autor quando existe identidade + * conectada no provider. O resto depende de vínculo manual. + */ + private function unlinkedAuthors(): Stat + { + $unlinked = ContentEntry::query()->whereNull('author_id')->count(); + + return Stat::make('Sem autor vinculado', Number::format($unlinked)) + ->description($unlinked === 0 + ? 'Todo artigo tem dono' + : 'Aguardando vínculo manual') + ->descriptionIcon($unlinked === 0 + ? Heroicon::OutlinedCheckCircle + : Heroicon::OutlinedExclamationTriangle) + ->icon(Heroicon::OutlinedUserPlus) + ->color($unlinked === 0 ? 'success' : 'warning'); + } + + private function reactions(): Stat + { + $total = (int) ContentEntry::query()->sum('reactions_count'); + $measured = ContentEntry::query()->whereNotNull('reactions_count')->count(); + + $average = $measured > 0 ? (int) round($total / $measured) : 0; + + return Stat::make('Reações no acervo', Number::format($total)) + ->description(sprintf('Média de %s por artigo medido', Number::format($average))) + ->descriptionIcon(Heroicon::OutlinedHeart) + ->icon(Heroicon::OutlinedHandThumbUp) + ->color('info'); + } + + private function lastSync(): Stat + { + /** @var CarbonImmutable|null $last */ + $last = ContentEntry::query()->max('metrics_synced_at'); + + $last = $last === null ? null : CarbonImmutable::parse($last); + + $isStale = !$last instanceof CarbonImmutable || $last->lt(now()->subWeek()); + + return Stat::make( + 'Última sincronização', + $last instanceof CarbonImmutable ? $last->diffForHumans() : 'Nunca', + ) + ->description($isStale + ? 'Métricas paradas há mais de uma semana' + : 'Métricas em dia') + ->descriptionIcon($isStale + ? Heroicon::OutlinedExclamationTriangle + : Heroicon::OutlinedCheckCircle) + ->icon(Heroicon::OutlinedArrowPath) + ->color($isStale ? 'danger' : 'success'); + } + + /** + * Publicações por semana, das oito últimas até a corrente. Semanas sem + * artigo entram como zero: sem isso o gráfico comprime o eixo e sugere uma + * cadência que não existiu. + * + * @return array + */ + private function publicationsPerWeek(): array + { + $timezone = config()->string('app.display_timezone'); + + $start = now()->timezone($timezone)->startOfWeek()->subWeeks(7); + + /** @var array $counts */ + $counts = ContentEntry::query() + ->where('published_at', '>=', $start) + ->selectRaw( + "to_char(date_trunc('week', published_at AT TIME ZONE ?), 'YYYY-MM-DD') as week, count(*) as total", + [$timezone], + ) + ->groupBy('week') + ->pluck('total', 'week') + ->all(); + + $series = []; + + for ($offset = 0; $offset < 8; $offset++) { + $week = $start->copy()->addWeeks($offset)->format('Y-m-d'); + + $series[] = (int) ($counts[$week] ?? 0); + } + + return $series; + } +} diff --git a/app-modules/panel-admin/tests/Feature/Contents/ContentEntryResourceTest.php b/app-modules/panel-admin/tests/Feature/Contents/ContentEntryResourceTest.php index 1921d667..a8b466ef 100644 --- a/app-modules/panel-admin/tests/Feature/Contents/ContentEntryResourceTest.php +++ b/app-modules/panel-admin/tests/Feature/Contents/ContentEntryResourceTest.php @@ -8,6 +8,8 @@ use He4rt\PanelAdmin\Filament\Resources\ContentEntries\ContentEntryResource; use He4rt\PanelAdmin\Filament\Resources\ContentEntries\Pages\EditContentEntry; use He4rt\PanelAdmin\Filament\Resources\ContentEntries\Pages\ListContentEntries; +use He4rt\PanelAdmin\Filament\Resources\ContentEntries\Pages\ViewContentEntry; +use He4rt\PanelAdmin\Filament\Resources\ContentEntries\Widgets\ContentEntryStatsWidget; use Illuminate\Foundation\Console\QueuedCommand; use Illuminate\Support\Facades\Queue; @@ -111,3 +113,92 @@ Queue::assertPushed(QueuedCommand::class); }); + +test('o cabeçalho da view carrega título, provider e data em vez de repeti-los no corpo', function (): void { + $entry = ContentEntry::factory()->create([ + 'title' => 'Como criei um agente de IA', + 'published_at' => now()->subDay(), + ]); + $entry->contentable->update(['reading_time_minutes' => 9]); + + $page = livewire(ViewContentEntry::class, ['record' => $entry->getKey()]); + $instance = $page->instance(); + + expect($instance->getTitle())->toBe('Como criei um agente de IA') + ->and($instance->getBreadcrumbs())->toBe([ + ContentEntryResource::getUrl() => 'Artigos', + 'Detalhes', + ]) + ->and($instance->getSubheading())->toContain('Dev.to') + ->and($instance->getSubheading())->toContain('9 min de leitura'); + + // O título não volta como campo do infolist. + $page->assertSchemaComponentDoesNotExist('title'); +}); + +test('a URL canônica só aparece quando difere do endereço', function (): void { + $same = ContentEntry::factory()->create(['url' => 'https://dev.to/he4rt/artigo']); + $same->contentable->update(['canonical_url' => 'https://dev.to/he4rt/artigo']); + + livewire(ViewContentEntry::class, ['record' => $same->getKey()]) + ->assertSchemaComponentHidden('contentable.canonical_url'); + + $different = ContentEntry::factory()->create(['url' => 'https://dev.to/he4rt/outro']); + $different->contentable->update(['canonical_url' => 'https://blog.he4rt.dev/outro']); + + livewire(ViewContentEntry::class, ['record' => $different->getKey()]) + ->assertSchemaComponentVisible('contentable.canonical_url'); +}); + +test('o autor vinculado só aparece quando existe', function (): void { + $unlinked = ContentEntry::factory()->create(['author_id' => null]); + + livewire(ViewContentEntry::class, ['record' => $unlinked->getKey()]) + ->assertSchemaComponentHidden('author.username'); + + $linked = ContentEntry::factory()->authoredBy($this->admin)->create(); + + livewire(ViewContentEntry::class, ['record' => $linked->getKey()]) + ->assertSchemaComponentVisible('author.username'); +}); + +test('o widget de stats conta o acervo e a fila de curadoria', function (): void { + ContentEntry::factory()->count(2)->create(['author_id' => null]); + ContentEntry::factory()->authoredBy($this->admin)->create(); + + livewire(ContentEntryStatsWidget::class) + ->assertOk() + ->assertSee('Artigos no acervo') + ->assertSee('Sem autor vinculado') + ->assertSee('Aguardando vínculo manual'); +}); + +test('o widget avisa quando as métricas estão paradas', function (): void { + ContentEntry::factory()->create(['metrics_synced_at' => now()->subMonth()]); + + livewire(ContentEntryStatsWidget::class) + ->assertSee('Métricas paradas há mais de uma semana'); + + ContentEntry::factory()->create(['metrics_synced_at' => now()]); + + livewire(ContentEntryStatsWidget::class) + ->assertSee('Métricas em dia'); +}); + +test('o widget não quebra com o acervo vazio', function (): void { + livewire(ContentEntryStatsWidget::class) + ->assertOk() + ->assertSee('Todo artigo tem dono') + ->assertSee('Nunca'); +}); + +test('a listagem exibe o widget de stats no topo', function (): void { + ContentEntry::factory()->create(); + + $widgets = livewire(ListContentEntries::class) + ->loadTable() + ->instance() + ->getVisibleHeaderWidgets(); + + expect($widgets)->not->toBeEmpty(); +}); From 00889763cef9571c2348a17f42296441cf198441 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 22 Aug 2026 19:51:08 -0300 Subject: [PATCH 09/10] chore(tests): atualiza timings de shard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inclui as classes novas de Pessoas e Conteúdo no balanceamento das shards. --- tests/.pest/shards.json | 398 +++++++++++++++++++++------------------- 1 file changed, 209 insertions(+), 189 deletions(-) diff --git a/tests/.pest/shards.json b/tests/.pest/shards.json index 25edd755..8da1148b 100644 --- a/tests/.pest/shards.json +++ b/tests/.pest/shards.json @@ -1,193 +1,213 @@ { "timings": { - "Appmodules\\activity\\tests\\Feature\\TrackContentContributionTest": 4.7661, - "Appmodules\\activity\\tests\\Unit\\Actions\\NewMessageTest": 4.4947, - "Appmodules\\activity\\tests\\Unit\\Actions\\NewVoiceMessageTest": 4.5283, - "Appmodules\\activity\\tests\\Unit\\Actions\\RecordVoicePresenceTest": 4.907, - "Appmodules\\activity\\tests\\Unit\\Queries\\GetCurrentVoiceChannelTest": 4.4951, - "Appmodules\\activity\\tests\\Unit\\Timeline\\CreatePostTest": 4.4684, - "Appmodules\\activity\\tests\\Unit\\Timeline\\CreateReplyTest": 4.5165, - "Appmodules\\activity\\tests\\Unit\\Timeline\\DeleteReplyTest": 4.4176, - "Appmodules\\activity\\tests\\Unit\\Timeline\\PublishModerationEntryTest": 4.5051, - "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineFeedQueryTest": 4.5055, - "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineModelTest": 4.4498, - "Appmodules\\activity\\tests\\Unit\\Timeline\\TogglePinPostTest": 4.4498, - "Appmodules\\activity\\tests\\Unit\\Tracking\\ApproveInteractionTest": 4.3206, - "Appmodules\\activity\\tests\\Unit\\Tracking\\CalculateRewardTest": 4.2051, - "Appmodules\\activity\\tests\\Unit\\Tracking\\ClassifyActivityTest": 0.3504, - "Appmodules\\activity\\tests\\Unit\\Tracking\\RejectInteractionTest": 4.3104, - "Appmodules\\activity\\tests\\Unit\\Tracking\\TrackActivityTest": 4.4445, - "Appmodules\\botdiscord\\tests\\Feature\\Actions\\VoiceChannel\\HandleStateChannelActionTest": 0.1923, - "Appmodules\\botdiscord\\tests\\Feature\\Events\\RawGatewayEventTest": 0.2848, - "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\AutoExecuteActionTest": 0.3285, - "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\NotifyModerationChannelTest": 0.3308, - "Appmodules\\botdiscord\\tests\\Feature\\Moderation\\DiscordModerationAdapterTest": 2.978, - "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\EditProfileCommandTest": 0.2626, - "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\IntroductionCommandTest": 0.188, - "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\ConfigureEmpresarialRoomActionTest": 0.5728, - "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\JoiningChannelActionTest": 0.2773, - "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\LeftChannelActionTest": 0.3486, - "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceTransitionResolverTest": 0.4959, - "Appmodules\\botdiscord\\tests\\Unit\\Enums\\EmpresarialRejectionReasonTest": 0.2944, - "Appmodules\\botdiscord\\tests\\Unit\\Events\\WelcomeMemberGuildIconTest": 0.391, - "Appmodules\\botdiscord\\tests\\Unit\\Moderation\\ModerationEmbedBuilderTest": 4.6285, - "Appmodules\\botdiscord\\tests\\Unit\\SlashCommands\\EditProfileModalValidationTest": 0.6477, - "Appmodules\\contents\\tests\\Feature\\ReconcileOrphanEntriesTest": 0.5425, - "Appmodules\\contents\\tests\\Feature\\SyncArticlesCommandTest": 0.2879, - "Appmodules\\contents\\tests\\Feature\\UpsertArticleTest": 0.6837, - "Appmodules\\contents\\tests\\Unit\\ContentProviderTest": 0.3866, - "Appmodules\\contents\\tests\\Unit\\TagListTest": 0.4251, - "Appmodules\\docs\\tests\\Feature\\Discovery\\CacheDocsCommandTest": 0.9993, - "Appmodules\\docs\\tests\\Feature\\Discovery\\DocsRoutingTest": 1.1426, - "Appmodules\\docs\\tests\\Unit\\Discovery\\BuildTreeTest": 0.7949, - "Appmodules\\docs\\tests\\Unit\\Discovery\\DiscoveryIntegrationTest": 0.389, - "Appmodules\\docs\\tests\\Unit\\Discovery\\DocumentTierTest": 0.8164, - "Appmodules\\docs\\tests\\Unit\\Discovery\\FoundationsTest": 1.1336, - "Appmodules\\docs\\tests\\Unit\\Discovery\\ParsingTest": 0.5012, - "Appmodules\\docs\\tests\\Unit\\Discovery\\StrategiesTest": 0.5557, - "Appmodules\\economy\\tests\\Feature\\EconomyFlowTest": 0.187, - "Appmodules\\economy\\tests\\Unit\\CreditTest": 4.005, - "Appmodules\\economy\\tests\\Unit\\DebitTest": 4.0772, - "Appmodules\\economy\\tests\\Unit\\TransferTest": 4.0654, - "Appmodules\\events\\tests\\Feature\\CheckInActionTest": 0.6654, - "Appmodules\\events\\tests\\Feature\\Closure\\CloseEventActionTest": 12.7188, - "Appmodules\\events\\tests\\Feature\\Closure\\ClosePendingEventsCommandTest": 0.4044, - "Appmodules\\events\\tests\\Feature\\Closure\\OverrideEnrollmentStatusActionTest": 0.7901, - "Appmodules\\events\\tests\\Feature\\Closure\\ProcessEventClosureJobTest": 0.3929, - "Appmodules\\events\\tests\\Feature\\EnrollUserActionTest": 2.6309, - "Appmodules\\events\\tests\\Feature\\Enrollment\\ApproveApplicationActionTest": 0.6035, - "Appmodules\\events\\tests\\Feature\\Enrollment\\EnrollmentPolicyTest": 0.5317, - "Appmodules\\events\\tests\\Feature\\Enrollment\\RejectApplicationActionTest": 0.32, - "Appmodules\\events\\tests\\Feature\\EventFactoriesTest": 1.3955, - "Appmodules\\events\\tests\\Feature\\EventResourceTest": 2.7618, - "Appmodules\\events\\tests\\Feature\\HandleBotCheckInTest": 1.6767, - "Appmodules\\events\\tests\\Feature\\NumericCodeCheckInActionTest": 1.2819, - "Appmodules\\events\\tests\\Feature\\QrCheckInActionTest": 1.3412, - "Appmodules\\events\\tests\\Unit\\EnrollmentScopeTest": 3.9488, - "Appmodules\\events\\tests\\Unit\\EnrollmentStatusTest": 0.8085, - "Appmodules\\events\\tests\\Unit\\EventStatusTest": 0.3734, - "Appmodules\\gamification\\tests\\Unit\\Character\\GenerateTextExperienceTest": 0.7167, - "Appmodules\\gamification\\tests\\Unit\\Character\\LevelCalculationTest": 5.0175, - "Appmodules\\gamification\\tests\\Unit\\Character\\VoiceExperienceTest": 4.3821, - "Appmodules\\identity\\tests\\Feature\\Auth\\AttachProviderToUserTest": 0.2373, - "Appmodules\\identity\\tests\\Feature\\Auth\\DetectMergeConflictTest": 0.2465, - "Appmodules\\identity\\tests\\Feature\\Auth\\EnrichUserOnFirstLoginTest": 0.4448, - "Appmodules\\identity\\tests\\Feature\\Auth\\FindOrCreateUserByProviderTest": 0.7829, - "Appmodules\\identity\\tests\\Feature\\Auth\\HandleOAuthCallbackActionTest": 0.1926, - "Appmodules\\identity\\tests\\Feature\\Auth\\MergeAccountsActionTest": 1.131, - "Appmodules\\identity\\tests\\Feature\\Auth\\OAuthControllerTest": 0.1501, - "Appmodules\\identity\\tests\\Feature\\ExternalIdentity\\ConnectApiKeyIdentityTest": 0.8908, - "Appmodules\\identity\\tests\\Unit\\ExternalIdentity\\FindExternalIdentityTest": 4.0491, - "Appmodules\\identity\\tests\\Unit\\ExternalIdentity\\IdentityProviderCredentialsTest": 0.558, - "Appmodules\\integrationdevto\\tests\\Feature\\DevToApiKeyClientTest": 0.3728, - "Appmodules\\integrationdevto\\tests\\Feature\\DevToArticleProviderTest": 0.5683, - "Appmodules\\integrationdevto\\tests\\Feature\\DevToOAuthTest": 0.2351, - "Appmodules\\integrationdevto\\tests\\Unit\\DevToArticleMapperTest": 0.6248, - "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordMessageTest": 4.3835, - "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordProfileTest": 1.8653, - "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\MergeDuplicateDiscordProfilesTest": 1.7844, - "Appmodules\\integrationdiscord\\tests\\Feature\\Models\\DiscordEventLogTest": 0.2367, - "Appmodules\\integrationdiscord\\tests\\Feature\\OAuth\\DiscordOAuthClientTest": 0.2764, - "Appmodules\\integrationdiscord\\tests\\Unit\\DiscordMessageAdapterTest": 1.7977, - "Appmodules\\integrationdiscord\\tests\\Unit\\Enums\\DiscordEventTypeTest": 0.1867, - "Appmodules\\integrationdiscord\\tests\\Unit\\Sync\\PurgeUnusedInvitesActionTest": 14.6413, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordConnectorTest": 0.1218, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordRoleResolverTest": 0.1982, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateBanTest": 0.119, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateDmChannelTest": 0.1115, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateMessageTest": 0.1153, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteInviteTest": 0.0666, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteMessageTest": 0.0675, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ExchangeCodeForTokenTest": 0.1005, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetCurrentUserTest": 0.1127, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetMemberTest": 0.0707, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ListGuildInvitesTest": 0.0703, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ModifyMemberTest": 0.099, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\RemoveMemberTest": 0.0767, - "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubCommandTest": 0.5524, - "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubRepositoryJobTest": 0.2853, - "Appmodules\\integrationgithub\\tests\\Feature\\BackfillRepositoryTest": 1.7092, - "Appmodules\\integrationgithub\\tests\\Feature\\GitHubOAuthClientTest": 0.3873, - "Appmodules\\integrationgithub\\tests\\Feature\\GithubContributionTest": 0.2887, - "Appmodules\\integrationgithub\\tests\\Feature\\GithubRepositoryTest": 0.3831, - "Appmodules\\integrationgithub\\tests\\Feature\\GithubWebhookTest": 1.1557, - "Appmodules\\integrationtwitch\\tests\\Feature\\LinkTwitchChannelCommandTest": 0.2116, - "Appmodules\\integrationtwitch\\tests\\Feature\\RegisterTwitchSubscriptionsActionTest": 0.2019, - "Appmodules\\integrationtwitch\\tests\\Feature\\SubscribeTwitchEventsCommandTest": 0.608, - "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchAppTokenServiceTest": 0.1367, - "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchWebhookTest": 0.7322, - "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\StoreWhatsAppEventActionTest": 0.6146, - "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\WebhookIngestTest": 0.9188, - "Appmodules\\moderation\\tests\\Feature\\AppealWorkflowTest": 0.6094, - "Appmodules\\moderation\\tests\\Feature\\Appeals\\FileAppealTest": 0.8299, - "Appmodules\\moderation\\tests\\Feature\\Appeals\\ReviewAppealTest": 0.678, - "Appmodules\\moderation\\tests\\Feature\\Audit\\AuditLogTest": 0.4778, - "Appmodules\\moderation\\tests\\Feature\\CaseWorkflowTest": 0.606, - "Appmodules\\moderation\\tests\\Feature\\Cases\\SubmitReportTest": 1.6175, - "Appmodules\\moderation\\tests\\Feature\\Classification\\RouteDecisionTest": 2.4959, - "Appmodules\\moderation\\tests\\Feature\\Classification\\RuleBasedClassifierTest": 0.7429, - "Appmodules\\moderation\\tests\\Feature\\Enforcement\\ExecuteActionTest": 0.7845, - "Appmodules\\moderation\\tests\\Feature\\ModelRelationshipTest": 0.9651, - "Appmodules\\moderation\\tests\\Feature\\PipelineTest": 0.9654, - "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ClassifyAndRouteTest": 0.3377, - "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ScreenContentTest": 0.3486, - "Appmodules\\moderation\\tests\\Feature\\Pipeline\\SubmitForModerationTest": 0.2785, - "Appmodules\\moderation\\tests\\Feature\\Platform\\WebAdapterTest": 1.2179, - "Appmodules\\moderation\\tests\\Unit\\ClassifierTest": 4.4205, - "Appmodules\\moderation\\tests\\Unit\\DTOTest": 0.275, - "Appmodules\\moderation\\tests\\Unit\\EnumTest": 0.2456, - "Appmodules\\moderation\\tests\\Unit\\ModerationRuleMatchTest": 4.3028, - "Appmodules\\moderation\\tests\\Unit\\PenaltyAdvisorTest": 5.2901, - "Appmodules\\moderation\\tests\\Unit\\PlatformRegistryTest": 0.1416, - "Appmodules\\moderation\\tests\\Unit\\RouteCaseActionTest": 4.5187, - "Appmodules\\paneladmin\\tests\\Feature\\AdminPanelAccessTest": 0.4824, - "Appmodules\\paneladmin\\tests\\Feature\\Discord\\DiscordClusterSmokeTest": 1.7329, - "Appmodules\\paneladmin\\tests\\Feature\\Github\\GithubRepositoryResourceTest": 0.7199, - "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\CommunityActivityStatsTest": 0.1784, - "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\LocationDashboardTest": 0.1751, - "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\MeetingShowcasePageTest": 0.6843, - "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\MembersByStateTest": 0.2398, - "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\PeriodStatsTest": 0.1584, - "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\AppealQueueTest": 1.3531, - "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationDashboardTest": 1.547, - "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationQueueTest": 1.3778, - "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\TestRuleActionTest": 0.633, - "Appmodules\\paneladmin\\tests\\Feature\\Twitch\\ConnectTwitchActionTest": 0.2945, - "Appmodules\\paneladmin\\tests\\Feature\\Twitch\\RegisterSubscriptionsActionTest": 0.1763, - "Appmodules\\panelapp\\tests\\Feature\\Auth\\LastAuthProviderTest": 0.4878, - "Appmodules\\panelapp\\tests\\Feature\\ConnectionHubTest": 0.7803, - "Appmodules\\panelapp\\tests\\Feature\\Events\\ApplicationEnrollmentTest": 1.0992, - "Appmodules\\panelapp\\tests\\Feature\\Events\\NumericCodeCheckInTest": 0.3965, - "Appmodules\\panelapp\\tests\\Feature\\Events\\RsvpEnrollmentTest": 1.2596, - "Appmodules\\panelapp\\tests\\Feature\\ProfilePageTest": 3.2171, - "Appmodules\\panelapp\\tests\\Feature\\Timeline\\ThreadPageTest": 0.7532, - "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectivePageTest": 0.8298, - "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectiveTest": 1.4968, - "Appmodules\\portal\\tests\\Feature\\HeroAvatarsTest": 0.4883, - "Appmodules\\portal\\tests\\Feature\\HomepageTest": 0.3311, - "Appmodules\\portal\\tests\\Feature\\RetrospectiveFiltersTest": 0.1261, - "Appmodules\\portal\\tests\\Feature\\SeoHeadTest": 0.9631, - "Appmodules\\portal\\tests\\Feature\\SocialLinksPageTest": 0.5476, - "Appmodules\\profile\\tests\\Feature\\ProfileCreationTest": 0.2459, - "Appmodules\\profile\\tests\\Feature\\ProfilePreferencesTest": 0.2618, - "Appmodules\\profile\\tests\\Feature\\ProfileWorkExperiencesTest": 0.1936, - "Appmodules\\profile\\tests\\Feature\\SkillSearchTest": 0.1717, - "Appmodules\\profile\\tests\\Feature\\SyncProfileSkillsTest": 0.6209, - "Appmodules\\profile\\tests\\Feature\\ToggleAvailabilityTest": 0.3555, - "Appmodules\\profile\\tests\\Feature\\UpsertProfileTest": 0.7613, - "Appmodules\\profile\\tests\\Feature\\WorkExperienceFactoryTest": 0.4952, - "Appmodules\\profile\\tests\\Unit\\ProfileEnumTest": 0.1806, - "Appmodules\\profile\\tests\\Unit\\ProfileSocialLinksTest": 0.0377, - "Appmodules\\profile\\tests\\Unit\\SkillEnumTest": 0.0841, - "Appmodules\\profile\\tests\\Unit\\SocialPlatformTest": 0.4898, - "Appmodules\\profile\\tests\\Unit\\WorkExperienceTest": 0.1091, - "Appmodules\\profile\\tests\\Unit\\WorkPreferencesTest": 0.2048, - "Tests\\Feature\\AddressTest": 4.4813, - "Tests\\Feature\\ConnectionHubMergeModalTest": 4.2395, - "Tests\\Feature\\Geo\\GeoLocationTest": 0.3586, - "Tests\\Feature\\LocaleSwitcherTest": 0.3083, - "Tests\\Feature\\PanelRobotsTest": 4.5887 + "Appmodules\\activity\\tests\\Feature\\TrackContentContributionTest": 3.9899, + "Appmodules\\activity\\tests\\Unit\\Actions\\NewMessageTest": 4.6501, + "Appmodules\\activity\\tests\\Unit\\Actions\\NewVoiceMessageTest": 4.5446, + "Appmodules\\activity\\tests\\Unit\\Actions\\RecordVoicePresenceTest": 5.0463, + "Appmodules\\activity\\tests\\Unit\\Queries\\GetCurrentVoiceChannelTest": 4.9506, + "Appmodules\\activity\\tests\\Unit\\Timeline\\CreatePostTest": 4.6624, + "Appmodules\\activity\\tests\\Unit\\Timeline\\CreateReplyTest": 4.5895, + "Appmodules\\activity\\tests\\Unit\\Timeline\\DeleteReplyTest": 4.6312, + "Appmodules\\activity\\tests\\Unit\\Timeline\\PublishModerationEntryTest": 4.7071, + "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineFeedQueryTest": 4.7347, + "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineModelTest": 4.6207, + "Appmodules\\activity\\tests\\Unit\\Timeline\\TogglePinPostTest": 4.6829, + "Appmodules\\activity\\tests\\Unit\\Tracking\\ApproveInteractionTest": 4.2124, + "Appmodules\\activity\\tests\\Unit\\Tracking\\CalculateRewardTest": 4.5073, + "Appmodules\\activity\\tests\\Unit\\Tracking\\ClassifyActivityTest": 0.4069, + "Appmodules\\activity\\tests\\Unit\\Tracking\\RejectInteractionTest": 4.2563, + "Appmodules\\activity\\tests\\Unit\\Tracking\\TrackActivityTest": 4.433, + "Appmodules\\botdiscord\\tests\\Feature\\Actions\\VoiceChannel\\HandleStateChannelActionTest": 0.2465, + "Appmodules\\botdiscord\\tests\\Feature\\Events\\RawGatewayEventTest": 0.3127, + "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\AutoExecuteActionTest": 0.3518, + "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\NotifyModerationChannelTest": 0.3875, + "Appmodules\\botdiscord\\tests\\Feature\\Moderation\\DiscordModerationAdapterTest": 2.5279, + "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\EditProfileCommandTest": 0.2788, + "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\IntroductionCommandTest": 0.184, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\ConfigureEmpresarialRoomActionTest": 0.6439, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\JoiningChannelActionTest": 0.2735, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\LeftChannelActionTest": 0.3785, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceTransitionResolverTest": 0.5148, + "Appmodules\\botdiscord\\tests\\Unit\\Enums\\EmpresarialRejectionReasonTest": 0.3492, + "Appmodules\\botdiscord\\tests\\Unit\\Events\\WelcomeMemberGuildIconTest": 0.4904, + "Appmodules\\botdiscord\\tests\\Unit\\Moderation\\ModerationEmbedBuilderTest": 4.7779, + "Appmodules\\botdiscord\\tests\\Unit\\SlashCommands\\EditProfileModalValidationTest": 0.7212, + "Appmodules\\contents\\tests\\Feature\\ReconcileOrphanEntriesTest": 0.509, + "Appmodules\\contents\\tests\\Feature\\SyncArticlesCommandTest": 1.1014, + "Appmodules\\contents\\tests\\Feature\\UpsertArticleTest": 0.607, + "Appmodules\\contents\\tests\\Unit\\ContentProviderTest": 0.4586, + "Appmodules\\contents\\tests\\Unit\\TagListTest": 0.446, + "Appmodules\\docs\\tests\\Feature\\Discovery\\CacheDocsCommandTest": 1.616, + "Appmodules\\docs\\tests\\Feature\\Discovery\\DocsRoutingTest": 1.2431, + "Appmodules\\docs\\tests\\Unit\\Discovery\\BuildTreeTest": 0.7286, + "Appmodules\\docs\\tests\\Unit\\Discovery\\DiscoveryIntegrationTest": 0.5317, + "Appmodules\\docs\\tests\\Unit\\Discovery\\DocumentTierTest": 0.7247, + "Appmodules\\docs\\tests\\Unit\\Discovery\\FoundationsTest": 1.3206, + "Appmodules\\docs\\tests\\Unit\\Discovery\\ParsingTest": 0.5709, + "Appmodules\\docs\\tests\\Unit\\Discovery\\StrategiesTest": 0.3731, + "Appmodules\\economy\\tests\\Feature\\EconomyFlowTest": 0.1792, + "Appmodules\\economy\\tests\\Unit\\CreditTest": 3.8231, + "Appmodules\\economy\\tests\\Unit\\DebitTest": 3.8601, + "Appmodules\\economy\\tests\\Unit\\TransferTest": 3.8935, + "Appmodules\\events\\tests\\Feature\\CheckInActionTest": 0.5777, + "Appmodules\\events\\tests\\Feature\\Closure\\CloseEventActionTest": 11.6253, + "Appmodules\\events\\tests\\Feature\\Closure\\ClosePendingEventsCommandTest": 0.3861, + "Appmodules\\events\\tests\\Feature\\Closure\\OverrideEnrollmentStatusActionTest": 0.7222, + "Appmodules\\events\\tests\\Feature\\Closure\\ProcessEventClosureJobTest": 0.3766, + "Appmodules\\events\\tests\\Feature\\EnrollUserActionTest": 1.9427, + "Appmodules\\events\\tests\\Feature\\Enrollment\\ApproveApplicationActionTest": 0.5179, + "Appmodules\\events\\tests\\Feature\\Enrollment\\EnrollmentPolicyTest": 0.4769, + "Appmodules\\events\\tests\\Feature\\Enrollment\\RejectApplicationActionTest": 0.2927, + "Appmodules\\events\\tests\\Feature\\EventFactoriesTest": 1.099, + "Appmodules\\events\\tests\\Feature\\EventResourceTest": 2.6177, + "Appmodules\\events\\tests\\Feature\\HandleBotCheckInTest": 1.19, + "Appmodules\\events\\tests\\Feature\\NumericCodeCheckInActionTest": 0.9225, + "Appmodules\\events\\tests\\Feature\\QrCheckInActionTest": 0.9344, + "Appmodules\\events\\tests\\Unit\\EnrollmentScopeTest": 3.7632, + "Appmodules\\events\\tests\\Unit\\EnrollmentStatusTest": 0.8157, + "Appmodules\\events\\tests\\Unit\\EventStatusTest": 0.4056, + "Appmodules\\gamification\\tests\\Unit\\Character\\GenerateTextExperienceTest": 0.7618, + "Appmodules\\gamification\\tests\\Unit\\Character\\LevelCalculationTest": 4.7333, + "Appmodules\\gamification\\tests\\Unit\\Character\\VoiceExperienceTest": 4.2109, + "Appmodules\\identity\\tests\\Feature\\Auth\\AttachProviderToUserTest": 0.1702, + "Appmodules\\identity\\tests\\Feature\\Auth\\DetectMergeConflictTest": 0.2667, + "Appmodules\\identity\\tests\\Feature\\Auth\\EnrichUserOnFirstLoginTest": 0.4057, + "Appmodules\\identity\\tests\\Feature\\Auth\\FindOrCreateUserByProviderTest": 0.5038, + "Appmodules\\identity\\tests\\Feature\\Auth\\HandleOAuthCallbackActionTest": 0.1899, + "Appmodules\\identity\\tests\\Feature\\Auth\\MergeAccountsActionTest": 0.8391, + "Appmodules\\identity\\tests\\Feature\\Auth\\OAuthControllerTest": 0.1437, + "Appmodules\\identity\\tests\\Feature\\ExternalIdentity\\ConnectApiKeyIdentityTest": 0.6957, + "Appmodules\\identity\\tests\\Unit\\ExternalIdentity\\FindExternalIdentityTest": 4.0465, + "Appmodules\\identity\\tests\\Unit\\ExternalIdentity\\IdentityProviderCredentialsTest": 0.5577, + "Appmodules\\identity\\tests\\Unit\\IdentityProviderColorTest": 1.3469, + "Appmodules\\identity\\tests\\Unit\\UserSituationTest": 0.4453, + "Appmodules\\integrationdevto\\tests\\Feature\\DevToApiKeyClientTest": 0.257, + "Appmodules\\integrationdevto\\tests\\Feature\\DevToArticleProviderTest": 0.3761, + "Appmodules\\integrationdevto\\tests\\Feature\\DevToOAuthTest": 0.1992, + "Appmodules\\integrationdevto\\tests\\Unit\\DevToArticleMapperTest": 0.6133, + "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordMessageTest": 4.9118, + "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordProfileTest": 1.4661, + "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\MergeDuplicateDiscordProfilesTest": 1.1865, + "Appmodules\\integrationdiscord\\tests\\Feature\\Models\\DiscordEventLogTest": 0.1489, + "Appmodules\\integrationdiscord\\tests\\Feature\\OAuth\\DiscordOAuthClientTest": 0.1923, + "Appmodules\\integrationdiscord\\tests\\Unit\\DiscordMessageAdapterTest": 2.1055, + "Appmodules\\integrationdiscord\\tests\\Unit\\Enums\\DiscordEventTypeTest": 0.1251, + "Appmodules\\integrationdiscord\\tests\\Unit\\Sync\\PurgeUnusedInvitesActionTest": 13.661, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordConnectorTest": 0.1473, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordRoleResolverTest": 0.2298, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateBanTest": 0.1363, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateDmChannelTest": 0.1432, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateMessageTest": 0.1366, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteInviteTest": 0.0864, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteMessageTest": 0.0919, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ExchangeCodeForTokenTest": 0.1279, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetCurrentUserTest": 0.1331, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetMemberTest": 0.0912, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ListGuildInvitesTest": 0.0934, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ModifyMemberTest": 0.1321, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\RemoveMemberTest": 0.0889, + "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubCommandTest": 0.3661, + "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubRepositoryJobTest": 0.1743, + "Appmodules\\integrationgithub\\tests\\Feature\\BackfillRepositoryTest": 1.4884, + "Appmodules\\integrationgithub\\tests\\Feature\\GitHubOAuthClientTest": 0.3107, + "Appmodules\\integrationgithub\\tests\\Feature\\GithubContributionTest": 0.2228, + "Appmodules\\integrationgithub\\tests\\Feature\\GithubRepositoryTest": 0.3094, + "Appmodules\\integrationgithub\\tests\\Feature\\GithubWebhookTest": 0.979, + "Appmodules\\integrationtwitch\\tests\\Feature\\LinkTwitchChannelCommandTest": 0.21, + "Appmodules\\integrationtwitch\\tests\\Feature\\RegisterTwitchSubscriptionsActionTest": 0.1593, + "Appmodules\\integrationtwitch\\tests\\Feature\\SubscribeTwitchEventsCommandTest": 0.6155, + "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchAppTokenServiceTest": 0.1521, + "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchWebhookTest": 0.6832, + "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\StoreWhatsAppEventActionTest": 0.5642, + "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\WebhookIngestTest": 0.9093, + "Appmodules\\marketing\\tests\\Feature\\CreateShortLinkTest": 0.6616, + "Appmodules\\marketing\\tests\\Feature\\RecordShortLinkClickTest": 1.2815, + "Appmodules\\marketing\\tests\\Feature\\ResolveShortLinkTest": 0.986, + "Appmodules\\marketing\\tests\\Feature\\UpdateShortLinkTest": 0.697, + "Appmodules\\marketing\\tests\\Unit\\DestinationUrlValidatorTest": 0.3595, + "Appmodules\\marketing\\tests\\Unit\\SlugGeneratorTest": 0.2728, + "Appmodules\\marketing\\tests\\Unit\\TagListTest": 0.6863, + "Appmodules\\marketing\\tests\\Unit\\UtmParametersTest": 0.8683, + "Appmodules\\moderation\\tests\\Feature\\AppealWorkflowTest": 0.4536, + "Appmodules\\moderation\\tests\\Feature\\Appeals\\FileAppealTest": 0.7053, + "Appmodules\\moderation\\tests\\Feature\\Appeals\\ReviewAppealTest": 0.4818, + "Appmodules\\moderation\\tests\\Feature\\Audit\\AuditLogTest": 0.3569, + "Appmodules\\moderation\\tests\\Feature\\CaseWorkflowTest": 0.4506, + "Appmodules\\moderation\\tests\\Feature\\Cases\\SubmitReportTest": 1.1986, + "Appmodules\\moderation\\tests\\Feature\\Classification\\RouteDecisionTest": 2.6148, + "Appmodules\\moderation\\tests\\Feature\\Classification\\RuleBasedClassifierTest": 0.795, + "Appmodules\\moderation\\tests\\Feature\\Enforcement\\ExecuteActionTest": 0.6478, + "Appmodules\\moderation\\tests\\Feature\\ModelRelationshipTest": 0.8299, + "Appmodules\\moderation\\tests\\Feature\\PipelineTest": 0.9098, + "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ClassifyAndRouteTest": 0.2871, + "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ScreenContentTest": 0.3112, + "Appmodules\\moderation\\tests\\Feature\\Pipeline\\SubmitForModerationTest": 0.301, + "Appmodules\\moderation\\tests\\Feature\\Platform\\WebAdapterTest": 1.3061, + "Appmodules\\moderation\\tests\\Unit\\ClassifierTest": 4.0565, + "Appmodules\\moderation\\tests\\Unit\\DTOTest": 0.357, + "Appmodules\\moderation\\tests\\Unit\\EnumTest": 0.2903, + "Appmodules\\moderation\\tests\\Unit\\ModerationRuleMatchTest": 3.9522, + "Appmodules\\moderation\\tests\\Unit\\PenaltyAdvisorTest": 4.7763, + "Appmodules\\moderation\\tests\\Unit\\PlatformRegistryTest": 0.1785, + "Appmodules\\moderation\\tests\\Unit\\RouteCaseActionTest": 4.1613, + "Appmodules\\paneladmin\\tests\\Feature\\AdminPanelAccessTest": 0.6402, + "Appmodules\\paneladmin\\tests\\Feature\\Contents\\ContentEntryResourceTest": 2.1585, + "Appmodules\\paneladmin\\tests\\Feature\\Discord\\DiscordClusterSmokeTest": 2.1382, + "Appmodules\\paneladmin\\tests\\Feature\\Github\\GithubRepositoryResourceTest": 0.9634, + "Appmodules\\paneladmin\\tests\\Feature\\Identity\\ExternalIdentityResourceTest": 0.8801, + "Appmodules\\paneladmin\\tests\\Feature\\Identity\\UserResourceTest": 2.3286, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\CommunityActivityStatsTest": 0.1469, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\LocationDashboardTest": 0.176, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\MeetingShowcasePageTest": 0.6732, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\MembersByStateTest": 0.2345, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\PeriodStatsTest": 0.1375, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\ShortLinkResourceTest": 2.853, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\AppealQueueTest": 1.3977, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationDashboardTest": 1.8212, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationQueueTest": 1.5606, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\TestRuleActionTest": 0.8005, + "Appmodules\\paneladmin\\tests\\Feature\\NavigationGroupsTest": 0.5594, + "Appmodules\\paneladmin\\tests\\Feature\\Profile\\ProfileResourceTest": 1.8129, + "Appmodules\\paneladmin\\tests\\Feature\\Profile\\SkillResourceTest": 1.9559, + "Appmodules\\paneladmin\\tests\\Feature\\Twitch\\ConnectTwitchActionTest": 0.4418, + "Appmodules\\paneladmin\\tests\\Feature\\Twitch\\RegisterSubscriptionsActionTest": 0.3179, + "Appmodules\\panelapp\\tests\\Feature\\Auth\\LastAuthProviderTest": 0.6764, + "Appmodules\\panelapp\\tests\\Feature\\ConnectionHubTest": 0.9239, + "Appmodules\\panelapp\\tests\\Feature\\Events\\ApplicationEnrollmentTest": 1.1921, + "Appmodules\\panelapp\\tests\\Feature\\Events\\NumericCodeCheckInTest": 0.3241, + "Appmodules\\panelapp\\tests\\Feature\\Events\\RsvpEnrollmentTest": 1.4225, + "Appmodules\\panelapp\\tests\\Feature\\ProfilePageTest": 3.7365, + "Appmodules\\panelapp\\tests\\Feature\\Timeline\\ThreadPageTest": 1.0204, + "Appmodules\\portal\\tests\\Feature\\ArticlesPageTest": 1.0679, + "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectivePageTest": 1.0345, + "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectiveTest": 1.7793, + "Appmodules\\portal\\tests\\Feature\\HeroAvatarsTest": 0.4612, + "Appmodules\\portal\\tests\\Feature\\HomepageTest": 0.753, + "Appmodules\\portal\\tests\\Feature\\RetrospectiveFiltersTest": 0.1801, + "Appmodules\\portal\\tests\\Feature\\SeoHeadTest": 1.1976, + "Appmodules\\portal\\tests\\Feature\\ShortLinkRedirectTest": 1.0845, + "Appmodules\\portal\\tests\\Feature\\SocialLinksPageTest": 0.726, + "Appmodules\\profile\\tests\\Feature\\ProfileCreationTest": 0.2961, + "Appmodules\\profile\\tests\\Feature\\ProfilePreferencesTest": 0.2713, + "Appmodules\\profile\\tests\\Feature\\ProfileWorkExperiencesTest": 0.2081, + "Appmodules\\profile\\tests\\Feature\\SkillSearchTest": 0.187, + "Appmodules\\profile\\tests\\Feature\\SyncProfileSkillsTest": 0.6848, + "Appmodules\\profile\\tests\\Feature\\ToggleAvailabilityTest": 0.4038, + "Appmodules\\profile\\tests\\Feature\\UpsertProfileTest": 0.8635, + "Appmodules\\profile\\tests\\Feature\\WorkExperienceFactoryTest": 0.4536, + "Appmodules\\profile\\tests\\Unit\\ProfileEnumTest": 0.2113, + "Appmodules\\profile\\tests\\Unit\\ProfileSocialLinksTest": 0.0494, + "Appmodules\\profile\\tests\\Unit\\SkillEnumTest": 0.0918, + "Appmodules\\profile\\tests\\Unit\\SocialPlatformTest": 0.6036, + "Appmodules\\profile\\tests\\Unit\\WorkExperienceTest": 0.1326, + "Appmodules\\profile\\tests\\Unit\\WorkPreferencesTest": 0.2459, + "Tests\\Feature\\AddressTest": 4.2192, + "Tests\\Feature\\ConnectionHubMergeModalTest": 3.8406, + "Tests\\Feature\\Geo\\GeoLocationTest": 0.4819, + "Tests\\Feature\\LocaleSwitcherTest": 0.4264, + "Tests\\Feature\\PanelRobotsTest": 4.0327, + "Tests\\Unit\\NoLooseArrayCastsTest": 0.512 }, - "checksum": "dfbff5c17639f8ac87de0262f2215488", - "updated_at": "2026-08-20T01:47:21+00:00" + "checksum": "b24d4a3090b8d13ee7187f36c9746167", + "updated_at": "2026-08-22T22:50:53+00:00" } From a3206e0445697ad7fe7fc4b95abff851393b9b72 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sun, 23 Aug 2026 10:07:32 -0300 Subject: [PATCH 10/10] fix(identity): UserSituationTest deixa de depender de banco MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O teste estava na suíte Unit mas criava usuários com factory. Só a suíte Feature recebe LazilyRefreshDatabase, então no CI, com banco limpo, a tabela nem existia. Localmente passava porque o schema já tinha sido migrado por execuções anteriores. `situation` deriva de duas colunas já carregadas e não consulta nada: os modelos agora são instanciados sem persistir. A suíte Unit inteira roda com o banco vazio. --- .../identity/tests/Unit/UserSituationTest.php | 37 +- tests/.pest/shards.json | 416 +++++++++--------- 2 files changed, 229 insertions(+), 224 deletions(-) diff --git a/app-modules/identity/tests/Unit/UserSituationTest.php b/app-modules/identity/tests/Unit/UserSituationTest.php index 77e45a28..e9083a30 100644 --- a/app-modules/identity/tests/Unit/UserSituationTest.php +++ b/app-modules/identity/tests/Unit/UserSituationTest.php @@ -2,38 +2,43 @@ declare(strict_types=1); +use Carbon\CarbonInterface; use He4rt\Identity\User\Enums\UserSituation; use He4rt\Identity\User\Models\User; -test('banned_at preenchido devolve Banned', function (): void { - $user = User::factory()->create(['banned_at' => now()->subDay()]); +/** + * `situation` é derivado de duas colunas já carregadas: não há consulta por + * trás. Os modelos aqui são instanciados sem persistir, porque a suíte Unit + * roda sem banco (só a Feature recebe LazilyRefreshDatabase). + */ +function userWith(?CarbonInterface $bannedAt = null, ?CarbonInterface $suspendedUntil = null): User +{ + $user = new User(); - expect($user->situation)->toBe(UserSituation::Banned); + $user->banned_at = $bannedAt; + $user->suspended_until = $suspendedUntil; + + return $user; +} + +test('banned_at preenchido devolve Banned', function (): void { + expect(userWith(bannedAt: now()->subDay())->situation)->toBe(UserSituation::Banned); }); test('suspensão vigente devolve Suspended', function (): void { - $user = User::factory()->create(['suspended_until' => now()->addWeek()]); - - expect($user->situation)->toBe(UserSituation::Suspended); + expect(userWith(suspendedUntil: now()->addWeek())->situation)->toBe(UserSituation::Suspended); }); test('suspensão vencida devolve Active', function (): void { - $user = User::factory()->create(['suspended_until' => now()->subDay()]); - - expect($user->situation)->toBe(UserSituation::Active); + expect(userWith(suspendedUntil: now()->subDay())->situation)->toBe(UserSituation::Active); }); test('sem punição devolve Active', function (): void { - $user = User::factory()->create(); - - expect($user->situation)->toBe(UserSituation::Active); + expect(userWith()->situation)->toBe(UserSituation::Active); }); test('banimento vence suspensão quando os dois estão preenchidos', function (): void { - $user = User::factory()->create([ - 'banned_at' => now()->subDay(), - 'suspended_until' => now()->addWeek(), - ]); + $user = userWith(bannedAt: now()->subDay(), suspendedUntil: now()->addWeek()); expect($user->situation)->toBe(UserSituation::Banned); }); diff --git a/tests/.pest/shards.json b/tests/.pest/shards.json index 8da1148b..51e915f3 100644 --- a/tests/.pest/shards.json +++ b/tests/.pest/shards.json @@ -1,213 +1,213 @@ { "timings": { - "Appmodules\\activity\\tests\\Feature\\TrackContentContributionTest": 3.9899, - "Appmodules\\activity\\tests\\Unit\\Actions\\NewMessageTest": 4.6501, - "Appmodules\\activity\\tests\\Unit\\Actions\\NewVoiceMessageTest": 4.5446, - "Appmodules\\activity\\tests\\Unit\\Actions\\RecordVoicePresenceTest": 5.0463, - "Appmodules\\activity\\tests\\Unit\\Queries\\GetCurrentVoiceChannelTest": 4.9506, - "Appmodules\\activity\\tests\\Unit\\Timeline\\CreatePostTest": 4.6624, - "Appmodules\\activity\\tests\\Unit\\Timeline\\CreateReplyTest": 4.5895, - "Appmodules\\activity\\tests\\Unit\\Timeline\\DeleteReplyTest": 4.6312, - "Appmodules\\activity\\tests\\Unit\\Timeline\\PublishModerationEntryTest": 4.7071, - "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineFeedQueryTest": 4.7347, - "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineModelTest": 4.6207, - "Appmodules\\activity\\tests\\Unit\\Timeline\\TogglePinPostTest": 4.6829, - "Appmodules\\activity\\tests\\Unit\\Tracking\\ApproveInteractionTest": 4.2124, - "Appmodules\\activity\\tests\\Unit\\Tracking\\CalculateRewardTest": 4.5073, - "Appmodules\\activity\\tests\\Unit\\Tracking\\ClassifyActivityTest": 0.4069, - "Appmodules\\activity\\tests\\Unit\\Tracking\\RejectInteractionTest": 4.2563, - "Appmodules\\activity\\tests\\Unit\\Tracking\\TrackActivityTest": 4.433, - "Appmodules\\botdiscord\\tests\\Feature\\Actions\\VoiceChannel\\HandleStateChannelActionTest": 0.2465, - "Appmodules\\botdiscord\\tests\\Feature\\Events\\RawGatewayEventTest": 0.3127, - "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\AutoExecuteActionTest": 0.3518, - "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\NotifyModerationChannelTest": 0.3875, - "Appmodules\\botdiscord\\tests\\Feature\\Moderation\\DiscordModerationAdapterTest": 2.5279, - "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\EditProfileCommandTest": 0.2788, - "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\IntroductionCommandTest": 0.184, - "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\ConfigureEmpresarialRoomActionTest": 0.6439, - "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\JoiningChannelActionTest": 0.2735, - "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\LeftChannelActionTest": 0.3785, - "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceTransitionResolverTest": 0.5148, - "Appmodules\\botdiscord\\tests\\Unit\\Enums\\EmpresarialRejectionReasonTest": 0.3492, - "Appmodules\\botdiscord\\tests\\Unit\\Events\\WelcomeMemberGuildIconTest": 0.4904, - "Appmodules\\botdiscord\\tests\\Unit\\Moderation\\ModerationEmbedBuilderTest": 4.7779, - "Appmodules\\botdiscord\\tests\\Unit\\SlashCommands\\EditProfileModalValidationTest": 0.7212, - "Appmodules\\contents\\tests\\Feature\\ReconcileOrphanEntriesTest": 0.509, - "Appmodules\\contents\\tests\\Feature\\SyncArticlesCommandTest": 1.1014, - "Appmodules\\contents\\tests\\Feature\\UpsertArticleTest": 0.607, - "Appmodules\\contents\\tests\\Unit\\ContentProviderTest": 0.4586, - "Appmodules\\contents\\tests\\Unit\\TagListTest": 0.446, - "Appmodules\\docs\\tests\\Feature\\Discovery\\CacheDocsCommandTest": 1.616, - "Appmodules\\docs\\tests\\Feature\\Discovery\\DocsRoutingTest": 1.2431, - "Appmodules\\docs\\tests\\Unit\\Discovery\\BuildTreeTest": 0.7286, - "Appmodules\\docs\\tests\\Unit\\Discovery\\DiscoveryIntegrationTest": 0.5317, - "Appmodules\\docs\\tests\\Unit\\Discovery\\DocumentTierTest": 0.7247, - "Appmodules\\docs\\tests\\Unit\\Discovery\\FoundationsTest": 1.3206, - "Appmodules\\docs\\tests\\Unit\\Discovery\\ParsingTest": 0.5709, - "Appmodules\\docs\\tests\\Unit\\Discovery\\StrategiesTest": 0.3731, - "Appmodules\\economy\\tests\\Feature\\EconomyFlowTest": 0.1792, - "Appmodules\\economy\\tests\\Unit\\CreditTest": 3.8231, - "Appmodules\\economy\\tests\\Unit\\DebitTest": 3.8601, - "Appmodules\\economy\\tests\\Unit\\TransferTest": 3.8935, - "Appmodules\\events\\tests\\Feature\\CheckInActionTest": 0.5777, - "Appmodules\\events\\tests\\Feature\\Closure\\CloseEventActionTest": 11.6253, - "Appmodules\\events\\tests\\Feature\\Closure\\ClosePendingEventsCommandTest": 0.3861, - "Appmodules\\events\\tests\\Feature\\Closure\\OverrideEnrollmentStatusActionTest": 0.7222, - "Appmodules\\events\\tests\\Feature\\Closure\\ProcessEventClosureJobTest": 0.3766, - "Appmodules\\events\\tests\\Feature\\EnrollUserActionTest": 1.9427, - "Appmodules\\events\\tests\\Feature\\Enrollment\\ApproveApplicationActionTest": 0.5179, - "Appmodules\\events\\tests\\Feature\\Enrollment\\EnrollmentPolicyTest": 0.4769, - "Appmodules\\events\\tests\\Feature\\Enrollment\\RejectApplicationActionTest": 0.2927, - "Appmodules\\events\\tests\\Feature\\EventFactoriesTest": 1.099, - "Appmodules\\events\\tests\\Feature\\EventResourceTest": 2.6177, - "Appmodules\\events\\tests\\Feature\\HandleBotCheckInTest": 1.19, - "Appmodules\\events\\tests\\Feature\\NumericCodeCheckInActionTest": 0.9225, - "Appmodules\\events\\tests\\Feature\\QrCheckInActionTest": 0.9344, - "Appmodules\\events\\tests\\Unit\\EnrollmentScopeTest": 3.7632, - "Appmodules\\events\\tests\\Unit\\EnrollmentStatusTest": 0.8157, - "Appmodules\\events\\tests\\Unit\\EventStatusTest": 0.4056, - "Appmodules\\gamification\\tests\\Unit\\Character\\GenerateTextExperienceTest": 0.7618, - "Appmodules\\gamification\\tests\\Unit\\Character\\LevelCalculationTest": 4.7333, - "Appmodules\\gamification\\tests\\Unit\\Character\\VoiceExperienceTest": 4.2109, - "Appmodules\\identity\\tests\\Feature\\Auth\\AttachProviderToUserTest": 0.1702, - "Appmodules\\identity\\tests\\Feature\\Auth\\DetectMergeConflictTest": 0.2667, - "Appmodules\\identity\\tests\\Feature\\Auth\\EnrichUserOnFirstLoginTest": 0.4057, - "Appmodules\\identity\\tests\\Feature\\Auth\\FindOrCreateUserByProviderTest": 0.5038, - "Appmodules\\identity\\tests\\Feature\\Auth\\HandleOAuthCallbackActionTest": 0.1899, - "Appmodules\\identity\\tests\\Feature\\Auth\\MergeAccountsActionTest": 0.8391, - "Appmodules\\identity\\tests\\Feature\\Auth\\OAuthControllerTest": 0.1437, - "Appmodules\\identity\\tests\\Feature\\ExternalIdentity\\ConnectApiKeyIdentityTest": 0.6957, - "Appmodules\\identity\\tests\\Unit\\ExternalIdentity\\FindExternalIdentityTest": 4.0465, - "Appmodules\\identity\\tests\\Unit\\ExternalIdentity\\IdentityProviderCredentialsTest": 0.5577, - "Appmodules\\identity\\tests\\Unit\\IdentityProviderColorTest": 1.3469, - "Appmodules\\identity\\tests\\Unit\\UserSituationTest": 0.4453, - "Appmodules\\integrationdevto\\tests\\Feature\\DevToApiKeyClientTest": 0.257, - "Appmodules\\integrationdevto\\tests\\Feature\\DevToArticleProviderTest": 0.3761, - "Appmodules\\integrationdevto\\tests\\Feature\\DevToOAuthTest": 0.1992, - "Appmodules\\integrationdevto\\tests\\Unit\\DevToArticleMapperTest": 0.6133, - "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordMessageTest": 4.9118, - "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordProfileTest": 1.4661, - "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\MergeDuplicateDiscordProfilesTest": 1.1865, - "Appmodules\\integrationdiscord\\tests\\Feature\\Models\\DiscordEventLogTest": 0.1489, - "Appmodules\\integrationdiscord\\tests\\Feature\\OAuth\\DiscordOAuthClientTest": 0.1923, - "Appmodules\\integrationdiscord\\tests\\Unit\\DiscordMessageAdapterTest": 2.1055, - "Appmodules\\integrationdiscord\\tests\\Unit\\Enums\\DiscordEventTypeTest": 0.1251, - "Appmodules\\integrationdiscord\\tests\\Unit\\Sync\\PurgeUnusedInvitesActionTest": 13.661, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordConnectorTest": 0.1473, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordRoleResolverTest": 0.2298, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateBanTest": 0.1363, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateDmChannelTest": 0.1432, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateMessageTest": 0.1366, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteInviteTest": 0.0864, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteMessageTest": 0.0919, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ExchangeCodeForTokenTest": 0.1279, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetCurrentUserTest": 0.1331, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetMemberTest": 0.0912, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ListGuildInvitesTest": 0.0934, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ModifyMemberTest": 0.1321, - "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\RemoveMemberTest": 0.0889, - "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubCommandTest": 0.3661, - "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubRepositoryJobTest": 0.1743, - "Appmodules\\integrationgithub\\tests\\Feature\\BackfillRepositoryTest": 1.4884, - "Appmodules\\integrationgithub\\tests\\Feature\\GitHubOAuthClientTest": 0.3107, - "Appmodules\\integrationgithub\\tests\\Feature\\GithubContributionTest": 0.2228, - "Appmodules\\integrationgithub\\tests\\Feature\\GithubRepositoryTest": 0.3094, - "Appmodules\\integrationgithub\\tests\\Feature\\GithubWebhookTest": 0.979, - "Appmodules\\integrationtwitch\\tests\\Feature\\LinkTwitchChannelCommandTest": 0.21, - "Appmodules\\integrationtwitch\\tests\\Feature\\RegisterTwitchSubscriptionsActionTest": 0.1593, - "Appmodules\\integrationtwitch\\tests\\Feature\\SubscribeTwitchEventsCommandTest": 0.6155, - "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchAppTokenServiceTest": 0.1521, - "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchWebhookTest": 0.6832, - "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\StoreWhatsAppEventActionTest": 0.5642, - "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\WebhookIngestTest": 0.9093, - "Appmodules\\marketing\\tests\\Feature\\CreateShortLinkTest": 0.6616, - "Appmodules\\marketing\\tests\\Feature\\RecordShortLinkClickTest": 1.2815, - "Appmodules\\marketing\\tests\\Feature\\ResolveShortLinkTest": 0.986, - "Appmodules\\marketing\\tests\\Feature\\UpdateShortLinkTest": 0.697, - "Appmodules\\marketing\\tests\\Unit\\DestinationUrlValidatorTest": 0.3595, - "Appmodules\\marketing\\tests\\Unit\\SlugGeneratorTest": 0.2728, - "Appmodules\\marketing\\tests\\Unit\\TagListTest": 0.6863, - "Appmodules\\marketing\\tests\\Unit\\UtmParametersTest": 0.8683, - "Appmodules\\moderation\\tests\\Feature\\AppealWorkflowTest": 0.4536, - "Appmodules\\moderation\\tests\\Feature\\Appeals\\FileAppealTest": 0.7053, - "Appmodules\\moderation\\tests\\Feature\\Appeals\\ReviewAppealTest": 0.4818, - "Appmodules\\moderation\\tests\\Feature\\Audit\\AuditLogTest": 0.3569, - "Appmodules\\moderation\\tests\\Feature\\CaseWorkflowTest": 0.4506, - "Appmodules\\moderation\\tests\\Feature\\Cases\\SubmitReportTest": 1.1986, - "Appmodules\\moderation\\tests\\Feature\\Classification\\RouteDecisionTest": 2.6148, - "Appmodules\\moderation\\tests\\Feature\\Classification\\RuleBasedClassifierTest": 0.795, - "Appmodules\\moderation\\tests\\Feature\\Enforcement\\ExecuteActionTest": 0.6478, - "Appmodules\\moderation\\tests\\Feature\\ModelRelationshipTest": 0.8299, - "Appmodules\\moderation\\tests\\Feature\\PipelineTest": 0.9098, - "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ClassifyAndRouteTest": 0.2871, - "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ScreenContentTest": 0.3112, - "Appmodules\\moderation\\tests\\Feature\\Pipeline\\SubmitForModerationTest": 0.301, - "Appmodules\\moderation\\tests\\Feature\\Platform\\WebAdapterTest": 1.3061, - "Appmodules\\moderation\\tests\\Unit\\ClassifierTest": 4.0565, - "Appmodules\\moderation\\tests\\Unit\\DTOTest": 0.357, - "Appmodules\\moderation\\tests\\Unit\\EnumTest": 0.2903, - "Appmodules\\moderation\\tests\\Unit\\ModerationRuleMatchTest": 3.9522, - "Appmodules\\moderation\\tests\\Unit\\PenaltyAdvisorTest": 4.7763, - "Appmodules\\moderation\\tests\\Unit\\PlatformRegistryTest": 0.1785, - "Appmodules\\moderation\\tests\\Unit\\RouteCaseActionTest": 4.1613, - "Appmodules\\paneladmin\\tests\\Feature\\AdminPanelAccessTest": 0.6402, - "Appmodules\\paneladmin\\tests\\Feature\\Contents\\ContentEntryResourceTest": 2.1585, - "Appmodules\\paneladmin\\tests\\Feature\\Discord\\DiscordClusterSmokeTest": 2.1382, - "Appmodules\\paneladmin\\tests\\Feature\\Github\\GithubRepositoryResourceTest": 0.9634, - "Appmodules\\paneladmin\\tests\\Feature\\Identity\\ExternalIdentityResourceTest": 0.8801, - "Appmodules\\paneladmin\\tests\\Feature\\Identity\\UserResourceTest": 2.3286, - "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\CommunityActivityStatsTest": 0.1469, - "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\LocationDashboardTest": 0.176, - "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\MeetingShowcasePageTest": 0.6732, - "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\MembersByStateTest": 0.2345, - "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\PeriodStatsTest": 0.1375, - "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\ShortLinkResourceTest": 2.853, - "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\AppealQueueTest": 1.3977, - "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationDashboardTest": 1.8212, - "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationQueueTest": 1.5606, - "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\TestRuleActionTest": 0.8005, - "Appmodules\\paneladmin\\tests\\Feature\\NavigationGroupsTest": 0.5594, - "Appmodules\\paneladmin\\tests\\Feature\\Profile\\ProfileResourceTest": 1.8129, - "Appmodules\\paneladmin\\tests\\Feature\\Profile\\SkillResourceTest": 1.9559, - "Appmodules\\paneladmin\\tests\\Feature\\Twitch\\ConnectTwitchActionTest": 0.4418, - "Appmodules\\paneladmin\\tests\\Feature\\Twitch\\RegisterSubscriptionsActionTest": 0.3179, - "Appmodules\\panelapp\\tests\\Feature\\Auth\\LastAuthProviderTest": 0.6764, - "Appmodules\\panelapp\\tests\\Feature\\ConnectionHubTest": 0.9239, - "Appmodules\\panelapp\\tests\\Feature\\Events\\ApplicationEnrollmentTest": 1.1921, - "Appmodules\\panelapp\\tests\\Feature\\Events\\NumericCodeCheckInTest": 0.3241, - "Appmodules\\panelapp\\tests\\Feature\\Events\\RsvpEnrollmentTest": 1.4225, - "Appmodules\\panelapp\\tests\\Feature\\ProfilePageTest": 3.7365, - "Appmodules\\panelapp\\tests\\Feature\\Timeline\\ThreadPageTest": 1.0204, - "Appmodules\\portal\\tests\\Feature\\ArticlesPageTest": 1.0679, - "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectivePageTest": 1.0345, - "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectiveTest": 1.7793, - "Appmodules\\portal\\tests\\Feature\\HeroAvatarsTest": 0.4612, - "Appmodules\\portal\\tests\\Feature\\HomepageTest": 0.753, - "Appmodules\\portal\\tests\\Feature\\RetrospectiveFiltersTest": 0.1801, - "Appmodules\\portal\\tests\\Feature\\SeoHeadTest": 1.1976, - "Appmodules\\portal\\tests\\Feature\\ShortLinkRedirectTest": 1.0845, - "Appmodules\\portal\\tests\\Feature\\SocialLinksPageTest": 0.726, - "Appmodules\\profile\\tests\\Feature\\ProfileCreationTest": 0.2961, - "Appmodules\\profile\\tests\\Feature\\ProfilePreferencesTest": 0.2713, - "Appmodules\\profile\\tests\\Feature\\ProfileWorkExperiencesTest": 0.2081, - "Appmodules\\profile\\tests\\Feature\\SkillSearchTest": 0.187, - "Appmodules\\profile\\tests\\Feature\\SyncProfileSkillsTest": 0.6848, - "Appmodules\\profile\\tests\\Feature\\ToggleAvailabilityTest": 0.4038, - "Appmodules\\profile\\tests\\Feature\\UpsertProfileTest": 0.8635, - "Appmodules\\profile\\tests\\Feature\\WorkExperienceFactoryTest": 0.4536, - "Appmodules\\profile\\tests\\Unit\\ProfileEnumTest": 0.2113, - "Appmodules\\profile\\tests\\Unit\\ProfileSocialLinksTest": 0.0494, - "Appmodules\\profile\\tests\\Unit\\SkillEnumTest": 0.0918, - "Appmodules\\profile\\tests\\Unit\\SocialPlatformTest": 0.6036, - "Appmodules\\profile\\tests\\Unit\\WorkExperienceTest": 0.1326, - "Appmodules\\profile\\tests\\Unit\\WorkPreferencesTest": 0.2459, - "Tests\\Feature\\AddressTest": 4.2192, - "Tests\\Feature\\ConnectionHubMergeModalTest": 3.8406, - "Tests\\Feature\\Geo\\GeoLocationTest": 0.4819, - "Tests\\Feature\\LocaleSwitcherTest": 0.4264, - "Tests\\Feature\\PanelRobotsTest": 4.0327, - "Tests\\Unit\\NoLooseArrayCastsTest": 0.512 + "Appmodules\\activity\\tests\\Feature\\TrackContentContributionTest": 3.9135, + "Appmodules\\activity\\tests\\Unit\\Actions\\NewMessageTest": 4.38, + "Appmodules\\activity\\tests\\Unit\\Actions\\NewVoiceMessageTest": 4.4855, + "Appmodules\\activity\\tests\\Unit\\Actions\\RecordVoicePresenceTest": 4.8308, + "Appmodules\\activity\\tests\\Unit\\Queries\\GetCurrentVoiceChannelTest": 4.736, + "Appmodules\\activity\\tests\\Unit\\Timeline\\CreatePostTest": 4.4272, + "Appmodules\\activity\\tests\\Unit\\Timeline\\CreateReplyTest": 4.3458, + "Appmodules\\activity\\tests\\Unit\\Timeline\\DeleteReplyTest": 4.3521, + "Appmodules\\activity\\tests\\Unit\\Timeline\\PublishModerationEntryTest": 4.4245, + "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineFeedQueryTest": 4.3919, + "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineModelTest": 4.3346, + "Appmodules\\activity\\tests\\Unit\\Timeline\\TogglePinPostTest": 4.5422, + "Appmodules\\activity\\tests\\Unit\\Tracking\\ApproveInteractionTest": 4.1986, + "Appmodules\\activity\\tests\\Unit\\Tracking\\CalculateRewardTest": 4.3271, + "Appmodules\\activity\\tests\\Unit\\Tracking\\ClassifyActivityTest": 0.3663, + "Appmodules\\activity\\tests\\Unit\\Tracking\\RejectInteractionTest": 4.2579, + "Appmodules\\activity\\tests\\Unit\\Tracking\\TrackActivityTest": 4.3533, + "Appmodules\\botdiscord\\tests\\Feature\\Actions\\VoiceChannel\\HandleStateChannelActionTest": 0.2344, + "Appmodules\\botdiscord\\tests\\Feature\\Events\\RawGatewayEventTest": 0.2712, + "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\AutoExecuteActionTest": 0.303, + "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\NotifyModerationChannelTest": 0.3197, + "Appmodules\\botdiscord\\tests\\Feature\\Moderation\\DiscordModerationAdapterTest": 2.443, + "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\EditProfileCommandTest": 0.2497, + "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\IntroductionCommandTest": 0.1672, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\ConfigureEmpresarialRoomActionTest": 0.6394, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\JoiningChannelActionTest": 0.3005, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\LeftChannelActionTest": 0.3339, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceTransitionResolverTest": 0.5217, + "Appmodules\\botdiscord\\tests\\Unit\\Enums\\EmpresarialRejectionReasonTest": 0.3346, + "Appmodules\\botdiscord\\tests\\Unit\\Events\\WelcomeMemberGuildIconTest": 0.4212, + "Appmodules\\botdiscord\\tests\\Unit\\Moderation\\ModerationEmbedBuilderTest": 4.5269, + "Appmodules\\botdiscord\\tests\\Unit\\SlashCommands\\EditProfileModalValidationTest": 0.6952, + "Appmodules\\contents\\tests\\Feature\\ReconcileOrphanEntriesTest": 0.4424, + "Appmodules\\contents\\tests\\Feature\\SyncArticlesCommandTest": 1.0514, + "Appmodules\\contents\\tests\\Feature\\UpsertArticleTest": 0.6044, + "Appmodules\\contents\\tests\\Unit\\ContentProviderTest": 0.4537, + "Appmodules\\contents\\tests\\Unit\\TagListTest": 0.437, + "Appmodules\\docs\\tests\\Feature\\Discovery\\CacheDocsCommandTest": 1.5924, + "Appmodules\\docs\\tests\\Feature\\Discovery\\DocsRoutingTest": 1.2186, + "Appmodules\\docs\\tests\\Unit\\Discovery\\BuildTreeTest": 0.6923, + "Appmodules\\docs\\tests\\Unit\\Discovery\\DiscoveryIntegrationTest": 0.4176, + "Appmodules\\docs\\tests\\Unit\\Discovery\\DocumentTierTest": 0.7024, + "Appmodules\\docs\\tests\\Unit\\Discovery\\FoundationsTest": 1.1673, + "Appmodules\\docs\\tests\\Unit\\Discovery\\ParsingTest": 0.5125, + "Appmodules\\docs\\tests\\Unit\\Discovery\\StrategiesTest": 0.3575, + "Appmodules\\economy\\tests\\Feature\\EconomyFlowTest": 0.1746, + "Appmodules\\economy\\tests\\Unit\\CreditTest": 3.7372, + "Appmodules\\economy\\tests\\Unit\\DebitTest": 3.8132, + "Appmodules\\economy\\tests\\Unit\\TransferTest": 3.813, + "Appmodules\\events\\tests\\Feature\\CheckInActionTest": 0.5787, + "Appmodules\\events\\tests\\Feature\\Closure\\CloseEventActionTest": 11.5977, + "Appmodules\\events\\tests\\Feature\\Closure\\ClosePendingEventsCommandTest": 0.4021, + "Appmodules\\events\\tests\\Feature\\Closure\\OverrideEnrollmentStatusActionTest": 0.7461, + "Appmodules\\events\\tests\\Feature\\Closure\\ProcessEventClosureJobTest": 0.3695, + "Appmodules\\events\\tests\\Feature\\EnrollUserActionTest": 1.9489, + "Appmodules\\events\\tests\\Feature\\Enrollment\\ApproveApplicationActionTest": 0.5309, + "Appmodules\\events\\tests\\Feature\\Enrollment\\EnrollmentPolicyTest": 0.4695, + "Appmodules\\events\\tests\\Feature\\Enrollment\\RejectApplicationActionTest": 0.2864, + "Appmodules\\events\\tests\\Feature\\EventFactoriesTest": 1.1108, + "Appmodules\\events\\tests\\Feature\\EventResourceTest": 2.5298, + "Appmodules\\events\\tests\\Feature\\HandleBotCheckInTest": 1.1903, + "Appmodules\\events\\tests\\Feature\\NumericCodeCheckInActionTest": 0.94, + "Appmodules\\events\\tests\\Feature\\QrCheckInActionTest": 0.9274, + "Appmodules\\events\\tests\\Unit\\EnrollmentScopeTest": 3.6764, + "Appmodules\\events\\tests\\Unit\\EnrollmentStatusTest": 0.7635, + "Appmodules\\events\\tests\\Unit\\EventStatusTest": 0.3683, + "Appmodules\\gamification\\tests\\Unit\\Character\\GenerateTextExperienceTest": 0.6906, + "Appmodules\\gamification\\tests\\Unit\\Character\\LevelCalculationTest": 4.5578, + "Appmodules\\gamification\\tests\\Unit\\Character\\VoiceExperienceTest": 4.0843, + "Appmodules\\identity\\tests\\Feature\\Auth\\AttachProviderToUserTest": 0.1643, + "Appmodules\\identity\\tests\\Feature\\Auth\\DetectMergeConflictTest": 0.2406, + "Appmodules\\identity\\tests\\Feature\\Auth\\EnrichUserOnFirstLoginTest": 0.394, + "Appmodules\\identity\\tests\\Feature\\Auth\\FindOrCreateUserByProviderTest": 0.4923, + "Appmodules\\identity\\tests\\Feature\\Auth\\HandleOAuthCallbackActionTest": 0.1845, + "Appmodules\\identity\\tests\\Feature\\Auth\\MergeAccountsActionTest": 0.7857, + "Appmodules\\identity\\tests\\Feature\\Auth\\OAuthControllerTest": 0.1483, + "Appmodules\\identity\\tests\\Feature\\ExternalIdentity\\ConnectApiKeyIdentityTest": 0.6793, + "Appmodules\\identity\\tests\\Unit\\ExternalIdentity\\FindExternalIdentityTest": 3.7713, + "Appmodules\\identity\\tests\\Unit\\ExternalIdentity\\IdentityProviderCredentialsTest": 0.4678, + "Appmodules\\identity\\tests\\Unit\\IdentityProviderColorTest": 1.2446, + "Appmodules\\identity\\tests\\Unit\\UserSituationTest": 0.3138, + "Appmodules\\integrationdevto\\tests\\Feature\\DevToApiKeyClientTest": 0.2464, + "Appmodules\\integrationdevto\\tests\\Feature\\DevToArticleProviderTest": 0.3621, + "Appmodules\\integrationdevto\\tests\\Feature\\DevToOAuthTest": 0.1884, + "Appmodules\\integrationdevto\\tests\\Unit\\DevToArticleMapperTest": 0.5682, + "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordMessageTest": 4.8357, + "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordProfileTest": 1.4767, + "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\MergeDuplicateDiscordProfilesTest": 1.2473, + "Appmodules\\integrationdiscord\\tests\\Feature\\Models\\DiscordEventLogTest": 0.1389, + "Appmodules\\integrationdiscord\\tests\\Feature\\OAuth\\DiscordOAuthClientTest": 0.1856, + "Appmodules\\integrationdiscord\\tests\\Unit\\DiscordMessageAdapterTest": 1.9971, + "Appmodules\\integrationdiscord\\tests\\Unit\\Enums\\DiscordEventTypeTest": 0.1206, + "Appmodules\\integrationdiscord\\tests\\Unit\\Sync\\PurgeUnusedInvitesActionTest": 15.0135, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordConnectorTest": 0.1374, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordRoleResolverTest": 0.2196, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateBanTest": 0.1262, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateDmChannelTest": 0.1287, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateMessageTest": 0.1173, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteInviteTest": 0.0833, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteMessageTest": 0.0813, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ExchangeCodeForTokenTest": 0.1475, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetCurrentUserTest": 0.1288, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetMemberTest": 0.0875, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ListGuildInvitesTest": 0.09, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ModifyMemberTest": 0.1327, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\RemoveMemberTest": 0.0898, + "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubCommandTest": 0.3456, + "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubRepositoryJobTest": 0.1469, + "Appmodules\\integrationgithub\\tests\\Feature\\BackfillRepositoryTest": 1.5081, + "Appmodules\\integrationgithub\\tests\\Feature\\GitHubOAuthClientTest": 0.3122, + "Appmodules\\integrationgithub\\tests\\Feature\\GithubContributionTest": 0.2245, + "Appmodules\\integrationgithub\\tests\\Feature\\GithubRepositoryTest": 0.2892, + "Appmodules\\integrationgithub\\tests\\Feature\\GithubWebhookTest": 1.0084, + "Appmodules\\integrationtwitch\\tests\\Feature\\LinkTwitchChannelCommandTest": 0.2023, + "Appmodules\\integrationtwitch\\tests\\Feature\\RegisterTwitchSubscriptionsActionTest": 0.1551, + "Appmodules\\integrationtwitch\\tests\\Feature\\SubscribeTwitchEventsCommandTest": 0.6497, + "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchAppTokenServiceTest": 0.1303, + "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchWebhookTest": 0.7509, + "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\StoreWhatsAppEventActionTest": 0.6072, + "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\WebhookIngestTest": 0.9361, + "Appmodules\\marketing\\tests\\Feature\\CreateShortLinkTest": 0.7279, + "Appmodules\\marketing\\tests\\Feature\\RecordShortLinkClickTest": 1.2057, + "Appmodules\\marketing\\tests\\Feature\\ResolveShortLinkTest": 0.9642, + "Appmodules\\marketing\\tests\\Feature\\UpdateShortLinkTest": 0.7461, + "Appmodules\\marketing\\tests\\Unit\\DestinationUrlValidatorTest": 0.3572, + "Appmodules\\marketing\\tests\\Unit\\SlugGeneratorTest": 0.2729, + "Appmodules\\marketing\\tests\\Unit\\TagListTest": 0.6915, + "Appmodules\\marketing\\tests\\Unit\\UtmParametersTest": 0.8433, + "Appmodules\\moderation\\tests\\Feature\\AppealWorkflowTest": 0.5228, + "Appmodules\\moderation\\tests\\Feature\\Appeals\\FileAppealTest": 0.6983, + "Appmodules\\moderation\\tests\\Feature\\Appeals\\ReviewAppealTest": 0.5568, + "Appmodules\\moderation\\tests\\Feature\\Audit\\AuditLogTest": 0.4525, + "Appmodules\\moderation\\tests\\Feature\\CaseWorkflowTest": 0.4552, + "Appmodules\\moderation\\tests\\Feature\\Cases\\SubmitReportTest": 1.1331, + "Appmodules\\moderation\\tests\\Feature\\Classification\\RouteDecisionTest": 2.6067, + "Appmodules\\moderation\\tests\\Feature\\Classification\\RuleBasedClassifierTest": 0.7564, + "Appmodules\\moderation\\tests\\Feature\\Enforcement\\ExecuteActionTest": 0.6204, + "Appmodules\\moderation\\tests\\Feature\\ModelRelationshipTest": 0.8316, + "Appmodules\\moderation\\tests\\Feature\\PipelineTest": 0.8245, + "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ClassifyAndRouteTest": 0.2672, + "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ScreenContentTest": 0.2493, + "Appmodules\\moderation\\tests\\Feature\\Pipeline\\SubmitForModerationTest": 0.2655, + "Appmodules\\moderation\\tests\\Feature\\Platform\\WebAdapterTest": 1.2762, + "Appmodules\\moderation\\tests\\Unit\\ClassifierTest": 4.1522, + "Appmodules\\moderation\\tests\\Unit\\DTOTest": 0.3559, + "Appmodules\\moderation\\tests\\Unit\\EnumTest": 0.2917, + "Appmodules\\moderation\\tests\\Unit\\ModerationRuleMatchTest": 4.0078, + "Appmodules\\moderation\\tests\\Unit\\PenaltyAdvisorTest": 4.7807, + "Appmodules\\moderation\\tests\\Unit\\PlatformRegistryTest": 0.1661, + "Appmodules\\moderation\\tests\\Unit\\RouteCaseActionTest": 4.2621, + "Appmodules\\paneladmin\\tests\\Feature\\AdminPanelAccessTest": 0.5618, + "Appmodules\\paneladmin\\tests\\Feature\\Contents\\ContentEntryResourceTest": 2.1499, + "Appmodules\\paneladmin\\tests\\Feature\\Discord\\DiscordClusterSmokeTest": 1.9952, + "Appmodules\\paneladmin\\tests\\Feature\\Github\\GithubRepositoryResourceTest": 0.8759, + "Appmodules\\paneladmin\\tests\\Feature\\Identity\\ExternalIdentityResourceTest": 0.8951, + "Appmodules\\paneladmin\\tests\\Feature\\Identity\\UserResourceTest": 2.2065, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\CommunityActivityStatsTest": 0.1507, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\LocationDashboardTest": 0.1603, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\MeetingShowcasePageTest": 0.6307, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\MembersByStateTest": 0.213, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\PeriodStatsTest": 0.1183, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\ShortLinkResourceTest": 2.7627, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\AppealQueueTest": 1.3056, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationDashboardTest": 1.6574, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationQueueTest": 1.5078, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\TestRuleActionTest": 0.8209, + "Appmodules\\paneladmin\\tests\\Feature\\NavigationGroupsTest": 0.4428, + "Appmodules\\paneladmin\\tests\\Feature\\Profile\\ProfileResourceTest": 1.7745, + "Appmodules\\paneladmin\\tests\\Feature\\Profile\\SkillResourceTest": 1.9038, + "Appmodules\\paneladmin\\tests\\Feature\\Twitch\\ConnectTwitchActionTest": 0.4005, + "Appmodules\\paneladmin\\tests\\Feature\\Twitch\\RegisterSubscriptionsActionTest": 0.2998, + "Appmodules\\panelapp\\tests\\Feature\\Auth\\LastAuthProviderTest": 0.5351, + "Appmodules\\panelapp\\tests\\Feature\\ConnectionHubTest": 0.9157, + "Appmodules\\panelapp\\tests\\Feature\\Events\\ApplicationEnrollmentTest": 1.1784, + "Appmodules\\panelapp\\tests\\Feature\\Events\\NumericCodeCheckInTest": 0.3146, + "Appmodules\\panelapp\\tests\\Feature\\Events\\RsvpEnrollmentTest": 1.4829, + "Appmodules\\panelapp\\tests\\Feature\\ProfilePageTest": 3.5812, + "Appmodules\\panelapp\\tests\\Feature\\Timeline\\ThreadPageTest": 0.9099, + "Appmodules\\portal\\tests\\Feature\\ArticlesPageTest": 1.0488, + "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectivePageTest": 0.9734, + "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectiveTest": 1.779, + "Appmodules\\portal\\tests\\Feature\\HeroAvatarsTest": 0.4479, + "Appmodules\\portal\\tests\\Feature\\HomepageTest": 0.7226, + "Appmodules\\portal\\tests\\Feature\\RetrospectiveFiltersTest": 0.1334, + "Appmodules\\portal\\tests\\Feature\\SeoHeadTest": 1.2084, + "Appmodules\\portal\\tests\\Feature\\ShortLinkRedirectTest": 1.1017, + "Appmodules\\portal\\tests\\Feature\\SocialLinksPageTest": 0.6632, + "Appmodules\\profile\\tests\\Feature\\ProfileCreationTest": 0.3059, + "Appmodules\\profile\\tests\\Feature\\ProfilePreferencesTest": 0.2919, + "Appmodules\\profile\\tests\\Feature\\ProfileWorkExperiencesTest": 0.2151, + "Appmodules\\profile\\tests\\Feature\\SkillSearchTest": 0.1724, + "Appmodules\\profile\\tests\\Feature\\SyncProfileSkillsTest": 0.6624, + "Appmodules\\profile\\tests\\Feature\\ToggleAvailabilityTest": 0.39, + "Appmodules\\profile\\tests\\Feature\\UpsertProfileTest": 0.8997, + "Appmodules\\profile\\tests\\Feature\\WorkExperienceFactoryTest": 0.4803, + "Appmodules\\profile\\tests\\Unit\\ProfileEnumTest": 0.2115, + "Appmodules\\profile\\tests\\Unit\\ProfileSocialLinksTest": 0.0427, + "Appmodules\\profile\\tests\\Unit\\SkillEnumTest": 0.0893, + "Appmodules\\profile\\tests\\Unit\\SocialPlatformTest": 0.5564, + "Appmodules\\profile\\tests\\Unit\\WorkExperienceTest": 0.1208, + "Appmodules\\profile\\tests\\Unit\\WorkPreferencesTest": 0.232, + "Tests\\Feature\\AddressTest": 4.1891, + "Tests\\Feature\\ConnectionHubMergeModalTest": 3.9707, + "Tests\\Feature\\Geo\\GeoLocationTest": 0.474, + "Tests\\Feature\\LocaleSwitcherTest": 0.4097, + "Tests\\Feature\\PanelRobotsTest": 3.9642, + "Tests\\Unit\\NoLooseArrayCastsTest": 0.5083 }, "checksum": "b24d4a3090b8d13ee7187f36c9746167", - "updated_at": "2026-08-22T22:50:53+00:00" + "updated_at": "2026-08-23T13:06:51+00:00" }