From f6ec164f27886e098f8959851d7a085e6a234cb0 Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 24 Jun 2026 14:41:28 +0200 Subject: [PATCH] Backfill avatars for existing users --- ..._123911_backfill_existing_user_avatars.php | 109 ++++++++++++++++++ database/seeders/UserSeeder.php | 11 +- tests/Feature/Models/UserTest.php | 52 +++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_06_24_123911_backfill_existing_user_avatars.php diff --git a/database/migrations/2026_06_24_123911_backfill_existing_user_avatars.php b/database/migrations/2026_06_24_123911_backfill_existing_user_avatars.php new file mode 100644 index 0000000..da81290 --- /dev/null +++ b/database/migrations/2026_06_24_123911_backfill_existing_user_avatars.php @@ -0,0 +1,109 @@ + + */ + private const COLORS = [ + '#F97316', + '#22C55E', + '#06B6D4', + '#EC4899', + '#8B5CF6', + '#EAB308', + '#EF4444', + '#14B8A6', + ]; + + /** + * Run the migrations. + */ + public function up(): void + { + DB::table('users') + ->select(['id', 'name']) + ->whereNull('avatar_path') + ->orderBy('id') + ->chunkById(100, function (Collection $users): void { + foreach ($users as $user) { + $color = self::COLORS[random_int(0, count(self::COLORS) - 1)]; + $path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid()); + + $stored = Storage::disk('public')->put( + $path, + $this->svg($this->initials((string) $user->name), $color), + ); + + if (! $stored) { + throw new RuntimeException('Generated avatar could not be stored.'); + } + + DB::table('users') + ->where('id', $user->id) + ->update([ + 'avatar_path' => $path, + 'avatar_color' => $color, + 'avatar_is_generated' => true, + ]); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::table('users') + ->select(['avatar_path']) + ->where('avatar_is_generated', true) + ->whereNotNull('avatar_path') + ->orderBy('id') + ->chunkById(100, function (Collection $users): void { + foreach ($users as $user) { + Storage::disk('public')->delete($user->avatar_path); + } + }); + + DB::table('users') + ->where('avatar_is_generated', true) + ->update([ + 'avatar_path' => null, + 'avatar_color' => null, + 'avatar_is_generated' => false, + ]); + } + + private function initials(string $name): string + { + $parts = preg_split('/\s+/u', trim($name)) ?: []; + + if ($parts === []) { + return '?'; + } + + $first = mb_substr($parts[0], 0, 1); + $last = count($parts) > 1 ? mb_substr($parts[count($parts) - 1], 0, 1) : ''; + + return mb_strtoupper($first.$last); + } + + private function svg(string $initials, string $color): string + { + $escapedInitials = htmlspecialchars($initials, ENT_QUOTES | ENT_XML1, 'UTF-8'); + + return << + + {$escapedInitials} + +SVG; + } +}; diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 960bdf0..e8c2c0d 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Actions\Users\UpdateUserAvatar; use App\Enums\Permission; use App\Models\PermissionAssignment; use App\Models\User; @@ -9,6 +10,10 @@ use Illuminate\Database\Seeder; class UserSeeder extends Seeder { + public function __construct( + private readonly UpdateUserAvatar $avatars, + ) {} + /** * Run the database seeds. */ @@ -18,6 +23,7 @@ class UserSeeder extends Seeder 'name' => 'Admin User', 'email' => 'admin@kanbangit.test', ]); + $this->avatars->generate($admin); PermissionAssignment::factory() ->forAssignee($admin) @@ -28,6 +34,7 @@ class UserSeeder extends Seeder 'name' => 'Test User', 'email' => 'test@example.com', ]); + $this->avatars->generate($testUser); PermissionAssignment::factory() ->forAssignee($testUser) @@ -35,6 +42,8 @@ class UserSeeder extends Seeder ->permission(Permission::ProjectCreate) ->create(); - User::factory(8)->create(); + User::factory(8) + ->create() + ->each(fn (User $user) => $this->avatars->generate($user)); } } diff --git a/tests/Feature/Models/UserTest.php b/tests/Feature/Models/UserTest.php index 295e5b3..c81dfb7 100644 --- a/tests/Feature/Models/UserTest.php +++ b/tests/Feature/Models/UserTest.php @@ -5,7 +5,10 @@ namespace Tests\Feature\Models; use App\Models\Team; use App\Models\User; use Database\Seeders\UserSeeder; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use Tests\TestCase; @@ -66,6 +69,44 @@ class UserTest extends TestCase $this->assertFalse($missing->hasUploadedAvatar()); } + public function test_avatar_migration_backfills_existing_users(): void + { + Storage::fake('public'); + + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn([ + 'avatar_path', + 'avatar_color', + 'avatar_is_generated', + ]); + }); + + DB::table('users')->insert([ + 'name' => 'Legacy User', + 'email' => 'legacy@example.com', + 'email_verified_at' => now(), + 'password' => 'password', + 'must_change_password_on_next_login' => false, + 'remember_token' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $avatarFieldsMigration = require database_path('migrations/2026_06_24_114831_add_avatar_fields_to_users_table.php'); + $avatarFieldsMigration->up(); + + $backfillMigration = require database_path('migrations/2026_06_24_123911_backfill_existing_user_avatars.php'); + $backfillMigration->up(); + + $user = DB::table('users')->where('email', 'legacy@example.com')->first(); + + $this->assertNotNull($user->avatar_path); + $this->assertNotNull($user->avatar_color); + $this->assertTrue((bool) $user->avatar_is_generated); + Storage::disk('public')->assertExists($user->avatar_path); + $this->assertStringContainsString('LU', Storage::disk('public')->get($user->avatar_path)); + } + /** * Ensure the admin factory state creates admin users. */ @@ -87,6 +128,8 @@ class UserTest extends TestCase */ public function test_user_seeder_creates_admin_and_regular_users(): void { + Storage::fake('public'); + $this->seed(UserSeeder::class); $admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail(); @@ -107,6 +150,15 @@ class UserTest extends TestCase 'scope_id' => null, 'scope_type' => null, ]); + + User::query() + ->get() + ->each(function (User $user): void { + $this->assertTrue($user->avatar_is_generated); + $this->assertNotNull($user->avatar_path); + $this->assertNotNull($user->avatar_color); + Storage::disk('public')->assertExists($user->avatar_path); + }); } /**