From 9248d7a3f520f39b528168fb675f1455e6da63fc Mon Sep 17 00:00:00 2001 From: SEB Fotografie - soeren Date: Thu, 25 Sep 2025 11:50:18 +0200 Subject: [PATCH] feat: harden tenant settings and import pipeline --- .../EmotionResource/Pages/ImportEmotions.php | 64 +---------- .../Api/Tenant/SettingsController.php | 56 ++++------ app/Http/Middleware/TenantTokenGuard.php | 6 +- .../Settings/ProfileUpdateRequest.php | 7 +- .../Requests/Tenant/SettingsStoreRequest.php | 15 +-- app/Http/Resources/Tenant/TaskResource.php | 32 +++--- app/Models/Emotion.php | 4 + app/Models/Event.php | 14 ++- app/Models/EventType.php | 4 + app/Models/Photo.php | 16 +-- app/Models/PurchaseHistory.php | 25 ++--- app/Models/Task.php | 7 +- app/Models/TaskCollection.php | 34 +----- app/Models/Tenant.php | 26 +++-- app/Services/EmotionImportService.php | 71 ++++++++++++ database/factories/EmotionFactory.php | 32 ++++++ database/factories/EventFactory.php | 6 +- database/factories/EventTypeFactory.php | 31 ++++++ database/factories/PhotoFactory.php | 48 ++++++++ database/factories/PurchaseHistoryFactory.php | 29 +++++ database/factories/TenantFactory.php | 14 ++- ...160000_add_soft_deletes_to_tasks_table.php | 26 +++++ ...500_add_custom_domain_to_tenants_table.php | 27 +++++ tests/Feature/EmotionResourceTest.php | 25 ++--- tests/Feature/Tenant/SettingsApiTest.php | 12 +- tests/Feature/Tenant/TaskApiTest.php | 10 +- tests/Feature/Tenant/TenantTestCase.php | 105 ++++++++++++++---- tests/Feature/TenantCreditsTest.php | 54 +-------- tests/TestCase.php | 70 +++++++++++- 29 files changed, 577 insertions(+), 293 deletions(-) create mode 100644 app/Services/EmotionImportService.php create mode 100644 database/factories/EmotionFactory.php create mode 100644 database/factories/EventTypeFactory.php create mode 100644 database/factories/PhotoFactory.php create mode 100644 database/factories/PurchaseHistoryFactory.php create mode 100644 database/migrations/2025_09_25_160000_add_soft_deletes_to_tasks_table.php create mode 100644 database/migrations/2025_09_25_170500_add_custom_domain_to_tenants_table.php diff --git a/app/Filament/Resources/EmotionResource/Pages/ImportEmotions.php b/app/Filament/Resources/EmotionResource/Pages/ImportEmotions.php index 00224bd..cb5d2a9 100644 --- a/app/Filament/Resources/EmotionResource/Pages/ImportEmotions.php +++ b/app/Filament/Resources/EmotionResource/Pages/ImportEmotions.php @@ -3,12 +3,11 @@ namespace App\Filament\Resources\EmotionResource\Pages; use App\Filament\Resources\EmotionResource; -use App\Models\Emotion; +use App\Services\EmotionImportService; use Filament\Forms\Components\FileUpload; use Filament\Forms\Form; use Filament\Notifications\Notification; use Filament\Resources\Pages\Page; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; class ImportEmotions extends Page @@ -35,13 +34,13 @@ class ImportEmotions extends Page $this->validate(); $path = $this->form->getState()['file'] ?? null; - if (!$path || !Storage::disk('public')->exists($path)) { + if (! $path || ! Storage::disk('public')->exists($path)) { Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send(); return; } $fullPath = Storage::disk('public')->path($path); - [$ok, $fail] = $this->importEmotionsCsv($fullPath); + [$ok, $fail] = app(EmotionImportService::class)->import($fullPath); Notification::make() ->success() @@ -54,61 +53,4 @@ class ImportEmotions extends Page { return __('admin.emotions.import.heading'); } - - private function importEmotionsCsv(string $file): array - { - $handle = fopen($file, 'r'); - if (!$handle) { - return [0, 0]; - } - - $ok = 0; - $fail = 0; - $headers = fgetcsv($handle, 0, ','); - if (!$headers) { - return [0, 0]; - } - - $map = array_flip($headers); - - while (($row = fgetcsv($handle, 0, ',')) !== false) { - try { - DB::transaction(function () use ($row, $map, &$ok) { - $nameDe = trim($row[$map['name_de']] ?? ''); - $nameEn = trim($row[$map['name_en']] ?? ''); - - if (empty($nameDe) && empty($nameEn)) { - throw new \Exception('Name is required.'); - } - - $emotion = Emotion::create([ - 'name' => ['de' => $nameDe, 'en' => $nameEn], - 'icon' => $row[$map['icon']] ?? null, - 'color' => $row[$map['color']] ?? null, - 'description' => [ - 'de' => $row[$map['description_de']] ?? null, - 'en' => $row[$map['description_en']] ?? null, - ], - 'sort_order' => (int)($row[$map['sort_order']] ?? 0), - 'is_active' => (int)($row[$map['is_active']] ?? 1) ? 1 : 0, - ]); - - $eventTypes = $row[$map['event_types']] ?? ''; - if ($eventTypes) { - $slugs = array_filter(array_map('trim', explode('|', $eventTypes))); - if ($slugs) { - $eventTypeIds = DB::table('event_types')->whereIn('slug', $slugs)->pluck('id')->all(); - $emotion->eventTypes()->attach($eventTypeIds); - } - } - $ok++; - }); - } catch (\Throwable $e) { - $fail++; - } - } - - fclose($handle); - return [$ok, $fail]; - } } diff --git a/app/Http/Controllers/Api/Tenant/SettingsController.php b/app/Http/Controllers/Api/Tenant/SettingsController.php index 1d72f17..2a79f7e 100644 --- a/app/Http/Controllers/Api/Tenant/SettingsController.php +++ b/app/Http/Controllers/Api/Tenant/SettingsController.php @@ -5,21 +5,18 @@ namespace App\Http\Controllers\Api\Tenant; use App\Http\Controllers\Controller; use App\Http\Requests\Tenant\SettingsStoreRequest; use App\Models\Tenant; -use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class SettingsController extends Controller { /** * Get the tenant's settings. - * - * @param Request $request - * @return JsonResponse */ public function index(Request $request): JsonResponse { $tenant = $request->tenant; - + return response()->json([ 'message' => 'Settings erfolgreich abgerufen.', 'data' => [ @@ -32,20 +29,14 @@ class SettingsController extends Controller /** * Update the tenant's settings. - * - * @param SettingsStoreRequest $request - * @return JsonResponse */ public function update(SettingsStoreRequest $request): JsonResponse { $tenant = $request->tenant; - - // Merge new settings with existing ones - $currentSettings = $tenant->settings ?? []; - $newSettings = array_merge($currentSettings, $request->validated()['settings']); - + $settings = $request->validated()['settings']; + $tenant->update([ - 'settings' => $newSettings, + 'settings' => $settings, 'settings_updated_at' => now(), ]); @@ -53,7 +44,7 @@ class SettingsController extends Controller 'message' => 'Settings erfolgreich aktualisiert.', 'data' => [ 'id' => $tenant->id, - 'settings' => $newSettings, + 'settings' => $settings, 'updated_at' => now()->toISOString(), ], ]); @@ -61,14 +52,11 @@ class SettingsController extends Controller /** * Reset tenant settings to defaults. - * - * @param Request $request - * @return JsonResponse */ public function reset(Request $request): JsonResponse { $tenant = $request->tenant; - + $defaultSettings = [ 'branding' => [ 'logo_url' => null, @@ -93,7 +81,7 @@ class SettingsController extends Controller ]); return response()->json([ - 'message' => 'Settings auf Standardwerte zurückgesetzt.', + 'message' => 'Settings auf Standardwerte zurueckgesetzt.', 'data' => [ 'id' => $tenant->id, 'settings' => $defaultSettings, @@ -104,32 +92,34 @@ class SettingsController extends Controller /** * Validate custom domain availability. - * - * @param Request $request - * @return JsonResponse */ public function validateDomain(Request $request): JsonResponse { $domain = $request->input('domain'); - - if (!$domain) { + + if (! $domain) { return response()->json(['error' => 'Domain ist erforderlich.'], 400); } - // Simple validation - in production, check DNS records or database uniqueness - if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/', $domain)) { + if (! $this->isValidDomain($domain)) { return response()->json([ 'available' => false, - 'message' => 'Ungültiges Domain-Format.', + 'message' => 'Ungueltiges Domain-Format.', ]); } - // Check if domain is already taken by another tenant - $taken = Tenant::where('custom_domain', $domain)->where('id', '!=', $request->tenant->id)->exists(); + $taken = Tenant::where('custom_domain', $domain) + ->where('id', '!=', $request->tenant->id) + ->exists(); return response()->json([ - 'available' => !$taken, - 'message' => $taken ? 'Domain ist bereits vergeben.' : 'Domain ist verfügbar.', + 'available' => ! $taken, + 'message' => $taken ? 'Domain ist bereits vergeben.' : 'Domain ist verfuegbar.', ]); } -} \ No newline at end of file + + private function isValidDomain(string $domain): bool + { + return filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; + } +} diff --git a/app/Http/Middleware/TenantTokenGuard.php b/app/Http/Middleware/TenantTokenGuard.php index e80d791..aad7840 100644 --- a/app/Http/Middleware/TenantTokenGuard.php +++ b/app/Http/Middleware/TenantTokenGuard.php @@ -70,8 +70,12 @@ class TenantTokenGuard Auth::setUser($principal); $request->setUserResolver(fn () => $principal); - $request->merge(['tenant_id' => $tenant->id]); + $request->merge([ + 'tenant_id' => $tenant->id, + 'tenant' => $tenant, + ]); $request->attributes->set('tenant_id', $tenant->id); + $request->attributes->set('tenant', $tenant); $request->attributes->set('decoded_token', $decoded); return $next($request); diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php index c502abf..7014415 100644 --- a/app/Http/Requests/Settings/ProfileUpdateRequest.php +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -50,10 +50,15 @@ class ProfileUpdateRequest extends FormRequest ], 'preferred_locale' => [ - 'required', + 'nullable', 'string', Rule::in($supportedLocales), ], ]; } } + + + + + diff --git a/app/Http/Requests/Tenant/SettingsStoreRequest.php b/app/Http/Requests/Tenant/SettingsStoreRequest.php index 3118846..211a918 100644 --- a/app/Http/Requests/Tenant/SettingsStoreRequest.php +++ b/app/Http/Requests/Tenant/SettingsStoreRequest.php @@ -33,7 +33,7 @@ class SettingsStoreRequest extends FormRequest 'settings.features.event_checklist' => ['nullable', 'boolean'], 'settings.features.custom_domain' => ['nullable', 'boolean'], 'settings.features.advanced_analytics' => ['nullable', 'boolean'], - 'settings.custom_domain' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/'], + 'settings.custom_domain' => ['nullable', 'string', 'max:255', 'regex:/^(?!-)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,}$/'], 'settings.contact_email' => ['nullable', 'email', 'max:255'], 'settings.event_default_type' => ['nullable', 'string', 'max:50'], ]; @@ -46,11 +46,11 @@ class SettingsStoreRequest extends FormRequest { return [ 'settings.required' => 'Settings-Daten sind erforderlich.', - 'settings.branding.logo_url.url' => 'Die Logo-URL muss eine gültige URL sein.', - 'settings.branding.primary_color.regex' => 'Die Primärfarbe muss ein gültiges Hex-Format (#RRGGBB) haben.', - 'settings.branding.secondary_color.regex' => 'Die Sekundärfarbe muss ein gültiges Hex-Format (#RRGGBB) haben.', - 'settings.custom_domain.regex' => 'Das Custom Domain muss ein gültiges Domain-Format haben.', - 'settings.contact_email.email' => 'Die Kontakt-E-Mail muss eine gültige E-Mail-Adresse sein.', + 'settings.branding.logo_url.url' => 'Die Logo-URL muss eine gOltige URL sein.', + 'settings.branding.primary_color.regex' => 'Die Prim??rfarbe muss ein gOltiges Hex-Format (#RRGGBB) haben.', + 'settings.branding.secondary_color.regex' => 'Die Sekund??rfarbe muss ein gOltiges Hex-Format (#RRGGBB) haben.', + 'settings.custom_domain.regex' => 'Das Custom Domain muss ein gOltiges Domain-Format haben.', + 'settings.contact_email.email' => 'Die Kontakt-E-Mail muss eine gOltige E-Mail-Adresse sein.', ]; } @@ -63,4 +63,5 @@ class SettingsStoreRequest extends FormRequest 'settings' => $this->input('settings', []), ]); } -} \ No newline at end of file +} + diff --git a/app/Http/Resources/Tenant/TaskResource.php b/app/Http/Resources/Tenant/TaskResource.php index 116d750..5d084d6 100644 --- a/app/Http/Resources/Tenant/TaskResource.php +++ b/app/Http/Resources/Tenant/TaskResource.php @@ -2,7 +2,6 @@ namespace App\Http\Resources\Tenant; -use App\Http\Resources\Tenant\EventResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -15,28 +14,27 @@ class TaskResource extends JsonResource */ public function toArray(Request $request): array { + $assignedEventsCount = $this->relationLoaded('assignedEvents') + ? $this->assignedEvents->count() + : $this->assignedEvents()->count(); + return [ 'id' => $this->id, + 'tenant_id' => $this->tenant_id, 'title' => $this->title, 'description' => $this->description, 'priority' => $this->priority, 'due_date' => $this->due_date?->toISOString(), - 'is_completed' => $this->is_completed, + 'is_completed' => (bool) $this->is_completed, 'collection_id' => $this->collection_id, - 'assigned_events_count' => $this->assignedEvents()->count(), - // TaskCollectionResource wird später implementiert - // 'collection' => $this->whenLoaded('taskCollection', function () { - // return new TaskCollectionResource($this->taskCollection); - // }), - 'assigned_events' => $this->whenLoaded('assignedEvents', function () { - return EventResource::collection($this->assignedEvents); - }), - // UserResource wird später implementiert - // 'assigned_to' => $this->whenLoaded('assignedTo', function () { - // return new UserResource($this->assignedTo); - // }), - 'created_at' => $this->created_at->toISOString(), - 'updated_at' => $this->updated_at->toISOString(), + 'assigned_events_count' => $assignedEventsCount, + 'assigned_events' => $this->whenLoaded( + 'assignedEvents', + fn () => EventResource::collection($this->assignedEvents) + ), + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), ]; } -} \ No newline at end of file +} + diff --git a/app/Models/Emotion.php b/app/Models/Emotion.php index c9c9afc..5e02dfd 100644 --- a/app/Models/Emotion.php +++ b/app/Models/Emotion.php @@ -2,12 +2,15 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; class Emotion extends Model { + use HasFactory; + protected $table = 'emotions'; protected $guarded = []; protected $casts = [ @@ -25,3 +28,4 @@ class Emotion extends Model return $this->hasMany(Task::class); } } + diff --git a/app/Models/Event.php b/app/Models/Event.php index b16f5b4..53a3cb9 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -4,7 +4,9 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\{BelongsTo, HasMany, BelongsToMany}; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; class Event extends Model { @@ -13,9 +15,7 @@ class Event extends Model protected $table = 'events'; protected $guarded = []; protected $casts = [ - 'date' => 'date', - 'name' => 'array', - 'description' => 'array', + 'date' => 'datetime', 'settings' => 'array', 'is_active' => 'boolean', ]; @@ -39,5 +39,11 @@ class Event extends Model 'task_collection_id' ); } + + public function tasks(): BelongsToMany + { + return $this->belongsToMany(Task::class, 'event_task', 'event_id', 'task_id') + ->withTimestamps(); + } } diff --git a/app/Models/EventType.php b/app/Models/EventType.php index b6524af..4354835 100644 --- a/app/Models/EventType.php +++ b/app/Models/EventType.php @@ -2,12 +2,15 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; class EventType extends Model { + use HasFactory; + protected $table = 'event_types'; protected $guarded = []; protected $casts = [ @@ -25,3 +28,4 @@ class EventType extends Model return $this->hasMany(Event::class); } } + diff --git a/app/Models/Photo.php b/app/Models/Photo.php index 77bbe0d..58e7563 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -2,13 +2,15 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Support\Facades\Storage; class Photo extends Model { + use HasFactory; + protected $table = 'photos'; protected $guarded = []; protected $casts = [ @@ -16,14 +18,12 @@ class Photo extends Model 'metadata' => 'array', ]; - // Accessor für die Kompatibilität mit der PhotoResource - public function getImagePathAttribute() + public function getImagePathAttribute(): ?string { return $this->file_path; } - // Mutator für die Kompatibilität mit der PhotoResource - public function setImagePathAttribute($value) + public function setImagePathAttribute(string $value): void { $this->attributes['file_path'] = $value; } @@ -33,19 +33,19 @@ class Photo extends Model return $this->belongsTo(Event::class); } - public function emotion() + public function emotion(): BelongsTo { return $this->belongsTo(Emotion::class); } - public function task() + public function task(): BelongsTo { return $this->belongsTo(Task::class); } public function likes(): HasMany { - return $this->hasMany(\App\Models\PhotoLike::class); + return $this->hasMany(PhotoLike::class); } } diff --git a/app/Models/PurchaseHistory.php b/app/Models/PurchaseHistory.php index 0534c87..e5ee23c 100644 --- a/app/Models/PurchaseHistory.php +++ b/app/Models/PurchaseHistory.php @@ -2,36 +2,31 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class PurchaseHistory extends Model { + use HasFactory; + protected $table = 'purchase_history'; - + public $timestamps = false; + public $incrementing = false; + protected $keyType = 'string'; + protected $guarded = []; - - protected $fillable = [ - 'id', - 'tenant_id', - 'package_id', - 'credits_added', - 'price', - 'currency', - 'platform', - 'transaction_id', - 'purchased_at', - ]; - + protected $casts = [ 'credits_added' => 'integer', 'price' => 'decimal:2', 'purchased_at' => 'datetime', 'created_at' => 'datetime', ]; - + public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); } } + diff --git a/app/Models/Task.php b/app/Models/Task.php index 920a943..1b2ea44 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -6,17 +6,18 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\SoftDeletes; class Task extends Model { use HasFactory; + use SoftDeletes; protected $table = 'tasks'; protected $guarded = []; protected $casts = [ - 'title' => 'array', - 'description' => 'array', - 'example_text' => 'array', + 'due_date' => 'datetime', + 'is_completed' => 'bool', ]; public function emotion(): BelongsTo diff --git a/app/Models/TaskCollection.php b/app/Models/TaskCollection.php index be115d6..cfc1bb4 100644 --- a/app/Models/TaskCollection.php +++ b/app/Models/TaskCollection.php @@ -11,21 +11,13 @@ class TaskCollection extends Model use HasFactory; protected $table = 'task_collections'; - + protected $fillable = [ 'tenant_id', 'name', 'description', ]; - protected $casts = [ - 'name' => 'array', - 'description' => 'array', - ]; - - /** - * Tasks in this collection - */ public function tasks(): BelongsToMany { return $this->belongsToMany( @@ -36,9 +28,6 @@ class TaskCollection extends Model ); } - /** - * Events that use this collection - */ public function events(): BelongsToMany { return $this->belongsToMany( @@ -48,24 +37,5 @@ class TaskCollection extends Model 'event_id' ); } +} - /** - * Get the localized name for the current locale - */ - public function getLocalizedNameAttribute(): string - { - $locale = app()->getLocale(); - return $this->name[$locale] ?? $this->name['de'] ?? 'Unnamed Collection'; - } - - /** - * Get the localized description for the current locale - */ - public function getLocalizedDescriptionAttribute(): ?string - { - if (!$this->description) return null; - - $locale = app()->getLocale(); - return $this->description[$locale] ?? $this->description['de'] ?? null; - } -} \ No newline at end of file diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index a088f57..c57309d 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -2,14 +2,12 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; -use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Facades\DB; -use App\Models\EventPurchase; -use App\Models\EventCreditsLedger; class Tenant extends Model { @@ -17,14 +15,16 @@ class Tenant extends Model protected $table = 'tenants'; protected $guarded = []; + protected $casts = [ - 'settings' => 'array', 'features' => 'array', + 'settings' => 'array', 'last_activity_at' => 'datetime', 'event_credits_balance' => 'integer', 'subscription_tier' => 'string', 'subscription_expires_at' => 'datetime', 'total_revenue' => 'decimal:2', + 'settings_updated_at' => 'datetime', ]; public function events(): HasMany @@ -37,10 +37,10 @@ class Tenant extends Model return $this->hasManyThrough( Photo::class, Event::class, - 'tenant_id', // Foreign key on events table... - 'event_id', // Foreign key on photos table... - 'id', // Local key on tenants table... - 'id' // Local key on events table... + 'tenant_id', + 'event_id', + 'id', + 'id' ); } @@ -59,6 +59,16 @@ class Tenant extends Model return $this->hasMany(EventCreditsLedger::class); } + public function setSettingsAttribute($value): void + { + if (is_string($value)) { + $this->attributes['settings'] = $value; + return; + } + + $this->attributes['settings'] = json_encode($value ?? []); + } + public function activeSubscription(): Attribute { return Attribute::make( diff --git a/app/Services/EmotionImportService.php b/app/Services/EmotionImportService.php new file mode 100644 index 0000000..d3e80b5 --- /dev/null +++ b/app/Services/EmotionImportService.php @@ -0,0 +1,71 @@ + ['de' => $nameDe, 'en' => $nameEn], + 'icon' => $row[$map['icon']] ?? null, + 'color' => $row[$map['color']] ?? null, + 'description' => [ + 'de' => $row[$map['description_de']] ?? null, + 'en' => $row[$map['description_en']] ?? null, + ], + 'sort_order' => (int) ($row[$map['sort_order']] ?? 0), + 'is_active' => (int) ($row[$map['is_active']] ?? 1) ? 1 : 0, + ]); + + $eventTypes = $row[$map['event_types']] ?? ''; + if ($eventTypes) { + $slugs = array_filter(array_map('trim', explode('|', $eventTypes))); + if ($slugs) { + $ids = DB::table('event_types')->whereIn('slug', $slugs)->pluck('id')->all(); + if ($ids) { + $emotion->eventTypes()->attach($ids); + } + } + } + + $success++; + }); + } catch (\Throwable $e) { + $failed++; + } + } + + fclose($handle); + + return [$success, $failed]; + } +} diff --git a/database/factories/EmotionFactory.php b/database/factories/EmotionFactory.php new file mode 100644 index 0000000..4e51578 --- /dev/null +++ b/database/factories/EmotionFactory.php @@ -0,0 +1,32 @@ +faker->unique()->word()); + + return [ + 'name' => [ + 'en' => $name, + 'de' => $name, + ], + 'icon' => $this->faker->randomElement(['smile', 'heart', 'star', 'sparkles']), + 'color' => $this->faker->hexColor(), + 'description' => [ + 'en' => $this->faker->sentence(), + 'de' => $this->faker->sentence(), + ], + 'sort_order' => $this->faker->numberBetween(0, 20), + 'is_active' => true, + ]; + } +} + diff --git a/database/factories/EventFactory.php b/database/factories/EventFactory.php index f5920cd..1f434a0 100644 --- a/database/factories/EventFactory.php +++ b/database/factories/EventFactory.php @@ -3,6 +3,7 @@ namespace Database\Factories; use App\Models\Event; +use App\Models\EventType; use App\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -18,12 +19,14 @@ class EventFactory extends Factory return [ 'tenant_id' => Tenant::factory(), + 'event_type_id' => EventType::factory(), 'name' => $name, 'slug' => $slug, 'description' => $this->faker->paragraph(), 'date' => $this->faker->dateTimeBetween('now', '+6 months'), 'location' => $this->faker->address(), 'max_participants' => $this->faker->numberBetween(50, 500), + 'settings' => null, 'is_active' => true, 'join_link_enabled' => true, 'photo_upload_enabled' => true, @@ -60,4 +63,5 @@ class EventFactory extends Factory 'date' => $this->faker->dateTimeBetween('now', '+1 month'), ]); } -} \ No newline at end of file +} + diff --git a/database/factories/EventTypeFactory.php b/database/factories/EventTypeFactory.php new file mode 100644 index 0000000..1aad7fc --- /dev/null +++ b/database/factories/EventTypeFactory.php @@ -0,0 +1,31 @@ +faker->unique()->words(2, true)); + $slug = Str::slug($name); + + return [ + 'name' => [ + 'de' => $name, + 'en' => $name, + ], + 'slug' => $slug, + 'icon' => $this->faker->randomElement(['party', 'camera', 'users', null]), + 'settings' => [ + 'color' => $this->faker->hexColor(), + ], + ]; + } +} + diff --git a/database/factories/PhotoFactory.php b/database/factories/PhotoFactory.php new file mode 100644 index 0000000..19e4bdd --- /dev/null +++ b/database/factories/PhotoFactory.php @@ -0,0 +1,48 @@ + Event::factory(), + 'emotion_id' => Emotion::factory(), + 'task_id' => null, + 'guest_name' => $this->faker->name(), + 'file_path' => 'photos/' . Str::uuid() . '.jpg', + 'thumbnail_path' => 'photos/thumbnails/' . Str::uuid() . '.jpg', + 'likes_count' => $this->faker->numberBetween(0, 25), + 'is_featured' => false, + 'metadata' => ['factory' => true], + ]; + } + + public function configure(): static + { + return $this->afterMaking(function (Photo $photo) { + if (! $photo->tenant_id && $photo->event) { + $photo->tenant_id = $photo->event->tenant_id; + } + })->afterCreating(function (Photo $photo) { + if ($photo->event && ! $photo->tenant_id) { + $photo->tenant_id = $photo->event->tenant_id; + $photo->save(); + } + + if ($photo->tenant_id && $photo->event && $photo->event->tenant_id !== $photo->tenant_id) { + $photo->event->update(['tenant_id' => $photo->tenant_id]); + } + }); + } +} + diff --git a/database/factories/PurchaseHistoryFactory.php b/database/factories/PurchaseHistoryFactory.php new file mode 100644 index 0000000..9a4ddf6 --- /dev/null +++ b/database/factories/PurchaseHistoryFactory.php @@ -0,0 +1,29 @@ + (string) Str::uuid(), + 'tenant_id' => Tenant::factory(), + 'package_id' => $this->faker->randomElement(['starter', 'pro', 'enterprise']), + 'credits_added' => $this->faker->numberBetween(1, 10), + 'price' => $this->faker->randomFloat(2, 9, 199), + 'currency' => 'EUR', + 'platform' => $this->faker->randomElement(['tenant-app', 'ios', 'android']), + 'transaction_id' => (string) Str::uuid(), + 'purchased_at' => now(), + ]; + } +} + diff --git a/database/factories/TenantFactory.php b/database/factories/TenantFactory.php index d3653f2..fd24a4f 100644 --- a/database/factories/TenantFactory.php +++ b/database/factories/TenantFactory.php @@ -14,17 +14,19 @@ class TenantFactory extends Factory { $name = $this->faker->company(); $slug = Str::slug($name); + $contactEmail = $this->faker->companyEmail(); return [ 'name' => $name, 'slug' => $slug, - 'contact_email' => $this->faker->companyEmail(), + 'contact_email' => $contactEmail, 'event_credits_balance' => $this->faker->numberBetween(1, 20), 'subscription_tier' => $this->faker->randomElement(['free', 'starter', 'pro']), 'subscription_expires_at' => $this->faker->dateTimeBetween('now', '+1 year'), 'is_active' => true, 'is_suspended' => false, - 'settings' => json_encode([ + 'settings_updated_at' => now(), + 'settings' => [ 'branding' => [ 'logo_url' => null, 'primary_color' => '#3B82F6', @@ -38,8 +40,9 @@ class TenantFactory extends Factory 'advanced_analytics' => false, ], 'custom_domain' => null, - ]), - 'settings_updated_at' => now(), + 'contact_email' => $contactEmail, + 'event_default_type' => 'general', + ], ]; } @@ -63,4 +66,5 @@ class TenantFactory extends Factory 'event_credits_balance' => 1, ]); } -} \ No newline at end of file +} + diff --git a/database/migrations/2025_09_25_160000_add_soft_deletes_to_tasks_table.php b/database/migrations/2025_09_25_160000_add_soft_deletes_to_tasks_table.php new file mode 100644 index 0000000..78f7b0e --- /dev/null +++ b/database/migrations/2025_09_25_160000_add_soft_deletes_to_tasks_table.php @@ -0,0 +1,26 @@ +softDeletes(); + } + }); + } + + public function down(): void + { + Schema::table('tasks', function (Blueprint $table) { + if (Schema::hasColumn('tasks', 'deleted_at')) { + $table->dropSoftDeletes(); + } + }); + } +}; diff --git a/database/migrations/2025_09_25_170500_add_custom_domain_to_tenants_table.php b/database/migrations/2025_09_25_170500_add_custom_domain_to_tenants_table.php new file mode 100644 index 0000000..23cee7f --- /dev/null +++ b/database/migrations/2025_09_25_170500_add_custom_domain_to_tenants_table.php @@ -0,0 +1,27 @@ +string('custom_domain')->nullable()->after('domain'); + }); + + Schema::table('tenants', function (Blueprint $table) { + $table->unique('custom_domain'); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropUnique('tenants_custom_domain_unique'); + $table->dropColumn('custom_domain'); + }); + } +}; diff --git a/tests/Feature/EmotionResourceTest.php b/tests/Feature/EmotionResourceTest.php index 2d06d5d..a384342 100644 --- a/tests/Feature/EmotionResourceTest.php +++ b/tests/Feature/EmotionResourceTest.php @@ -2,29 +2,26 @@ namespace Tests\Feature; -use App\Filament\Resources\EmotionResource\Pages\ImportEmotions; use App\Models\Emotion; -use App\Models\User; -use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Storage; -use Livewire\Livewire; +use App\Services\EmotionImportService; +use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class EmotionResourceTest extends TestCase { + use RefreshDatabase; + public function test_import_emotions_csv() { - Storage::fake('public'); - - $user = User::factory()->create(); - $this->actingAs($user); - $csvData = "name_de,name_en,icon,color,description_de,description_en,sort_order,is_active,event_types\nGlück,Joy,😊,#FFD700,Gefühl des Glücks,Feeling of joy,1,1,wedding|birthday"; - $csvFile = UploadedFile::fake()->createWithContent('emotions.csv', $csvData); - Livewire::test(ImportEmotions::class) - ->set('file', $csvFile->getRealPath()) - ->call('doImport'); + $tempFile = tempnam(sys_get_temp_dir(), 'emotions_'); + file_put_contents($tempFile, $csvData); + + [$success, $failed] = app(EmotionImportService::class)->import($tempFile); + + $this->assertSame(1, $success); + $this->assertSame(0, $failed); $this->assertDatabaseHas('emotions', [ 'name' => json_encode(['de' => 'Glück', 'en' => 'Joy']), diff --git a/tests/Feature/Tenant/SettingsApiTest.php b/tests/Feature/Tenant/SettingsApiTest.php index c19fbbc..ea06ccb 100644 --- a/tests/Feature/Tenant/SettingsApiTest.php +++ b/tests/Feature/Tenant/SettingsApiTest.php @@ -71,7 +71,7 @@ class SettingsApiTest extends TenantTestCase $this->assertDatabaseHas('tenants', [ 'id' => $this->tenant->id, - 'settings' => $settingsData['settings'], + 'settings' => json_encode($settingsData['settings']), ]); } @@ -100,7 +100,7 @@ class SettingsApiTest extends TenantTestCase $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/reset'); $response->assertStatus(200) - ->assertJson(['message' => 'Settings auf Standardwerte zurückgesetzt.']) + ->assertJson(['message' => 'Settings auf Standardwerte zurueckgesetzt.']) ->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') ->assertJsonPath('data.settings.features.photo_likes_enabled', true); @@ -136,7 +136,7 @@ class SettingsApiTest extends TenantTestCase $response->assertStatus(200) ->assertJson(['available' => true]) - ->assertJson(['message' => 'Domain ist verfügbar.']); + ->assertJson(['message' => 'Domain ist verfuegbar.']); // Invalid domain format $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain', [ @@ -145,7 +145,7 @@ class SettingsApiTest extends TenantTestCase $response->assertStatus(200) ->assertJson(['available' => false]) - ->assertJson(['message' => 'Ungültiges Domain-Format.']); + ->assertJson(['message' => 'Ungueltiges Domain-Format.']); // Taken domain (create another tenant with same domain) $otherTenant = Tenant::factory()->create(['custom_domain' => 'taken.example.com']); @@ -187,4 +187,6 @@ class SettingsApiTest extends TenantTestCase ->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') // Default for this tenant ->assertJsonMissing(['#FF0000']); // Other tenant's color } -} \ No newline at end of file +} + + diff --git a/tests/Feature/Tenant/TaskApiTest.php b/tests/Feature/Tenant/TaskApiTest.php index 2ec9400..45e1882 100644 --- a/tests/Feature/Tenant/TaskApiTest.php +++ b/tests/Feature/Tenant/TaskApiTest.php @@ -263,7 +263,9 @@ class TaskApiTest extends TenantTestCase 'tenant_id' => $this->tenant->id, 'priority' => 'medium', ]); - $collectionTasks->each(fn($task) => $task->taskCollection()->associate($collection)); + $collectionTasks->each(function ($task) use ($collection) { + $task->update(['collection_id' => $collection->id]); + }); Task::factory(3)->create([ 'tenant_id' => $this->tenant->id, @@ -303,4 +305,8 @@ class TaskApiTest extends TenantTestCase ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.title', 'Search Test'); } -} \ No newline at end of file +} + + + + diff --git a/tests/Feature/Tenant/TenantTestCase.php b/tests/Feature/Tenant/TenantTestCase.php index 710279f..7884b88 100644 --- a/tests/Feature/Tenant/TenantTestCase.php +++ b/tests/Feature/Tenant/TenantTestCase.php @@ -2,11 +2,11 @@ namespace Tests\Feature\Tenant; +use App\Models\OAuthClient; use App\Models\Tenant; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Route; +use Illuminate\Support\Str; use Tests\TestCase; abstract class TenantTestCase extends TestCase @@ -15,42 +15,103 @@ abstract class TenantTestCase extends TestCase protected Tenant $tenant; protected User $tenantUser; + protected OAuthClient $oauthClient; protected string $token; + protected ?string $refreshToken = null; protected function setUp(): void { parent::setUp(); + $this->initialiseTenantContext(); + } + + protected function authenticatedRequest(string $method, string $uri, array $data = [], array $headers = []) + { + $headers = array_merge([ + 'Authorization' => 'Bearer '.$this->token, + ], $headers); + + return $this->withHeaders($headers)->json($method, $uri, $data); + } + + protected function mockTenantContext(): void + { + $this->app->instance('tenant', $this->tenant); + } + + protected function initialiseTenantContext(array $scopes = ['tenant:read', 'tenant:write']): void + { $this->tenant = Tenant::factory()->create([ 'name' => 'Test Tenant', - 'slug' => 'test-tenant', + 'slug' => 'test-tenant-'.Str::random(6), ]); $this->tenantUser = User::factory()->create([ 'name' => 'Test User', - 'email' => 'test@example.com', + 'email' => 'test-'.Str::random(6).'@example.com', 'tenant_id' => $this->tenant->id, 'role' => 'admin', ]); - $this->token = 'mock-jwt-token-' . $this->tenant->id . '-' . time(); - } + $this->oauthClient = $this->createTenantClient($this->tenant, $scopes); + [$this->token, $this->refreshToken] = $this->issueTokens($this->oauthClient, $scopes); - protected function authenticatedRequest($method, $uri, array $data = [], array $headers = []) - { - $headers['Authorization'] = 'Bearer ' . $this->token; - - // Temporarily override the middleware to skip auth and set tenant - $this->app['router']->pushMiddlewareToGroup('api', MockTenantMiddleware::class, 'mock-tenant'); - - return $this->withHeaders($headers)->json($method, $uri, $data); - } - - protected function mockTenantContext() - { - $this->actingAs($this->tenantUser); - - // Set tenant globally for tests $this->app->instance('tenant', $this->tenant); } -} \ No newline at end of file + + protected function createTenantClient(Tenant $tenant, array $scopes): OAuthClient + { + return OAuthClient::create([ + 'id' => (string) Str::uuid(), + 'client_id' => 'tenant-admin-app-'.$tenant->id, + 'tenant_id' => $tenant->id, + 'client_secret' => null, + 'redirect_uris' => ['http://localhost/callback'], + 'scopes' => $scopes, + 'is_active' => true, + ]); + } + + protected function issueTokens(OAuthClient $client, array $scopes = ['tenant:read', 'tenant:write']): array + { + $codeVerifier = 'tenant-code-verifier-'.Str::random(32); + $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '='); + $state = Str::random(10); + + $response = $this->get('/api/v1/oauth/authorize?'.http_build_query([ + 'client_id' => $client->client_id, + 'redirect_uri' => 'http://localhost/callback', + 'response_type' => 'code', + 'scope' => implode(' ', $scopes), + 'state' => $state, + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => 'S256', + ])); + + $response->assertRedirect(); + $location = $response->headers->get('Location'); + $this->assertNotNull($location); + + $query = []; + parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query); + $authorizationCode = $query['code'] ?? null; + $this->assertNotNull($authorizationCode, 'Authorization code should be present'); + + $tokenResponse = $this->post('/api/v1/oauth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $authorizationCode, + 'client_id' => $client->client_id, + 'redirect_uri' => 'http://localhost/callback', + 'code_verifier' => $codeVerifier, + ]); + + $tokenResponse->assertOk(); + + return [ + $tokenResponse->json('access_token'), + $tokenResponse->json('refresh_token'), + ]; + } +} + diff --git a/tests/Feature/TenantCreditsTest.php b/tests/Feature/TenantCreditsTest.php index ae5a12b..2ccbc01 100644 --- a/tests/Feature/TenantCreditsTest.php +++ b/tests/Feature/TenantCreditsTest.php @@ -5,7 +5,6 @@ namespace Tests\Feature; use App\Models\OAuthClient; use App\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Tests\TestCase; @@ -13,57 +12,6 @@ class TenantCreditsTest extends TestCase { use RefreshDatabase; - private const PUBLIC_KEY = <<create([ @@ -170,5 +118,5 @@ KEY; $tokenResponse->json('refresh_token'), ]; } - } + diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..100fdb3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,5 +6,73 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - // + private const JWT_PUBLIC_KEY = <<<'KEY' +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlrZWbp/7pXo83BIJX3v/ +9f/51fxYFGZnZz9diqHkiOtDjggNdwze0LXruVeVb8YsaTI68RclgYCcsE4haTCG +LlTivKFJL2O10IEzswjjD08MsanHer3xZRO6VZ7JLXmBNKp5C71zfFf8AhMnQ+Y6 +uGQ3wMOT6PWAiAmVBVYC8+KQsqyOkDu58bamhGGOrDsdWvrfDgRU1w8dxbgFYALQ +v1pVVmYT9oBxZcS5FlT8auf8zLcHXEl6S7X61ZPd/GTWT5htdSiJyXfSa/xM7bJP +CCv+mK6Gd5+1UG3RHGuwoi8Rch2O8PMglZqF6ybv/w836jUQKPl+sndePNN3soKQ +5wIDAQAB +-----END PUBLIC KEY----- +KEY; + + private const JWT_PRIVATE_KEY = <<<'KEY' +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCWtlZun/ulejzc +Eglfe//1//nV/FgUZmdnP12KoeSI60OOCA13DN7Qteu5V5VvxixpMjrxFyWBgJyw +TiFpMIYuVOK8oUkvY7XQgTOzCOMPTwyxqcd6vfFlE7pVnskteYE0qnkLvXN8V/wC +EydD5jq4ZDfAw5Po9YCICZUFVgLz4pCyrI6QO7nxtqaEYY6sOx1a+t8OBFTXDx3F +uAVgAtC/WlVWZhP2gHFlxLkWVPxq5/zMtwdcSXpLtfrVk938ZNZPmG11KInJd9Jr +/Eztsk8IK/6YroZ3n7VQbdEca7CiLxFyHY7w8yCVmoXrJu//DzfqNRAo+X6yd148 +03eygpDnAgMBAAECggEAFoldk11I/A2zXBU2YZjhRZ/pdB4v7Z0CiWXoTvq2eeL0 +TyDVIqBCEWOixCxcpEI2EeT4+2RCr4LT62lDhb9D0VnQLfTQRM3cOjmXyYXirj9b +3pVMxwXwOvUgP/1mh+5La9yyDRdfVZCylnzWukiLL1eNHr4gOA2+EpmcNxgNiPp1 +Z8USUp2kmSZMPmQDkGEAJnrqmW7LyBvda3yuW557WtpaQlHTprvNQdBIUoFhLiiS +HnV9kZfQHM3BdM06zx8c7W6sbVavLQlaD0mhM6Z7o7566pq1JKScjhfoGcZRTmLs +kshQVSf38ayhAz8CikWiJgqFJigIZI0bR9fROOy+wQKBgQDOWjVRq8Ql+Eu0so/B +3hS1TGaBOFe5vymeX+hnC87Zu7yVsj96mhmofnlTJdbSZLHfO631XD9O3qCcYzuK +1PLzOvO38ZVZLq/CkiwkC4qfGVQb3/8v0QyIXCKhMrwkwuL6AYMjQi6vd/+4vp2C +5EJefbNBfdvsC90t84wxqBpIDQKBgQC6+Rs7cBD9VOAKkNH1O4k9cE1JCDX6aqlg +RtO/93+kbqxz3llvIebI9z3CPE7Wp0n2GEFjvDCTy5kST7BQvdwm4VlthSpfhx+l +4ahw1+xbB3KQxemmf3MroTZWHLfTOGvHdei05EIdRZv8Mpi9UcHd7OhVO82SUnLn +pBqGLZGrwwKBgB2FiltE16sW+r2/ThHOU+gcJg4WoXZRgwLFddpINi+wTCqedbZ0 +lXcloPXkU/eFsGzffOO9btE5yICXMc2K6bcil/uY9GTt6PdNMkN14z8fwIi8YyXU +Ipbfl5S4TXJ070QVM024CjXQVSV5H8+6GESsdxjHiM8cY2hPj58LDbeBAoGAfd5r +FcVoupJjzNkXbwboagLrFGpBpFYfth+YN1hPhou27r3V6TmiWtIOsm7VCC5QXSqR +AqpS7XwXjTs2T/Swe0AjatZF409c39gdA/JoPBO0bX++voZ4Kvv5T1k/6yLFc96N +jRFI7NnKm6oYJwMeBt+QvKhoyMNWdViFPqT4tu8CgYEAmcInq55jIJOr7GNvf6jV +wojrBxhEGOF8U8YqX6FgVEmVDkEOer3mFDnkZT/S2IFjH4eruo/ZTFFtyw9K9JGd +06FINYtK/H91SdcOJHuWdELuTQw0+Jtr47tSUlp1c3L0J7Mt1Sqqzg8lLoLYPcLJ +d7faJuYR8uKalWG3ZimbGNo= +-----END PRIVATE KEY----- +KEY; + + protected function setUp(): void + { + parent::setUp(); + + $this->ensureJwtKeys(); + } + + protected function ensureJwtKeys(): void + { + $storagePath = storage_path('app'); + + if (! is_dir($storagePath)) { + mkdir($storagePath, 0755, true); + } + + $publicKeyPath = $storagePath . DIRECTORY_SEPARATOR . 'public.key'; + if (! file_exists($publicKeyPath)) { + file_put_contents($publicKeyPath, self::JWT_PUBLIC_KEY); + } + + $privateKeyPath = $storagePath . DIRECTORY_SEPARATOR . 'private.key'; + if (! file_exists($privateKeyPath)) { + file_put_contents($privateKeyPath, self::JWT_PRIVATE_KEY); + } + } } +