Add profile avatar uploads #104
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
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 <<<SVG
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192" role="img" aria-label="Profile avatar">
|
||||||
|
<rect width="192" height="192" rx="42" fill="{$color}"/>
|
||||||
|
<text x="50%" y="52%" dominant-baseline="middle" text-anchor="middle" fill="#ffffff" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="72" font-weight="700">{$escapedInitials}</text>
|
||||||
|
</svg>
|
||||||
|
SVG;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Actions\Users\UpdateUserAvatar;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Models\PermissionAssignment;
|
use App\Models\PermissionAssignment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -9,6 +10,10 @@ use Illuminate\Database\Seeder;
|
|||||||
|
|
||||||
class UserSeeder extends Seeder
|
class UserSeeder extends Seeder
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UpdateUserAvatar $avatars,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the database seeds.
|
* Run the database seeds.
|
||||||
*/
|
*/
|
||||||
@@ -18,6 +23,7 @@ class UserSeeder extends Seeder
|
|||||||
'name' => 'Admin User',
|
'name' => 'Admin User',
|
||||||
'email' => 'admin@kanbangit.test',
|
'email' => 'admin@kanbangit.test',
|
||||||
]);
|
]);
|
||||||
|
$this->avatars->generate($admin);
|
||||||
|
|
||||||
PermissionAssignment::factory()
|
PermissionAssignment::factory()
|
||||||
->forAssignee($admin)
|
->forAssignee($admin)
|
||||||
@@ -28,6 +34,7 @@ class UserSeeder extends Seeder
|
|||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
]);
|
]);
|
||||||
|
$this->avatars->generate($testUser);
|
||||||
|
|
||||||
PermissionAssignment::factory()
|
PermissionAssignment::factory()
|
||||||
->forAssignee($testUser)
|
->forAssignee($testUser)
|
||||||
@@ -35,6 +42,8 @@ class UserSeeder extends Seeder
|
|||||||
->permission(Permission::ProjectCreate)
|
->permission(Permission::ProjectCreate)
|
||||||
->create();
|
->create();
|
||||||
|
|
||||||
User::factory(8)->create();
|
User::factory(8)
|
||||||
|
->create()
|
||||||
|
->each(fn (User $user) => $this->avatars->generate($user));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ namespace Tests\Feature\Models;
|
|||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Database\Seeders\UserSeeder;
|
use Database\Seeders\UserSeeder;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -66,6 +69,44 @@ class UserTest extends TestCase
|
|||||||
$this->assertFalse($missing->hasUploadedAvatar());
|
$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.
|
* 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
|
public function test_user_seeder_creates_admin_and_regular_users(): void
|
||||||
{
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
$this->seed(UserSeeder::class);
|
$this->seed(UserSeeder::class);
|
||||||
|
|
||||||
$admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
|
$admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
|
||||||
@@ -107,6 +150,15 @@ class UserTest extends TestCase
|
|||||||
'scope_id' => null,
|
'scope_id' => null,
|
||||||
'scope_type' => 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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user