*/ private const REDACTED_USER_COLUMNS = [ 'password', 'remember_token', 'session_token', 'api_token', 'resetguid', 'verification_token', 'sabapikey', 'nzbgetpassword', 'nzbvortex_api_key', 'cp_api', ]; public function __construct( private readonly GdprDataInventory $inventory = new GdprDataInventory, private readonly GdprAuditService $audit = new GdprAuditService, private readonly GdprNotificationService $notifications = new GdprNotificationService, ) {} /** * Generate and persist a JSON data export for a GDPR access/data portability request. * * @return array{disk: string, path: string, expires_at: Carbon} */ public function generate(User $user, ?GdprRequest $request = null, ?User $actor = null): array { $payload = $this->buildPayload($user); $encoded = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); $path = 'gdpr/exports/user-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(12).'.json'; Storage::disk(self::EXPORT_DISK)->put($path, $encoded); $expiresAt = now()->addDays(7); if ($request !== null) { $request->update([ 'status' => GdprRequest::STATUS_COMPLETED, 'export_disk' => self::EXPORT_DISK, 'export_path' => $path, 'export_expires_at' => $expiresAt, 'completed_at' => now(), 'response_payload' => [ 'format' => 'json', 'retained_records' => $this->inventory->retainedRecords(), 'expires_at' => $expiresAt->toIso8601String(), ], ]); } $this->audit->record( event: 'export_generated', description: 'GDPR data export generated.', subject: $user, actor: $actor, request: $request, metadata: ['path' => $path, 'expires_at' => $expiresAt->toIso8601String()] ); if ($request !== null) { $this->notifications->exportReady($request->fresh() ?? $request); } return [ 'disk' => self::EXPORT_DISK, 'path' => $path, 'expires_at' => $expiresAt, ]; } /** * @return array */ public function buildPayload(User $user): array { return [ 'generated_at' => now()->toIso8601String(), 'format_version' => 1, 'subject' => [ 'id' => $user->id, 'username' => $user->username, 'email' => $user->email, ], 'account' => $this->exportAccount($user), 'related_records' => $this->exportRelatedTables($user), 'retained_audit_records' => $this->exportAuditRecords($user), 'retained_payment_records' => $this->exportPaymentRecords($user), 'retention_notice' => [ 'summary' => 'Payment and audit records may be retained after erasure where required for legal, accounting, security, dispute, or GDPR accountability purposes, with direct account identifiers anonymized or minimized where practical.', 'records' => $this->inventory->retainedRecords(), ], 'essential_cookies' => $this->inventory->essentialCookies(), ]; } /** * @return array */ private function exportAccount(User $user): array { $attributes = $user->getAttributes(); foreach (self::REDACTED_USER_COLUMNS as $column) { unset($attributes[$column]); } $attributes['redacted_columns'] = self::REDACTED_USER_COLUMNS; $attributes['roles'] = $user->roles()->pluck('name')->all(); if (Schema::hasTable('passkeys')) { $attributes['passkeys'] = $this->tableRows('passkeys', 'authenticatable_id', (int) $user->id, redactedColumns: ['credential_id', 'data']); } return $attributes; } /** * @return array */ private function exportRelatedTables(User $user): array { $records = []; foreach ($this->inventory->exportTables() as $key => $definition) { $table = $definition['table'] ?? $key; $userColumn = $definition['user_column']; $orderBy = $definition['order_by'] ?? null; $records[$key] = $this->tableRows($table, $userColumn, (int) $user->id, $orderBy); } return $records; } /** * @return array> */ private function exportAuditRecords(User $user): array { if (! Schema::hasTable('user_activities')) { return []; } return UserActivity::query() ->where('user_id', $user->id) ->orWhere('username', $user->username) ->orderByDesc('created_at') ->get() ->map(fn (UserActivity $activity): array => $this->modelToArray($activity)) ->all(); } /** * @return array> */ private function exportPaymentRecords(User $user): array { if (! Schema::hasTable('payments')) { return []; } return Payment::query() ->where('email', $user->email) ->orWhere('username', $user->username) ->orderByDesc('created_at') ->get() ->map(fn (Payment $payment): array => $this->modelToArray($payment)) ->all(); } /** * @param list $redactedColumns * @return array> */ private function tableRows(string $table, string $userColumn, int $userId, ?string $orderBy = null, array $redactedColumns = []): array { if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $userColumn)) { return []; } $query = DB::table($table)->where($userColumn, $userId); if ($orderBy !== null && Schema::hasColumn($table, $orderBy)) { $query->orderByDesc($orderBy); } return $query->get() ->map(fn (object $row): array => $this->rowToArray($row, $redactedColumns)) ->all(); } /** * @param list $redactedColumns * @return array */ private function rowToArray(object $row, array $redactedColumns = []): array { $data = json_decode(json_encode($row, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); foreach ($redactedColumns as $column) { if (array_key_exists($column, $data)) { $data[$column] = '[redacted]'; } } return $data; } /** * @return array */ private function modelToArray(object $model): array { return json_decode(json_encode($model, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); } }