im profil kann ein nutzer nun seine daten exportieren. man kann seinen account löschen. nach 2 jahren werden inaktive accounts gelöscht, 1 monat vorher wird eine email geschickt. Hilfetexte und Legal Pages in der Guest PWA korrigiert und vom layout her optimiert (dark mode).

This commit is contained in:
Codex Agent
2025-11-10 19:55:46 +01:00
parent 447a90a742
commit 2587b2049d
37 changed files with 1650 additions and 50 deletions

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Console\Commands;
use App\Jobs\AnonymizeAccount;
use App\Models\Tenant;
use App\Notifications\InactiveTenantDeletionWarning;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Notification;
class ProcessTenantRetention extends Command
{
protected $signature = 'tenants:retention-scan';
protected $description = 'Scans tenants for Inaktivität, verschickt Warnungen und startet Anonymisierungen.';
public function handle(): int
{
$warningThreshold = now()->subMonths(23);
$deletionThreshold = now()->subMonths(24);
Tenant::query()
->whereNull('anonymized_at')
->with(['user'])
->withMax('events as last_event_activity', 'updated_at')
->withMax('purchases as last_purchase_activity', 'purchased_at')
->withMax('photos as last_photo_activity', 'created_at')
->chunkById(100, function ($tenants) use ($warningThreshold, $deletionThreshold) {
foreach ($tenants as $tenant) {
$lastActivity = $this->determineLastActivity($tenant);
if (! $lastActivity) {
$lastActivity = $tenant->created_at ?? now();
}
if ($this->hasActiveSubscription($tenant)) {
continue;
}
if ($lastActivity->lte($deletionThreshold)) {
$this->dispatchAnonymization($tenant);
continue;
}
if ($lastActivity->lte($warningThreshold) && $tenant->deletion_warning_sent_at === null) {
$this->sendWarning($tenant, $lastActivity->copy()->addMonths(24));
}
}
});
$this->info(__('profile.retention.scan_complete'));
return self::SUCCESS;
}
protected function determineLastActivity(Tenant $tenant): ?Carbon
{
$candidates = collect([
$tenant->last_activity_at,
$tenant->last_event_activity ? Carbon::parse($tenant->last_event_activity) : null,
$tenant->last_purchase_activity ? Carbon::parse($tenant->last_purchase_activity) : null,
$tenant->last_photo_activity ? Carbon::parse($tenant->last_photo_activity) : null,
])->filter();
if ($candidates->isEmpty()) {
return $tenant->created_at ? $tenant->created_at->copy() : null;
}
return $candidates->sort()->last();
}
protected function hasActiveSubscription(Tenant $tenant): bool
{
return $tenant->tenantPackages()
->where('active', true)
->whereHas('package', fn ($query) => $query->where('type', 'reseller'))
->exists();
}
protected function sendWarning(Tenant $tenant, Carbon $plannedDeletion): void
{
$email = $tenant->contact_email
?? $tenant->email
?? $tenant->user?->email;
if (! $email) {
return;
}
Notification::route('mail', $email)
->notify(new InactiveTenantDeletionWarning($tenant, $plannedDeletion));
$tenant->forceFill([
'deletion_warning_sent_at' => now(),
'pending_deletion_at' => $plannedDeletion,
])->save();
}
protected function dispatchAnonymization(Tenant $tenant): void
{
if ($tenant->anonymized_at) {
return;
}
AnonymizeAccount::dispatch($tenant->user?->id, $tenant->id);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Models\DataExport;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class PurgeExpiredDataExports extends Command
{
protected $signature = 'exports:purge';
protected $description = 'Delete abgelaufene Datenexporte und entfernen die entsprechenden Dateien.';
public function handle(): int
{
$expired = DataExport::query()
->whereNotNull('expires_at')
->where('expires_at', '<', now())
->get();
if ($expired->isEmpty()) {
$this->info(__('profile.export.purge.none'));
return self::SUCCESS;
}
$count = 0;
foreach ($expired as $export) {
if ($export->path && Storage::disk('local')->exists($export->path)) {
Storage::disk('local')->delete($export->path);
}
$export->delete();
$count++;
}
$this->info(trans_choice('profile.export.purge.deleted', $count, ['count' => $count]));
return self::SUCCESS;
}
}

View File

@@ -6,10 +6,35 @@ use App\Models\LegalPage;
use App\Support\ApiError; use App\Support\ApiError;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController; use Illuminate\Routing\Controller as BaseController;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class LegalController extends BaseController class LegalController extends BaseController
{ {
protected MarkdownConverter $markdown;
public function __construct()
{
$environment = new Environment([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new TableExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new StrikethroughExtension());
$environment->addExtension(new TaskListExtension());
$this->markdown = new MarkdownConverter($environment);
}
public function show(Request $request, string $slug) public function show(Request $request, string $slug)
{ {
$locale = $request->query('lang', 'de'); $locale = $request->query('lang', 'de');
@@ -39,7 +64,7 @@ class LegalController extends BaseController
} }
$title = $page->title[$locale] ?? $page->title[$page->locale_fallback] ?? $page->title['de'] ?? $page->title['en'] ?? $page->slug; $title = $page->title[$locale] ?? $page->title[$page->locale_fallback] ?? $page->title['de'] ?? $page->title['en'] ?? $page->slug;
$body = $page->body_markdown[$locale] ?? $page->body_markdown[$page->locale_fallback] ?? reset($page->body_markdown); $body = $page->body_markdown[$locale] ?? $page->body_markdown[$page->locale_fallback] ?? reset($page->body_markdown) ?? '';
return response()->json([ return response()->json([
'slug' => $page->slug, 'slug' => $page->slug,
@@ -48,6 +73,12 @@ class LegalController extends BaseController
'locale' => $locale, 'locale' => $locale,
'title' => $title, 'title' => $title,
'body_markdown' => (string) $body, 'body_markdown' => (string) $body,
'body_html' => $this->convertMarkdownToHtml($body),
])->header('Cache-Control', 'no-store'); ])->header('Cache-Control', 'no-store');
} }
protected function convertMarkdownToHtml(string $markdown): string
{
return trim((string) $this->markdown->convert($markdown));
}
} }

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\AnonymizeAccount;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class ProfileAccountController extends Controller
{
public function destroy(Request $request): RedirectResponse
{
$confirmationWord = Str::upper(__('profile.delete.confirmation_keyword'));
$request->validate([
'confirmation' => [
'required',
function ($attribute, $value, $fail) use ($confirmationWord) {
if (Str::upper(trim((string) $value)) !== $confirmationWord) {
$fail(__('profile.delete.validation', ['word' => $confirmationWord]));
}
},
],
]);
$user = $request->user();
abort_unless($user, 403);
if ($user->tenant?->anonymized_at) {
return back()->with('status', __('profile.delete.already'));
}
AnonymizeAccount::dispatch($user->id);
return redirect()->route('profile.index')->with('status', __('profile.delete.started'));
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\DataExport;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@@ -43,6 +44,36 @@ class ProfileController extends Controller
->values() ->values()
->all(); ->all();
$recentExports = $user->dataExports()
->latest()
->limit(5)
->get()
->map(fn ($export) => [
'id' => $export->id,
'status' => $export->status,
'size' => $export->size_bytes,
'expires_at' => optional($export->expires_at)->toIso8601String(),
'created_at' => optional($export->created_at)->toIso8601String(),
'download_url' => $export->isReady() && ! $export->hasExpired()
? route('profile.data-exports.download', $export)
: null,
'error_message' => $export->error_message,
]);
$pendingExport = $user->dataExports()
->whereIn('status', [
DataExport::STATUS_PENDING,
DataExport::STATUS_PROCESSING,
])
->exists();
$lastReadyExport = $user->dataExports()
->where('status', DataExport::STATUS_READY)
->latest('created_at')
->first();
$nextExportAt = $lastReadyExport?->created_at?->clone()->addDay();
return Inertia::render('Profile/Index', [ return Inertia::render('Profile/Index', [
'userData' => [ 'userData' => [
'id' => $user->id, 'id' => $user->id,
@@ -68,6 +99,11 @@ class ProfileController extends Controller
] : null, ] : null,
] : null, ] : null,
'purchases' => $purchases, 'purchases' => $purchases,
'dataExport' => [
'exports' => $recentExports,
'hasPending' => $pendingExport,
'nextRequestAt' => $nextExportAt?->toIso8601String(),
],
]); ]);
} }
} }

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\GenerateDataExport;
use App\Models\DataExport;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ProfileDataExportController extends Controller
{
public function store(Request $request): RedirectResponse
{
$user = $request->user();
abort_unless($user, 403);
$hasRecentExport = $user->dataExports()
->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING])
->exists();
if ($hasRecentExport) {
return back()->with('error', __('profile.export.messages.in_progress'));
}
$recentReadyExport = $user->dataExports()
->where('status', DataExport::STATUS_READY)
->where('created_at', '>=', now()->subDay())
->exists();
if ($recentReadyExport) {
return back()->with('error', __('profile.export.messages.recent_ready'));
}
$export = $user->dataExports()->create([
'tenant_id' => $user->tenant_id,
'status' => DataExport::STATUS_PENDING,
]);
GenerateDataExport::dispatch($export->id);
return back()->with('status', __('profile.export.messages.started'));
}
public function download(Request $request, DataExport $export)
{
$user = $request->user();
abort_unless($user && $export->user_id === $user->id, 403);
if (! $export->isReady() || $export->hasExpired() || ! $export->path) {
return back()->with('error', __('profile.export.messages.not_available'));
}
return Storage::disk('local')->download($export->path, sprintf('fotospiel-data-export-%s.zip', $export->created_at?->format('Ymd') ?? now()->format('Ymd')));
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Jobs;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Compliance\AccountAnonymizer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class AnonymizeAccount implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(private readonly ?int $userId = null, private readonly ?int $tenantId = null)
{
if ($this->userId === null && $this->tenantId === null) {
throw new \InvalidArgumentException('An anonymization job requires either a user or tenant id.');
}
$this->onQueue('default');
}
public function handle(AccountAnonymizer $anonymizer): void
{
if ($this->userId) {
$user = User::with('tenant')->find($this->userId);
if (! $user || $user->tenant?->anonymized_at) {
return;
}
$anonymizer->anonymize($user);
return;
}
if ($this->tenantId) {
$tenant = Tenant::with('user')->find($this->tenantId);
if (! $tenant || $tenant->anonymized_at) {
return;
}
if ($tenant->user) {
$anonymizer->anonymize($tenant->user);
} else {
$anonymizer->anonymizeTenantOnly($tenant);
}
}
}
public function userId(): ?int
{
return $this->userId;
}
public function tenantId(): ?int
{
return $this->tenantId;
}
}

View File

@@ -0,0 +1,224 @@
<?php
namespace App\Jobs;
use App\Models\DataExport;
use App\Models\Event;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use ZipArchive;
class GenerateDataExport implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(private readonly int $exportId)
{
$this->onQueue('default');
}
public function handle(): void
{
$export = DataExport::with(['user', 'tenant'])->find($this->exportId);
if (! $export) {
return;
}
if (! $export->user) {
$export->update([
'status' => DataExport::STATUS_FAILED,
'error_message' => 'User no longer exists.',
]);
return;
}
$export->update(['status' => DataExport::STATUS_PROCESSING, 'error_message' => null]);
try {
$payload = $this->buildPayload($export->user, $export->tenant);
$zipPath = $this->writeArchive($export, $payload);
$export->update([
'status' => DataExport::STATUS_READY,
'path' => $zipPath,
'size_bytes' => Storage::disk('local')->size($zipPath),
'expires_at' => now()->addDays(14),
]);
} catch (\Throwable $exception) {
$export->update([
'status' => DataExport::STATUS_FAILED,
'error_message' => $exception->getMessage(),
]);
report($exception);
}
}
/**
* @return array<string, mixed>
*/
protected function buildPayload(User $user, ?Tenant $tenant): array
{
$profile = [
'generated_at' => now()->toIso8601String(),
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'username' => $user->username,
'preferred_locale' => $user->preferred_locale,
'created_at' => optional($user->created_at)->toIso8601String(),
'email_verified_at' => optional($user->email_verified_at)->toIso8601String(),
],
];
if ($tenant) {
$profile['tenant'] = [
'id' => $tenant->id,
'name' => $tenant->name,
'slug' => $tenant->slug,
'contact_email' => $tenant->contact_email,
'contact_phone' => $tenant->contact_phone,
'subscription_status' => $tenant->subscription_status,
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toIso8601String(),
'created_at' => optional($tenant->created_at)->toIso8601String(),
];
}
$events = $tenant
? $this->collectEvents($tenant)
: [];
$invoices = $tenant
? $this->collectInvoices($tenant)
: [];
return [
'profile' => $profile,
'events' => $events,
'invoices' => $invoices,
];
}
/**
* @return array<int, array<string, mixed>>
*/
protected function collectEvents(Tenant $tenant): array
{
$events = Event::query()
->withCount([
'photos as photos_total',
'photos as featured_photos_total' => fn ($query) => $query->where('is_featured', true),
'joinTokens',
'members',
])
->where('tenant_id', $tenant->id)
->orderBy('date')
->get();
$likeCounts = DB::table('photo_likes')
->join('photos', 'photo_likes.photo_id', '=', 'photos.id')
->where('photos.tenant_id', $tenant->id)
->groupBy('photos.event_id')
->pluck(DB::raw('COUNT(*)'), 'photos.event_id');
return $events
->map(function (Event $event) use ($likeCounts): array {
$likes = (int) ($likeCounts[$event->id] ?? 0);
return [
'id' => $event->id,
'slug' => $event->slug,
'status' => $event->status,
'name' => $event->name,
'location' => $event->location,
'date' => optional($event->date)->toIso8601String(),
'photos_total' => (int) ($event->photos_total ?? 0),
'featured_photos_total' => (int) ($event->featured_photos_total ?? 0),
'join_tokens_total' => (int) ($event->join_tokens_count ?? 0),
'members_total' => (int) ($event->members_count ?? 0),
'likes_total' => $likes,
'created_at' => optional($event->created_at)->toIso8601String(),
'updated_at' => optional($event->updated_at)->toIso8601String(),
];
})
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
protected function collectInvoices(Tenant $tenant): array
{
return PackagePurchase::query()
->where('tenant_id', $tenant->id)
->with('package')
->latest('purchased_at')
->get()
->map(function (PackagePurchase $purchase): array {
return [
'id' => $purchase->id,
'package' => $purchase->package?->getNameForLocale(app()->getLocale())
?? $purchase->package?->name,
'price' => $purchase->price !== null ? (float) $purchase->price : null,
'currency' => 'EUR',
'type' => $purchase->type,
'provider' => $purchase->provider,
'provider_id' => $purchase->provider_id,
'purchased_at' => optional($purchase->purchased_at)->toIso8601String(),
'refunded' => (bool) $purchase->refunded,
];
})
->all();
}
/**
* @param array<string, mixed> $payload
*/
protected function writeArchive(DataExport $export, array $payload): string
{
$directory = 'exports/user-'.$export->user_id;
Storage::disk('local')->makeDirectory($directory);
$filename = sprintf('data-export-%s.zip', Str::uuid());
$path = $directory.'/'.$filename;
$zip = new ZipArchive;
$fullPath = Storage::disk('local')->path($path);
if ($zip->open($fullPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Unable to create export archive.');
}
$zip->addFromString('profile.json', json_encode($payload['profile'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$zip->addFromString('events.json', json_encode($payload['events'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$zip->addFromString('invoices.json', json_encode($payload['invoices'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$locale = $export->user?->preferred_locale ?? app()->getLocale();
$readme = implode("\n", [
__('profile.export.readme.title', [], $locale),
'---------------------',
__('profile.export.readme.description', [], $locale),
__('profile.export.readme.generated', [
'date' => now()->copy()->locale($locale)->translatedFormat('d. F Y H:i'),
], $locale),
__('profile.export.readme.expiry', [], $locale),
]);
$zip->addFromString('README.txt', $readme);
$zip->close();
return $path;
}
}

54
app/Models/DataExport.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DataExport extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_READY = 'ready';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'user_id',
'tenant_id',
'status',
'path',
'size_bytes',
'expires_at',
'error_message',
];
protected $casts = [
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function isReady(): bool
{
return $this->status === self::STATUS_READY;
}
public function hasExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
}

View File

@@ -156,4 +156,9 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
{ {
return $this->hasMany(EventMember::class); return $this->hasMany(EventMember::class);
} }
public function dataExports(): HasMany
{
return $this->hasMany(DataExport::class);
}
} }

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Notifications;
use App\Models\Tenant;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class InactiveTenantDeletionWarning extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly Tenant $tenant, private readonly Carbon $plannedDeletion)
{
$this->afterCommit();
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$locale = $this->tenant->user?->preferred_locale ?? app()->getLocale();
$formattedDate = $this->plannedDeletion->copy()->locale($locale)->translatedFormat('d. F Y');
return (new MailMessage)
->locale($locale)
->subject(__('profile.retention.warning_subject', [], $locale))
->line(__('profile.retention.line1', ['name' => $this->tenant->name], $locale))
->line(__('profile.retention.line2', ['date' => $formattedDate], $locale))
->line(__('profile.retention.line3', [], $locale))
->action(__('profile.retention.action', [], $locale), url('/login'));
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Services\Compliance;
use App\Models\CheckoutSession;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Models\EventMediaAsset;
use App\Models\EventMember;
use App\Models\PackagePurchase;
use App\Models\Photo;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class AccountAnonymizer
{
public function anonymize(User $user): void
{
DB::transaction(function () use ($user) {
$tenant = $user->tenant;
if ($tenant) {
$this->purgeTenantMedia($tenant);
$this->scrubTenantData($tenant);
}
$this->scrubUser($user);
});
}
public function anonymizeTenantOnly(Tenant $tenant): void
{
DB::transaction(function () use ($tenant) {
$this->purgeTenantMedia($tenant);
$this->scrubTenantData($tenant);
});
}
protected function purgeTenantMedia(Tenant $tenant): void
{
EventMediaAsset::query()
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenant->id))
->chunkById(100, function ($assets) {
foreach ($assets as $asset) {
if ($asset->disk && $asset->path) {
Storage::disk($asset->disk)->delete($asset->path);
}
$asset->delete();
}
});
Photo::query()
->where(function ($query) use ($tenant) {
$query->where('tenant_id', $tenant->id)
->orWhereHas('event', fn ($inner) => $inner->where('tenant_id', $tenant->id));
})
->chunkById(200, function ($photos) {
foreach ($photos as $photo) {
$photo->delete();
}
});
}
protected function scrubTenantData(Tenant $tenant): void
{
$eventIds = Event::query()
->where('tenant_id', $tenant->id)
->pluck('id');
EventJoinToken::query()->whereIn('event_id', $eventIds)->delete();
EventMember::query()->where('tenant_id', $tenant->id)->delete();
Event::query()
->whereIn('id', $eventIds)
->chunkById(100, function ($events) {
foreach ($events as $event) {
$event->forceFill([
'name' => ['de' => 'Anonymisiertes Event', 'en' => 'Anonymized Event'],
'description' => null,
'location' => null,
'slug' => 'anonymized-event-'.$event->id,
'status' => 'archived',
'photobooth_enabled' => false,
'photobooth_path' => null,
])->save();
}
});
PackagePurchase::query()
->where('tenant_id', $tenant->id)
->update([
'ip_address' => null,
'user_agent' => null,
]);
CheckoutSession::query()
->where('tenant_id', $tenant->id)
->update([
'tenant_id' => null,
'user_id' => null,
'provider_metadata' => [],
]);
TenantPackage::query()
->where('tenant_id', $tenant->id)
->update(['active' => false]);
$tenant->forceFill([
'name' => 'Anonymisierter Tenant #'.$tenant->id,
'slug' => 'anonymized-tenant-'.$tenant->id,
'contact_name' => null,
'contact_email' => null,
'contact_phone' => null,
'email' => null,
'domain' => null,
'custom_domain' => null,
'user_id' => null,
'anonymized_at' => now(),
'pending_deletion_at' => null,
'deletion_warning_sent_at' => null,
'subscription_status' => 'deleted',
'is_active' => 0,
'is_suspended' => 1,
])->save();
}
protected function scrubUser(User $user): void
{
$placeholderEmail = sprintf('deleted+%s@fotospiel.app', $user->id);
$user->forceFill([
'name' => 'Anonymisierter Benutzer',
'email' => $placeholderEmail,
'username' => null,
'first_name' => null,
'last_name' => null,
'address' => null,
'phone' => null,
'password' => Str::random(40),
'remember_token' => null,
'pending_purchase' => false,
'email_verified_at' => null,
'role' => 'user',
])->save();
}
}

View File

@@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use League\CommonMark\Environment\Environment; use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\MarkdownConverter; use League\CommonMark\MarkdownConverter;
use RuntimeException; use RuntimeException;
use Symfony\Component\Finder\SplFileInfo; use Symfony\Component\Finder\SplFileInfo;
@@ -22,6 +23,7 @@ class HelpSyncService
{ {
$environment = new Environment; $environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension); $environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new TableExtension);
$this->converter = new MarkdownConverter($environment); $this->converter = new MarkdownConverter($environment);
} }

View File

@@ -27,11 +27,15 @@ return Application::configure(basePath: dirname(__DIR__))
\App\Console\Commands\MonitorStorageCommand::class, \App\Console\Commands\MonitorStorageCommand::class,
\App\Console\Commands\DispatchStorageArchiveCommand::class, \App\Console\Commands\DispatchStorageArchiveCommand::class,
\App\Console\Commands\CheckUploadQueuesCommand::class, \App\Console\Commands\CheckUploadQueuesCommand::class,
\App\Console\Commands\PurgeExpiredDataExports::class,
\App\Console\Commands\ProcessTenantRetention::class,
]) ])
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) { ->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) {
$schedule->command('package:check-status')->dailyAt('06:00'); $schedule->command('package:check-status')->dailyAt('06:00');
$schedule->command('photobooth:cleanup-expired')->hourly()->withoutOverlapping(); $schedule->command('photobooth:cleanup-expired')->hourly()->withoutOverlapping();
$schedule->command('photobooth:ingest')->everyFiveMinutes()->withoutOverlapping(); $schedule->command('photobooth:ingest')->everyFiveMinutes()->withoutOverlapping();
$schedule->command('exports:purge')->dailyAt('02:00');
$schedule->command('tenants:retention-scan')->dailyAt('03:00');
}) })
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
$middleware->alias([ $middleware->alias([

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Checkout reminder cron job
# Run hourly to send abandoned checkout reminders
set -euo pipefail
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$APP_DIR"
LOG_DIR="$APP_DIR/storage/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/cron-checkout-reminders.log"
LOCK_FILE="$LOG_DIR/checkout_reminders.lock"
exec 204>"$LOCK_FILE"
if ! flock -n 204; then
exit 0
fi
timestamp() {
date --iso-8601=seconds
}
echo "[$(timestamp)] Running checkout:send-reminders" >> "$LOG_FILE"
if /usr/bin/env php artisan checkout:send-reminders --no-interaction --quiet >> "$LOG_FILE" 2>&1; then
echo "[$(timestamp)] checkout:send-reminders completed" >> "$LOG_FILE"
else
status=$?
echo "[$(timestamp)] checkout:send-reminders failed (exit $status)" >> "$LOG_FILE"
exit $status
fi

32
cron/scheduler.sh Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Laravel scheduler runner
# Add to crontab: * * * * * /path/to/repo/cron/scheduler.sh
set -euo pipefail
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$APP_DIR"
LOG_DIR="$APP_DIR/storage/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/cron-scheduler.log"
LOCK_FILE="$LOG_DIR/scheduler.lock"
exec 203>"$LOCK_FILE"
if ! flock -n 203; then
exit 0
fi
timestamp() {
date --iso-8601=seconds
}
echo "[$(timestamp)] Running php artisan schedule:run" >> "$LOG_FILE"
if /usr/bin/env php artisan schedule:run --no-interaction >> "$LOG_FILE" 2>&1; then
echo "[$(timestamp)] schedule:run completed" >> "$LOG_FILE"
else
status=$?
echo "[$(timestamp)] schedule:run failed (exit $status)" >> "$LOG_FILE"
exit $status
fi

View File

@@ -0,0 +1,31 @@
<?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::create('data_exports', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete();
$table->string('status', 32)->default('pending');
$table->string('path')->nullable();
$table->unsignedBigInteger('size_bytes')->nullable();
$table->timestamp('expires_at')->nullable();
$table->text('error_message')->nullable();
$table->timestamps();
$table->index(['status']);
$table->index(['expires_at']);
});
}
public function down(): void
{
Schema::dropIfExists('data_exports');
}
};

View File

@@ -0,0 +1,42 @@
<?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) {
if (! Schema::hasColumn('tenants', 'anonymized_at')) {
$table->timestamp('anonymized_at')->nullable()->after('last_activity_at');
}
if (! Schema::hasColumn('tenants', 'pending_deletion_at')) {
$table->timestamp('pending_deletion_at')->nullable()->after('anonymized_at');
}
if (! Schema::hasColumn('tenants', 'deletion_warning_sent_at')) {
$table->timestamp('deletion_warning_sent_at')->nullable()->after('pending_deletion_at');
}
});
}
public function down(): void
{
if (app()->environment('local', 'testing')) {
Schema::table('tenants', function (Blueprint $table) {
if (Schema::hasColumn('tenants', 'deletion_warning_sent_at')) {
$table->dropColumn('deletion_warning_sent_at');
}
if (Schema::hasColumn('tenants', 'pending_deletion_at')) {
$table->dropColumn('pending_deletion_at');
}
if (Schema::hasColumn('tenants', 'anonymized_at')) {
$table->dropColumn('anonymized_at');
}
});
}
}
};

66
lang/de/profile.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
return [
'export' => [
'title' => 'Datenexport',
'description' => 'Fordere eine Kopie deiner Profildaten inklusive Events und Rechnungen an. Der Link bleibt 14 Tage gültig.',
'empty' => 'Bisher wurde kein Export erstellt. Du kannst jederzeit einen neuen Export anfordern.',
'table' => [
'status' => 'Status',
'created' => 'Erstellt',
'expires' => 'Ablauf',
'size' => 'Größe',
'action' => 'Aktion',
],
'status' => [
'pending' => 'Geplant',
'processing' => 'In Arbeit',
'ready' => 'Bereit',
'failed' => 'Fehlgeschlagen',
],
'pending_alert_title' => 'Export wird vorbereitet',
'pending_alert_body' => 'Wir stellen deine Daten zusammen. Du erhältst eine Benachrichtigung, sobald der Download bereitsteht.',
'cooldown' => 'Ein neuer Export ist ab dem :date verfügbar.',
'button' => 'Datenexport anfordern',
'download' => 'Download',
'failed_reason' => 'Fehler: :reason',
'messages' => [
'in_progress' => 'Ein Datenexport ist bereits in Arbeit.',
'recent_ready' => 'Du kannst nur alle 24 Stunden einen neuen Export anfordern.',
'started' => 'Wir bereiten deinen Datenexport vor. Du erhältst eine Benachrichtigung, sobald er bereitsteht.',
'not_available' => 'Dieser Export ist nicht mehr verfügbar.',
],
'purge' => [
'none' => 'Keine abgelaufenen Datenexporte gefunden.',
'deleted' => '{1} :count Datenexport gelöscht.|[2,*] :count Datenexporte gelöscht.',
],
'readme' => [
'title' => 'Fotospiel Datenexport',
'description' => 'Dieses Archiv enthält persönliche Daten aus deinem Konto (Profil, Events, Rechnungen).',
'generated' => 'Erstellt am: :date',
'expiry' => 'Der Download-Link ist 14 Tage gültig.',
],
],
'delete' => [
'title' => 'Konto anonymisieren & löschen',
'description' => 'Entfernt alle Fotos, anonymisiert Events und sperrt den Login unwiderruflich. So erfüllst du das Recht auf Vergessenwerden.',
'warning_title' => 'Wichtiger Hinweis',
'warning_body' => 'Alle Medien und Photobooth-Uploads werden dauerhaft gelöscht. Statistiken bleiben lediglich anonymisiert erhalten.',
'confirmation_label' => 'Bestätigung',
'confirmation_placeholder' => 'LÖSCHEN',
'confirmation_hint' => 'Bitte tippe LÖSCHEN ein, um die Anonymisierung zu bestätigen.',
'confirmation_keyword' => 'LÖSCHEN',
'button' => 'Konto anonymisieren',
'validation' => 'Bitte gib ":word" ein, um die Anonymisierung zu bestätigen.',
'started' => 'Wir führen die Anonymisierung deines Kontos jetzt durch. Du wirst nach Abschluss informiert.',
'already' => 'Dein Konto wurde bereits anonymisiert.',
],
'retention' => [
'warning_subject' => 'Dein Fotospiel-Konto wird gelöscht',
'line1' => 'Wir haben in den letzten 24 Monaten keine Aktivität für dein Konto „:name“ festgestellt.',
'line2' => 'Wenn du das Konto weiter nutzen möchtest, logge dich bitte vor dem :date ein oder kontaktiere uns.',
'line3' => 'Nach Ablauf der Frist werden alle Fotos dauerhaft gelöscht und das Konto anonymisiert.',
'action' => 'Jetzt anmelden',
'scan_complete' => 'Retention-Scan abgeschlossen.',
],
];

66
lang/en/profile.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
return [
'export' => [
'title' => 'Data export',
'description' => 'Request a copy of your profile data including events and invoices. The link remains valid for 14 days.',
'empty' => 'No export has been created yet. You can request one at any time.',
'table' => [
'status' => 'Status',
'created' => 'Created',
'expires' => 'Expires',
'size' => 'Size',
'action' => 'Action',
],
'status' => [
'pending' => 'Scheduled',
'processing' => 'Processing',
'ready' => 'Ready',
'failed' => 'Failed',
],
'pending_alert_title' => 'Export in progress',
'pending_alert_body' => 'We are preparing your data. You will be notified as soon as the download is ready.',
'cooldown' => 'A new export is available on :date.',
'button' => 'Request data export',
'download' => 'Download',
'failed_reason' => 'Error: :reason',
'messages' => [
'in_progress' => 'A data export is already in progress.',
'recent_ready' => 'You can request a new export only once every 24 hours.',
'started' => 'We are preparing your data export. You will be notified once it is ready.',
'not_available' => 'This export is no longer available.',
],
'purge' => [
'none' => 'No expired data exports found.',
'deleted' => '{1} :count data export deleted.|[2,*] :count data exports deleted.',
],
'readme' => [
'title' => 'Fotospiel Data Export',
'description' => 'This archive contains personal data from your account (profile, events, invoices).',
'generated' => 'Generated on: :date',
'expiry' => 'The download link stays valid for 14 days.',
],
],
'delete' => [
'title' => 'Anonymise & delete account',
'description' => 'Removes all photos, anonymises events, and permanently blocks login. This fulfils the right to be forgotten.',
'warning_title' => 'Important notice',
'warning_body' => 'All media and photobooth uploads will be permanently deleted. Statistics remain available only in anonymised form.',
'confirmation_label' => 'Confirmation',
'confirmation_placeholder' => 'DELETE',
'confirmation_hint' => 'Please type DELETE to confirm the anonymisation.',
'confirmation_keyword' => 'DELETE',
'button' => 'Anonymise account',
'validation' => 'Please enter ":word" to confirm the anonymisation.',
'started' => 'We are anonymising your account now. You will be notified once the process is finished.',
'already' => 'Your account has already been anonymised.',
],
'retention' => [
'warning_subject' => 'Your Fotospiel account will be deleted',
'line1' => 'We have not detected any activity for your account “:name” during the past 24 months.',
'line2' => 'If you want to keep it, please sign in before :date or contact us.',
'line3' => 'After that deadline all photos will be permanently deleted and the account will be anonymised.',
'action' => 'Sign in now',
'scan_complete' => 'Retention scan completed.',
],
];

View File

@@ -0,0 +1,36 @@
{
"export": {
"title": "Datenexport",
"description": "Fordere eine Kopie deiner Profildaten inklusive Events und Rechnungen an. Der Link bleibt 14 Tage gültig.",
"empty": "Bisher wurde kein Export erstellt. Du kannst jederzeit einen neuen Export anfordern.",
"table": {
"status": "Status",
"created": "Erstellt",
"expires": "Ablauf",
"size": "Größe",
"action": "Aktion"
},
"status": {
"pending": "Geplant",
"processing": "In Arbeit",
"ready": "Bereit",
"failed": "Fehlgeschlagen"
},
"pending_alert_title": "Export wird vorbereitet",
"pending_alert_body": "Wir stellen deine Daten zusammen. Du erhältst eine Benachrichtigung, sobald der Download bereitsteht.",
"cooldown": "Ein neuer Export ist ab dem {{date}} verfügbar.",
"button": "Datenexport anfordern",
"download": "Download",
"failed_reason": "Fehler: {{reason}}"
},
"delete": {
"title": "Konto anonymisieren & löschen",
"description": "Entfernt alle Fotos, anonymisiert Events und sperrt den Login unwiderruflich. So erfüllst du das Recht auf Vergessenwerden.",
"warning_title": "Wichtiger Hinweis",
"warning_body": "Alle Medien und Photobooth-Uploads werden dauerhaft gelöscht. Statistiken bleiben lediglich anonymisiert erhalten.",
"confirmation_label": "Bestätigung",
"confirmation_placeholder": "LÖSCHEN",
"confirmation_hint": "Bitte tippe LÖSCHEN ein, um die Anonymisierung zu bestätigen.",
"button": "Konto anonymisieren"
}
}

View File

@@ -0,0 +1,36 @@
{
"export": {
"title": "Data export",
"description": "Request a copy of your profile data including events and invoices. The link remains valid for 14 days.",
"empty": "No export has been created yet. You can request one at any time.",
"table": {
"status": "Status",
"created": "Created",
"expires": "Expires",
"size": "Size",
"action": "Action"
},
"status": {
"pending": "Scheduled",
"processing": "Processing",
"ready": "Ready",
"failed": "Failed"
},
"pending_alert_title": "Export in progress",
"pending_alert_body": "We are preparing your data. You will be notified as soon as the download is ready.",
"cooldown": "A new export is available on {{date}}.",
"button": "Request data export",
"download": "Download",
"failed_reason": "Error: {{reason}}"
},
"delete": {
"title": "Anonymise & delete account",
"description": "Removes all photos, anonymises events, and permanently blocks login. This fulfils the right to be forgotten.",
"warning_title": "Important notice",
"warning_body": "All media and photobooth uploads will be permanently deleted. Statistics remain available only in anonymised form.",
"confirmation_label": "Confirmation",
"confirmation_placeholder": "DELETE",
"confirmation_hint": "Please type DELETE to confirm the anonymisation.",
"button": "Anonymise account"
}
}

View File

@@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next';
import i18n from './i18n'; import i18n from './i18n';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { ConsentProvider } from './contexts/consent'; import { ConsentProvider } from './contexts/consent';
import CookieBanner from '@/components/consent/CookieBanner';
import React from 'react'; import React from 'react';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -45,6 +46,7 @@ createInertiaApp({
<ConsentProvider> <ConsentProvider>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<App {...props} /> <App {...props} />
<CookieBanner />
<Toaster position="top-right" toastOptions={{ duration: 4000 }} /> <Toaster position="top-right" toastOptions={{ duration: 4000 }} />
</I18nextProvider> </I18nextProvider>
</ConsentProvider> </ConsentProvider>

View File

@@ -1,24 +1,31 @@
import React from "react"; import React from "react";
type Props = { type Props = {
markdown: string; markdown?: string;
html?: string;
}; };
export function LegalMarkdown({ markdown }: Props) { export function LegalMarkdown({ markdown = '', html }: Props) {
const html = React.useMemo(() => { const derived = React.useMemo(() => {
let safe = markdown if (html && html.trim().length > 0) {
return html;
}
const escaped = markdown
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;'); .replace(/>/g, '&gt;');
safe = safe.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
safe = safe.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>'); return escaped
safe = safe.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
safe = safe
.split(/\n{2,}/) .split(/\n{2,}/)
.map((block) => `<p>${block.replace(/\n/g, '<br/>')}</p>`) .map((block) => `<p>${block.replace(/\n/g, '<br/>')}</p>`)
.join('\n'); .join('\n');
return safe; }, [markdown, html]);
}, [markdown]);
return <div className="prose prose-sm dark:prose-invert" dangerouslySetInnerHTML={{ __html: html }} />; return (
<div
className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
dangerouslySetInnerHTML={{ __html: derived }}
/>
);
} }

View File

@@ -36,10 +36,10 @@ type ViewState =
}; };
type LegalDocumentState = type LegalDocumentState =
| { phase: 'idle'; title: string; body: string } | { phase: 'idle'; title: string; markdown: string; html: string }
| { phase: 'loading'; title: string; body: string } | { phase: 'loading'; title: string; markdown: string; html: string }
| { phase: 'ready'; title: string; body: string } | { phase: 'ready'; title: string; markdown: string; html: string }
| { phase: 'error'; title: string; body: string }; | { phase: 'error'; title: string; markdown: string; html: string };
type NameStatus = 'idle' | 'saved'; type NameStatus = 'idle' | 'saved';
@@ -223,10 +223,10 @@ function LegalView({
<CardHeader> <CardHeader>
<CardTitle>{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}</CardTitle> <CardTitle>{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none dark:prose-invert"> <CardContent className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground">
<LegalMarkdown markdown={document.body} /> <LegalMarkdown markdown={document.markdown} html={document.html} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }
@@ -418,17 +418,18 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen
const [state, setState] = React.useState<LegalDocumentState>({ const [state, setState] = React.useState<LegalDocumentState>({
phase: 'idle', phase: 'idle',
title: '', title: '',
body: '', markdown: '',
html: '',
}); });
React.useEffect(() => { React.useEffect(() => {
if (!slug) { if (!slug) {
setState({ phase: 'idle', title: '', body: '' }); setState({ phase: 'idle', title: '', markdown: '', html: '' });
return; return;
} }
const controller = new AbortController(); const controller = new AbortController();
setState({ phase: 'loading', title: '', body: '' }); setState({ phase: 'loading', title: '', markdown: '', html: '' });
const langParam = encodeURIComponent(locale); const langParam = encodeURIComponent(locale);
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${langParam}`, { fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${langParam}`, {
@@ -443,7 +444,8 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen
setState({ setState({
phase: 'ready', phase: 'ready',
title: payload.title ?? '', title: payload.title ?? '',
body: payload.body_markdown ?? '', markdown: payload.body_markdown ?? '',
html: payload.body_html ?? '',
}); });
}) })
.catch((error) => { .catch((error) => {
@@ -451,7 +453,7 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen
return; return;
} }
console.error('Failed to load legal page', error); console.error('Failed to load legal page', error);
setState({ phase: 'error', title: '', body: '' }); setState({ phase: 'error', title: '', markdown: '', html: '' });
}); });
return () => controller.abort(); return () => controller.abort();

View File

@@ -2,20 +2,48 @@ import React from 'react';
import { translate, DEFAULT_LOCALE, type LocaleCode } from './messages'; import { translate, DEFAULT_LOCALE, type LocaleCode } from './messages';
import { useLocale } from './LocaleContext'; import { useLocale } from './LocaleContext';
export type TranslateFn = (key: string, fallback?: string) => string; type ReplacementValues = Record<string, string | number>;
export type TranslateFn = {
(key: string): string;
(key: string, fallback: string): string;
(key: string, replacements: ReplacementValues): string;
(key: string, replacements: ReplacementValues, fallback: string): string;
};
function resolveTranslation(locale: LocaleCode, key: string, fallback?: string): string { function resolveTranslation(locale: LocaleCode, key: string, fallback?: string): string {
return translate(locale, key) ?? translate(DEFAULT_LOCALE, key) ?? fallback ?? key; return translate(locale, key) ?? translate(DEFAULT_LOCALE, key) ?? fallback ?? key;
} }
function applyReplacements(value: string, replacements?: ReplacementValues): string {
if (!replacements) {
return value;
}
return Object.entries(replacements).reduce((acc, [token, replacement]) => {
const pattern = new RegExp(`\\{${token}\\}`, 'g');
return acc.replace(pattern, String(replacement));
}, value);
}
export function useTranslation() { export function useTranslation() {
const { locale } = useLocale(); const { locale } = useLocale();
const t = React.useCallback<TranslateFn>( const t = React.useCallback<TranslateFn>((key: string, arg2?: ReplacementValues | string, arg3?: string) => {
(key, fallback) => resolveTranslation(locale, key, fallback), let replacements: ReplacementValues | undefined;
[locale], let fallback: string | undefined;
);
if (typeof arg2 === 'string' || arg2 === undefined) {
fallback = arg2 ?? arg3;
} else {
replacements = arg2;
fallback = arg3;
}
const raw = resolveTranslation(locale, key, fallback);
return applyReplacements(raw, replacements);
}, [locale]);
return React.useMemo(() => ({ t, locale }), [t, locale]); return React.useMemo(() => ({ t, locale }), [t, locale]);
} }

View File

@@ -73,16 +73,18 @@ export default function HelpArticlePage() {
{article.updated_at && ( {article.updated_at && (
<div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div> <div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div>
)} )}
{servedFromCache && ( <Button variant="ghost" size="sm" asChild>
<Badge variant="secondary" className="bg-amber-200/70 text-amber-900 dark:bg-amber-500/30 dark:text-amber-100"> <Link to={basePath}>
{t('help.center.offlineBadge')} {t('help.article.back')}
</Badge> </Link>
)} </Button>
</div>
<div className="overflow-x-auto">
<div
className="prose prose-sm max-w-none dark:prose-invert [&_table]:w-full [&_table]:text-sm [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
/>
</div> </div>
<div
className="prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
/>
{article.related && article.related.length > 0 && ( {article.related && article.related.length > 0 && (
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-base font-semibold text-foreground">{t('help.article.relatedTitle')}</h3> <h3 className="text-base font-semibold text-foreground">{t('help.article.relatedTitle')}</h3>
@@ -95,7 +97,7 @@ export default function HelpArticlePage() {
asChild asChild
> >
<Link to={`${basePath}/${encodeURIComponent(rel.slug)}`}> <Link to={`${basePath}/${encodeURIComponent(rel.slug)}`}>
{rel.slug} {rel.title ?? rel.slug}
</Link> </Link>
</Button> </Button>
))} ))}

View File

@@ -8,6 +8,7 @@ export default function LegalPage() {
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [title, setTitle] = React.useState(''); const [title, setTitle] = React.useState('');
const [body, setBody] = React.useState(''); const [body, setBody] = React.useState('');
const [html, setHtml] = React.useState('');
React.useEffect(() => { React.useEffect(() => {
if (!page) { if (!page) {
@@ -29,11 +30,13 @@ export default function LegalPage() {
const data = await res.json(); const data = await res.json();
setTitle(data.title || ''); setTitle(data.title || '');
setBody(data.body_markdown || ''); setBody(data.body_markdown || '');
setHtml(data.body_html || '');
} catch (error) { } catch (error) {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
console.error('Failed to load legal page', error); console.error('Failed to load legal page', error);
setTitle(''); setTitle('');
setBody(''); setBody('');
setHtml('');
} }
} finally { } finally {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@@ -50,7 +53,7 @@ export default function LegalPage() {
return ( return (
<Page title={title || fallbackTitle}> <Page title={title || fallbackTitle}>
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} />} {loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} html={html} />}
</Page> </Page>
); );
} }

View File

@@ -11,7 +11,7 @@ export type HelpArticleSummary = {
last_reviewed_at?: string; last_reviewed_at?: string;
owner?: string; owner?: string;
updated_at?: string; updated_at?: string;
related?: Array<{ slug: string }>; related?: Array<{ slug: string; title?: string }>;
}; };
export type HelpArticleDetail = HelpArticleSummary & { export type HelpArticleDetail = HelpArticleSummary & {

View File

@@ -16,7 +16,7 @@ i18n
backend: { backend: {
loadPath: '/lang/{{lng}}/{{ns}}.json', loadPath: '/lang/{{lng}}/{{ns}}.json',
}, },
ns: ['marketing', 'auth'], ns: ['marketing', 'auth', 'profile', 'common', 'legal'],
defaultNS: 'marketing', defaultNS: 'marketing',
supportedLngs: ['de', 'en'], supportedLngs: ['de', 'en'],
detection: { detection: {

View File

@@ -2,7 +2,6 @@
import { Head, Link, router, usePage } from '@inertiajs/react'; import { Head, Link, router, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import MatomoTracker, { MatomoConfig } from '@/components/analytics/MatomoTracker'; import MatomoTracker, { MatomoConfig } from '@/components/analytics/MatomoTracker';
import CookieBanner from '@/components/consent/CookieBanner';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import Footer from '@/layouts/app/Footer'; import Footer from '@/layouts/app/Footer';
import { useAppearance } from '@/hooks/use-appearance'; import { useAppearance } from '@/hooks/use-appearance';
@@ -165,7 +164,6 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
))} ))}
</Head> </Head>
<MatomoTracker config={analytics?.matomo} /> <MatomoTracker config={analytics?.matomo} />
<CookieBanner />
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<header className="sticky top-0 z-40 border-b border-gray-200/60 bg-white/95 backdrop-blur"> <header className="sticky top-0 z-40 border-b border-gray-200/60 bg-white/95 backdrop-blur">
<div className="container mx-auto flex items-center justify-between px-4 py-4"> <div className="container mx-auto flex items-center justify-between px-4 py-4">

View File

@@ -1,6 +1,7 @@
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { Head, Form, Link, usePage } from '@inertiajs/react'; import { Head, Form, Link, router, useForm, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import { CalendarClock, CheckCircle2, MailWarning, ReceiptText } from 'lucide-react'; import { CalendarClock, CheckCircle2, MailWarning, ReceiptText } from 'lucide-react';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
@@ -49,6 +50,19 @@ type ProfilePageProps = {
type: string | null; type: string | null;
provider: string | null; provider: string | null;
}>; }>;
dataExport: {
exports: Array<{
id: number;
status: string;
size: number | null;
created_at: string | null;
expires_at: string | null;
download_url: string | null;
error_message: string | null;
}>;
hasPending: boolean;
nextRequestAt?: string | null;
};
}; };
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
@@ -59,7 +73,8 @@ const breadcrumbs: BreadcrumbItem[] = [
]; ];
export default function ProfileIndex() { export default function ProfileIndex() {
const { userData, tenant, purchases, supportedLocales, locale } = usePage<SharedData & ProfilePageProps>().props; const { userData, tenant, purchases, supportedLocales, locale, dataExport } = usePage<SharedData & ProfilePageProps>().props;
const { t } = useTranslation('profile');
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', { const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', {
day: '2-digit', day: '2-digit',
@@ -73,6 +88,44 @@ export default function ProfileIndex() {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}), [locale]); }), [locale]);
const byteFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', {
maximumFractionDigits: 1,
}), [locale]);
const dataExportInfo = dataExport ?? { exports: [], hasPending: false, nextRequestAt: null };
const nextRequestDeadline = dataExportInfo.nextRequestAt ? new Date(dataExportInfo.nextRequestAt) : null;
const canRequestExport = !dataExportInfo.hasPending && (!nextRequestDeadline || nextRequestDeadline <= new Date());
const handleExportRequest = () => {
router.post('/profile/data-exports');
};
const deleteForm = useForm({ confirmation: '' });
const handleDeleteSubmit = (event: React.FormEvent) => {
event.preventDefault();
deleteForm.delete('/profile/account');
};
const formatExportStatus = (status: string) => t(`export.status.${status}`, status);
const formatBytes = (input: number | null) => {
if (!input) {
return '—';
}
const units = ['B', 'KB', 'MB', 'GB'];
let value = input;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${byteFormatter.format(value)} ${units[unitIndex]}`;
};
const registrationDate = useMemo(() => { const registrationDate = useMemo(() => {
if (!userData.emailVerifiedAt) { if (!userData.emailVerifiedAt) {
return null; return null;
@@ -229,6 +282,108 @@ export default function ProfileIndex() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="border border-muted-foreground/30 bg-white/95 dark:bg-gray-950/50">
<CardHeader>
<CardTitle>{t('export.title')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('export.description')}</p>
</CardHeader>
<CardContent className="space-y-4">
{dataExportInfo.exports.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('export.empty')}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('export.table.status')}</TableHead>
<TableHead>{t('export.table.created')}</TableHead>
<TableHead className="hidden md:table-cell">{t('export.table.expires')}</TableHead>
<TableHead className="hidden lg:table-cell">{t('export.table.size')}</TableHead>
<TableHead className="text-right">{t('export.table.action')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dataExportInfo.exports.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{formatExportStatus(item.status)}</TableCell>
<TableCell>{formatDate(item.created_at)}</TableCell>
<TableCell className="hidden md:table-cell">{formatDate(item.expires_at)}</TableCell>
<TableCell className="hidden lg:table-cell">{formatBytes(item.size)}</TableCell>
<TableCell className="text-right">
{item.download_url && item.status === 'ready' ? (
<a
href={item.download_url}
className="text-sm font-semibold text-pink-600 transition hover:text-pink-700"
>
{t('export.download')}
</a>
) : item.status === 'failed' && item.error_message ? (
<span className="text-xs text-destructive">{t('export.failed_reason', { reason: item.error_message })}</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{dataExportInfo.hasPending && (
<Alert variant="default" className="border-dashed border-muted-foreground/30 bg-muted/20">
<AlertTitle>{t('export.pending_alert_title')}</AlertTitle>
<AlertDescription>{t('export.pending_alert_body')}</AlertDescription>
</Alert>
)}
{!canRequestExport && nextRequestDeadline && (
<p className="text-xs text-muted-foreground">
{t('export.cooldown', {
date: nextRequestDeadline.toLocaleDateString(locale ?? 'de-DE'),
})}
</p>
)}
</CardContent>
<CardFooter>
<Button onClick={handleExportRequest} disabled={!canRequestExport}>
{t('export.button')}
</Button>
</CardFooter>
</Card>
<Card className="border border-red-200 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20">
<CardHeader>
<CardTitle>{t('delete.title')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('delete.description')}</p>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertTitle>{t('delete.warning_title')}</AlertTitle>
<AlertDescription>{t('delete.warning_body')}</AlertDescription>
</Alert>
<form onSubmit={handleDeleteSubmit} className="space-y-3">
<div className="grid gap-2">
<Label htmlFor="confirmation">{t('delete.confirmation_label')}</Label>
<Input
id="confirmation"
name="confirmation"
value={deleteForm.data.confirmation}
onChange={(event) => deleteForm.setData('confirmation', event.target.value.toUpperCase())}
placeholder={t('delete.confirmation_placeholder')}
/>
<p className="text-xs text-muted-foreground">{t('delete.confirmation_hint')}</p>
<InputError message={deleteForm.errors.confirmation} />
</div>
<Button type="submit" variant="destructive" className="w-full" disabled={deleteForm.processing}>
{t('delete.button')}
</Button>
</form>
</CardContent>
</Card>
</div>
</AppLayout> </AppLayout>
); );
} }

View File

@@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\Api\EventPublicController; use App\Http\Controllers\Api\EventPublicController;
use App\Http\Controllers\Api\LegalController;
use App\Http\Controllers\Api\HelpController; use App\Http\Controllers\Api\HelpController;
use App\Http\Controllers\Api\Marketing\CouponPreviewController; use App\Http\Controllers\Api\Marketing\CouponPreviewController;
use App\Http\Controllers\Api\PackageController; use App\Http\Controllers\Api\PackageController;
@@ -61,6 +62,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::middleware('throttle:100,1')->group(function () { Route::middleware('throttle:100,1')->group(function () {
Route::get('/help', [HelpController::class, 'index'])->name('help.index'); Route::get('/help', [HelpController::class, 'index'])->name('help.index');
Route::get('/help/{slug}', [HelpController::class, 'show'])->name('help.show'); Route::get('/help/{slug}', [HelpController::class, 'show'])->name('help.show');
Route::get('/legal/{slug}', [LegalController::class, 'show'])->name('legal.show');
Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show'); Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show');
Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats'); Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats');

View File

@@ -10,7 +10,9 @@ use App\Http\Controllers\LocaleController;
use App\Http\Controllers\MarketingController; use App\Http\Controllers\MarketingController;
use App\Http\Controllers\PaddleCheckoutController; use App\Http\Controllers\PaddleCheckoutController;
use App\Http\Controllers\PaddleWebhookController; use App\Http\Controllers\PaddleWebhookController;
use App\Http\Controllers\ProfileAccountController;
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ProfileDataExportController;
use App\Http\Controllers\Tenant\EventPhotoArchiveController; use App\Http\Controllers\Tenant\EventPhotoArchiveController;
use App\Http\Controllers\TenantAdminAuthController; use App\Http\Controllers\TenantAdminAuthController;
use App\Http\Controllers\TenantAdminGoogleController; use App\Http\Controllers\TenantAdminGoogleController;
@@ -242,6 +244,12 @@ Route::get('/dashboard', DashboardController::class)
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'index']) Route::get('/profile', [ProfileController::class, 'index'])
->name('profile.index'); ->name('profile.index');
Route::post('/profile/data-exports', [ProfileDataExportController::class, 'store'])
->name('profile.data-exports.store');
Route::get('/profile/data-exports/{export}', [ProfileDataExportController::class, 'download'])
->name('profile.data-exports.download');
Route::delete('/profile/account', [ProfileAccountController::class, 'destroy'])
->name('profile.account.destroy');
}); });
Route::prefix('event-admin')->group(function () { Route::prefix('event-admin')->group(function () {
$renderAdmin = fn () => view('admin'); $renderAdmin = fn () => view('admin');

View File

@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature;
use App\Jobs\AnonymizeAccount;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
class ProfileAccountDeletionTest extends TestCase
{
use RefreshDatabase;
public function test_confirmation_is_required(): void
{
Queue::fake();
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->actingAs($user)->delete('/profile/account', [
'confirmation' => 'WRONG',
]);
$response->assertSessionHasErrors('confirmation');
Queue::assertNothingPushed();
}
public function test_account_deletion_dispatches_job(): void
{
Queue::fake();
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$keyword = Str::upper(__('profile.delete.confirmation_keyword'));
$response = $this->actingAs($user)->delete('/profile/account', [
'confirmation' => $keyword,
]);
$response->assertRedirect('/profile');
Queue::assertPushed(AnonymizeAccount::class, function (AnonymizeAccount $job) use ($user) {
return $job->userId() === $user->id;
});
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature;
use App\Jobs\GenerateDataExport;
use App\Models\DataExport;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ProfileDataExportTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_request_data_export(): void
{
Queue::fake();
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->actingAs($user)->post('/profile/data-exports');
$response->assertRedirect();
$this->assertDatabaseCount('data_exports', 1);
Queue::assertPushed(GenerateDataExport::class);
}
public function test_ready_export_can_be_downloaded(): void
{
Storage::fake('local');
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
Storage::disk('local')->put('exports/demo.zip', 'demo-content');
$export = DataExport::create([
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'status' => DataExport::STATUS_READY,
'path' => 'exports/demo.zip',
'size_bytes' => 123,
'expires_at' => now()->addDay(),
]);
$response = $this->actingAs($user)->get(route('profile.data-exports.download', $export));
$response->assertOk();
$response->assertHeader('content-disposition');
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Tests\Feature;
use App\Jobs\AnonymizeAccount;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\User;
use App\Notifications\InactiveTenantDeletionWarning;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class TenantRetentionCommandTest extends TestCase
{
use RefreshDatabase;
public function test_inactive_tenant_gets_anonymized(): void
{
Queue::fake();
$tenant = Tenant::factory()->create([
'last_activity_at' => now()->subMonths(25),
]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$tenant->user()->associate($user)->save();
$this->artisan('tenants:retention-scan')->assertExitCode(0);
Queue::assertPushed(AnonymizeAccount::class, function (AnonymizeAccount $job) use ($tenant) {
return $job->tenantId() === $tenant->id;
});
}
public function test_warning_is_sent_one_month_before(): void
{
Queue::fake();
Notification::fake();
$tenant = Tenant::factory()->create([
'last_activity_at' => now()->subMonths(23)->subWeek(),
'contact_email' => 'owner@example.com',
]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$tenant->user()->associate($user)->save();
$this->artisan('tenants:retention-scan')->assertExitCode(0);
Notification::assertSentOnDemand(
InactiveTenantDeletionWarning::class,
function (InactiveTenantDeletionWarning $notification, array $channels, $notifiable) {
return in_array('mail', $channels, true);
}
);
$this->assertNotNull($tenant->fresh()->deletion_warning_sent_at);
}
public function test_active_subscription_is_whitelisted(): void
{
Queue::fake();
$tenant = Tenant::factory()->create([
'last_activity_at' => now()->subMonths(25),
]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$tenant->user()->associate($user)->save();
$package = Package::factory()->create(['type' => 'reseller']);
TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => 99,
'purchased_at' => now()->subMonth(),
'expires_at' => now()->addYear(),
'used_events' => 0,
'active' => true,
]);
$this->artisan('tenants:retention-scan')->assertExitCode(0);
Queue::assertNothingPushed();
}
}