From 0f6baed7327f4b4d28fe7241ba3c850fbbff323e Mon Sep 17 00:00:00 2001 From: YuriSouzaDev Date: Sun, 26 Jul 2026 17:30:49 -0300 Subject: [PATCH 1/6] feat(panel-admin): UserResource com List/View/Edit e Role hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona uma tela única no /admin para staff visualizar e editar um membro por inteiro, agregando dados hoje espalhados entre Character, ExternalIdentity, Profile, Address e ModerationCase — mantendo a fronteira presentation/core (seções agregadas são lidas via relacionamento, sem duplicar lógica de escrita de outros domínios). Identity: - Enum Role (Staff, Compliance, Recruiter, SquadCaptain, Member) com hierarquia isStaff()/isCompliance()/canViewUsers(). - SoftDeletes no User + migration adicionando `role` e `deleted_at`; unique index de `username` vira parcial (WHERE deleted_at IS NULL) para não travar reuso de username por conta soft-deletada. - UserPolicy: viewAny/view liberam staff/compliance/recruiter/squad captain; update/delete restritos a staff; restore/forceDelete restritos a compliance (hard delete nunca é o padrão). - Relações profile()/workExperiences()/profileSkills() no User. Panel-admin (UserResource, sem Create — contas só nascem via OAuth): - List: colunas de senioridade/disponibilidade/cidade/nível/status computado (ativo/suspenso/banido/removido), paginação [25,50,100], filtros de role/senioridade/disponibilidade/trashed. - Edit: identidade (username/name/email/role/is_donator), perfil profissional via Section::relationship('profile') com hooks pra achatar/reagrupar o cast custom de preferences, e endereço via Section::relationship('address'). - View: mesmos dados em modo leitura, mais Gamificação/Atividade/ Moderação agregadas por relacionamento; seção de Moderação oculta para quem não é staff. - RelationManagers de Skills e Experiências (create/edit/delete staff-only; somente leitura para recruiter/squad captain). Testes: UserPolicyTest cobrindo a hierarquia de roles; UserResource- Test cobrindo autorização por página/seção, edição multi-seção com persistência de preferences/social_links/endereço, relation managers, soft delete padrão e hard delete restrito a compliance. --- .../database/factories/UserFactory.php | 22 + ...d_role_and_soft_deletes_to_users_table.php | 37 ++ app-modules/identity/lang/en/enums.php | 13 + app-modules/identity/lang/pt_BR/enums.php | 13 + .../identity/src/IdentityServiceProvider.php | 1 + app-modules/identity/src/User/Enums/Role.php | 73 ++++ app-modules/identity/src/User/Models/User.php | 53 +++ .../identity/src/User/Policies/UserPolicy.php | 40 ++ .../tests/Unit/User/UserPolicyTest.php | 62 +++ .../Resources/Users/Pages/EditUser.php | 29 ++ .../Resources/Users/Pages/ListUsers.php | 13 + .../Resources/Users/Pages/ViewUser.php | 24 ++ .../ProfileSkillsRelationManager.php | 103 +++++ .../WorkExperiencesRelationManager.php | 122 ++++++ .../Resources/Users/Schemas/UserForm.php | 201 +++++++++ .../Resources/Users/Schemas/UserInfolist.php | 395 ++++++++++++++++++ .../Resources/Users/Tables/UsersTable.php | 135 ++++++ .../Filament/Resources/Users/UserResource.php | 81 ++++ .../src/PanelAdminServiceProvider.php | 3 + .../tests/Feature/Users/UserResourceTest.php | 341 +++++++++++++++ app/Providers/AuthServiceProvider.php | 4 +- 21 files changed, 1764 insertions(+), 1 deletion(-) create mode 100644 app-modules/identity/database/migrations/2026_07_26_120000_add_role_and_soft_deletes_to_users_table.php create mode 100644 app-modules/identity/lang/en/enums.php create mode 100644 app-modules/identity/lang/pt_BR/enums.php create mode 100644 app-modules/identity/src/User/Enums/Role.php create mode 100644 app-modules/identity/src/User/Policies/UserPolicy.php create mode 100644 app-modules/identity/tests/Unit/User/UserPolicyTest.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/ProfileSkillsRelationManager.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/WorkExperiencesRelationManager.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/Users/UserResourceTest.php diff --git a/app-modules/identity/database/factories/UserFactory.php b/app-modules/identity/database/factories/UserFactory.php index 41d491ae2..3452ab007 100644 --- a/app-modules/identity/database/factories/UserFactory.php +++ b/app-modules/identity/database/factories/UserFactory.php @@ -4,6 +4,7 @@ namespace He4rt\Identity\Database\Factories; +use He4rt\Identity\User\Enums\Role; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; @@ -24,6 +25,27 @@ public function definition(): array 'email' => fake()->email(), 'password' => Hash::make('password'), 'is_donator' => false, + 'role' => Role::Member, ]; } + + public function staff(): static + { + return $this->state(['role' => Role::Staff]); + } + + public function compliance(): static + { + return $this->state(['role' => Role::Compliance]); + } + + public function recruiter(): static + { + return $this->state(['role' => Role::Recruiter]); + } + + public function squadCaptain(): static + { + return $this->state(['role' => Role::SquadCaptain]); + } } diff --git a/app-modules/identity/database/migrations/2026_07_26_120000_add_role_and_soft_deletes_to_users_table.php b/app-modules/identity/database/migrations/2026_07_26_120000_add_role_and_soft_deletes_to_users_table.php new file mode 100644 index 000000000..0e0a212b1 --- /dev/null +++ b/app-modules/identity/database/migrations/2026_07_26_120000_add_role_and_soft_deletes_to_users_table.php @@ -0,0 +1,37 @@ +string('role')->default(Role::Member->value)->after('is_donator'); + $table->softDeletesTz(); + }); + + DB::statement('ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_unique'); + + // Scoped to active rows only: soft-deleted users must not block a + // username from being reused (e.g. account merges reassign it). + DB::statement('CREATE UNIQUE INDEX users_username_unique ON users (username) WHERE deleted_at IS NULL'); + } + + public function down(): void + { + DB::statement('ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_unique'); + DB::statement('DROP INDEX IF EXISTS users_username_unique'); + + Schema::table('users', static function (Blueprint $table): void { + $table->unique('username'); + $table->dropColumn(['role', 'deleted_at']); + }); + } +}; diff --git a/app-modules/identity/lang/en/enums.php b/app-modules/identity/lang/en/enums.php new file mode 100644 index 000000000..da4984e8b --- /dev/null +++ b/app-modules/identity/lang/en/enums.php @@ -0,0 +1,13 @@ + [ + 'staff' => 'Staff', + 'compliance' => 'Compliance', + 'recruiter' => 'Recruiter', + 'squad_captain' => 'Squad Captain', + 'member' => 'Member', + ], +]; diff --git a/app-modules/identity/lang/pt_BR/enums.php b/app-modules/identity/lang/pt_BR/enums.php new file mode 100644 index 000000000..662d5a394 --- /dev/null +++ b/app-modules/identity/lang/pt_BR/enums.php @@ -0,0 +1,13 @@ + [ + 'staff' => 'Staff', + 'compliance' => 'Compliance', + 'recruiter' => 'Recrutador', + 'squad_captain' => 'Capitão de Squad', + 'member' => 'Membro', + ], +]; diff --git a/app-modules/identity/src/IdentityServiceProvider.php b/app-modules/identity/src/IdentityServiceProvider.php index af40eb289..3f36334a3 100644 --- a/app-modules/identity/src/IdentityServiceProvider.php +++ b/app-modules/identity/src/IdentityServiceProvider.php @@ -13,6 +13,7 @@ class IdentityServiceProvider extends ServiceProvider public function boot(): void { $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + $this->loadTranslationsFrom(__DIR__.'/../lang', 'identity'); Relation::morphMap([ 'user' => User::class, diff --git a/app-modules/identity/src/User/Enums/Role.php b/app-modules/identity/src/User/Enums/Role.php new file mode 100644 index 000000000..146e98e9b --- /dev/null +++ b/app-modules/identity/src/User/Enums/Role.php @@ -0,0 +1,73 @@ +value); + } + + public function getColor(): array + { + return match ($this) { + self::Staff => Color::Amber, + self::Compliance => Color::Red, + self::Recruiter => Color::Blue, + self::SquadCaptain => Color::Purple, + self::Member => Color::Gray, + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + self::Staff => Heroicon::ShieldCheck, + self::Compliance => Heroicon::Scale, + self::Recruiter => Heroicon::Briefcase, + self::SquadCaptain => Heroicon::Flag, + self::Member => Heroicon::User, + }; + } + + /** + * Compliance é hierarquicamente um superconjunto de Staff: quem pode + * fazer hard delete também pode editar/soft-deletar. + */ + public function isStaff(): bool + { + return in_array($this, [self::Staff, self::Compliance], strict: true); + } + + public function isCompliance(): bool + { + return $this === self::Compliance; + } + + /** + * Quem enxerga a ficha de um membro no painel: staff/compliance (gestão) + * e recruiter/squad captain (recrutamento), mas não member. + */ + public function canViewUsers(): bool + { + return in_array($this, [self::Staff, self::Compliance, self::Recruiter, self::SquadCaptain], strict: true); + } +} diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index e91b55c87..be7d2a9f6 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -12,15 +12,21 @@ use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\Database\Factories\UserFactory; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; +use He4rt\Identity\User\Enums\Role; use He4rt\Identity\User\Observers\UserObserver; +use He4rt\Profile\Models\Profile; +use He4rt\Profile\Models\ProfileSkill; +use He4rt\Profile\Models\WorkExperience; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Spatie\MediaLibrary\HasMedia; @@ -32,12 +38,14 @@ * @property string $username * @property string|null $email * @property bool $is_donator + * @property Role $role * @property CarbonInterface|null $suspended_until * @property CarbonInterface|null $banned_at * @property CarbonInterface|null $first_login_at * @property string|null $remember_token * @property CarbonInterface|null $created_at * @property CarbonInterface|null $updated_at + * @property CarbonInterface|null $deleted_at */ #[ObservedBy(classes: UserObserver::class)] #[Table(name: 'users')] @@ -50,12 +58,18 @@ final class User extends Authenticatable implements FilamentUser, HasMedia, HasN use HasUuids; use InteractsWithMedia; use Notifiable; + use SoftDeletes; public function isAdmin(): bool { return in_array($this->username, str(config('he4rt.admins'))->explode(',')->toArray(), strict: true); } + public function hasRole(Role $role): bool + { + return $this->role === $role; + } + /** * @return MorphMany */ @@ -72,6 +86,44 @@ public function character(): HasOne return $this->hasOne(Character::class); } + /** + * @return HasOne + */ + public function profile(): HasOne + { + return $this->hasOne(Profile::class); + } + + /** + * @return HasManyThrough + */ + public function workExperiences(): HasManyThrough + { + return $this->hasManyThrough( + WorkExperience::class, + Profile::class, + 'user_id', + 'profile_id', + 'id', + 'id', + ); + } + + /** + * @return HasManyThrough + */ + public function profileSkills(): HasManyThrough + { + return $this->hasManyThrough( + ProfileSkill::class, + Profile::class, + 'user_id', + 'profile_id', + 'id', + 'id', + ); + } + public function getFilamentName(): string { return $this->username; @@ -131,6 +183,7 @@ protected function casts(): array return [ 'is_donator' => 'boolean', 'password' => 'hashed', + 'role' => Role::class, 'suspended_until' => 'datetime', 'banned_at' => 'datetime', 'first_login_at' => 'datetime', diff --git a/app-modules/identity/src/User/Policies/UserPolicy.php b/app-modules/identity/src/User/Policies/UserPolicy.php new file mode 100644 index 000000000..583c4473b --- /dev/null +++ b/app-modules/identity/src/User/Policies/UserPolicy.php @@ -0,0 +1,40 @@ +role->canViewUsers(); + } + + public function view(User $user): bool + { + return $user->role->canViewUsers(); + } + + public function update(User $user): bool + { + return $user->role->isStaff(); + } + + public function delete(User $user): bool + { + return $user->role->isStaff(); + } + + public function restore(User $user): bool + { + return $user->role->isCompliance(); + } + + public function forceDelete(User $user): bool + { + return $user->role->isCompliance(); + } +} diff --git a/app-modules/identity/tests/Unit/User/UserPolicyTest.php b/app-modules/identity/tests/Unit/User/UserPolicyTest.php new file mode 100644 index 000000000..5a77bf4b8 --- /dev/null +++ b/app-modules/identity/tests/Unit/User/UserPolicyTest.php @@ -0,0 +1,62 @@ +policy = new UserPolicy(); +}); + +test('viewAny allows staff, compliance, recruiter and squad captain', function (): void { + expect($this->policy->viewAny(User::factory()->staff()->create()))->toBeTrue() + ->and($this->policy->viewAny(User::factory()->compliance()->create()))->toBeTrue() + ->and($this->policy->viewAny(User::factory()->recruiter()->create()))->toBeTrue() + ->and($this->policy->viewAny(User::factory()->squadCaptain()->create()))->toBeTrue(); +}); + +test('viewAny denies member', function (): void { + expect($this->policy->viewAny(User::factory()->create()))->toBeFalse(); +}); + +test('view allows staff, compliance, recruiter and squad captain', function (): void { + expect($this->policy->view(User::factory()->staff()->create()))->toBeTrue() + ->and($this->policy->view(User::factory()->compliance()->create()))->toBeTrue() + ->and($this->policy->view(User::factory()->recruiter()->create()))->toBeTrue() + ->and($this->policy->view(User::factory()->squadCaptain()->create()))->toBeTrue(); +}); + +test('view denies member', function (): void { + expect($this->policy->view(User::factory()->create()))->toBeFalse(); +}); + +test('update and delete allow staff and compliance', function (): void { + $staff = User::factory()->staff()->create(); + $compliance = User::factory()->compliance()->create(); + + expect($this->policy->update($staff))->toBeTrue() + ->and($this->policy->delete($staff))->toBeTrue() + ->and($this->policy->update($compliance))->toBeTrue() + ->and($this->policy->delete($compliance))->toBeTrue(); +}); + +test('update and delete deny non-staff roles', function (): void { + $member = User::factory()->create(); + + expect($this->policy->update($member))->toBeFalse() + ->and($this->policy->delete($member))->toBeFalse(); +}); + +test('restore and forceDelete require compliance', function (): void { + $compliance = User::factory()->compliance()->create(); + $staff = User::factory()->staff()->create(); + + expect($this->policy->restore($compliance))->toBeTrue() + ->and($this->policy->forceDelete($compliance))->toBeTrue() + ->and($this->policy->restore($staff))->toBeFalse() + ->and($this->policy->forceDelete($staff))->toBeFalse(); +}); 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 000000000..f13b90007 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php @@ -0,0 +1,29 @@ +components([ + Select::make('skill_id') + ->label('Skill') + ->relationship('skill', 'name') + ->searchable() + ->preload() + ->required(), + + Select::make('proficiency') + ->label('Proficiency') + ->options(SkillProficiency::class) + ->required(), + + TextInput::make('years_experience') + ->label('Years of Experience') + ->integer(), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('skill.name') + ->columns([ + TextColumn::make('skill.name') + ->label('Skill') + ->searchable(), + + TextColumn::make('proficiency') + ->label('Proficiency') + ->badge(), + + TextColumn::make('years_experience') + ->label('Years of Experience') + ->placeholder('—'), + ]) + ->headerActions([ + CreateAction::make() + ->using($this->createProfileSkill(...)) + ->visible($this->isEditableByCurrentUser(...)), + ]) + ->recordActions([ + EditAction::make() + ->visible($this->isEditableByCurrentUser(...)), + DeleteAction::make() + ->visible($this->isEditableByCurrentUser(...)), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ])->visible($this->isEditableByCurrentUser(...)), + ]); + } + + private function isEditableByCurrentUser(): bool + { + return auth()->user()?->role->isStaff() ?? false; + } + + /** + * @param array $data + */ + private function createProfileSkill(array $data): ProfileSkill + { + /** @var User $owner */ + $owner = $this->getOwnerRecord(); + + $profile = Profile::ensureExists($owner->id); + + $data['profile_id'] = $profile->id; + + return ProfileSkill::query()->create($data); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/WorkExperiencesRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/WorkExperiencesRelationManager.php new file mode 100644 index 000000000..1171c53fa --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/WorkExperiencesRelationManager.php @@ -0,0 +1,122 @@ +components([ + TextInput::make('company_name') + ->label('Company') + ->required(), + + TextInput::make('position') + ->label('Position') + ->required(), + + Textarea::make('description') + ->label('Description') + ->required() + ->rows(3), + + DatePicker::make('start_date') + ->label('Start Date') + ->required(), + + DatePicker::make('end_date') + ->label('End Date'), + + Checkbox::make('is_currently_working_here') + ->label('Currently Working Here') + ->live(), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('position') + ->columns([ + TextColumn::make('company_name') + ->label('Company') + ->searchable(), + + TextColumn::make('position') + ->label('Position') + ->searchable(), + + TextColumn::make('start_date') + ->label('Start Date') + ->date(), + + TextColumn::make('end_date') + ->label('End Date') + ->date() + ->placeholder('—'), + + IconColumn::make('is_currently_working_here') + ->label('Current') + ->boolean(), + ]) + ->headerActions([ + CreateAction::make() + ->using($this->createWorkExperience(...)) + ->visible($this->isEditableByCurrentUser(...)), + ]) + ->recordActions([ + EditAction::make() + ->visible($this->isEditableByCurrentUser(...)), + DeleteAction::make() + ->visible($this->isEditableByCurrentUser(...)), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ])->visible($this->isEditableByCurrentUser(...)), + ]); + } + + private function isEditableByCurrentUser(): bool + { + return auth()->user()?->role->isStaff() ?? false; + } + + /** + * @param array $data + */ + private function createWorkExperience(array $data): WorkExperience + { + /** @var User $owner */ + $owner = $this->getOwnerRecord(); + + $profile = Profile::ensureExists($owner->id); + + $data['profile_id'] = $profile->id; + + return WorkExperience::query()->create($data); + } +} 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 000000000..3b6dac6d0 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserForm.php @@ -0,0 +1,201 @@ +components([ + Tabs::make('User') + ->tabs([ + Tab::make('Identidade') + ->schema(self::identitySchema()), + + Tab::make('Perfil') + ->schema([self::profileSection()]), + + Tab::make('Endereço') + ->schema([self::addressSection()]), + ]) + ->columnSpanFull(), + ]); + } + + /** + * @return array + */ + private static function identitySchema(): array + { + return [ + TextInput::make('username') + ->label('Username') + ->required() + ->unique(ignoreRecord: true), + + TextInput::make('name') + ->label('Name') + ->required() + ->unique(ignoreRecord: true), + + TextInput::make('email') + ->label('Email') + ->email(), + + Select::make('role') + ->label('Role') + ->options(Role::class) + ->required(), + + Toggle::make('is_donator') + ->label('Donator'), + ]; + } + + private static function profileSection(): Section + { + return Section::make('Perfil') + ->relationship('profile') + ->mutateRelationshipDataBeforeFillUsing(function (array $data): array { + /** @var WorkPreferences $preferences */ + $preferences = $data['preferences'] ?? new WorkPreferences(); + + return [ + ...Arr::except($data, ['preferences']), + 'has_disability' => $preferences->hasDisability, + 'willing_to_relocate' => $preferences->willingToRelocate, + 'is_open_to_remote' => $preferences->isOpenToRemote, + 'employment_types' => array_map( + static fn (EmploymentType $type): string => $type->value, + $preferences->employmentTypes, + ), + ]; + }) + ->mutateRelationshipDataBeforeSaveUsing(fn (array $data): array => [ + ...Arr::except($data, [ + 'has_disability', + 'willing_to_relocate', + 'is_open_to_remote', + 'employment_types', + ]), + 'preferences' => [ + 'has_disability' => $data['has_disability'] ?? false, + 'willing_to_relocate' => $data['willing_to_relocate'] ?? false, + 'is_open_to_remote' => $data['is_open_to_remote'] ?? false, + 'employment_types' => $data['employment_types'] ?? [], + ], + ]) + ->schema([ + TextInput::make('nickname') + ->label('Nickname'), + + TextInput::make('headline') + ->label('Headline'), + + Textarea::make('about') + ->label('Sobre') + ->rows(3), + + DatePicker::make('birthdate') + ->label('Data de Nascimento'), + + Select::make('seniority_level') + ->label('Senioridade') + ->options(SeniorityLevel::class), + + TextInput::make('years_experience') + ->label('Anos de Experiência') + ->integer(), + + Toggle::make('available_for_proposals') + ->label('Disponível para Propostas'), + + Select::make('start_availability') + ->label('Disponibilidade de Início') + ->options(StartAvailability::class), + + TextInput::make('expected_salary_min') + ->label('Pretensão Salarial Mínima') + ->numeric() + ->prefix('R$'), + + TextInput::make('expected_salary_max') + ->label('Pretensão Salarial Máxima') + ->numeric() + ->prefix('R$'), + + TextInput::make('social_links.'.SocialPlatform::Instagram->value) + ->label('Instagram'), + + TextInput::make('social_links.'.SocialPlatform::Twitter->value) + ->label('Twitter'), + + TextInput::make('social_links.'.SocialPlatform::Website->value) + ->label('Website'), + + TextInput::make('social_links.'.SocialPlatform::YouTube->value) + ->label('YouTube'), + + TextInput::make('social_links.'.SocialPlatform::Bluesky->value) + ->label('Bluesky'), + + Toggle::make('has_disability') + ->label('Possui Deficiência'), + + Toggle::make('willing_to_relocate') + ->label('Disponível para Relocação'), + + Toggle::make('is_open_to_remote') + ->label('Aberto a Trabalho Remoto'), + + CheckboxList::make('employment_types') + ->label('Tipos de Contratação') + ->options(EmploymentType::class), + ]) + ->columnSpanFull(); + } + + private static function addressSection(): Section + { + return Section::make('Endereço') + ->relationship('address') + ->schema([ + TextInput::make('country') + ->label('País') + ->maxLength(4), + + TextInput::make('state') + ->label('Estado'), + + TextInput::make('city') + ->label('Cidade'), + + TextInput::make('zip_code') + ->label('CEP') + ->mask('99999-999'), + ]) + ->columnSpanFull(); + } +} 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 000000000..52a7bddbb --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserInfolist.php @@ -0,0 +1,395 @@ +components([ + Tabs::make('User') + ->tabs([ + Tab::make('Identidade') + ->schema(self::identitySchema()), + + Tab::make('Perfil') + ->schema(self::profileSchema()), + + Tab::make('Endereço') + ->schema(self::addressSchema()), + + Tab::make('Gamificação') + ->schema(self::gamificationSchema()), + + Tab::make('Atividade') + ->schema(self::activitySchema()), + + Tab::make('Moderação') + ->schema(self::moderationSchema()) + ->hidden(static fn (): bool => !(auth()->user()?->role->isStaff() ?? false)), + ]) + ->columnSpanFull(), + ]); + } + + /** + * @return array + */ + private static function identitySchema(): array + { + return [ + TextEntry::make('username') + ->label('Username'), + + TextEntry::make('name') + ->label('Name'), + + TextEntry::make('email') + ->label('Email') + ->placeholder('—'), + + TextEntry::make('role') + ->label('Role') + ->badge(), + + IconEntry::make('is_donator') + ->label('Donator') + ->boolean(), + + TextEntry::make('created_at') + ->label('Membro desde') + ->dateTime(), + ]; + } + + /** + * @return array + */ + private static function profileSchema(): array + { + return [ + TextEntry::make('profile.nickname') + ->label('Nickname') + ->placeholder('—'), + + TextEntry::make('profile.headline') + ->label('Headline') + ->placeholder('—'), + + TextEntry::make('profile.about') + ->label('Sobre') + ->placeholder('—') + ->columnSpanFull(), + + TextEntry::make('profile.birthdate') + ->label('Data de Nascimento') + ->date() + ->placeholder('—'), + + TextEntry::make('profile.seniority_level') + ->label('Senioridade') + ->badge() + ->placeholder('—'), + + TextEntry::make('profile.years_experience') + ->label('Anos de Experiência') + ->placeholder('—'), + + IconEntry::make('profile.available_for_proposals') + ->label('Disponível para Propostas') + ->boolean(), + + TextEntry::make('profile.start_availability') + ->label('Disponibilidade de Início') + ->badge() + ->placeholder('—'), + + TextEntry::make('profile.expected_salary_min') + ->label('Pretensão Salarial Mínima') + ->money('BRL') + ->placeholder('—'), + + TextEntry::make('profile.expected_salary_max') + ->label('Pretensão Salarial Máxima') + ->money('BRL') + ->placeholder('—'), + + TextEntry::make('profile.social_links.'.SocialPlatform::Instagram->value) + ->label('Instagram') + ->placeholder('—'), + + TextEntry::make('profile.social_links.'.SocialPlatform::Twitter->value) + ->label('Twitter') + ->placeholder('—'), + + TextEntry::make('profile.social_links.'.SocialPlatform::Website->value) + ->label('Website') + ->placeholder('—'), + + TextEntry::make('profile.social_links.'.SocialPlatform::YouTube->value) + ->label('YouTube') + ->placeholder('—'), + + TextEntry::make('profile.social_links.'.SocialPlatform::Bluesky->value) + ->label('Bluesky') + ->placeholder('—'), + + IconEntry::make('profile.preferences.hasDisability') + ->label('Possui Deficiência') + ->boolean(), + + IconEntry::make('profile.preferences.willingToRelocate') + ->label('Disponível para Relocação') + ->boolean(), + + IconEntry::make('profile.preferences.isOpenToRemote') + ->label('Aberto a Trabalho Remoto') + ->boolean(), + + TextEntry::make('profile.preferences.employmentTypes') + ->label('Tipos de Contratação') + ->badge() + ->placeholder('—'), + ]; + } + + /** + * @return array + */ + private static function addressSchema(): array + { + return [ + TextEntry::make('address.country') + ->label('País') + ->placeholder('—'), + + TextEntry::make('address.state') + ->label('Estado') + ->placeholder('—'), + + TextEntry::make('address.city') + ->label('Cidade') + ->placeholder('—'), + + TextEntry::make('address.zip_code') + ->label('CEP') + ->placeholder('—'), + ]; + } + + /** + * @return array + */ + private static function gamificationSchema(): array + { + return [ + TextEntry::make('character.level') + ->label('Nível') + ->badge() + ->placeholder('—'), + + TextEntry::make('character.experience') + ->label('Experiência') + ->placeholder('—'), + + TextEntry::make('character.reputation') + ->label('Reputação') + ->placeholder('—'), + + RepeatableEntry::make('character.badges') + ->label('Badges') + ->schema([ + TextEntry::make('name') + ->label('Nome'), + + TextEntry::make('pivot.claimed_at') + ->label('Conquistada em') + ->dateTime(), + ]) + ->columns(2) + ->columnSpanFull(), + + RepeatableEntry::make('character.wallets') + ->label('Carteira') + ->schema([ + TextEntry::make('currency') + ->label('Moeda'), + + TextEntry::make('balance') + ->label('Saldo'), + ]) + ->columns(2) + ->columnSpanFull(), + ]; + } + + /** + * @return array + */ + private static function activitySchema(): array + { + return [ + RepeatableEntry::make('providers') + ->label('Conexões') + ->schema([ + TextEntry::make('provider') + ->label('Provider') + ->badge(), + + TextEntry::make('external_account_id') + ->label('ID Externo') + ->placeholder('—'), + + TextEntry::make('connected_at') + ->label('Conectado em') + ->dateTime() + ->placeholder('—'), + + TextEntry::make('messages_count') + ->label('Mensagens'), + ]) + ->columns(4) + ->columnSpanFull(), + + TextEntry::make('activity_messages_total') + ->label('Total de Mensagens') + ->state(static fn (User $record): int => self::messagesCount($record)), + + TextEntry::make('activity_voice_hours') + ->label('Horas de Voice (aproximado)') + ->state(static fn (User $record): float => self::voiceHours($record)), + + TextEntry::make('activity_discord_roles') + ->label('Cargos no Discord') + ->state(static fn (User $record): array => self::discordRoles($record)) + ->badge() + ->placeholder('—'), + ]; + } + + /** + * @return array + */ + private static function moderationSchema(): array + { + $caseSchema = [ + TextEntry::make('status') + ->label('Status') + ->badge(), + + TextEntry::make('severity') + ->label('Severidade') + ->badge() + ->placeholder('—'), + + TextEntry::make('violation_type') + ->label('Tipo de Violação') + ->badge() + ->placeholder('—'), + + TextEntry::make('created_at') + ->label('Criado em') + ->dateTime(), + ]; + + return [ + RepeatableEntry::make('moderation_authored_cases') + ->label('Casos como Autor') + ->state(static fn (User $record) => ModerationCase::query() + ->where('author_id', $record->id) + ->latest() + ->limit(10) + ->get()) + ->schema($caseSchema) + ->columns(4) + ->columnSpanFull(), + + RepeatableEntry::make('moderation_assigned_cases') + ->label('Casos como Responsável') + ->state(static fn (User $record) => ModerationCase::query() + ->where('assigned_to', $record->id) + ->latest() + ->limit(10) + ->get()) + ->schema($caseSchema) + ->columns(4) + ->columnSpanFull(), + + TextEntry::make('suspended_until') + ->label('Suspenso até') + ->dateTime() + ->placeholder('—'), + + TextEntry::make('banned_at') + ->label('Banido em') + ->dateTime() + ->placeholder('—'), + ]; + } + + private static function messagesCount(User $record): int + { + /** @var Collection $providers */ + $providers = $record->providers; + + return $providers->sum(static fn (ExternalIdentity $identity): int => $identity->messages_count); + } + + private static function voiceHours(User $record): float + { + $identityIds = $record->providers->pluck('id'); + + if ($identityIds->isEmpty()) { + return 0.0; + } + + $joinedCount = Voice::query() + ->whereIn('external_identity_id', $identityIds) + ->where('state', 'joined') + ->count(); + + return round($joinedCount * 0.75, 1); + } + + /** + * @return array + */ + private static function discordRoles(User $record): array + { + /** @var ExternalIdentity|null $discordIdentity */ + $discordIdentity = $record->providers->firstWhere('provider', IdentityProvider::Discord); + + if ($discordIdentity === null) { + return []; + } + + $member = DiscordMember::query() + ->where('external_identity_id', $discordIdentity->id) + ->with('roles') + ->first(); + + return $member?->roles + ->map(static fn (DiscordRole $role): string => $role->name) + ->all() ?? []; + } +} 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 000000000..93bca287d --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Tables/UsersTable.php @@ -0,0 +1,135 @@ +columns([ + TextColumn::make('username') + ->label('Username') + ->searchable() + ->sortable(), + + TextColumn::make('name') + ->label('Name') + ->searchable() + ->sortable(), + + TextColumn::make('email') + ->label('Email') + ->searchable() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('role') + ->label('Role') + ->badge(), + + TextColumn::make('profile.seniority_level') + ->label('Senioridade') + ->badge() + ->placeholder('—'), + + IconColumn::make('profile.available_for_proposals') + ->label('Disponível') + ->boolean(), + + TextColumn::make('address.city') + ->label('Cidade') + ->placeholder('—'), + + TextColumn::make('character.level') + ->label('Nível') + ->badge() + ->placeholder('—'), + + TextColumn::make('status') + ->label('Status') + ->state(static fn (User $record): string => match (true) { + $record->trashed() => 'removed', + $record->banned_at !== null => 'banned', + $record->suspended_until?->isFuture() => 'suspended', + default => 'active', + }) + ->badge() + ->color(static fn (string $state): string => match ($state) { + 'removed' => 'gray', + 'banned' => 'danger', + 'suspended' => 'warning', + default => 'success', + }) + ->formatStateUsing(static fn (string $state): string => match ($state) { + 'removed' => 'Removido', + 'banned' => 'Banido', + 'suspended' => 'Suspenso', + default => 'Ativo', + }), + + IconColumn::make('is_donator') + ->label('Donator') + ->boolean(), + ]) + ->paginated([25, 50, 100]) + ->filters([ + SelectFilter::make('role') + ->label('Role') + ->options(Role::class), + + SelectFilter::make('seniority_level') + ->label('Senioridade') + ->options(SeniorityLevel::class) + ->query(static fn (Builder $query, array $data): Builder => $query->when( + $data['value'] ?? null, + static fn (Builder $q, mixed $value): Builder => $q->whereRelation('profile', 'seniority_level', $value), + )), + + TernaryFilter::make('available_for_proposals') + ->label('Disponível para Propostas') + ->queries( + true: static fn (Builder $query): Builder => $query->whereRelation('profile', 'available_for_proposals', operator: true), + false: static fn (Builder $query): Builder => $query->whereRelation('profile', 'available_for_proposals', operator: false), + ), + + TrashedFilter::make(), + ]) + ->recordActions([ + ViewAction::make(), + EditAction::make(), + DeleteAction::make(), + RestoreAction::make(), + ForceDeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + RestoreBulkAction::make(), + ForceDeleteBulkAction::make(), + ]), + ]); + } +} 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 000000000..da29fa2f9 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php @@ -0,0 +1,81 @@ + + */ + public static function getPages(): array + { + return [ + 'index' => ListUsers::route('/'), + 'view' => ViewUser::route('/{record}'), + 'edit' => EditUser::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]) + ->with(['profile', 'address', 'character']); + } + + public static function getGloballySearchableAttributes(): array + { + return ['username', 'name', 'email']; + } +} diff --git a/app-modules/panel-admin/src/PanelAdminServiceProvider.php b/app-modules/panel-admin/src/PanelAdminServiceProvider.php index c379aa647..30e59f712 100644 --- a/app-modules/panel-admin/src/PanelAdminServiceProvider.php +++ b/app-modules/panel-admin/src/PanelAdminServiceProvider.php @@ -9,6 +9,7 @@ use Filament\Panel; use He4rt\PanelAdmin\Discord\DiscordCluster; use He4rt\PanelAdmin\Filament\Resources\ExternalIdentities\ExternalIdentityResource; +use He4rt\PanelAdmin\Filament\Resources\Users\UserResource; use He4rt\PanelAdmin\Github\GithubCluster; use He4rt\PanelAdmin\Marketing\MarketingCluster; use He4rt\PanelAdmin\Moderation\Livewire\AppealQueue; @@ -42,6 +43,7 @@ public function register(): void ->navigation($this->buildNavigation(...)) ->resources([ ExternalIdentityResource::class, + UserResource::class, ]) ->discoverResources( in: __DIR__.'/Moderation/Resources', @@ -125,6 +127,7 @@ private function defaultNavigation(NavigationBuilder $builder): NavigationBuilde ...TwitchCluster::getNavigationItems(), ...GithubCluster::getNavigationItems(), ...ExternalIdentityResource::getNavigationItems(), + ...UserResource::getNavigationItems(), ...DiscordCluster::getNavigationItems(), ]); } diff --git a/app-modules/panel-admin/tests/Feature/Users/UserResourceTest.php b/app-modules/panel-admin/tests/Feature/Users/UserResourceTest.php new file mode 100644 index 000000000..b80822f6a --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Users/UserResourceTest.php @@ -0,0 +1,341 @@ +create(); + + foreach (['staff', 'recruiter', 'squadCaptain'] as $state) { + $viewer = User::factory()->{$state}()->create(); + + $this->actingAs($viewer); + + livewire(ListUsers::class) + ->loadTable() + ->assertCanSeeTableRecords([$viewer, $member]); + } +}); + +test('membro não pode acessar a listagem', function (): void { + $member = User::factory()->create(); + + $this->actingAs($member); + + livewire(ListUsers::class) + ->assertForbidden(); +}); + +test('membro não pode acessar a view de outro usuário', function (): void { + $member = User::factory()->create(); + $target = User::factory()->create(); + + $this->actingAs($member); + + livewire(ViewUser::class, ['record' => $target->id]) + ->assertForbidden(); +}); + +test('staff vê a seção de moderação na view, recrutador não vê', function (): void { + $target = User::factory()->create(); + + $staff = User::factory()->staff()->create(); + $this->actingAs($staff); + + livewire(ViewUser::class, ['record' => $target->id]) + ->assertOk() + ->assertSee('Moderação'); + + $recruiter = User::factory()->recruiter()->create(); + $this->actingAs($recruiter); + + livewire(ViewUser::class, ['record' => $target->id]) + ->assertOk() + ->assertDontSee('Moderação'); +}); + +test('view não quebra para usuário sem character', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + + expect($target->character)->toBeNull(); + + $this->actingAs($staff); + + livewire(ViewUser::class, ['record' => $target->id]) + ->assertOk(); +}); + +test('staff edita identidade, perfil e endereço em um único submit', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + + $this->actingAs($staff); + + livewire(EditUser::class, ['record' => $target->id]) + ->fillForm([ + 'role' => Role::Recruiter->value, + 'is_donator' => true, + 'profile.nickname' => 'devzin', + 'profile.headline' => 'Backend Developer', + 'profile.seniority_level' => 'senior', + 'profile.available_for_proposals' => true, + 'profile.social_links.instagram' => 'devzin.insta', + 'profile.has_disability' => true, + 'profile.willing_to_relocate' => true, + 'profile.is_open_to_remote' => true, + 'profile.employment_types' => ['clt'], + 'address.country' => 'BR', + 'address.state' => 'SP', + 'address.city' => 'Campinas', + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $target->refresh(); + + expect($target->role)->toBe(Role::Recruiter) + ->and($target->is_donator)->toBeTrue() + ->and($target->profile->nickname)->toBe('devzin') + ->and($target->profile->seniority_level->value)->toBe('senior') + ->and($target->profile->available_for_proposals)->toBeTrue() + ->and($target->profile->social_links)->toMatchArray(['instagram' => 'devzin.insta']) + ->and($target->profile->preferences->hasDisability)->toBeTrue() + ->and($target->profile->preferences->willingToRelocate)->toBeTrue() + ->and($target->profile->preferences->isOpenToRemote)->toBeTrue() + ->and($target->address->city)->toBe('Campinas'); +}); + +test('rejeita username duplicado na edição', function (): void { + $staff = User::factory()->staff()->create(); + User::factory()->create(['username' => 'taken']); + $target = User::factory()->create(); + + $this->actingAs($staff); + + livewire(EditUser::class, ['record' => $target->id]) + ->fillForm(['username' => 'taken']) + ->call('save') + ->assertHasFormErrors(['username']); +}); + +test('recrutador e squad captain não conseguem acessar a edição', function (): void { + $target = User::factory()->create(); + + foreach (['recruiter', 'squadCaptain'] as $state) { + $viewer = User::factory()->{$state}()->create(); + + $this->actingAs($viewer); + + livewire(EditUser::class, ['record' => $target->id]) + ->assertForbidden(); + } +}); + +test('cria uma work experience preenchendo o profile_id automaticamente', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + + $this->actingAs($staff); + + livewire(WorkExperiencesRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => EditUser::class, + ]) + ->callAction(TestAction::make('create')->table(), data: [ + 'company_name' => 'He4rt', + 'position' => 'Developer', + 'description' => 'Building community tools.', + 'start_date' => now()->subYear()->toDateString(), + 'is_currently_working_here' => true, + ]) + ->assertHasNoTableActionErrors(); + + $workExperience = WorkExperience::query()->where('company_name', 'He4rt')->sole(); + + expect($workExperience->profile->user_id)->toBe($target->id); +}); + +test('staff edita uma work experience existente', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + $profile = Profile::ensureExists($target->id); + $workExperience = WorkExperience::factory()->for($profile)->create(['position' => 'Junior Developer']); + + $this->actingAs($staff); + + livewire(WorkExperiencesRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => EditUser::class, + ]) + ->callAction( + TestAction::make('edit')->table($workExperience), + data: ['position' => 'Senior Developer'], + ) + ->assertHasNoTableActionErrors(); + + expect($workExperience->fresh()->position)->toBe('Senior Developer'); +}); + +test('staff exclui uma work experience', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + $profile = Profile::ensureExists($target->id); + $workExperience = WorkExperience::factory()->for($profile)->create(); + + $this->actingAs($staff); + + livewire(WorkExperiencesRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => EditUser::class, + ]) + ->callAction(TestAction::make('delete')->table($workExperience)); + + expect(WorkExperience::query()->find($workExperience->id))->toBeNull(); +}); + +test('cria uma profile skill preenchendo o profile_id automaticamente', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + $skill = Skill::factory()->create(); + + $this->actingAs($staff); + + livewire(ProfileSkillsRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => EditUser::class, + ]) + ->callAction(TestAction::make('create')->table(), data: [ + 'skill_id' => $skill->id, + 'proficiency' => SkillProficiency::Advanced->value, + 'years_experience' => 3, + ]) + ->assertHasNoTableActionErrors(); + + $profileSkill = ProfileSkill::query()->where('skill_id', $skill->id)->sole(); + + expect($profileSkill->profile->user_id)->toBe($target->id); +}); + +test('staff edita a proficiência de uma profile skill existente', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + $profile = Profile::ensureExists($target->id); + $profileSkill = ProfileSkill::factory()->for($profile)->create(['proficiency' => SkillProficiency::Beginner]); + + $this->actingAs($staff); + + livewire(ProfileSkillsRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => EditUser::class, + ]) + ->callAction( + TestAction::make('edit')->table($profileSkill), + data: ['proficiency' => SkillProficiency::Expert->value], + ) + ->assertHasNoTableActionErrors(); + + expect($profileSkill->fresh()->proficiency)->toBe(SkillProficiency::Expert); +}); + +test('staff exclui (detach) uma profile skill', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + $profile = Profile::ensureExists($target->id); + $profileSkill = ProfileSkill::factory()->for($profile)->create(); + + $this->actingAs($staff); + + livewire(ProfileSkillsRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => EditUser::class, + ]) + ->callAction(TestAction::make('delete')->table($profileSkill)); + + expect(ProfileSkill::query()->find($profileSkill->id))->toBeNull(); +}); + +test('recrutador não vê ações de criar/editar/excluir nas relation managers', function (): void { + $recruiter = User::factory()->recruiter()->create(); + $target = User::factory()->create(); + + $this->actingAs($recruiter); + + livewire(WorkExperiencesRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => ViewUser::class, + ]) + ->assertActionHidden(TestAction::make('create')->table()) + ->assertOk(); + + livewire(ProfileSkillsRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => ViewUser::class, + ]) + ->assertActionHidden(TestAction::make('create')->table()) + ->assertOk(); +}); + +test('soft delete é a ação padrão', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + + $this->actingAs($staff); + + livewire(ListUsers::class) + ->callAction(TestAction::make('delete')->table($target)); + + expect($target->fresh()->trashed())->toBeTrue(); +}); + +test('hard delete é indisponível sem permissão de compliance e disponível com ela', function (): void { + $target = User::factory()->create(['deleted_at' => now()]); + + $staff = User::factory()->staff()->create(); + $this->actingAs($staff); + + livewire(ListUsers::class) + ->assertActionHidden(TestAction::make('forceDelete')->table($target)); + + $compliance = User::factory()->compliance()->create(); + $this->actingAs($compliance); + + livewire(ListUsers::class) + ->callAction(TestAction::make('forceDelete')->table($target)); + + expect(User::withTrashed()->find($target->id))->toBeNull(); +}); + +test('view não quebra quando o usuário tem casos de moderação como autor e responsável', function (): void { + $target = User::factory()->create(); + ModerationCase::factory()->create(['author_id' => $target->id]); + ModerationCase::factory()->create(['assigned_to' => $target->id]); + + $staff = User::factory()->staff()->create(); + $this->actingAs($staff); + + livewire(ViewUser::class, ['record' => $target->id]) + ->assertOk() + ->assertSee('Moderação'); +}); diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index bd00bd38d..70385d55d 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -5,6 +5,8 @@ namespace App\Providers; // use Illuminate\Support\Facades\Gate; +use He4rt\Identity\User\Models\User; +use He4rt\Identity\User\Policies\UserPolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; final class AuthServiceProvider extends ServiceProvider @@ -15,7 +17,7 @@ final class AuthServiceProvider extends ServiceProvider * @var array */ protected $policies = [ - // 'App\Models\Model' => 'App\Policies\ModelPolicy', + User::class => UserPolicy::class, ]; /** From 87bada05a3bc47e9926b3c4065b8a745b527da88 Mon Sep 17 00:00:00 2001 From: YuriSouzaDev Date: Mon, 27 Jul 2026 00:03:44 -0300 Subject: [PATCH 2/6] =?UTF-8?q?fix(panel-admin):=20restringe=20acesso=20ao?= =?UTF-8?q?=20painel=20admin=20por=20role=20e=20centraliza=20autoriza?= =?UTF-8?q?=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canAccessPanel() comparava com IDs de panel que nunca existiram, então qualquer usuário autenticado (inclusive Member) entrava em /admin via default => true. Agora exige isAdmin() ou role->canViewUsers(). Usernames configurados em HE4RT_ADMINS_USERNAMES são promovidos para Role::Staff automaticamente na criação (UserObserver) e via migration de backfill para quem já existia, para que a autorização de recursos dependa só de role em vez de duas fontes de verdade divergentes. RelationManagers e o Infolist de Users agora reusam UserPolicy::update() via Gate em vez de duplicar auth()->user()?->role->isStaff() em cada lugar, e corrige um edit quebrado deixado em WorkExperiencesRelationManager (return UsePolicy::class). Também adiciona validação de unicidade de skill por profile em ProfileSkillsRelationManager (antes estourava exception crua do banco). --- .../database/factories/UserFactory.php | 5 +++ ...romote_configured_admins_to_staff_role.php | 34 +++++++++++++++++++ app-modules/identity/src/User/Models/User.php | 8 ++++- .../src/User/Observers/UserObserver.php | 10 ++++++ .../tests/Unit/User/UserPolicyTest.php | 14 +++++--- .../ProfileSkillsRelationManager.php | 19 +++++++++-- .../WorkExperiencesRelationManager.php | 10 ++++-- .../Resources/Users/Schemas/UserForm.php | 8 ++--- .../Resources/Users/Schemas/UserInfolist.php | 2 +- .../tests/Feature/Users/UserResourceTest.php | 23 +++++++++++++ database/seeders/BaseSeeder.php | 1 + 11 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 app-modules/identity/database/migrations/2026_07_27_000000_promote_configured_admins_to_staff_role.php diff --git a/app-modules/identity/database/factories/UserFactory.php b/app-modules/identity/database/factories/UserFactory.php index 3452ab007..e4a5eda56 100644 --- a/app-modules/identity/database/factories/UserFactory.php +++ b/app-modules/identity/database/factories/UserFactory.php @@ -29,6 +29,11 @@ public function definition(): array ]; } + public function member(): static + { + return $this->state(['role' => Role::Member]); + } + public function staff(): static { return $this->state(['role' => Role::Staff]); diff --git a/app-modules/identity/database/migrations/2026_07_27_000000_promote_configured_admins_to_staff_role.php b/app-modules/identity/database/migrations/2026_07_27_000000_promote_configured_admins_to_staff_role.php new file mode 100644 index 000000000..f23b99694 --- /dev/null +++ b/app-modules/identity/database/migrations/2026_07_27_000000_promote_configured_admins_to_staff_role.php @@ -0,0 +1,34 @@ +explode(',')->filter()->values()->toArray(); + + if ($usernames === []) { + return; + } + + DB::table('users') + ->whereIn('username', $usernames) + ->whereNotIn('role', [Role::Staff->value, Role::Compliance->value]) + ->update(['role' => Role::Staff->value]); + } + + public function down(): void + { + // Data backfill, not reversible. + } +}; diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index be7d2a9f6..c5f82cff9 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -5,6 +5,7 @@ namespace He4rt\Identity\User\Models; use App\Concerns\HasAddress; +use App\Enums\FilamentPanel; use Carbon\CarbonInterface; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasName; @@ -65,6 +66,11 @@ public function isAdmin(): bool return in_array($this->username, str(config('he4rt.admins'))->explode(',')->toArray(), strict: true); } + public function isStaff(): bool + { + return $this->hasRole(Role::Staff); + } + public function hasRole(Role $role): bool { return $this->role === $role; @@ -143,7 +149,7 @@ public function registerMediaCollections(): void public function canAccessPanel(Panel $panel): bool { return match ($panel->getId()) { - 'admin' => app()->isProduction() ? $this->isAdmin() : true, + FilamentPanel::Admin->value => $this->isAdmin() || $this->role->canViewUsers(), default => true }; } diff --git a/app-modules/identity/src/User/Observers/UserObserver.php b/app-modules/identity/src/User/Observers/UserObserver.php index e6b6c2548..55a3bbd0e 100644 --- a/app-modules/identity/src/User/Observers/UserObserver.php +++ b/app-modules/identity/src/User/Observers/UserObserver.php @@ -4,6 +4,7 @@ namespace He4rt\Identity\User\Observers; +use He4rt\Identity\User\Enums\Role; use He4rt\Identity\User\Models\User; use He4rt\Profile\Models\Profile; @@ -14,6 +15,10 @@ public function creating(User $user): void if (blank($user->username)) { $user->username = str($user->name)->snake()->toString(); } + + if ($this->isConfiguredAdmin($user->username) && !in_array($user->role, [Role::Staff, Role::Compliance], strict: true)) { + $user->role = Role::Staff; + } } public function created(User $user): void @@ -35,4 +40,9 @@ private function ensureProfileExists(User $user): void { Profile::ensureExists((string) $user->getKey()); } + + private function isConfiguredAdmin(string $username): bool + { + return in_array($username, str(config('he4rt.admins'))->explode(',')->toArray(), strict: true); + } } diff --git a/app-modules/identity/tests/Unit/User/UserPolicyTest.php b/app-modules/identity/tests/Unit/User/UserPolicyTest.php index 5a77bf4b8..0d07cceda 100644 --- a/app-modules/identity/tests/Unit/User/UserPolicyTest.php +++ b/app-modules/identity/tests/Unit/User/UserPolicyTest.php @@ -44,12 +44,16 @@ ->and($this->policy->delete($compliance))->toBeTrue(); }); -test('update and delete deny non-staff roles', function (): void { - $member = User::factory()->create(); +test('update and delete deny non-staff roles', function (string $factoryState): void { + $user = User::factory()->{$factoryState}()->create(); - expect($this->policy->update($member))->toBeFalse() - ->and($this->policy->delete($member))->toBeFalse(); -}); + expect($this->policy->update($user))->toBeFalse() + ->and($this->policy->delete($user))->toBeFalse(); +})->with([ + 'member' => 'member', + 'recruiter' => 'recruiter', + 'squad captain' => 'squadCaptain', +]); test('restore and forceDelete require compliance', function (): void { $compliance = User::factory()->compliance()->create(); diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/ProfileSkillsRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/ProfileSkillsRelationManager.php index 6eb20b2a2..8e655eab4 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/ProfileSkillsRelationManager.php +++ b/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/ProfileSkillsRelationManager.php @@ -19,6 +19,7 @@ use He4rt\Profile\Enums\SkillProficiency; use He4rt\Profile\Models\Profile; use He4rt\Profile\Models\ProfileSkill; +use Illuminate\Validation\Rules\Unique; class ProfileSkillsRelationManager extends RelationManager { @@ -33,7 +34,13 @@ public function form(Schema $schema): Schema ->relationship('skill', 'name') ->searchable() ->preload() - ->required(), + ->required() + ->unique( + table: 'profile_skills', + column: 'skill_id', + ignoreRecord: true, + modifyRuleUsing: fn (Unique $rule): Unique => $rule->where('profile_id', $this->ownerProfileId()), + ), Select::make('proficiency') ->label('Proficiency') @@ -83,7 +90,7 @@ public function table(Table $table): Table private function isEditableByCurrentUser(): bool { - return auth()->user()?->role->isStaff() ?? false; + return auth()->user()?->can('update', User::class) ?? false; } /** @@ -100,4 +107,12 @@ private function createProfileSkill(array $data): ProfileSkill return ProfileSkill::query()->create($data); } + + private function ownerProfileId(): ?string + { + /** @var User $owner */ + $owner = $this->getOwnerRecord(); + + return Profile::query()->where('user_id', $owner->id)->value('id'); + } } diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/WorkExperiencesRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/WorkExperiencesRelationManager.php index 1171c53fa..dfc155de3 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/WorkExperiencesRelationManager.php +++ b/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/WorkExperiencesRelationManager.php @@ -14,6 +14,7 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Resources\RelationManagers\RelationManager; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; @@ -45,10 +46,13 @@ public function form(Schema $schema): Schema DatePicker::make('start_date') ->label('Start Date') - ->required(), + ->required() + ->maxDate(today()), DatePicker::make('end_date') - ->label('End Date'), + ->label('End Date') + ->afterOrEqual('start_date') + ->hidden(fn (Get $get): bool => (bool) $get('is_currently_working_here')), Checkbox::make('is_currently_working_here') ->label('Currently Working Here') @@ -102,7 +106,7 @@ public function table(Table $table): Table private function isEditableByCurrentUser(): bool { - return auth()->user()?->role->isStaff() ?? false; + return auth()->user()?->can('update', User::class) ?? false; } /** 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 index 3b6dac6d0..9247eddf5 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserForm.php +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserForm.php @@ -57,8 +57,7 @@ private static function identitySchema(): array TextInput::make('name') ->label('Name') - ->required() - ->unique(ignoreRecord: true), + ->required(), TextInput::make('email') ->label('Email') @@ -79,8 +78,9 @@ private static function profileSection(): Section return Section::make('Perfil') ->relationship('profile') ->mutateRelationshipDataBeforeFillUsing(function (array $data): array { - /** @var WorkPreferences $preferences */ - $preferences = $data['preferences'] ?? new WorkPreferences(); + $preferences = $data['preferences'] instanceof WorkPreferences + ? $data['preferences'] + : new WorkPreferences(); return [ ...Arr::except($data, ['preferences']), 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 index 52a7bddbb..61728dba5 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserInfolist.php +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserInfolist.php @@ -46,7 +46,7 @@ public static function configure(Schema $schema): Schema Tab::make('Moderação') ->schema(self::moderationSchema()) - ->hidden(static fn (): bool => !(auth()->user()?->role->isStaff() ?? false)), + ->hidden(static fn (): bool => !(auth()->user()?->can('update', User::class) ?? false)), ]) ->columnSpanFull(), ]); diff --git a/app-modules/panel-admin/tests/Feature/Users/UserResourceTest.php b/app-modules/panel-admin/tests/Feature/Users/UserResourceTest.php index b80822f6a..a93f799d9 100644 --- a/app-modules/panel-admin/tests/Feature/Users/UserResourceTest.php +++ b/app-modules/panel-admin/tests/Feature/Users/UserResourceTest.php @@ -238,6 +238,29 @@ expect($profileSkill->profile->user_id)->toBe($target->id); }); +test('não permite duplicar a mesma skill no profile', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + $profile = Profile::ensureExists($target->id); + $skill = Skill::factory()->create(); + ProfileSkill::factory()->for($profile)->for($skill)->create(); + + $this->actingAs($staff); + + livewire(ProfileSkillsRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => EditUser::class, + ]) + ->callAction(TestAction::make('create')->table(), data: [ + 'skill_id' => $skill->id, + 'proficiency' => SkillProficiency::Advanced->value, + 'years_experience' => 3, + ]) + ->assertHasTableActionErrors(['skill_id' => 'unique']); + + expect(ProfileSkill::query()->where('skill_id', $skill->id)->count())->toBe(1); +}); + test('staff edita a proficiência de uma profile skill existente', function (): void { $staff = User::factory()->staff()->create(); $target = User::factory()->create(); diff --git a/database/seeders/BaseSeeder.php b/database/seeders/BaseSeeder.php index 798aaa28a..222169173 100644 --- a/database/seeders/BaseSeeder.php +++ b/database/seeders/BaseSeeder.php @@ -23,6 +23,7 @@ public function run(): void 'name' => 'Daniel Reis', 'email' => 'admin@admin.com', 'password' => Hash::make('admin'), + 'role' => 'staff', ]); Character::factory() From 91d9c7b791972395f64e6aa4b082b5acb0bade01 Mon Sep 17 00:00:00 2001 From: YuriSouzaDev Date: Mon, 27 Jul 2026 00:21:01 -0300 Subject: [PATCH 3/6] =?UTF-8?q?fix(identity):=20n=C3=A3o=20apaga=20address?= =?UTF-8?q?=20no=20soft=20delete=20e=20torna=20rollback=20seguro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserObserver::deleted() disparava tanto em soft delete quanto em force delete, então restaurar um usuário soft-deletado deixava o address perdido para sempre. Move o cleanup para o evento forceDeleted, que só dispara na exclusão permanente. down() da migration de role/soft-deletes tentava recriar a constraint unique('username') global sem antes tratar duplicatas entre linhas ativas e soft-deletadas — que up() permite intencionalmente (reuso de username em merge de conta). Isso quebraria o rollback com duplicate key violation. Adiciona um UPDATE que renomeia as duplicatas perdedoras com um sufixo neutro (_dup_, não "_deleted_", já que a linha renomeada não é necessariamente a trashed) antes de restaurar a constraint. --- ...d_role_and_soft_deletes_to_users_table.php | 22 +++++++++++++++++++ .../src/User/Observers/UserObserver.php | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app-modules/identity/database/migrations/2026_07_26_120000_add_role_and_soft_deletes_to_users_table.php b/app-modules/identity/database/migrations/2026_07_26_120000_add_role_and_soft_deletes_to_users_table.php index 0e0a212b1..9055c3f48 100644 --- a/app-modules/identity/database/migrations/2026_07_26_120000_add_role_and_soft_deletes_to_users_table.php +++ b/app-modules/identity/database/migrations/2026_07_26_120000_add_role_and_soft_deletes_to_users_table.php @@ -29,6 +29,28 @@ public function down(): void DB::statement('ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_unique'); DB::statement('DROP INDEX IF EXISTS users_username_unique'); + // up() only enforced uniqueness among active rows, so a soft-deleted + // row may share a username with an active row (or another trashed + // row). Rename those duplicates before restoring the global unique + // constraint below, keeping the active row (or the oldest one, if + // all are trashed) untouched. The suffix is intentionally neutral + // (not "_deleted_"): the renamed row isn't necessarily trashed. + DB::statement(<<<'SQL' + UPDATE users + SET username = username || '_dup_' || substr(id::text, 1, 8) + WHERE id IN ( + SELECT id + FROM ( + SELECT id, row_number() OVER ( + PARTITION BY username + ORDER BY deleted_at IS NULL DESC, created_at ASC + ) AS row_number + FROM users + ) ranked + WHERE row_number > 1 + ) + SQL); + Schema::table('users', static function (Blueprint $table): void { $table->unique('username'); $table->dropColumn(['role', 'deleted_at']); diff --git a/app-modules/identity/src/User/Observers/UserObserver.php b/app-modules/identity/src/User/Observers/UserObserver.php index 55a3bbd0e..e2fe67087 100644 --- a/app-modules/identity/src/User/Observers/UserObserver.php +++ b/app-modules/identity/src/User/Observers/UserObserver.php @@ -26,7 +26,7 @@ public function created(User $user): void $this->ensureProfileExists($user); } - public function deleted(User $user): void + public function forceDeleted(User $user): void { $user->address()->delete(); } From ec9d5b30912919f9709d1008209d093977fc1c66 Mon Sep 17 00:00:00 2001 From: YuriSouzaDev Date: Mon, 27 Jul 2026 00:25:53 -0300 Subject: [PATCH 4/6] =?UTF-8?q?fix(identity):=20normaliza=20espa=C3=A7os?= =?UTF-8?q?=20na=20lista=20de=20admins=20configurados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O parse de HE4RT_ADMINS_USERNAMES fazia split por vírgula sem trim, então "alice, bob" (com espaço) nunca batia contra in_array strict, causando promoção/acesso inconsistentes. Centraliza o parse em User::configuredAdminUsernames() (com trim + filtro de vazios) e faz User::isAdmin(), UserObserver e a migration de backfill reusarem o mesmo helper em vez de duplicar a lógica cada um do seu jeito. --- ...00_promote_configured_admins_to_staff_role.php | 3 ++- app-modules/identity/src/User/Models/User.php | 15 ++++++++++++++- .../identity/src/User/Observers/UserObserver.php | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app-modules/identity/database/migrations/2026_07_27_000000_promote_configured_admins_to_staff_role.php b/app-modules/identity/database/migrations/2026_07_27_000000_promote_configured_admins_to_staff_role.php index f23b99694..88a8564c1 100644 --- a/app-modules/identity/database/migrations/2026_07_27_000000_promote_configured_admins_to_staff_role.php +++ b/app-modules/identity/database/migrations/2026_07_27_000000_promote_configured_admins_to_staff_role.php @@ -3,6 +3,7 @@ declare(strict_types=1); use He4rt\Identity\User\Enums\Role; +use He4rt\Identity\User\Models\User; use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; @@ -15,7 +16,7 @@ */ public function up(): void { - $usernames = str(config('he4rt.admins'))->explode(',')->filter()->values()->toArray(); + $usernames = User::configuredAdminUsernames(); if ($usernames === []) { return; diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index c5f82cff9..e06c04843 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -61,9 +61,22 @@ final class User extends Authenticatable implements FilamentUser, HasMedia, HasN use Notifiable; use SoftDeletes; + /** + * @return list + */ + public static function configuredAdminUsernames(): array + { + $usernames = array_map( + mb_trim(...), + explode(',', config()->string('he4rt.admins')), + ); + + return array_values(array_filter($usernames, static fn (string $username): bool => $username !== '')); + } + public function isAdmin(): bool { - return in_array($this->username, str(config('he4rt.admins'))->explode(',')->toArray(), strict: true); + return in_array($this->username, self::configuredAdminUsernames(), strict: true); } public function isStaff(): bool diff --git a/app-modules/identity/src/User/Observers/UserObserver.php b/app-modules/identity/src/User/Observers/UserObserver.php index e2fe67087..05048c757 100644 --- a/app-modules/identity/src/User/Observers/UserObserver.php +++ b/app-modules/identity/src/User/Observers/UserObserver.php @@ -43,6 +43,6 @@ private function ensureProfileExists(User $user): void private function isConfiguredAdmin(string $username): bool { - return in_array($username, str(config('he4rt.admins'))->explode(',')->toArray(), strict: true); + return in_array($username, User::configuredAdminUsernames(), strict: true); } } From bc3d71e5296304f8c71adb882188a4a6d089b5c0 Mon Sep 17 00:00:00 2001 From: YuriSouzaDev Date: Mon, 27 Jul 2026 00:30:25 -0300 Subject: [PATCH 5/6] =?UTF-8?q?fix(identity):=20garante=20username=20sem?= =?UTF-8?q?=20colis=C3=A3o=20no=20rollback=20de=20soft-deletes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O dedup do down() gerava um sufixo determinístico (_dup_<8 chars do id>) sem checar contra os usernames já existentes na tabela. Se o candidato coincidisse com um username não relacionado já cadastrado, a UPDATE passava (sem constraint ativa no momento), mas o unique('username') logo depois quebrava — e por ser determinístico, rodar de novo falhava do mesmo jeito. Move o dedup para PHP: monta o conjunto de todos os usernames já em uso, e para cada duplicata perdedora incrementa um contador até achar um candidato livre. Testado forçando uma colisão proposital via tinker + rollback real. --- ...d_role_and_soft_deletes_to_users_table.php | 71 +++++++++++++------ 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/app-modules/identity/database/migrations/2026_07_26_120000_add_role_and_soft_deletes_to_users_table.php b/app-modules/identity/database/migrations/2026_07_26_120000_add_role_and_soft_deletes_to_users_table.php index 9055c3f48..af699e164 100644 --- a/app-modules/identity/database/migrations/2026_07_26_120000_add_role_and_soft_deletes_to_users_table.php +++ b/app-modules/identity/database/migrations/2026_07_26_120000_add_role_and_soft_deletes_to_users_table.php @@ -29,31 +29,60 @@ public function down(): void DB::statement('ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_unique'); DB::statement('DROP INDEX IF EXISTS users_username_unique'); - // up() only enforced uniqueness among active rows, so a soft-deleted - // row may share a username with an active row (or another trashed - // row). Rename those duplicates before restoring the global unique - // constraint below, keeping the active row (or the oldest one, if - // all are trashed) untouched. The suffix is intentionally neutral - // (not "_deleted_"): the renamed row isn't necessarily trashed. - DB::statement(<<<'SQL' - UPDATE users - SET username = username || '_dup_' || substr(id::text, 1, 8) - WHERE id IN ( - SELECT id - FROM ( - SELECT id, row_number() OVER ( - PARTITION BY username - ORDER BY deleted_at IS NULL DESC, created_at ASC - ) AS row_number - FROM users - ) ranked - WHERE row_number > 1 - ) - SQL); + $this->deduplicateUsernames(); Schema::table('users', static function (Blueprint $table): void { $table->unique('username'); $table->dropColumn(['role', 'deleted_at']); }); } + + /** + * up() only enforced uniqueness among active rows, so a soft-deleted row + * may share a username with an active row (or another trashed row). + * Rename those duplicates before restoring the global unique constraint, + * keeping the active row (or the oldest one, if all are trashed) + * untouched. Candidates are checked against every username already in + * the table (not just the other duplicates), incrementing a counter + * until a free one is found, so this can't collide with an unrelated + * pre-existing username. The suffix is intentionally neutral (not + * "_deleted_"): the renamed row isn't necessarily trashed. + */ + private function deduplicateUsernames(): void + { + /** @var array $takenUsernames */ + $takenUsernames = DB::table('users')->pluck('username') + ->mapWithKeys(static fn (string $username): array => [$username => true]) + ->all(); + + $duplicateLosers = DB::select(<<<'SQL' + SELECT id, username + FROM ( + SELECT id, username, row_number() OVER ( + PARTITION BY username + ORDER BY deleted_at IS NULL DESC, created_at ASC + ) AS row_number + FROM users + ) ranked + WHERE row_number > 1 + SQL); + + foreach ($duplicateLosers as $row) { + /** @var array{id: string, username: string} $row */ + $row = (array) $row; + $id = $row['id']; + $base = $row['username'].'_dup_'.mb_substr($id, 0, 8); + $candidate = $base; + $attempt = 1; + + while (isset($takenUsernames[$candidate])) { + $candidate = $base.'_'.$attempt; + $attempt++; + } + + $takenUsernames[$candidate] = true; + + DB::table('users')->where('id', $id)->update(['username' => $candidate]); + } + } }; From 310e496958de9f812e247e0c4c7d07c6e2292d52 Mon Sep 17 00:00:00 2001 From: YuriSouzaDev Date: Mon, 27 Jul 2026 00:37:30 -0300 Subject: [PATCH 6/6] test: atualiza AddressTest pro comportamento correto de soft/force delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O teste antigo assumia que soft delete de User cascateava a exclusão do address, que era exatamente o bug corrigido (UserObserver::deleted() -> forceDeleted()). Divide em dois casos: soft delete preserva o address, force delete apaga. --- tests/Feature/AddressTest.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/Feature/AddressTest.php b/tests/Feature/AddressTest.php index 80d3a9fca..24286edc0 100644 --- a/tests/Feature/AddressTest.php +++ b/tests/Feature/AddressTest.php @@ -30,7 +30,7 @@ expect($user->address)->toBeNull(); }); -it('deletar user deleta address via cascade', function (): void { +it('soft delete do user preserva o address', function (): void { $user = User::factory()->create(); Address::factory()->forUser($user)->create(); @@ -39,6 +39,18 @@ $user->delete(); + expect(Address::query()->where('addressable_id', $user->id)->exists())->toBeTrue(); +}); + +it('force delete do user deleta address via cascade', function (): void { + $user = User::factory()->create(); + + Address::factory()->forUser($user)->create(); + + expect(Address::query()->where('addressable_id', $user->id)->exists())->toBeTrue(); + + $user->forceDelete(); + expect(Address::query()->where('addressable_id', $user->id)->exists())->toBeFalse(); });