feat: implement tenant OAuth flow and guest achievements

This commit is contained in:
2025-09-25 08:32:37 +02:00
parent ef6203c603
commit b22d91ed32
84 changed files with 5984 additions and 1399 deletions

View File

@@ -12,9 +12,11 @@ return new class extends Migration
public function up(): void
{
Schema::table('photos', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('event_id');
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
$table->index('tenant_id');
if (!Schema::hasColumn('photos', 'tenant_id')) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('event_id');
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
$table->index('tenant_id');
}
});
}
@@ -24,8 +26,10 @@ return new class extends Migration
public function down(): void
{
Schema::table('photos', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
$table->dropColumn('tenant_id');
if (Schema::hasColumn('photos', 'tenant_id')) {
$table->dropForeign(['tenant_id']);
$table->dropColumn('tenant_id');
}
});
}
};
};

View File

@@ -0,0 +1,79 @@
<?php
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
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasColumn('oauth_clients', 'is_active')) {
Schema::table('oauth_clients', function (Blueprint $table) {
$table->boolean('is_active')->default(true);
});
}
$clients = DB::table('oauth_clients')->get(['id', 'scopes', 'redirect_uris', 'is_active']);
foreach ($clients as $client) {
$scopes = $this->normaliseValue($client->scopes, ['tenant:read', 'tenant:write']);
$redirects = $this->normaliseValue($client->redirect_uris);
DB::table('oauth_clients')
->where('id', $client->id)
->update([
'scopes' => $scopes === null ? null : json_encode($scopes),
'redirect_uris' => $redirects === null ? null : json_encode($redirects),
'is_active' => $client->is_active ?? true,
]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('oauth_clients', 'is_active')) {
Schema::table('oauth_clients', function (Blueprint $table) {
$table->dropColumn('is_active');
});
}
}
private function normaliseValue(mixed $value, ?array $fallback = null): ?array
{
if ($value === null) {
return $fallback;
}
if (is_array($value)) {
return $this->cleanArray($value) ?: $fallback;
}
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
return $this->cleanArray($decoded) ?: $fallback;
}
$parts = preg_split('/[\r\n,]+/', $value) ?: [];
return $this->cleanArray($parts) ?: $fallback;
}
return $fallback;
}
private function cleanArray(array $items): array
{
$items = array_map(fn ($item) => is_string($item) ? trim($item) : $item, $items);
$items = array_filter($items, fn ($item) => ! ($item === null || $item === ''));
return array_values($items);
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('oauth_clients', function (Blueprint $table) {
if (!Schema::hasColumn('oauth_clients', 'tenant_id')) {
$table->foreignId('tenant_id')
->nullable()
->after('client_secret')
->constrained('tenants')
->nullOnDelete();
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('oauth_clients', function (Blueprint $table) {
if (Schema::hasColumn('oauth_clients', 'tenant_id')) {
$table->dropConstrainedForeignId('tenant_id');
}
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('refresh_tokens', function (Blueprint $table) {
if (!Schema::hasColumn('refresh_tokens', 'client_id')) {
$table->string('client_id', 255)->nullable()->after('tenant_id')->index();
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('refresh_tokens', function (Blueprint $table) {
if (Schema::hasColumn('refresh_tokens', 'client_id')) {
$table->dropColumn('client_id');
}
});
}
};

View File

@@ -1,9 +1,7 @@
<?php
<?php
namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
@@ -31,6 +29,14 @@ class DatabaseSeeder extends Seeder
$this->call([
SuperAdminSeeder::class,
DemoEventSeeder::class,
OAuthClientSeeder::class,
]);
if (app()->environment(['local', 'development', 'demo'])) {
$this->call([
DemoPhotosSeeder::class,
DemoAchievementsSeeder::class,
]);
}
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Database\Seeders;
use App\Models\Emotion;
use App\Models\Event;
use App\Models\Photo;
use App\Models\PhotoLike;
use App\Models\Task;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class DemoAchievementsSeeder extends Seeder
{
public function run(): void
{
$event = Event::where('slug', 'demo-wedding-2025')->first();
$tenant = Tenant::where('slug', 'demo')->first();
if (! $event || ! $tenant) {
$this->command?->warn('Demo event/tenant missing skipping DemoAchievementsSeeder');
return;
}
$tasks = Task::where('tenant_id', $tenant->id)->pluck('id')->all();
$emotions = Emotion::pluck('id')->all();
if ($tasks === [] || $emotions === []) {
$this->command?->warn('Tasks or emotions missing skipping DemoAchievementsSeeder');
return;
}
$sourceFiles = collect(Storage::disk('public')->files('photos'))
->filter(fn ($path) => Str::endsWith(Str::lower($path), '.jpg'))
->values();
if ($sourceFiles->isEmpty()) {
$this->command?->warn('No demo photo files found skipping DemoAchievementsSeeder');
return;
}
$blueprints = [
['guest' => 'Anna Mueller', 'photos' => 6, 'likes' => [12, 8, 5, 4, 2, 1], 'withTasks' => true],
['guest' => 'Max Schmidt', 'photos' => 4, 'likes' => [9, 7, 4, 2], 'withTasks' => true],
['guest' => 'Lisa Weber', 'photos' => 2, 'likes' => [3, 1], 'withTasks' => false],
['guest' => 'Tom Fischer', 'photos' => 1, 'likes' => [14], 'withTasks' => true],
['guest' => 'Team Brautparty', 'photos' => 5, 'likes' => [5, 4, 3, 3, 2], 'withTasks' => true],
];
$eventDate = $event->date ? CarbonImmutable::parse($event->date) : CarbonImmutable::now();
$baseDir = "events/{$event->id}/achievements";
Storage::disk('public')->makeDirectory($baseDir);
Storage::disk('public')->makeDirectory("{$baseDir}/thumbs");
$photoIndex = 0;
foreach ($blueprints as $groupIndex => $blueprint) {
for ($i = 0; $i < $blueprint['photos']; $i++) {
$source = $sourceFiles[$photoIndex % $sourceFiles->count()];
$photoIndex++;
$filename = Str::slug($blueprint['guest'] . '-' . $groupIndex . '-' . $i) . '.jpg';
$destPath = "{$baseDir}/{$filename}";
if (! Storage::disk('public')->exists($destPath)) {
Storage::disk('public')->copy($source, $destPath);
}
$thumbSource = str_replace('photos/', 'thumbnails/', $source);
$thumbDest = "{$baseDir}/thumbs/{$filename}";
if (Storage::disk('public')->exists($thumbSource)) {
Storage::disk('public')->copy($thumbSource, $thumbDest);
} else {
Storage::disk('public')->copy($source, $thumbDest);
}
$taskId = $blueprint['withTasks'] ? $tasks[($groupIndex + $i) % count($tasks)] : null;
$emotionId = $emotions[($groupIndex * 3 + $i) % count($emotions)];
$createdAt = $eventDate->addHours($groupIndex * 2 + $i);
$likes = $blueprint['likes'][$i] ?? 0;
$photo = Photo::updateOrCreate(
[
'tenant_id' => $tenant->id,
'event_id' => $event->id,
'guest_name' => $blueprint['guest'],
'file_path' => $destPath,
],
[
'task_id' => $taskId,
'emotion_id' => $emotionId,
'thumbnail_path' => $thumbDest,
'likes_count' => $likes,
'is_featured' => $i === 0,
'metadata' => ['demo' => true],
'created_at' => $createdAt,
'updated_at' => $createdAt,
]
);
PhotoLike::where('photo_id', $photo->id)->delete();
for ($like = 0; $like < min($likes, 15); $like++) {
PhotoLike::create([
'photo_id' => $photo->id,
'guest_name' => 'Guest_' . Str::random(6),
'ip_address' => '10.0.' . rand(0, 254) . '.' . rand(0, 254),
'created_at' => $createdAt->addMinutes($like * 3),
]);
}
}
}
$this->command?->info('Demo achievements seeded.');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Database\Seeders;
use App\Models\OAuthClient;
use App\Models\Tenant;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
class OAuthClientSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$clientId = 'tenant-admin-app';
$tenantId = Tenant::where('slug', 'demo')->value('id')
?? Tenant::query()->orderBy('id')->value('id');
$redirectUris = [
'http://localhost:5174/auth/callback',
'http://localhost:8000/auth/callback',
];
$scopes = [
'tenant:read',
'tenant:write',
];
$client = OAuthClient::firstOrNew(['client_id' => $clientId]);
if (!$client->exists) {
$client->id = (string) Str::uuid();
}
$client->fill([
'client_secret' => null, // Public client, no secret needed for PKCE
'tenant_id' => $tenantId,
'redirect_uris' => $redirectUris,
'scopes' => $scopes,
'is_active' => true,
]);
$client->save();
}
}