diff --git a/app-modules/identity/database/factories/UserFactory.php b/app-modules/identity/database/factories/UserFactory.php index b5cfc9be..80c7c809 100644 --- a/app-modules/identity/database/factories/UserFactory.php +++ b/app-modules/identity/database/factories/UserFactory.php @@ -31,10 +31,35 @@ public function definition(): array public function superAdmin(): static { - return $this->afterCreating(function (User $user): void { - Role::findOrCreate(UserRole::SuperAdmin->value, UserRole::GUARD); + return $this->withRole(UserRole::SuperAdmin); + } + + public function staff(): static + { + return $this->withRole(UserRole::Staff); + } + + public function compliance(): static + { + return $this->withRole(UserRole::Compliance); + } + + public function recruiter(): static + { + return $this->withRole(UserRole::Recruiter); + } + + public function squadCaptain(): static + { + return $this->withRole(UserRole::SquadCaptain); + } + + private function withRole(UserRole $role): static + { + return $this->afterCreating(function (User $user) use ($role): void { + Role::findOrCreate($role->value, UserRole::GUARD); - $user->assignRole(UserRole::SuperAdmin); + $user->assignRole($role); }); } } diff --git a/app-modules/identity/database/migrations/2026_09_14_120000_add_deleted_at_to_users_table.php b/app-modules/identity/database/migrations/2026_09_14_120000_add_deleted_at_to_users_table.php new file mode 100644 index 00000000..dacc1903 --- /dev/null +++ b/app-modules/identity/database/migrations/2026_09_14_120000_add_deleted_at_to_users_table.php @@ -0,0 +1,24 @@ +timestampTz('deleted_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('users', static function (Blueprint $table): void { + $table->dropColumn('deleted_at'); + }); + } +}; diff --git a/app-modules/identity/database/migrations/2026_09_14_200835_make_users_username_unique_index_partial.php b/app-modules/identity/database/migrations/2026_09_14_200835_make_users_username_unique_index_partial.php new file mode 100644 index 00000000..0eac9c9e --- /dev/null +++ b/app-modules/identity/database/migrations/2026_09_14_200835_make_users_username_unique_index_partial.php @@ -0,0 +1,27 @@ +where('model_type', (new User)->getMorphClass()) ->first(); - if ($identity?->model instanceof User) { - return $identity->model; + $user = $identity !== null + ? User::query()->withTrashed()->find($identity->model_id) + : null; + + if (!$user instanceof User && $oauthUser->email !== null) { + $user = User::query()->withTrashed()->where('email', $oauthUser->email)->first(); + } + + if ($user === null) { + return null; } - if ($oauthUser->email !== null) { - return User::query() - ->where('email', $oauthUser->email) - ->first(); + if ($user->trashed()) { + throw AccountSoftDeletedException::make(); } - return null; + return $user; } private function createUser(OAuthUserDTO $oauthUser): User diff --git a/app-modules/identity/src/Auth/Exceptions/AccountSoftDeletedException.php b/app-modules/identity/src/Auth/Exceptions/AccountSoftDeletedException.php new file mode 100644 index 00000000..9a2a33f6 --- /dev/null +++ b/app-modules/identity/src/Auth/Exceptions/AccountSoftDeletedException.php @@ -0,0 +1,13 @@ +execute($state, $identityProvider, $code); + } catch (AccountSoftDeletedException) { + session()->flash('error', 'Esta conta foi excluída e não pode ser reativada fazendo login novamente.'); + + return redirect()->to($state->returnUrl ?? '/'); } catch (OAuthFlowException $oAuthFlowException) { Log::warning('OAuth flow failed', ['provider' => $provider, 'error' => $oAuthFlowException->getMessage()]); diff --git a/app-modules/identity/src/Authorization/Enums/UserRole.php b/app-modules/identity/src/Authorization/Enums/UserRole.php index 02eb6341..a0856b3e 100644 --- a/app-modules/identity/src/Authorization/Enums/UserRole.php +++ b/app-modules/identity/src/Authorization/Enums/UserRole.php @@ -24,6 +24,10 @@ enum UserRole: string implements HasColor, HasDescription, HasIcon, HasLabel use StringifyEnum; case SuperAdmin = 'super-admin'; + case Staff = 'staff'; + case Compliance = 'compliance'; + case Recruiter = 'recruiter'; + case SquadCaptain = 'squad_captain'; public const string GUARD = 'web'; @@ -31,6 +35,10 @@ public function getLabel(): string { return match ($this) { self::SuperAdmin => 'Super admin', + self::Staff => 'Staff', + self::Compliance => 'Compliance', + self::Recruiter => 'Recrutador', + self::SquadCaptain => 'Capitão de squad', }; } @@ -41,6 +49,10 @@ public function getColor(): array { return match ($this) { self::SuperAdmin => Color::Red, + self::Staff => Color::Amber, + self::Compliance => Color::Orange, + self::Recruiter => Color::Blue, + self::SquadCaptain => Color::Purple, }; } @@ -48,6 +60,10 @@ public function getDescription(): string { return match ($this) { self::SuperAdmin => 'Acesso total ao painel admin. Passa por cima de qualquer verificação de permissão.', + self::Staff => 'Gerencia usuários: edita identidade, perfil e endereço, e pode soft-deletar.', + self::Compliance => 'Acumula as permissões de Staff e é o único papel que pode excluir um usuário permanentemente.', + self::Recruiter => 'Vê a ficha de um membro para fins de recrutamento, sem acesso a moderação.', + self::SquadCaptain => 'Vê a ficha de um membro do squad, sem acesso a moderação.', }; } @@ -55,6 +71,10 @@ public function getIcon(): Heroicon { return match ($this) { self::SuperAdmin => Heroicon::OutlinedShieldCheck, + self::Staff => Heroicon::OutlinedIdentification, + self::Compliance => Heroicon::OutlinedScale, + self::Recruiter => Heroicon::OutlinedBriefcase, + self::SquadCaptain => Heroicon::OutlinedFlag, }; } } diff --git a/app-modules/identity/src/IdentityServiceProvider.php b/app-modules/identity/src/IdentityServiceProvider.php index 64e6d6a1..a9130274 100644 --- a/app-modules/identity/src/IdentityServiceProvider.php +++ b/app-modules/identity/src/IdentityServiceProvider.php @@ -15,6 +15,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/Models/User.php b/app-modules/identity/src/User/Models/User.php index da61c76e..29081fd0 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -18,6 +18,8 @@ use He4rt\Identity\User\Enums\UserSituation; 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; @@ -26,8 +28,10 @@ use Illuminate\Database\Eloquent\Collection; 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; @@ -44,9 +48,10 @@ * @property CarbonInterface|null $suspended_until * @property CarbonInterface|null $banned_at * @property CarbonInterface|null $first_login_at - * @property string|null $remember_token + * @property CarbonInterface|null $deleted_at * @property CarbonInterface|null $created_at * @property CarbonInterface|null $updated_at + * @property string|null $remember_token * @property-read UserSituation $situation * @property-read Collection $roles */ @@ -65,12 +70,47 @@ final class User extends Authenticatable implements FilamentUser, HasMedia, HasN use HasUuids; use InteractsWithMedia; use Notifiable; + use SoftDeletes; public function isSuperAdmin(): bool { return $this->hasRole(UserRole::SuperAdmin); } + public function isStaff(): bool + { + return $this->hasRole(UserRole::Staff); + } + + public function isCompliance(): bool + { + return $this->hasRole(UserRole::Compliance); + } + + /** + * Quem gerencia usuários: edita identidade/perfil/endereço e soft-deleta. + */ + public function canManageUsers(): bool + { + return $this->isSuperAdmin() || $this->isStaff() || $this->isCompliance(); + } + + /** + * Hard delete é exclusivo de Compliance (e super admin, que sobrepõe tudo). + */ + public function canHardDeleteUsers(): bool + { + return $this->isSuperAdmin() || $this->isCompliance(); + } + + /** + * Recruiter/SquadCaptain veem a ficha do usuário, mas não a seção de Moderação. + */ + public function canViewModeration(): bool + { + return $this->isSuperAdmin() || $this->isStaff() || $this->isCompliance(); + } + /** * @return MorphMany */ @@ -95,6 +135,36 @@ public function profile(): HasOne return $this->hasOne(Profile::class); } + /** + * @return HasManyThrough + */ + public function profileSkills(): HasManyThrough + { + return $this->hasManyThrough( + ProfileSkill::class, + Profile::class, + 'user_id', + 'profile_id', + 'id', + 'id', + ); + } + + /** + * @return HasManyThrough + */ + public function workExperiences(): HasManyThrough + { + return $this->hasManyThrough( + WorkExperience::class, + Profile::class, + 'user_id', + 'profile_id', + 'id', + 'id', + ); + } + public function getFilamentName(): string { return $this->username; @@ -108,7 +178,13 @@ public function registerMediaCollections(): void public function canAccessPanel(Panel $panel): bool { return match ($panel->getId()) { - 'admin' => app()->isProduction() ? $this->isSuperAdmin() : true, + 'admin' => app()->isProduction() ? $this->hasAnyRole([ + UserRole::SuperAdmin, + UserRole::Staff, + UserRole::Compliance, + UserRole::Recruiter, + UserRole::SquadCaptain, + ]) : true, default => true }; } diff --git a/app-modules/identity/src/User/Observers/UserObserver.php b/app-modules/identity/src/User/Observers/UserObserver.php index e6b6c254..917883ef 100644 --- a/app-modules/identity/src/User/Observers/UserObserver.php +++ b/app-modules/identity/src/User/Observers/UserObserver.php @@ -21,7 +21,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(); } diff --git a/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php b/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php index 8ad6db6c..35d19309 100644 --- a/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php +++ b/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php @@ -5,6 +5,7 @@ use He4rt\Identity\Auth\Actions\FindOrCreateUserByProvider; use He4rt\Identity\Auth\DTOs\OAuthAccessDTO; use He4rt\Identity\Auth\DTOs\OAuthUserDTO; +use He4rt\Identity\Auth\Exceptions\AccountSoftDeletedException; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\User\Models\User; @@ -111,3 +112,29 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self expect($result->username)->toBe('danielhe4rt-4'); }); + +test('throws AccountSoftDeletedException when user is soft-deleted', function (): void { + $user = User::factory()->create(); + + ExternalIdentity::factory()->create([ + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::GitHub, + 'external_account_id' => '12345', + ]); + + $user->delete(); + + expect($user->trashed())->toBeTrue(); + + $action = resolve(FindOrCreateUserByProvider::class); + + $threw = false; + try { + $action->execute(makeOAuthUser(providerId: '12345', provider: IdentityProvider::GitHub)); + } catch (AccountSoftDeletedException) { + $threw = true; + } + + expect($threw)->toBeTrue(); +}); 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 index 9c3e371c..3760370e 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php @@ -7,11 +7,19 @@ use Filament\Actions\ViewAction; use Filament\Resources\Pages\EditRecord; use He4rt\PanelAdmin\Filament\Resources\Users\UserResource; +use He4rt\Profile\Models\Profile; class EditUser extends EditRecord { protected static string $resource = UserResource::class; + public function mount(string|int $record): void + { + parent::mount($record); + + Profile::ensureExists((string) $record); + } + /** * @return array */ 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 new file mode 100644 index 00000000..1736be44 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/ProfileSkillsRelationManager.php @@ -0,0 +1,94 @@ +components([ + Hidden::make('profile_id') + ->default(fn (): string => Profile::ensureExists($this->getOwnerRecord()->getKey())->getKey()), + + 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/Users/RelationManagers/WorkExperiencesRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/WorkExperiencesRelationManager.php new file mode 100644 index 00000000..ff6b73ac --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/RelationManagers/WorkExperiencesRelationManager.php @@ -0,0 +1,124 @@ +columns(2) + ->components([ + Hidden::make('profile_id') + ->default(fn (): string => Profile::ensureExists($this->getOwnerRecord()->getKey())->getKey()), + + 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')) + ->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/Users/Schemas/UserForm.php b/app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserForm.php index 127ff2a2..6db592ad 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 @@ -5,12 +5,20 @@ namespace He4rt\PanelAdmin\Filament\Resources\Users\Schemas; use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\KeyValue; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use He4rt\Identity\Authorization\Enums\UserRole; use He4rt\Identity\User\Models\User; +use He4rt\Profile\Data\WorkPreferences; +use He4rt\Profile\Enums\EmploymentType; +use He4rt\Profile\Enums\SeniorityLevel; +use He4rt\Profile\Enums\StartAvailability; use Spatie\Permission\Models\Role; class UserForm @@ -64,6 +72,101 @@ public static function configure(Schema $schema): Schema ->disabled(self::isEditingSelf(...)) ->helperText(fn (?User $record): ?string => self::isEditingSelf($record) ? 'Você não pode alterar os próprios papéis.' : null), ]), + + Section::make('Perfil') + ->columnSpanFull() + ->columns(2) + ->relationship('profile') + ->mutateRelationshipDataBeforeFillUsing(static function (array $data): array { + $preferences = $data['preferences'] ?? null; + $data['preferences'] = $preferences instanceof WorkPreferences ? $preferences->toArray() : $preferences; + + return $data; + }) + ->mutateRelationshipDataBeforeSaveUsing(static function (array $data): array { + $preferences = (array) ($data['preferences'] ?? []); + $data['preferences'] = WorkPreferences::makeFromPayload($preferences); + + return $data; + }) + ->schema([ + TextInput::make('nickname') + ->label('Apelido') + ->maxLength(255), + + TextInput::make('headline') + ->label('Headline') + ->maxLength(255), + + Textarea::make('about') + ->label('Sobre') + ->columnSpanFull(), + + 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() + ->minValue(0), + + Toggle::make('available_for_proposals') + ->label('Aberto a propostas'), + + Select::make('start_availability') + ->label('Disponibilidade') + ->options(StartAvailability::class), + + TextInput::make('expected_salary_min') + ->label('Pretensão salarial (mín.)') + ->numeric(), + + TextInput::make('expected_salary_max') + ->label('Pretensão salarial (máx.)') + ->numeric(), + + KeyValue::make('social_links') + ->label('Redes sociais') + ->keyLabel('Plataforma') + ->valueLabel('Handle/URL') + ->columnSpanFull(), + + Toggle::make('preferences.has_disability') + ->label('Possui deficiência'), + + Toggle::make('preferences.willing_to_relocate') + ->label('Aberto a relocação'), + + Toggle::make('preferences.is_open_to_remote') + ->label('Aberto a remoto'), + + CheckboxList::make('preferences.employment_types') + ->label('Tipos de contrato aceitos') + ->options(EmploymentType::class) + ->columnSpanFull(), + ]), + + Section::make('Endereço') + ->columnSpanFull() + ->columns(3) + ->relationship('address') + ->schema([ + TextInput::make('country') + ->label('País') + ->maxLength(255), + + TextInput::make('state') + ->label('Estado') + ->maxLength(255), + + TextInput::make('city') + ->label('Cidade') + ->maxLength(255), + ]), ]); } 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 b9be2341..a681bd55 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 @@ -5,13 +5,17 @@ namespace He4rt\PanelAdmin\Filament\Resources\Users\Schemas; use Filament\Infolists\Components\IconEntry; +use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use He4rt\Identity\Authorization\Enums\UserRole; +use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\User\Enums\UserSituation; use He4rt\Identity\User\Models\User; +use He4rt\IntegrationDiscord\Models\DiscordMember; +use He4rt\Moderation\Cases\Models\ModerationCase; class UserInfolist { @@ -100,6 +104,102 @@ public static function configure(Schema $schema): Schema ->label('Aberto a propostas') ->boolean(), ]), + + Section::make('Gamificação') + ->icon(Heroicon::OutlinedTrophy) + ->description('Somente leitura. Valores derivados não são ajustáveis pelo painel.') + ->collapsible() + ->columns(4) + ->schema([ + TextEntry::make('character.level') + ->label('Nível') + ->placeholder('—'), + + TextEntry::make('character.experience') + ->label('XP') + ->placeholder('—'), + + TextEntry::make('character.reputation') + ->label('Reputação') + ->placeholder('—'), + + TextEntry::make('wallet_balance') + ->label('Carteira') + ->state(fn (User $record): ?int => $record->character?->wallet()?->balance) + ->placeholder('—'), + + TextEntry::make('badges') + ->label('Badges') + ->state(fn (User $record): string => (string) $record->character?->badges->pluck('name')->implode(', ')) + ->placeholder('Nenhuma') + ->columnSpanFull(), + ]), + + Section::make('Atividade') + ->icon(Heroicon::OutlinedSignal) + ->collapsible() + ->schema([ + RepeatableEntry::make('providers') + ->label('Conexões') + ->columns(4) + ->schema([ + TextEntry::make('provider') + ->label('Provider') + ->badge(), + + TextEntry::make('external_account_id') + ->label('Conta'), + + TextEntry::make('connected_at') + ->label('Conectado em') + ->dateTime('d/m/Y H:i') + ->placeholder('—'), + + TextEntry::make('messages_count') + ->label('Mensagens'), + ]) + ->placeholder('Nenhuma identidade conectada'), + + TextEntry::make('discord_roles') + ->label('Roles do Discord') + ->state(fn (User $record): string => self::discordRoles($record)) + ->placeholder('—') + ->columnSpanFull(), + ]), + + Section::make('Moderação') + ->icon(Heroicon::OutlinedShieldExclamation) + ->description('Somente leitura. Casos são abertos e resolvidos pelo fluxo de moderação.') + ->collapsible() + ->columns(2) + ->visible(fn (): bool => auth()->user()?->canViewModeration() ?? false) + ->schema([ + TextEntry::make('moderation_cases_as_author') + ->label('Casos como autor') + ->state(fn (User $record): int => ModerationCase::query()->where('author_id', $record->id)->count()), + + TextEntry::make('moderation_cases_as_assignee') + ->label('Casos como responsável') + ->state(fn (User $record): int => ModerationCase::query()->where('assigned_to', $record->id)->count()), + ]), ]); } + + private static function discordRoles(User $record): string + { + $discordIdentity = $record->providers + ->firstWhere('provider', IdentityProvider::Discord); + + if ($discordIdentity === null) { + return ''; + } + + $roles = DiscordMember::query() + ->where('external_identity_id', $discordIdentity->id) + ->with('roles') + ->first() + ?->roles; + + return (string) $roles?->pluck('name')->implode(', '); + } } 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 index ca7007c0..26b89e61 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Users/Tables/UsersTable.php +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Tables/UsersTable.php @@ -5,7 +5,10 @@ namespace He4rt\PanelAdmin\Filament\Resources\Users\Tables; use Filament\Actions\Action; +use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; +use Filament\Actions\ForceDeleteAction; +use Filament\Actions\RestoreAction; use Filament\Actions\ViewAction; use Filament\Support\Enums\FontWeight; use Filament\Support\Icons\Heroicon; @@ -14,11 +17,13 @@ use Filament\Tables\Filters\Filter; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\TernaryFilter; +use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use He4rt\Identity\Authorization\Enums\UserRole; use He4rt\Identity\User\Enums\UserSituation; use He4rt\Identity\User\Models\User; use He4rt\PanelAdmin\Moderation\Resources\ModerationCaseResource; +use He4rt\Profile\Enums\SeniorityLevel; use Illuminate\Database\Eloquent\Builder; use Spatie\Permission\Models\Role; @@ -37,10 +42,27 @@ public static function configure(Table $table): Table TextColumn::make('name') ->label('Nome') - ->searchable() + ->searchable(['name', 'email']) ->sortable() ->description(fn (User $record): ?string => $record->email), + TextColumn::make('profile.seniority_level') + ->label('Senioridade') + ->badge() + ->placeholder('—'), + + IconColumn::make('profile.available_for_proposals') + ->label('Aberto a propostas') + ->boolean(), + + TextColumn::make('address.city') + ->label('Cidade') + ->placeholder('—'), + + TextColumn::make('character.level') + ->label('Nível') + ->placeholder('—'), + TextColumn::make('situation') ->label('Situação') ->badge() @@ -86,7 +108,26 @@ public static function configure(Table $table): Table ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) + ->paginated([25, 50, 100]) ->filters([ + SelectFilter::make('seniority_level') + ->label('Senioridade') + ->options(SeniorityLevel::class) + ->query(static fn (Builder $query, array $data): Builder => match ($data['value'] ?? null) { + null, '' => $query, + default => $query->whereHas('profile', static fn (Builder $profile): Builder => $profile->where('seniority_level', $data['value'])), + }), + + TernaryFilter::make('available_for_proposals') + ->label('Aberto a propostas') + ->queries( + true: static fn (Builder $query): Builder => $query->whereHas('profile', static fn (Builder $profile): Builder => $profile->where('available_for_proposals', operator: true)), + false: static fn (Builder $query): Builder => $query->whereHas('profile', static fn (Builder $profile): Builder => $profile->where('available_for_proposals', operator: false)), + blank: static fn (Builder $query): Builder => $query, + ), + + TrashedFilter::make(), + SelectFilter::make('situation') ->label('Situação') ->options(UserSituation::class) @@ -118,6 +159,10 @@ public static function configure(Table $table): Table ->recordActions([ ViewAction::make(), EditAction::make(), + DeleteAction::make(), + RestoreAction::make(), + ForceDeleteAction::make() + ->requiresConfirmation(), Action::make('moderationCases') ->label('Casos de moderação') ->icon(Heroicon::OutlinedShieldExclamation) diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php b/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php index 4a0bc768..8dc10a61 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php +++ b/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php @@ -14,12 +14,15 @@ use He4rt\PanelAdmin\Filament\Resources\Users\Pages\EditUser; use He4rt\PanelAdmin\Filament\Resources\Users\Pages\ListUsers; use He4rt\PanelAdmin\Filament\Resources\Users\Pages\ViewUser; +use He4rt\PanelAdmin\Filament\Resources\Users\RelationManagers\ProfileSkillsRelationManager; use He4rt\PanelAdmin\Filament\Resources\Users\RelationManagers\ProvidersRelationManager; +use He4rt\PanelAdmin\Filament\Resources\Users\RelationManagers\WorkExperiencesRelationManager; use He4rt\PanelAdmin\Filament\Resources\Users\Schemas\UserForm; use He4rt\PanelAdmin\Filament\Resources\Users\Schemas\UserInfolist; use He4rt\PanelAdmin\Filament\Resources\Users\Tables\UsersTable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletingScope; class UserResource extends Resource { @@ -59,9 +62,45 @@ public static function getRelations(): array { return [ ProvidersRelationManager::class, + ProfileSkillsRelationManager::class, + WorkExperiencesRelationManager::class, ]; } + public static function canEdit(Model $record): bool + { + return auth()->user()?->canManageUsers() ?? false; + } + + public static function canDelete(Model $record): bool + { + return auth()->user()?->canManageUsers() ?? false; + } + + public static function canForceDelete(Model $record): bool + { + return auth()->user()?->canHardDeleteUsers() ?? false; + } + + public static function canRestore(Model $record): bool + { + return auth()->user()?->canManageUsers() ?? false; + } + + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery() + ->with(['profile', 'address', 'character', 'providers', 'roles']); + + if (auth()->user()?->canManageUsers()) { + $query->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } + + return $query; + } + /** * @return array */ @@ -74,15 +113,6 @@ public static function getPages(): array ]; } - 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', 'roles']); - } - /** * @return array */ diff --git a/app-modules/panel-admin/tests/Feature/Identity/UserResourceTest.php b/app-modules/panel-admin/tests/Feature/Identity/UserResourceTest.php index 40452ab0..fde8f5cb 100644 --- a/app-modules/panel-admin/tests/Feature/Identity/UserResourceTest.php +++ b/app-modules/panel-admin/tests/Feature/Identity/UserResourceTest.php @@ -10,8 +10,13 @@ use He4rt\Identity\User\Models\User; use He4rt\PanelAdmin\Filament\Resources\Users\Pages\EditUser; use He4rt\PanelAdmin\Filament\Resources\Users\Pages\ListUsers; +use He4rt\PanelAdmin\Filament\Resources\Users\RelationManagers\ProfileSkillsRelationManager; use He4rt\PanelAdmin\Filament\Resources\Users\RelationManagers\ProvidersRelationManager; +use He4rt\PanelAdmin\Filament\Resources\Users\RelationManagers\WorkExperiencesRelationManager; use He4rt\PanelAdmin\Filament\Resources\Users\UserResource; +use He4rt\Profile\Enums\SkillProficiency; +use He4rt\Profile\Models\Profile; +use He4rt\Profile\Models\Skill; use Illuminate\Support\Str; use Spatie\Permission\Models\Role; @@ -180,3 +185,212 @@ ->assertCanSeeTableRecords([$this->admin]) ->assertCanNotSeeTableRecords([$regular]); }); + +test('Staff pode editar usuário', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + + expect(UserResource::canEdit($staff))->toBeTrue(); +}); + +test('Recruiter não pode editar usuário', function (): void { + $recruiter = User::factory()->recruiter()->create(); + $target = User::factory()->create(); + + $this->actingAs($recruiter); + + expect(UserResource::canEdit($recruiter))->toBeFalse(); +}); + +test('SquadCaptain não pode editar usuário', function (): void { + $captain = User::factory()->squadCaptain()->create(); + $target = User::factory()->create(); + + $this->actingAs($captain); + + expect(UserResource::canEdit($captain))->toBeFalse(); +}); + +test('Compliance pode force-deletar', function (): void { + $compliance = User::factory()->compliance()->create(); + $target = User::factory()->create(); + + $this->actingAs($compliance); + + expect(UserResource::canForceDelete($compliance))->toBeTrue(); +}); + +test('Staff não pode force-deletar', function (): void { + $staff = User::factory()->staff()->create(); + $this->actingAs($staff); + + expect(UserResource::canForceDelete($staff))->toBeFalse(); +}); + +test('a tabela tem coluna senioridade', function (): void { + livewire(ListUsers::class)->loadTable()->assertTableColumnExists('profile.seniority_level'); +}); + +test('a tabela tem coluna de cidade', function (): void { + livewire(ListUsers::class)->loadTable()->assertTableColumnExists('address.city'); +}); + +test('a tabela tem coluna de nível do character', function (): void { + livewire(ListUsers::class)->loadTable()->assertTableColumnExists('character.level'); +}); + +test('o method canDelete retorna verdadeiro para Staff', function (): void { + $staff = User::factory()->staff()->create(); + $this->actingAs($staff); + + expect(UserResource::canDelete($staff))->toBeTrue(); +}); + +test('o method canDelete retorna falso para Recruiter', function (): void { + $recruiter = User::factory()->recruiter()->create(); + $this->actingAs($recruiter); + + expect(UserResource::canDelete($recruiter))->toBeFalse(); +}); + +test('o relation manager de skills existe', function (): void { + expect(UserResource::getRelations())->toContain(ProfileSkillsRelationManager::class); +}); + +test('o relation manager de experiências existe', function (): void { + expect(UserResource::getRelations())->toContain(WorkExperiencesRelationManager::class); +}); + +test('canEdit retorna falso para Recruiter', function (): void { + $recruiter = User::factory()->recruiter()->create(); + $this->actingAs($recruiter); + + expect(UserResource::canEdit($recruiter))->toBeFalse(); +}); + +test('canEdit retorna verdadeiro para Staff', function (): void { + $staff = User::factory()->staff()->create(); + $this->actingAs($staff); + + expect(UserResource::canEdit($staff))->toBeTrue(); +}); + +test('canForceDelete retorna verdadeiro para Compliance', function (): void { + $compliance = User::factory()->compliance()->create(); + $this->actingAs($compliance); + + expect(UserResource::canForceDelete($compliance))->toBeTrue(); +}); + +test('canForceDelete retorna falso para Staff', function (): void { + $staff = User::factory()->staff()->create(); + $this->actingAs($staff); + + expect(UserResource::canForceDelete($staff))->toBeFalse(); +}); + +test('Staff soft-deleta um usuário pela tabela', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + + $this->actingAs($staff); + + livewire(ListUsers::class) + ->loadTable() + ->callTableAction('delete', $target); + + expect($target->fresh()?->trashed())->toBeTrue(); +}); + +test('Staff não vê a ação de force-delete na tabela', function (): void { + $staff = User::factory()->staff()->create(); + $target = User::factory()->create(); + + $this->actingAs($staff); + + livewire(ListUsers::class) + ->loadTable() + ->assertTableActionHidden('forceDelete', $target); +}); + +test('Compliance force-deleta um usuário soft-deletado pela tabela', function (): void { + $compliance = User::factory()->compliance()->create(); + $target = User::factory()->create(); + $target->delete(); + + $this->actingAs($compliance); + + livewire(ListUsers::class) + ->loadTable() + ->filterTable('trashed') + ->callTableAction('forceDelete', $target); + + expect(User::withTrashed()->find($target->getKey()))->toBeNull(); +}); + +test('Compliance restaura um usuário soft-deletado pela tabela', function (): void { + $compliance = User::factory()->compliance()->create(); + $target = User::factory()->create(); + $target->delete(); + + $this->actingAs($compliance); + + livewire(ListUsers::class) + ->loadTable() + ->filterTable('trashed') + ->callTableAction('restore', $target); + + expect($target->fresh()?->trashed())->toBeFalse(); +}); + +test('o relation manager de skills anexa e remove uma skill do usuário', function (): void { + $target = User::factory()->create(); + Profile::ensureExists($target->getKey()); + $skill = Skill::factory()->create(); + + $manager = livewire(ProfileSkillsRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => EditUser::class, + ])->loadTable()->assertOk(); + + $manager->callTableAction('create', data: [ + 'skill_id' => $skill->getKey(), + 'proficiency' => SkillProficiency::cases()[0]->value, + 'years_experience' => 3, + ])->assertHasNoTableActionErrors(); + + expect($target->fresh()->profileSkills()->where('skill_id', $skill->getKey())->exists())->toBeTrue(); + + $profileSkill = $target->fresh()->profileSkills()->where('skill_id', $skill->getKey())->first(); + + $manager->callTableAction('delete', $profileSkill); + + expect($target->fresh()->profileSkills()->where('skill_id', $skill->getKey())->exists())->toBeFalse(); +}); + +test('o relation manager de experiências cria e remove uma experiência do usuário', function (): void { + $target = User::factory()->create(); + Profile::ensureExists($target->getKey()); + + $manager = livewire(WorkExperiencesRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => EditUser::class, + ])->loadTable()->assertOk(); + + $manager->callTableAction('create', data: [ + 'company_name' => 'He4rt Developers', + 'position' => 'Engenheiro de Software', + 'description' => 'Trabalhando em projetos open source.', + 'start_date' => now()->subYear()->format('Y-m-d'), + 'is_currently_working_here' => true, + 'end_date' => null, + ])->assertHasNoTableActionErrors(); + + $workExperience = $target->fresh()->workExperiences()->where('company_name', 'He4rt Developers')->first(); + + expect($workExperience)->not->toBeNull(); + + $manager->callTableAction('delete', $workExperience); + + expect($target->fresh()->workExperiences()->whereKey($workExperience->getKey())->exists())->toBeFalse(); +}); diff --git a/database/seeders/BaseSeeder.php b/database/seeders/BaseSeeder.php index 559000be..a7edbdcf 100644 --- a/database/seeders/BaseSeeder.php +++ b/database/seeders/BaseSeeder.php @@ -27,6 +27,7 @@ public function run(): void 'name' => 'Daniel Reis', 'email' => 'admin@admin.com', 'password' => Hash::make('admin'), + 'role' => 'staff', ]); $admin->assignRole(UserRole::SuperAdmin); diff --git a/tests/Feature/AddressTest.php b/tests/Feature/AddressTest.php index 80d3a9fc..24286edc 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(); });