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

View File

@@ -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.',
]);
}
}
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.',
];
}
@@ -63,4 +63,5 @@ class SettingsStoreRequest extends FormRequest
'settings' => $this->input('settings', []),
]);
}
}
}

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

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

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

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];
}
}