From 2587b2049d43d7cb01109c4c11facb5b2e4b8cb2 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 10 Nov 2025 19:55:46 +0100 Subject: [PATCH] =?UTF-8?q?im=20profil=20kann=20ein=20nutzer=20nun=20seine?= =?UTF-8?q?=20daten=20exportieren.=20man=20kann=20seinen=20account=20l?= =?UTF-8?q?=C3=B6schen.=20nach=202=20jahren=20werden=20inaktive=20accounts?= =?UTF-8?q?=20gel=C3=B6scht,=201=20monat=20vorher=20wird=20eine=20email=20?= =?UTF-8?q?geschickt.=20Hilfetexte=20und=20Legal=20Pages=20in=20der=20Gues?= =?UTF-8?q?t=20PWA=20korrigiert=20und=20vom=20layout=20her=20optimiert=20(?= =?UTF-8?q?dark=20mode).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/ProcessTenantRetention.php | 109 +++++++++ .../Commands/PurgeExpiredDataExports.php | 41 ++++ app/Http/Controllers/Api/LegalController.php | 33 ++- .../Controllers/ProfileAccountController.php | 39 +++ app/Http/Controllers/ProfileController.php | 36 +++ .../ProfileDataExportController.php | 58 +++++ app/Jobs/AnonymizeAccount.php | 68 ++++++ app/Jobs/GenerateDataExport.php | 224 ++++++++++++++++++ app/Models/DataExport.php | 54 +++++ app/Models/User.php | 5 + .../InactiveTenantDeletionWarning.php | 39 +++ app/Services/Compliance/AccountAnonymizer.php | 151 ++++++++++++ app/Services/Help/HelpSyncService.php | 2 + bootstrap/app.php | 4 + cron/checkout_reminders.sh | 32 +++ cron/scheduler.sh | 32 +++ ...11_15_120000_create_data_exports_table.php | 31 +++ ...dd_compliance_columns_to_tenants_table.php | 42 ++++ lang/de/profile.php | 66 ++++++ lang/en/profile.php | 66 ++++++ public/lang/de/profile.json | 36 +++ public/lang/en/profile.json | 36 +++ resources/js/app.tsx | 2 + .../js/guest/components/legal-markdown.tsx | 31 ++- .../js/guest/components/settings-sheet.tsx | 28 ++- resources/js/guest/i18n/useTranslation.ts | 40 +++- resources/js/guest/pages/HelpArticlePage.tsx | 22 +- resources/js/guest/pages/LegalPage.tsx | 5 +- resources/js/guest/services/helpApi.ts | 2 +- resources/js/i18n.js | 4 +- resources/js/layouts/mainWebsite.tsx | 2 - resources/js/pages/Profile/Index.tsx | 159 ++++++++++++- routes/api.php | 2 + routes/web.php | 8 + tests/Feature/ProfileAccountDeletionTest.php | 51 ++++ tests/Feature/ProfileDataExportTest.php | 55 +++++ tests/Feature/TenantRetentionCommandTest.php | 85 +++++++ 37 files changed, 1650 insertions(+), 50 deletions(-) create mode 100644 app/Console/Commands/ProcessTenantRetention.php create mode 100644 app/Console/Commands/PurgeExpiredDataExports.php create mode 100644 app/Http/Controllers/ProfileAccountController.php create mode 100644 app/Http/Controllers/ProfileDataExportController.php create mode 100644 app/Jobs/AnonymizeAccount.php create mode 100644 app/Jobs/GenerateDataExport.php create mode 100644 app/Models/DataExport.php create mode 100644 app/Notifications/InactiveTenantDeletionWarning.php create mode 100644 app/Services/Compliance/AccountAnonymizer.php create mode 100644 cron/checkout_reminders.sh create mode 100644 cron/scheduler.sh create mode 100644 database/migrations/2025_11_15_120000_create_data_exports_table.php create mode 100644 database/migrations/2025_11_15_120100_add_compliance_columns_to_tenants_table.php create mode 100644 lang/de/profile.php create mode 100644 lang/en/profile.php create mode 100644 public/lang/de/profile.json create mode 100644 public/lang/en/profile.json create mode 100644 tests/Feature/ProfileAccountDeletionTest.php create mode 100644 tests/Feature/ProfileDataExportTest.php create mode 100644 tests/Feature/TenantRetentionCommandTest.php diff --git a/app/Console/Commands/ProcessTenantRetention.php b/app/Console/Commands/ProcessTenantRetention.php new file mode 100644 index 0000000..66425de --- /dev/null +++ b/app/Console/Commands/ProcessTenantRetention.php @@ -0,0 +1,109 @@ +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); + } +} diff --git a/app/Console/Commands/PurgeExpiredDataExports.php b/app/Console/Commands/PurgeExpiredDataExports.php new file mode 100644 index 0000000..7d57c9b --- /dev/null +++ b/app/Console/Commands/PurgeExpiredDataExports.php @@ -0,0 +1,41 @@ +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; + } +} diff --git a/app/Http/Controllers/Api/LegalController.php b/app/Http/Controllers/Api/LegalController.php index 0c98db3..bae47c9 100644 --- a/app/Http/Controllers/Api/LegalController.php +++ b/app/Http/Controllers/Api/LegalController.php @@ -6,10 +6,35 @@ use App\Models\LegalPage; use App\Support\ApiError; use Illuminate\Http\Request; 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; 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) { $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; - $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([ 'slug' => $page->slug, @@ -48,6 +73,12 @@ class LegalController extends BaseController 'locale' => $locale, 'title' => $title, 'body_markdown' => (string) $body, + 'body_html' => $this->convertMarkdownToHtml($body), ])->header('Cache-Control', 'no-store'); } + + protected function convertMarkdownToHtml(string $markdown): string + { + return trim((string) $this->markdown->convert($markdown)); + } } diff --git a/app/Http/Controllers/ProfileAccountController.php b/app/Http/Controllers/ProfileAccountController.php new file mode 100644 index 0000000..69ccc2e --- /dev/null +++ b/app/Http/Controllers/ProfileAccountController.php @@ -0,0 +1,39 @@ +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')); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index ec7b29c..a510b87 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\DataExport; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\Request; use Inertia\Inertia; @@ -43,6 +44,36 @@ class ProfileController extends Controller ->values() ->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', [ 'userData' => [ 'id' => $user->id, @@ -68,6 +99,11 @@ class ProfileController extends Controller ] : null, ] : null, 'purchases' => $purchases, + 'dataExport' => [ + 'exports' => $recentExports, + 'hasPending' => $pendingExport, + 'nextRequestAt' => $nextExportAt?->toIso8601String(), + ], ]); } } diff --git a/app/Http/Controllers/ProfileDataExportController.php b/app/Http/Controllers/ProfileDataExportController.php new file mode 100644 index 0000000..331ce4f --- /dev/null +++ b/app/Http/Controllers/ProfileDataExportController.php @@ -0,0 +1,58 @@ +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'))); + } +} diff --git a/app/Jobs/AnonymizeAccount.php b/app/Jobs/AnonymizeAccount.php new file mode 100644 index 0000000..7998dd5 --- /dev/null +++ b/app/Jobs/AnonymizeAccount.php @@ -0,0 +1,68 @@ +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; + } +} diff --git a/app/Jobs/GenerateDataExport.php b/app/Jobs/GenerateDataExport.php new file mode 100644 index 0000000..46d02e6 --- /dev/null +++ b/app/Jobs/GenerateDataExport.php @@ -0,0 +1,224 @@ +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 + */ + 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> + */ + 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> + */ + 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 $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; + } +} diff --git a/app/Models/DataExport.php b/app/Models/DataExport.php new file mode 100644 index 0000000..304bbd0 --- /dev/null +++ b/app/Models/DataExport.php @@ -0,0 +1,54 @@ + '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(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 55a46ef..30e58b4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -156,4 +156,9 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser, { return $this->hasMany(EventMember::class); } + + public function dataExports(): HasMany + { + return $this->hasMany(DataExport::class); + } } diff --git a/app/Notifications/InactiveTenantDeletionWarning.php b/app/Notifications/InactiveTenantDeletionWarning.php new file mode 100644 index 0000000..a8c4c06 --- /dev/null +++ b/app/Notifications/InactiveTenantDeletionWarning.php @@ -0,0 +1,39 @@ +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')); + } +} diff --git a/app/Services/Compliance/AccountAnonymizer.php b/app/Services/Compliance/AccountAnonymizer.php new file mode 100644 index 0000000..93d1457 --- /dev/null +++ b/app/Services/Compliance/AccountAnonymizer.php @@ -0,0 +1,151 @@ +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(); + } +} diff --git a/app/Services/Help/HelpSyncService.php b/app/Services/Help/HelpSyncService.php index e121f53..cc83203 100644 --- a/app/Services/Help/HelpSyncService.php +++ b/app/Services/Help/HelpSyncService.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\Table\TableExtension; use League\CommonMark\MarkdownConverter; use RuntimeException; use Symfony\Component\Finder\SplFileInfo; @@ -22,6 +23,7 @@ class HelpSyncService { $environment = new Environment; $environment->addExtension(new CommonMarkCoreExtension); + $environment->addExtension(new TableExtension); $this->converter = new MarkdownConverter($environment); } diff --git a/bootstrap/app.php b/bootstrap/app.php index f118d47..d8e9bb3 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -27,11 +27,15 @@ return Application::configure(basePath: dirname(__DIR__)) \App\Console\Commands\MonitorStorageCommand::class, \App\Console\Commands\DispatchStorageArchiveCommand::class, \App\Console\Commands\CheckUploadQueuesCommand::class, + \App\Console\Commands\PurgeExpiredDataExports::class, + \App\Console\Commands\ProcessTenantRetention::class, ]) ->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) { $schedule->command('package:check-status')->dailyAt('06:00'); $schedule->command('photobooth:cleanup-expired')->hourly()->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) { $middleware->alias([ diff --git a/cron/checkout_reminders.sh b/cron/checkout_reminders.sh new file mode 100644 index 0000000..6f207d0 --- /dev/null +++ b/cron/checkout_reminders.sh @@ -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 diff --git a/cron/scheduler.sh b/cron/scheduler.sh new file mode 100644 index 0000000..6709fc2 --- /dev/null +++ b/cron/scheduler.sh @@ -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 diff --git a/database/migrations/2025_11_15_120000_create_data_exports_table.php b/database/migrations/2025_11_15_120000_create_data_exports_table.php new file mode 100644 index 0000000..16bf4fa --- /dev/null +++ b/database/migrations/2025_11_15_120000_create_data_exports_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/migrations/2025_11_15_120100_add_compliance_columns_to_tenants_table.php b/database/migrations/2025_11_15_120100_add_compliance_columns_to_tenants_table.php new file mode 100644 index 0000000..24d8e1a --- /dev/null +++ b/database/migrations/2025_11_15_120100_add_compliance_columns_to_tenants_table.php @@ -0,0 +1,42 @@ +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'); + } + }); + } + } +}; diff --git a/lang/de/profile.php b/lang/de/profile.php new file mode 100644 index 0000000..5007d4b --- /dev/null +++ b/lang/de/profile.php @@ -0,0 +1,66 @@ + [ + '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.', + ], +]; diff --git a/lang/en/profile.php b/lang/en/profile.php new file mode 100644 index 0000000..bfe66b6 --- /dev/null +++ b/lang/en/profile.php @@ -0,0 +1,66 @@ + [ + '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.', + ], +]; diff --git a/public/lang/de/profile.json b/public/lang/de/profile.json new file mode 100644 index 0000000..ea5cd9d --- /dev/null +++ b/public/lang/de/profile.json @@ -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" + } +} diff --git a/public/lang/en/profile.json b/public/lang/en/profile.json new file mode 100644 index 0000000..ba65f27 --- /dev/null +++ b/public/lang/en/profile.json @@ -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" + } +} diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 36e85c9..89822d5 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next'; import i18n from './i18n'; import { Toaster } from 'react-hot-toast'; import { ConsentProvider } from './contexts/consent'; +import CookieBanner from '@/components/consent/CookieBanner'; import React from 'react'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; @@ -45,6 +46,7 @@ createInertiaApp({ + diff --git a/resources/js/guest/components/legal-markdown.tsx b/resources/js/guest/components/legal-markdown.tsx index 0b9df4d..d06cdd8 100644 --- a/resources/js/guest/components/legal-markdown.tsx +++ b/resources/js/guest/components/legal-markdown.tsx @@ -1,24 +1,31 @@ import React from "react"; type Props = { - markdown: string; + markdown?: string; + html?: string; }; -export function LegalMarkdown({ markdown }: Props) { - const html = React.useMemo(() => { - let safe = markdown +export function LegalMarkdown({ markdown = '', html }: Props) { + const derived = React.useMemo(() => { + if (html && html.trim().length > 0) { + return html; + } + + const escaped = markdown .replace(/&/g, '&') .replace(//g, '>'); - safe = safe.replace(/\*\*(.+?)\*\*/g, '$1'); - safe = safe.replace(/(?$1'); - safe = safe.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '$1'); - safe = safe + + return escaped .split(/\n{2,}/) .map((block) => `

${block.replace(/\n/g, '
')}

`) .join('\n'); - return safe; - }, [markdown]); + }, [markdown, html]); - return
; -} \ No newline at end of file + return ( +
+ ); +} diff --git a/resources/js/guest/components/settings-sheet.tsx b/resources/js/guest/components/settings-sheet.tsx index 181fdff..9139afc 100644 --- a/resources/js/guest/components/settings-sheet.tsx +++ b/resources/js/guest/components/settings-sheet.tsx @@ -36,10 +36,10 @@ type ViewState = }; type LegalDocumentState = - | { phase: 'idle'; title: string; body: string } - | { phase: 'loading'; title: string; body: string } - | { phase: 'ready'; title: string; body: string } - | { phase: 'error'; title: string; body: string }; + | { phase: 'idle'; title: string; markdown: string; html: string } + | { phase: 'loading'; title: string; markdown: string; html: string } + | { phase: 'ready'; title: string; markdown: string; html: string } + | { phase: 'error'; title: string; markdown: string; html: string }; type NameStatus = 'idle' | 'saved'; @@ -223,10 +223,10 @@ function LegalView({ {document.title || t(translationKey ?? 'settings.legal.fallbackTitle')} - - - - + + + +
); } @@ -418,17 +418,18 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen const [state, setState] = React.useState({ phase: 'idle', title: '', - body: '', + markdown: '', + html: '', }); React.useEffect(() => { if (!slug) { - setState({ phase: 'idle', title: '', body: '' }); + setState({ phase: 'idle', title: '', markdown: '', html: '' }); return; } const controller = new AbortController(); - setState({ phase: 'loading', title: '', body: '' }); + setState({ phase: 'loading', title: '', markdown: '', html: '' }); const langParam = encodeURIComponent(locale); fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${langParam}`, { @@ -443,7 +444,8 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen setState({ phase: 'ready', title: payload.title ?? '', - body: payload.body_markdown ?? '', + markdown: payload.body_markdown ?? '', + html: payload.body_html ?? '', }); }) .catch((error) => { @@ -451,7 +453,7 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen return; } console.error('Failed to load legal page', error); - setState({ phase: 'error', title: '', body: '' }); + setState({ phase: 'error', title: '', markdown: '', html: '' }); }); return () => controller.abort(); diff --git a/resources/js/guest/i18n/useTranslation.ts b/resources/js/guest/i18n/useTranslation.ts index 20a93fb..f59e162 100644 --- a/resources/js/guest/i18n/useTranslation.ts +++ b/resources/js/guest/i18n/useTranslation.ts @@ -2,20 +2,48 @@ import React from 'react'; import { translate, DEFAULT_LOCALE, type LocaleCode } from './messages'; import { useLocale } from './LocaleContext'; -export type TranslateFn = (key: string, fallback?: string) => string; +type ReplacementValues = Record; + +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 { 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() { const { locale } = useLocale(); - const t = React.useCallback( - (key, fallback) => resolveTranslation(locale, key, fallback), - [locale], - ); + const t = React.useCallback((key: string, arg2?: ReplacementValues | string, arg3?: string) => { + let replacements: ReplacementValues | undefined; + 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]); } - diff --git a/resources/js/guest/pages/HelpArticlePage.tsx b/resources/js/guest/pages/HelpArticlePage.tsx index e5834b0..44e7b53 100644 --- a/resources/js/guest/pages/HelpArticlePage.tsx +++ b/resources/js/guest/pages/HelpArticlePage.tsx @@ -73,16 +73,18 @@ export default function HelpArticlePage() { {article.updated_at && (
{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}
)} - {servedFromCache && ( - - {t('help.center.offlineBadge')} - - )} + +
+
+
-
{article.related && article.related.length > 0 && (

{t('help.article.relatedTitle')}

@@ -95,7 +97,7 @@ export default function HelpArticlePage() { asChild > - {rel.slug} + {rel.title ?? rel.slug} ))} diff --git a/resources/js/guest/pages/LegalPage.tsx b/resources/js/guest/pages/LegalPage.tsx index d626b71..2abd3ed 100644 --- a/resources/js/guest/pages/LegalPage.tsx +++ b/resources/js/guest/pages/LegalPage.tsx @@ -8,6 +8,7 @@ export default function LegalPage() { const [loading, setLoading] = React.useState(true); const [title, setTitle] = React.useState(''); const [body, setBody] = React.useState(''); + const [html, setHtml] = React.useState(''); React.useEffect(() => { if (!page) { @@ -29,11 +30,13 @@ export default function LegalPage() { const data = await res.json(); setTitle(data.title || ''); setBody(data.body_markdown || ''); + setHtml(data.body_html || ''); } catch (error) { if (!controller.signal.aborted) { console.error('Failed to load legal page', error); setTitle(''); setBody(''); + setHtml(''); } } finally { if (!controller.signal.aborted) { @@ -50,7 +53,7 @@ export default function LegalPage() { return ( - {loading ?

Laedt...

: } + {loading ?

Laedt...

: }
); } diff --git a/resources/js/guest/services/helpApi.ts b/resources/js/guest/services/helpApi.ts index c013349..ab2df3d 100644 --- a/resources/js/guest/services/helpApi.ts +++ b/resources/js/guest/services/helpApi.ts @@ -11,7 +11,7 @@ export type HelpArticleSummary = { last_reviewed_at?: string; owner?: string; updated_at?: string; - related?: Array<{ slug: string }>; + related?: Array<{ slug: string; title?: string }>; }; export type HelpArticleDetail = HelpArticleSummary & { diff --git a/resources/js/i18n.js b/resources/js/i18n.js index 94a4af0..4d83a1f 100644 --- a/resources/js/i18n.js +++ b/resources/js/i18n.js @@ -16,7 +16,7 @@ i18n backend: { loadPath: '/lang/{{lng}}/{{ns}}.json', }, - ns: ['marketing', 'auth'], + ns: ['marketing', 'auth', 'profile', 'common', 'legal'], defaultNS: 'marketing', supportedLngs: ['de', 'en'], detection: { @@ -26,4 +26,4 @@ i18n }, }); -export default i18n; \ No newline at end of file +export default i18n; diff --git a/resources/js/layouts/mainWebsite.tsx b/resources/js/layouts/mainWebsite.tsx index 6a7d91a..960be6d 100644 --- a/resources/js/layouts/mainWebsite.tsx +++ b/resources/js/layouts/mainWebsite.tsx @@ -2,7 +2,6 @@ import { Head, Link, router, usePage } from '@inertiajs/react'; import { useTranslation } from 'react-i18next'; import MatomoTracker, { MatomoConfig } from '@/components/analytics/MatomoTracker'; -import CookieBanner from '@/components/consent/CookieBanner'; import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import Footer from '@/layouts/app/Footer'; import { useAppearance } from '@/hooks/use-appearance'; @@ -165,7 +164,6 @@ const MarketingLayout: React.FC = ({ children, title }) => ))} -
diff --git a/resources/js/pages/Profile/Index.tsx b/resources/js/pages/Profile/Index.tsx index 5887136..a03a107 100644 --- a/resources/js/pages/Profile/Index.tsx +++ b/resources/js/pages/Profile/Index.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef } from '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 ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; @@ -49,6 +50,19 @@ type ProfilePageProps = { type: 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[] = [ @@ -59,7 +73,8 @@ const breadcrumbs: BreadcrumbItem[] = [ ]; export default function ProfileIndex() { - const { userData, tenant, purchases, supportedLocales, locale } = usePage().props; + const { userData, tenant, purchases, supportedLocales, locale, dataExport } = usePage().props; + const { t } = useTranslation('profile'); const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', { day: '2-digit', @@ -73,6 +88,44 @@ export default function ProfileIndex() { maximumFractionDigits: 2, }), [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(() => { if (!userData.emailVerifiedAt) { return null; @@ -229,6 +282,108 @@ export default function ProfileIndex() {
+ +
+ + + {t('export.title')} +

{t('export.description')}

+
+ + {dataExportInfo.exports.length === 0 ? ( +

{t('export.empty')}

+ ) : ( + + + + {t('export.table.status')} + {t('export.table.created')} + {t('export.table.expires')} + {t('export.table.size')} + {t('export.table.action')} + + + + {dataExportInfo.exports.map((item) => ( + + {formatExportStatus(item.status)} + {formatDate(item.created_at)} + {formatDate(item.expires_at)} + {formatBytes(item.size)} + + {item.download_url && item.status === 'ready' ? ( + + {t('export.download')} + + ) : item.status === 'failed' && item.error_message ? ( + {t('export.failed_reason', { reason: item.error_message })} + ) : ( + + )} + + + ))} + +
+ )} + + {dataExportInfo.hasPending && ( + + {t('export.pending_alert_title')} + {t('export.pending_alert_body')} + + )} + + {!canRequestExport && nextRequestDeadline && ( +

+ {t('export.cooldown', { + date: nextRequestDeadline.toLocaleDateString(locale ?? 'de-DE'), + })} +

+ )} +
+ + + +
+ + + + {t('delete.title')} +

{t('delete.description')}

+
+ + + {t('delete.warning_title')} + {t('delete.warning_body')} + + +
+
+ + deleteForm.setData('confirmation', event.target.value.toUpperCase())} + placeholder={t('delete.confirmation_placeholder')} + /> +

{t('delete.confirmation_hint')}

+ +
+ + +
+
+
+
); } diff --git a/routes/api.php b/routes/api.php index 0c586d9..fa619b5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ name('api.v1.')->group(function () { Route::middleware('throttle:100,1')->group(function () { Route::get('/help', [HelpController::class, 'index'])->name('help.index'); 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}/stats', [EventPublicController::class, 'stats'])->name('events.stats'); diff --git a/routes/web.php b/routes/web.php index a8d1656..201891f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,7 +10,9 @@ use App\Http\Controllers\LocaleController; use App\Http\Controllers\MarketingController; use App\Http\Controllers\PaddleCheckoutController; use App\Http\Controllers\PaddleWebhookController; +use App\Http\Controllers\ProfileAccountController; use App\Http\Controllers\ProfileController; +use App\Http\Controllers\ProfileDataExportController; use App\Http\Controllers\Tenant\EventPhotoArchiveController; use App\Http\Controllers\TenantAdminAuthController; use App\Http\Controllers\TenantAdminGoogleController; @@ -242,6 +244,12 @@ Route::get('/dashboard', DashboardController::class) Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, '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 () { $renderAdmin = fn () => view('admin'); diff --git a/tests/Feature/ProfileAccountDeletionTest.php b/tests/Feature/ProfileAccountDeletionTest.php new file mode 100644 index 0000000..588b354 --- /dev/null +++ b/tests/Feature/ProfileAccountDeletionTest.php @@ -0,0 +1,51 @@ +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; + }); + } +} diff --git a/tests/Feature/ProfileDataExportTest.php b/tests/Feature/ProfileDataExportTest.php new file mode 100644 index 0000000..1d23993 --- /dev/null +++ b/tests/Feature/ProfileDataExportTest.php @@ -0,0 +1,55 @@ +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'); + } +} diff --git a/tests/Feature/TenantRetentionCommandTest.php b/tests/Feature/TenantRetentionCommandTest.php new file mode 100644 index 0000000..17105fe --- /dev/null +++ b/tests/Feature/TenantRetentionCommandTest.php @@ -0,0 +1,85 @@ +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(); + } +}