feat: harden tenant settings and import pipeline

This commit is contained in:
2025-09-25 11:50:18 +02:00
parent b22d91ed32
commit 9248d7a3f5
29 changed files with 577 additions and 293 deletions

View File

@@ -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
@@ -41,7 +40,7 @@ class ImportEmotions extends Page
}
$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];
}
}

View File

@@ -5,16 +5,13 @@ 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
{
@@ -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,9 +52,6 @@ class SettingsController extends Controller
/**
* Reset tenant settings to defaults.
*
* @param Request $request
* @return JsonResponse
*/
public function reset(Request $request): JsonResponse
{
@@ -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,9 +92,6 @@ class SettingsController extends Controller
/**
* Validate custom domain availability.
*
* @param Request $request
* @return JsonResponse
*/
public function validateDomain(Request $request): JsonResponse
{
@@ -116,20 +101,25 @@ class SettingsController extends Controller
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.',
'message' => $taken ? 'Domain ist bereits vergeben.' : 'Domain ist verfuegbar.',
]);
}
private function isValidDomain(string $domain): bool
{
return filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
}
}

View File

@@ -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);

View File

@@ -50,10 +50,15 @@ class ProfileUpdateRequest extends FormRequest
],
'preferred_locale' => [
'required',
'nullable',
'string',
Rule::in($supportedLocales),
],
];
}
}

View File

@@ -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.',
];
}
@@ -64,3 +64,4 @@ class SettingsStoreRequest extends FormRequest
]);
}
}

View File

@@ -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(),
];
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -2,27 +2,21 @@
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',
@@ -35,3 +29,4 @@ class PurchaseHistory extends Model
return $this->belongsTo(Tenant::class);
}
}

View File

@@ -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

View File

@@ -18,14 +18,6 @@ class TaskCollection extends Model
'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;
}
}

View File

@@ -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(

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Services;
use App\Models\Emotion;
use Illuminate\Support\Facades\DB;
class EmotionImportService
{
public function import(string $filePath): array
{
$handle = fopen($filePath, 'r');
if (! $handle) {
return [0, 0];
}
$success = 0;
$failed = 0;
$headers = fgetcsv($handle, 0, ',');
if (! $headers) {
fclose($handle);
return [0, 0];
}
$map = array_flip($headers);
while (($row = fgetcsv($handle, 0, ',')) !== false) {
try {
DB::transaction(function () use ($row, $map, &$success) {
$nameDe = trim($row[$map['name_de']] ?? '');
$nameEn = trim($row[$map['name_en']] ?? '');
if ($nameDe === '' && $nameEn === '') {
throw new \RuntimeException('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) {
$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];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Database\Factories;
use App\Models\Emotion;
use Illuminate\Database\Eloquent\Factories\Factory;
class EmotionFactory extends Factory
{
protected $model = Emotion::class;
public function definition(): array
{
$name = ucfirst($this->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,
];
}
}

View File

@@ -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,
@@ -61,3 +64,4 @@ class EventFactory extends Factory
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Database\Factories;
use App\Models\EventType;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class EventTypeFactory extends Factory
{
protected $model = EventType::class;
public function definition(): array
{
$name = ucfirst($this->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(),
],
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Database\Factories;
use App\Models\Emotion;
use App\Models\Event;
use App\Models\Photo;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class PhotoFactory extends Factory
{
protected $model = Photo::class;
public function definition(): array
{
return [
'event_id' => 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]);
}
});
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\PurchaseHistory;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class PurchaseHistoryFactory extends Factory
{
protected $model = PurchaseHistory::class;
public function definition(): array
{
return [
'id' => (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(),
];
}
}

View File

@@ -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',
],
];
}
@@ -64,3 +67,4 @@ class TenantFactory extends Factory
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tasks', function (Blueprint $table) {
if (! Schema::hasColumn('tasks', 'deleted_at')) {
$table->softDeletes();
}
});
}
public function down(): void
{
Schema::table('tasks', function (Blueprint $table) {
if (Schema::hasColumn('tasks', 'deleted_at')) {
$table->dropSoftDeletes();
}
});
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -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']),

View File

@@ -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']);
@@ -188,3 +188,5 @@ class SettingsApiTest extends TenantTestCase
->assertJsonMissing(['#FF0000']); // Other tenant's color
}
}

View File

@@ -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,
@@ -304,3 +306,7 @@ class TaskApiTest extends TenantTestCase
->assertJsonPath('data.0.title', 'Search Test');
}
}

View File

@@ -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->tenant = Tenant::factory()->create([
'name' => 'Test Tenant',
'slug' => 'test-tenant',
]);
$this->tenantUser = User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
'tenant_id' => $this->tenant->id,
'role' => 'admin',
]);
$this->token = 'mock-jwt-token-' . $this->tenant->id . '-' . time();
$this->initialiseTenantContext();
}
protected function authenticatedRequest($method, $uri, array $data = [], array $headers = [])
protected function authenticatedRequest(string $method, string $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');
$headers = array_merge([
'Authorization' => 'Bearer '.$this->token,
], $headers);
return $this->withHeaders($headers)->json($method, $uri, $data);
}
protected function mockTenantContext()
protected function mockTenantContext(): void
{
$this->actingAs($this->tenantUser);
// Set tenant globally for tests
$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-'.Str::random(6),
]);
$this->tenantUser = User::factory()->create([
'name' => 'Test User',
'email' => 'test-'.Str::random(6).'@example.com',
'tenant_id' => $this->tenant->id,
'role' => 'admin',
]);
$this->oauthClient = $this->createTenantClient($this->tenant, $scopes);
[$this->token, $this->refreshToken] = $this->issueTokens($this->oauthClient, $scopes);
$this->app->instance('tenant', $this->tenant);
}
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'),
];
}
}

View File

@@ -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 = <<<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 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();
file_put_contents(storage_path('app/public.key'), self::PUBLIC_KEY);
file_put_contents(storage_path('app/private.key'), self::PRIVATE_KEY);
}
public function test_tenant_can_retrieve_balance_and_purchase_credits(): void
{
$tenant = Tenant::factory()->create([
@@ -170,5 +118,5 @@ KEY;
$tokenResponse->json('refresh_token'),
];
}
}

View File

@@ -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);
}
}
}