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

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

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