Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions app-modules/identity/database/factories/UserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +25,32 @@ public function definition(): array
'email' => fake()->email(),
'password' => Hash::make('password'),
'is_donator' => false,
'role' => Role::Member,
];
}

public function member(): static
{
return $this->state(['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]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

use He4rt\Identity\User\Enums\Role;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('users', static function (Blueprint $table): void {
$table->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');

$this->deduplicateUsernames();

Schema::table('users', static function (Blueprint $table): void {
$table->unique('username');
$table->dropColumn(['role', 'deleted_at']);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}

/**
* 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<string, true> $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]);
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

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;

return new class extends Migration
{
/**
* Backfills users listed in HE4RT_ADMINS_USERNAMES (isAdmin() bypass)
* that don't already have Staff/Compliance role, so panel authorization
* relies on a single source of truth (role) instead of the env list.
*/
public function up(): void
{
$usernames = User::configuredAdminUsernames();

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.
}
};
13 changes: 13 additions & 0 deletions app-modules/identity/lang/en/enums.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

return [
'role' => [
'staff' => 'Staff',
'compliance' => 'Compliance',
'recruiter' => 'Recruiter',
'squad_captain' => 'Squad Captain',
'member' => 'Member',
],
];
13 changes: 13 additions & 0 deletions app-modules/identity/lang/pt_BR/enums.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

return [
'role' => [
'staff' => 'Staff',
'compliance' => 'Compliance',
'recruiter' => 'Recrutador',
'squad_captain' => 'Capitão de Squad',
'member' => 'Membro',
],
];
1 change: 1 addition & 0 deletions app-modules/identity/src/IdentityServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
73 changes: 73 additions & 0 deletions app-modules/identity/src/User/Enums/Role.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\User\Enums;

use App\Enums\Concerns\StringifyEnum;
use Filament\Support\Colors\Color;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
use Filament\Support\Icons\Heroicon;

enum Role: string implements HasColor, HasIcon, HasLabel
{
use StringifyEnum;

case Staff = 'staff';
case Compliance = 'compliance';
case Recruiter = 'recruiter';
case SquadCaptain = 'squad_captain';
case Member = 'member';

public function getLabel(): string
{
return __('identity::enums.role.'.$this->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);
}
}
Loading