diff --git a/backend/app/Console/Kernel.php b/backend/app/Console/Kernel.php index b07bd8b5ec..4bcb96b574 100644 --- a/backend/app/Console/Kernel.php +++ b/backend/app/Console/Kernel.php @@ -2,6 +2,7 @@ namespace HiEvents\Console; +use HiEvents\Jobs\Account\ProcessScheduledAccountDeletionsJob; use HiEvents\Jobs\Message\SendScheduledMessagesJob; use HiEvents\Jobs\Waitlist\ProcessExpiredWaitlistOffersJob; use Illuminate\Console\Scheduling\Schedule; @@ -15,6 +16,7 @@ protected function schedule(Schedule $schedule): void { $schedule->job(new SendScheduledMessagesJob)->everyMinute()->withoutOverlapping(); $schedule->job(new ProcessExpiredWaitlistOffersJob)->everyMinute()->withoutOverlapping(); + $schedule->job(new ProcessScheduledAccountDeletionsJob)->hourly()->withoutOverlapping(); $schedule->call(function (): void { $count = DB::table('failed_jobs')->count(); diff --git a/backend/app/DomainObjects/AccountDeletionRequestDomainObject.php b/backend/app/DomainObjects/AccountDeletionRequestDomainObject.php new file mode 100644 index 0000000000..f8f7d82af4 --- /dev/null +++ b/backend/app/DomainObjects/AccountDeletionRequestDomainObject.php @@ -0,0 +1,7 @@ +messagingTier; @@ -15,4 +17,14 @@ public function setMessagingTier(AccountMessagingTierDomainObject $messagingTier { $this->messagingTier = $messagingTier; } + + public function getActiveDeletionRequest(): ?AccountDeletionRequestDomainObject + { + return $this->activeDeletionRequest; + } + + public function setActiveDeletionRequest(?AccountDeletionRequestDomainObject $activeDeletionRequest): void + { + $this->activeDeletionRequest = $activeDeletionRequest; + } } diff --git a/backend/app/DomainObjects/Enums/AccountDeletionInitiator.php b/backend/app/DomainObjects/Enums/AccountDeletionInitiator.php new file mode 100644 index 0000000000..24565b5f9c --- /dev/null +++ b/backend/app/DomainObjects/Enums/AccountDeletionInitiator.php @@ -0,0 +1,11 @@ + $this->id ?? null, + 'account_id' => $this->account_id ?? null, + 'requested_by_user_id' => $this->requested_by_user_id ?? null, + 'cancelled_by_user_id' => $this->cancelled_by_user_id ?? null, + 'initiated_by' => $this->initiated_by ?? null, + 'reason' => $this->reason ?? null, + 'status' => $this->status ?? null, + 'expected_outcome' => $this->expected_outcome ?? null, + 'outcome' => $this->outcome ?? null, + 'scheduled_deletion_at' => $this->scheduled_deletion_at ?? null, + 'reminder_sent_at' => $this->reminder_sent_at ?? null, + 'cancelled_at' => $this->cancelled_at ?? null, + 'completed_at' => $this->completed_at ?? null, + 'deletion_manifest' => $this->deletion_manifest ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setAccountId(int $account_id): self + { + $this->account_id = $account_id; + return $this; + } + + public function getAccountId(): int + { + return $this->account_id; + } + + public function setRequestedByUserId(int $requested_by_user_id): self + { + $this->requested_by_user_id = $requested_by_user_id; + return $this; + } + + public function getRequestedByUserId(): int + { + return $this->requested_by_user_id; + } + + public function setCancelledByUserId(?int $cancelled_by_user_id): self + { + $this->cancelled_by_user_id = $cancelled_by_user_id; + return $this; + } + + public function getCancelledByUserId(): ?int + { + return $this->cancelled_by_user_id; + } + + public function setInitiatedBy(string $initiated_by): self + { + $this->initiated_by = $initiated_by; + return $this; + } + + public function getInitiatedBy(): string + { + return $this->initiated_by; + } + + public function setReason(?string $reason): self + { + $this->reason = $reason; + return $this; + } + + public function getReason(): ?string + { + return $this->reason; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setExpectedOutcome(?string $expected_outcome): self + { + $this->expected_outcome = $expected_outcome; + return $this; + } + + public function getExpectedOutcome(): ?string + { + return $this->expected_outcome; + } + + public function setOutcome(?string $outcome): self + { + $this->outcome = $outcome; + return $this; + } + + public function getOutcome(): ?string + { + return $this->outcome; + } + + public function setScheduledDeletionAt(string $scheduled_deletion_at): self + { + $this->scheduled_deletion_at = $scheduled_deletion_at; + return $this; + } + + public function getScheduledDeletionAt(): string + { + return $this->scheduled_deletion_at; + } + + public function setReminderSentAt(?string $reminder_sent_at): self + { + $this->reminder_sent_at = $reminder_sent_at; + return $this; + } + + public function getReminderSentAt(): ?string + { + return $this->reminder_sent_at; + } + + public function setCancelledAt(?string $cancelled_at): self + { + $this->cancelled_at = $cancelled_at; + return $this; + } + + public function getCancelledAt(): ?string + { + return $this->cancelled_at; + } + + public function setCompletedAt(?string $completed_at): self + { + $this->completed_at = $completed_at; + return $this; + } + + public function getCompletedAt(): ?string + { + return $this->completed_at; + } + + public function setDeletionManifest(array|string|null $deletion_manifest): self + { + $this->deletion_manifest = $deletion_manifest; + return $this; + } + + public function getDeletionManifest(): array|string|null + { + return $this->deletion_manifest; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/Status/AccountDeletionRequestStatus.php b/backend/app/DomainObjects/Status/AccountDeletionRequestStatus.php new file mode 100644 index 0000000000..33ae8e0fa9 --- /dev/null +++ b/backend/app/DomainObjects/Status/AccountDeletionRequestStatus.php @@ -0,0 +1,14 @@ +minimumAllowedRole(Role::ADMIN); + + try { + $deletionRequest = $this->cancelAccountDeletionHandler->handle( + accountId: $this->getAuthenticatedAccountId(), + cancelledByUserId: $this->getAuthenticatedUser()->getId(), + ); + } catch (AccountDeletionRequestNotFoundException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: HttpResponse::HTTP_NOT_FOUND, + ); + } + + return $this->resourceResponse(AccountDeletionRequestResource::class, $deletionRequest); + } +} diff --git a/backend/app/Http/Actions/Accounts/DeletionRequest/GetAccountDeletionStatusAction.php b/backend/app/Http/Actions/Accounts/DeletionRequest/GetAccountDeletionStatusAction.php new file mode 100644 index 0000000000..8c587e47a1 --- /dev/null +++ b/backend/app/Http/Actions/Accounts/DeletionRequest/GetAccountDeletionStatusAction.php @@ -0,0 +1,36 @@ +minimumAllowedRole(Role::ADMIN); + + $status = $this->getAccountDeletionStatusHandler->handle($this->getAuthenticatedAccountId()); + + return $this->jsonResponse([ + 'data' => [ + 'deletion_request' => $status->activeRequest + ? (new AccountDeletionRequestResource($status->activeRequest))->toArray(request()) + : null, + 'can_request_deletion' => $status->canRequestDeletion, + 'cannot_delete_reason' => $status->cannotDeleteReason, + 'expected_outcome' => $status->expectedOutcome, + ], + ]); + } +} diff --git a/backend/app/Http/Actions/Accounts/DeletionRequest/RequestAccountDeletionAction.php b/backend/app/Http/Actions/Accounts/DeletionRequest/RequestAccountDeletionAction.php new file mode 100644 index 0000000000..345e22bb03 --- /dev/null +++ b/backend/app/Http/Actions/Accounts/DeletionRequest/RequestAccountDeletionAction.php @@ -0,0 +1,62 @@ +minimumAllowedRole(Role::ADMIN); + + $user = $this->getAuthenticatedUser(); + + if (! $user->getCurrentAccountUser()?->getIsAccountOwner()) { + return $this->errorResponse( + message: __('Only the account owner can request account deletion.'), + statusCode: HttpResponse::HTTP_FORBIDDEN, + ); + } + + try { + $deletionRequest = $this->requestAccountDeletionHandler->handle(new RequestAccountDeletionDTO( + accountId: $this->getAuthenticatedAccountId(), + requestedByUserId: $user->getId(), + initiatedBy: AccountDeletionInitiator::ACCOUNT_OWNER, + confirmation: $request->validated('confirmation'), + reason: $request->validated('reason'), + )); + } catch (CannotDeleteEntityException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: HttpResponse::HTTP_CONFLICT, + ); + } + + return $this->resourceResponse( + resource: AccountDeletionRequestResource::class, + data: $deletionRequest, + statusCode: HttpResponse::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Actions/Accounts/GetAccountAction.php b/backend/app/Http/Actions/Accounts/GetAccountAction.php index 26f0280632..70e04bdb0d 100644 --- a/backend/app/Http/Actions/Accounts/GetAccountAction.php +++ b/backend/app/Http/Actions/Accounts/GetAccountAction.php @@ -8,22 +8,24 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Resources\Account\AccountResource; +use HiEvents\Services\Domain\Account\AccountDeletionService; use Illuminate\Http\JsonResponse; class GetAccountAction extends BaseAction { - protected AccountRepositoryInterface $accountRepository; - - public function __construct(AccountRepositoryInterface $accountRepository) - { - $this->accountRepository = $accountRepository; - } + public function __construct( + private readonly AccountRepositoryInterface $accountRepository, + private readonly AccountDeletionService $accountDeletionService, + ) {} public function __invoke(?int $accountId = null): JsonResponse { $this->minimumAllowedRole(Role::ORGANIZER); - $account = $this->accountRepository->findById($this->getAuthenticatedAccountId()); + $authenticatedAccountId = $this->getAuthenticatedAccountId(); + + $account = $this->accountRepository->findById($authenticatedAccountId); + $account->setActiveDeletionRequest($this->accountDeletionService->findActiveRequest($authenticatedAccountId)); return $this->resourceResponse(AccountResource::class, $account); } diff --git a/backend/app/Http/Actions/Admin/DeletionRequests/AdminCancelAccountDeletionAction.php b/backend/app/Http/Actions/Admin/DeletionRequests/AdminCancelAccountDeletionAction.php new file mode 100644 index 0000000000..fb4dd00845 --- /dev/null +++ b/backend/app/Http/Actions/Admin/DeletionRequests/AdminCancelAccountDeletionAction.php @@ -0,0 +1,47 @@ +minimumAllowedRole(Role::SUPERADMIN); + + $deletionRequest = $this->deletionRequestRepository->findById($deletionRequestId); + + try { + $cancelledRequest = $this->accountDeletionService->cancelDeletion( + accountId: $deletionRequest->getAccountId(), + cancelledByUserId: $this->getAuthenticatedUser()->getId(), + ); + } catch (AccountDeletionRequestNotFoundException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: HttpResponse::HTTP_NOT_FOUND, + ); + } + + return $this->resourceResponse(AccountDeletionRequestResource::class, $cancelledRequest); + } +} diff --git a/backend/app/Http/Actions/Admin/DeletionRequests/AdminExecuteAccountDeletionAction.php b/backend/app/Http/Actions/Admin/DeletionRequests/AdminExecuteAccountDeletionAction.php new file mode 100644 index 0000000000..609acf9b42 --- /dev/null +++ b/backend/app/Http/Actions/Admin/DeletionRequests/AdminExecuteAccountDeletionAction.php @@ -0,0 +1,40 @@ +minimumAllowedRole(Role::SUPERADMIN); + + $deletionRequest = $this->deletionRequestRepository->findById($deletionRequestId); + + if ($deletionRequest->getStatus() !== AccountDeletionRequestStatus::REQUESTED->name) { + return $this->errorResponse( + message: __('Only pending deletion requests can be executed.'), + statusCode: HttpResponse::HTTP_CONFLICT, + ); + } + + ExecuteAccountDeletionJob::dispatch($deletionRequestId); + + return $this->jsonResponse([ + 'message' => __('Account deletion has been queued for execution.'), + ]); + } +} diff --git a/backend/app/Http/Actions/Admin/DeletionRequests/AdminRequestAccountDeletionAction.php b/backend/app/Http/Actions/Admin/DeletionRequests/AdminRequestAccountDeletionAction.php new file mode 100644 index 0000000000..1a97fd3a59 --- /dev/null +++ b/backend/app/Http/Actions/Admin/DeletionRequests/AdminRequestAccountDeletionAction.php @@ -0,0 +1,51 @@ +minimumAllowedRole(Role::SUPERADMIN); + + try { + $deletionRequest = $this->accountDeletionService->requestDeletion( + accountId: $accountId, + requestedByUserId: $this->getAuthenticatedUser()->getId(), + initiator: AccountDeletionInitiator::ADMIN, + reason: $request->input('reason'), + ); + } catch (CannotDeleteEntityException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: HttpResponse::HTTP_CONFLICT, + ); + } + + return $this->resourceResponse( + resource: AccountDeletionRequestResource::class, + data: $deletionRequest, + statusCode: HttpResponse::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Actions/Admin/DeletionRequests/GetAllAccountDeletionRequestsAction.php b/backend/app/Http/Actions/Admin/DeletionRequests/GetAllAccountDeletionRequestsAction.php new file mode 100644 index 0000000000..d6e4327421 --- /dev/null +++ b/backend/app/Http/Actions/Admin/DeletionRequests/GetAllAccountDeletionRequestsAction.php @@ -0,0 +1,36 @@ +minimumAllowedRole(Role::SUPERADMIN); + + $deletionRequests = $this->handler->handle(new GetAllAccountDeletionRequestsDTO( + perPage: min((int) $request->query('per_page', 20), 100), + search: $request->query('search'), + status: $request->query('status'), + )); + + return $this->resourceResponse( + resource: AdminAccountDeletionRequestResource::class, + data: $deletionRequests, + ); + } +} diff --git a/backend/app/Http/Kernel.php b/backend/app/Http/Kernel.php index e91a53aafe..69a1d37812 100644 --- a/backend/app/Http/Kernel.php +++ b/backend/app/Http/Kernel.php @@ -4,6 +4,7 @@ use HiEvents\Http\Middleware\Authenticate; use HiEvents\Http\Middleware\EncryptCookies; +use HiEvents\Http\Middleware\EnsureAccountIsNotPendingDeletion; use HiEvents\Http\Middleware\HandleDeprecatedTimezones; use HiEvents\Http\Middleware\LogImpersonationMiddleware; use HiEvents\Http\Middleware\PreventRequestForgery; @@ -71,6 +72,7 @@ class Kernel extends HttpKernel ThrottleRequests::class.':api', SubstituteBindings::class, SetAccountContext::class, + EnsureAccountIsNotPendingDeletion::class, SetUserLocaleMiddleware::class, LogImpersonationMiddleware::class, ], diff --git a/backend/app/Http/Middleware/EnsureAccountIsNotPendingDeletion.php b/backend/app/Http/Middleware/EnsureAccountIsNotPendingDeletion.php new file mode 100644 index 0000000000..588584aa0b --- /dev/null +++ b/backend/app/Http/Middleware/EnsureAccountIsNotPendingDeletion.php @@ -0,0 +1,63 @@ +authUserService->getAuthenticatedAccountId(); + + if ($accountId === null || $this->isAllowedWhilePendingDeletion($request)) { + return $next($request); + } + + if (! $this->accountDeletionService->isAccountPendingDeletion($accountId)) { + return $next($request); + } + + return response()->json([ + 'message' => __('This account is scheduled for deletion. Cancel the deletion request to continue using it.'), + 'error_code' => self::ERROR_CODE, + ], Response::HTTP_FORBIDDEN); + } + + private function isAllowedWhilePendingDeletion(Request $request): bool + { + $path = trim($request->path(), '/'); + + if (str_starts_with($path, 'auth/') || str_starts_with($path, 'admin/')) { + return true; + } + + if ($path === 'accounts/deletion-request') { + return true; + } + + if (! $request->isMethod('GET')) { + return false; + } + + return $path === 'users/me' + || $path === 'accounts' + || preg_match('#^accounts/\d+$#', $path) === 1; + } +} diff --git a/backend/app/Http/Request/Account/RequestAccountDeletionRequest.php b/backend/app/Http/Request/Account/RequestAccountDeletionRequest.php new file mode 100644 index 0000000000..755fa42afe --- /dev/null +++ b/backend/app/Http/Request/Account/RequestAccountDeletionRequest.php @@ -0,0 +1,16 @@ + 'required|string|max:255', + 'reason' => 'nullable|string|max:2000', + ]; + } +} diff --git a/backend/app/Jobs/Account/ExecuteAccountDeletionJob.php b/backend/app/Jobs/Account/ExecuteAccountDeletionJob.php new file mode 100644 index 0000000000..11732a829f --- /dev/null +++ b/backend/app/Jobs/Account/ExecuteAccountDeletionJob.php @@ -0,0 +1,32 @@ +executeDeletion($this->deletionRequestId); + } +} diff --git a/backend/app/Jobs/Account/ProcessScheduledAccountDeletionsJob.php b/backend/app/Jobs/Account/ProcessScheduledAccountDeletionsJob.php new file mode 100644 index 0000000000..4c13e85445 --- /dev/null +++ b/backend/app/Jobs/Account/ProcessScheduledAccountDeletionsJob.php @@ -0,0 +1,80 @@ +sendReminders($deletionRequestRepository, $accountRepository); + $this->dispatchDueDeletions($deletionRequestRepository); + } + + private function sendReminders( + AccountDeletionRequestRepositoryInterface $deletionRequestRepository, + AccountRepositoryInterface $accountRepository, + ): void { + $dueForReminder = $deletionRequestRepository->findDueForReminder(self::REMINDER_DAYS_BEFORE_DELETION); + + /** @var AccountDeletionRequestDomainObject $deletionRequest */ + foreach ($dueForReminder as $deletionRequest) { + try { + $account = $accountRepository->findById($deletionRequest->getAccountId()); + + Mail::to($account->getEmail())->queue(new AccountDeletionReminderEmail( + accountName: $account->getName(), + scheduledDeletionDate: Carbon::parse($deletionRequest->getScheduledDeletionAt()) + ->setTimezone($account->getTimezone() ?? 'UTC') + ->toFormattedDateString(), + )); + + $deletionRequestRepository->updateFromArray($deletionRequest->getId(), [ + AccountDeletionRequestDomainObjectAbstract::REMINDER_SENT_AT => now(), + ]); + } catch (Throwable $e) { + Log::error('Failed to send account deletion reminder', [ + 'deletion_request_id' => $deletionRequest->getId(), + 'error' => $e->getMessage(), + ]); + } + } + } + + private function dispatchDueDeletions( + AccountDeletionRequestRepositoryInterface $deletionRequestRepository, + ): void { + $dueRequests = $deletionRequestRepository->findWhere([ + AccountDeletionRequestDomainObjectAbstract::STATUS => AccountDeletionRequestStatus::REQUESTED->name, + [AccountDeletionRequestDomainObjectAbstract::SCHEDULED_DELETION_AT, '<=', Carbon::now()->toDateTimeString()], + ]); + + /** @var AccountDeletionRequestDomainObject $deletionRequest */ + foreach ($dueRequests as $deletionRequest) { + ExecuteAccountDeletionJob::dispatch($deletionRequest->getId()); + } + } +} diff --git a/backend/app/Mail/Account/AccountDeletionCancelledEmail.php b/backend/app/Mail/Account/AccountDeletionCancelledEmail.php new file mode 100644 index 0000000000..c5c23177bd --- /dev/null +++ b/backend/app/Mail/Account/AccountDeletionCancelledEmail.php @@ -0,0 +1,39 @@ + config('app.name'), + ]), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.account.deletion-cancelled', + with: [ + 'account' => $this->account, + ], + ); + } +} diff --git a/backend/app/Mail/Account/AccountDeletionCompletedEmail.php b/backend/app/Mail/Account/AccountDeletionCompletedEmail.php new file mode 100644 index 0000000000..5cea785333 --- /dev/null +++ b/backend/app/Mail/Account/AccountDeletionCompletedEmail.php @@ -0,0 +1,40 @@ + config('app.name'), + ]), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.account.deletion-completed', + with: [ + 'accountName' => $this->accountName, + 'wasAnonymized' => $this->wasAnonymized, + ], + ); + } +} diff --git a/backend/app/Mail/Account/AccountDeletionReminderEmail.php b/backend/app/Mail/Account/AccountDeletionReminderEmail.php new file mode 100644 index 0000000000..0ad7d1b127 --- /dev/null +++ b/backend/app/Mail/Account/AccountDeletionReminderEmail.php @@ -0,0 +1,43 @@ + config('app.name'), + 'date' => $this->scheduledDeletionDate, + ]), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.account.deletion-reminder', + with: [ + 'accountName' => $this->accountName, + 'scheduledDeletionDate' => $this->scheduledDeletionDate, + 'cancelLink' => Url::getFrontEndUrlFromConfig(Url::ACCOUNT_DANGER_ZONE), + ], + ); + } +} diff --git a/backend/app/Mail/Account/AccountDeletionRequestedEmail.php b/backend/app/Mail/Account/AccountDeletionRequestedEmail.php new file mode 100644 index 0000000000..675a14a818 --- /dev/null +++ b/backend/app/Mail/Account/AccountDeletionRequestedEmail.php @@ -0,0 +1,49 @@ + config('app.name'), + ]), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.account.deletion-requested', + with: [ + 'account' => $this->account, + 'scheduledDeletionDate' => Carbon::parse($this->deletionRequest->getScheduledDeletionAt()) + ->setTimezone($this->account->getTimezone() ?? 'UTC') + ->toFormattedDateString(), + 'willBeAnonymized' => $this->deletionRequest->getExpectedOutcome() === AccountDeletionOutcome::ANONYMIZE->name, + 'cancelLink' => Url::getFrontEndUrlFromConfig(Url::ACCOUNT_DANGER_ZONE), + ], + ); + } +} diff --git a/backend/app/Models/AccountDeletionRequest.php b/backend/app/Models/AccountDeletionRequest.php new file mode 100644 index 0000000000..3d81e158f2 --- /dev/null +++ b/backend/app/Models/AccountDeletionRequest.php @@ -0,0 +1,53 @@ + 'array', + 'scheduled_deletion_at' => 'datetime', + 'reminder_sent_at' => 'datetime', + 'cancelled_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + } + + protected function getFillableFields(): array + { + return [ + 'account_id', + 'requested_by_user_id', + 'initiated_by', + 'reason', + 'status', + 'expected_outcome', + 'outcome', + 'scheduled_deletion_at', + 'reminder_sent_at', + 'cancelled_at', + 'cancelled_by_user_id', + 'completed_at', + 'deletion_manifest', + ]; + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class)->withTrashed(); + } + + public function requestedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'requested_by_user_id')->withTrashed(); + } + + public function cancelledByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'cancelled_by_user_id')->withTrashed(); + } +} diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index ad28f4bc4f..a7cfae3410 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -6,6 +6,7 @@ use HiEvents\Repository\Eloquent\AccountAttributionRepository; use HiEvents\Repository\Eloquent\AccountConfigurationRepository; +use HiEvents\Repository\Eloquent\AccountDeletionRequestRepository; use HiEvents\Repository\Eloquent\AccountMessagingTierRepository; use HiEvents\Repository\Eloquent\AccountRepository; use HiEvents\Repository\Eloquent\AccountUserRepository; @@ -63,6 +64,7 @@ use HiEvents\Repository\Eloquent\WebhookRepository; use HiEvents\Repository\Interfaces\AccountAttributionRepositoryInterface; use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface; +use HiEvents\Repository\Interfaces\AccountDeletionRequestRepositoryInterface; use HiEvents\Repository\Interfaces\AccountMessagingTierRepositoryInterface; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; @@ -129,6 +131,7 @@ class RepositoryServiceProvider extends ServiceProvider UserRepositoryInterface::class => UserRepository::class, AccountRepositoryInterface::class => AccountRepository::class, AccountAttributionRepositoryInterface::class => AccountAttributionRepository::class, + AccountDeletionRequestRepositoryInterface::class => AccountDeletionRequestRepository::class, EventRepositoryInterface::class => EventRepository::class, ProductRepositoryInterface::class => ProductRepository::class, OrderRepositoryInterface::class => OrderRepository::class, diff --git a/backend/app/Repository/Eloquent/AccountDeletionRequestRepository.php b/backend/app/Repository/Eloquent/AccountDeletionRequestRepository.php new file mode 100644 index 0000000000..191c48ce8a --- /dev/null +++ b/backend/app/Repository/Eloquent/AccountDeletionRequestRepository.php @@ -0,0 +1,63 @@ + + */ +class AccountDeletionRequestRepository extends BaseRepository implements AccountDeletionRequestRepositoryInterface +{ + protected function getModel(): string + { + return AccountDeletionRequest::class; + } + + public function getDomainObject(): string + { + return AccountDeletionRequestDomainObject::class; + } + + public function findDueForReminder(int $daysBefore): Collection + { + return $this->runQuery(function () use ($daysBefore) { + $requests = $this->model + ->where('status', AccountDeletionRequestStatus::REQUESTED->name) + ->where('scheduled_deletion_at', '<=', now()->addDays($daysBefore)) + ->whereNull('reminder_sent_at') + ->get(); + + return $this->handleResults($requests); + }); + } + + public function getAllRequestsWithAccounts(?string $search, ?string $status, int $perPage): LengthAwarePaginator + { + return $this->runQuery(function () use ($search, $status, $perPage) { + $query = $this->model + ->with(['account', 'requestedByUser', 'cancelledByUser']); + + if ($status) { + $query->where('status', $status); + } + + if ($search) { + $query->whereHas('account', function ($accountQuery) use ($search) { + $accountQuery->withTrashed() + ->where(function ($q) use ($search) { + $q->where('accounts.name', 'ilike', "{$search}%") + ->orWhere('accounts.email', 'ilike', "{$search}%"); + }); + }); + } + + return $query->orderBy('created_at', 'desc')->paginate($perPage); + }); + } +} diff --git a/backend/app/Repository/Eloquent/EventRepository.php b/backend/app/Repository/Eloquent/EventRepository.php index 11fe369a84..be6059f222 100644 --- a/backend/app/Repository/Eloquent/EventRepository.php +++ b/backend/app/Repository/Eloquent/EventRepository.php @@ -12,12 +12,14 @@ use HiEvents\DomainObjects\Generated\EventSettingDomainObjectAbstract; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\Status\EventStatus; +use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Models\Event; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; /** @@ -228,4 +230,29 @@ public function getSitemapEventCount(): int ->whereNull('events.'.EventDomainObjectAbstract::DELETED_AT) ->count(); } + + public function getUpcomingEventsWithCompletedOrders(int $accountId): Collection + { + return $this->runQuery(function () use ($accountId) { + $events = $this->model + ->where('events.'.EventDomainObjectAbstract::ACCOUNT_ID, $accountId) + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('event_occurrences') + ->whereColumn('event_occurrences.event_id', 'events.id') + ->whereNull('event_occurrences.deleted_at') + ->whereRaw('COALESCE(event_occurrences.end_date, event_occurrences.start_date) >= ?', [now()]); + }) + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('orders') + ->whereColumn('orders.event_id', 'events.id') + ->whereNull('orders.deleted_at') + ->where('orders.status', OrderStatus::COMPLETED->name); + }) + ->get(); + + return $this->handleResults($events); + }); + } } diff --git a/backend/app/Repository/Eloquent/OrderRepository.php b/backend/app/Repository/Eloquent/OrderRepository.php index 7448288776..5e4a9e9186 100644 --- a/backend/app/Repository/Eloquent/OrderRepository.php +++ b/backend/app/Repository/Eloquent/OrderRepository.php @@ -278,4 +278,13 @@ public function hasCompletedPaidOrderForAccount(int $accountId): bool return $exists; } + + public function accountHasCompletedOrders(int $accountId): bool + { + return $this->runQuery(fn () => $this->model + ->join('events', 'orders.event_id', '=', 'events.id') + ->where('events.account_id', $accountId) + ->where('orders.status', OrderStatus::COMPLETED->name) + ->exists()); + } } diff --git a/backend/app/Repository/Interfaces/AccountDeletionRequestRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountDeletionRequestRepositoryInterface.php new file mode 100644 index 0000000000..c3b13ecd19 --- /dev/null +++ b/backend/app/Repository/Interfaces/AccountDeletionRequestRepositoryInterface.php @@ -0,0 +1,17 @@ + + */ +interface AccountDeletionRequestRepositoryInterface extends RepositoryInterface +{ + public function findDueForReminder(int $daysBefore): Collection; + + public function getAllRequestsWithAccounts(?string $search, ?string $status, int $perPage): LengthAwarePaginator; +} diff --git a/backend/app/Repository/Interfaces/EventRepositoryInterface.php b/backend/app/Repository/Interfaces/EventRepositoryInterface.php index 0486a79424..228b3a306d 100644 --- a/backend/app/Repository/Interfaces/EventRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/EventRepositoryInterface.php @@ -7,6 +7,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; /** * @extends RepositoryInterface @@ -31,4 +32,6 @@ public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator; public function getSitemapEventCount(): int; public function findByIdLocked(int $id): EventDomainObject; + + public function getUpcomingEventsWithCompletedOrders(int $accountId): Collection; } diff --git a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php index f866063b61..41b1f67429 100644 --- a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php @@ -53,4 +53,6 @@ public function getAllOrdersForAdmin( ): LengthAwarePaginator; public function hasCompletedPaidOrderForAccount(int $accountId): bool; + + public function accountHasCompletedOrders(int $accountId): bool; } diff --git a/backend/app/Resources/Account/AccountDeletionRequestResource.php b/backend/app/Resources/Account/AccountDeletionRequestResource.php new file mode 100644 index 0000000000..70864a341f --- /dev/null +++ b/backend/app/Resources/Account/AccountDeletionRequestResource.php @@ -0,0 +1,26 @@ + $this->getId(), + 'status' => $this->getStatus(), + 'initiated_by' => $this->getInitiatedBy(), + 'expected_outcome' => $this->getExpectedOutcome(), + 'scheduled_deletion_at' => $this->getScheduledDeletionAt(), + 'cancelled_at' => $this->getCancelledAt(), + 'requested_at' => $this->getCreatedAt(), + ]; + } +} diff --git a/backend/app/Resources/Account/AccountResource.php b/backend/app/Resources/Account/AccountResource.php index 4d73626072..cac9885590 100644 --- a/backend/app/Resources/Account/AccountResource.php +++ b/backend/app/Resources/Account/AccountResource.php @@ -23,6 +23,10 @@ public function toArray(Request $request): array 'is_account_email_confirmed' => $this->getAccountVerifiedAt() !== null, 'is_saas_mode_enabled' => config('app.saas_mode_enabled'), 'requires_manual_verification' => config('app.saas_mode_enabled') && ! $this->getIsManuallyVerified(), + + 'deletion_request' => $this->getActiveDeletionRequest() + ? new AccountDeletionRequestResource($this->getActiveDeletionRequest()) + : null, ]; } } diff --git a/backend/app/Resources/Admin/AdminAccountDeletionRequestResource.php b/backend/app/Resources/Admin/AdminAccountDeletionRequestResource.php new file mode 100644 index 0000000000..5be3a700a5 --- /dev/null +++ b/backend/app/Resources/Admin/AdminAccountDeletionRequestResource.php @@ -0,0 +1,45 @@ + $this->id, + 'status' => $this->status, + 'initiated_by' => $this->initiated_by, + 'reason' => $this->reason, + 'expected_outcome' => $this->expected_outcome, + 'outcome' => $this->outcome, + 'scheduled_deletion_at' => $this->scheduled_deletion_at?->toIso8601String(), + 'reminder_sent_at' => $this->reminder_sent_at?->toIso8601String(), + 'cancelled_at' => $this->cancelled_at?->toIso8601String(), + 'completed_at' => $this->completed_at?->toIso8601String(), + 'requested_at' => $this->created_at?->toIso8601String(), + 'deletion_manifest' => $this->deletion_manifest, + 'account' => $this->account ? [ + 'id' => $this->account->id, + 'name' => $this->account->name, + 'email' => $this->account->email, + ] : null, + 'requested_by_user' => $this->requestedByUser ? [ + 'id' => $this->requestedByUser->id, + 'full_name' => trim($this->requestedByUser->first_name.' '.$this->requestedByUser->last_name), + 'email' => $this->requestedByUser->email, + ] : null, + 'cancelled_by_user' => $this->cancelledByUser ? [ + 'id' => $this->cancelledByUser->id, + 'full_name' => trim($this->cancelledByUser->first_name.' '.$this->cancelledByUser->last_name), + ] : null, + ]; + } +} diff --git a/backend/app/Services/Application/Handlers/Account/DeletionRequest/CancelAccountDeletionHandler.php b/backend/app/Services/Application/Handlers/Account/DeletionRequest/CancelAccountDeletionHandler.php new file mode 100644 index 0000000000..c470040191 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/DeletionRequest/CancelAccountDeletionHandler.php @@ -0,0 +1,24 @@ +accountDeletionService->cancelDeletion($accountId, $cancelledByUserId); + } +} diff --git a/backend/app/Services/Application/Handlers/Account/DeletionRequest/DTO/GetAccountDeletionStatusDTO.php b/backend/app/Services/Application/Handlers/Account/DeletionRequest/DTO/GetAccountDeletionStatusDTO.php new file mode 100644 index 0000000000..7e5d9f5d5c --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/DeletionRequest/DTO/GetAccountDeletionStatusDTO.php @@ -0,0 +1,16 @@ +accountDeletionService->findActiveRequest($accountId); + $cannotDeleteReason = $this->accountDeletionService->getCannotDeleteReason($accountId); + + return new GetAccountDeletionStatusDTO( + activeRequest: $activeRequest, + canRequestDeletion: $cannotDeleteReason === null, + cannotDeleteReason: $cannotDeleteReason, + expectedOutcome: $this->accountDeletionService->determineOutcome($accountId)->name, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Account/DeletionRequest/RequestAccountDeletionHandler.php b/backend/app/Services/Application/Handlers/Account/DeletionRequest/RequestAccountDeletionHandler.php new file mode 100644 index 0000000000..fd0b3dc51d --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/DeletionRequest/RequestAccountDeletionHandler.php @@ -0,0 +1,42 @@ +accountRepository->findById($dto->accountId); + + if (mb_strtolower(trim($dto->confirmation)) !== mb_strtolower(trim($account->getName()))) { + throw ValidationException::withMessages([ + 'confirmation' => __('The confirmation does not match your account name.'), + ]); + } + + return $this->accountDeletionService->requestDeletion( + accountId: $dto->accountId, + requestedByUserId: $dto->requestedByUserId, + initiator: $dto->initiatedBy, + reason: $dto->reason, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllAccountDeletionRequestsDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllAccountDeletionRequestsDTO.php new file mode 100644 index 0000000000..c467804c4b --- /dev/null +++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllAccountDeletionRequestsDTO.php @@ -0,0 +1,14 @@ +deletionRequestRepository->getAllRequestsWithAccounts( + search: $dto->search, + status: $dto->status, + perPage: $dto->perPage, + ); + } +} diff --git a/backend/app/Services/Domain/Account/AccountAnonymizationService.php b/backend/app/Services/Domain/Account/AccountAnonymizationService.php new file mode 100644 index 0000000000..f7ddf078cb --- /dev/null +++ b/backend/app/Services/Domain/Account/AccountAnonymizationService.php @@ -0,0 +1,93 @@ +dataResolver->resolve($accountId); + + $this->logger->info('Anonymizing account', [ + 'account_id' => $accountId, + 'stripe_account_ids' => $context->stripeAccountIds, + ]); + + $results = $this->databaseManager->transaction(function () use ($context) { + $anonymizers = [ + $this->orderAnonymizer, + $this->eventContentAnonymizer, + $this->activityLogAnonymizer, + $this->partnerAnonymizer, + $this->userAnonymizer, + $this->organizerAnonymizer, + $this->imageAnonymizer, + $this->accountAnonymizer, + ]; + + return collect($anonymizers) + ->flatMap(fn ($anonymizer) => $anonymizer->anonymize($context)) + ->values(); + }); + + $this->deleteImageFiles($context->imageFiles); + + $manifest = $results + ->map(fn (EntityAnonymizationResult $result) => $result->toArray()) + ->all(); + + $this->logger->info('Account anonymized', [ + 'account_id' => $accountId, + 'manifest' => $manifest, + ]); + + return $manifest; + } + + private function deleteImageFiles(array $imageFiles): void + { + foreach ($imageFiles as $imageFile) { + try { + Storage::disk($imageFile['disk'])->delete($imageFile['path']); + } catch (Throwable $exception) { + $this->logger->warning('Failed to delete image file during account anonymization', [ + 'disk' => $imageFile['disk'], + 'path' => $imageFile['path'], + 'error' => $exception->getMessage(), + ]); + } + } + } +} diff --git a/backend/app/Services/Domain/Account/AccountDeletionService.php b/backend/app/Services/Domain/Account/AccountDeletionService.php new file mode 100644 index 0000000000..ed97189023 --- /dev/null +++ b/backend/app/Services/Domain/Account/AccountDeletionService.php @@ -0,0 +1,235 @@ +deletionRequestRepository->findFirstWhere([ + AccountDeletionRequestDomainObjectAbstract::ACCOUNT_ID => $accountId, + AccountDeletionRequestDomainObjectAbstract::STATUS => AccountDeletionRequestStatus::REQUESTED->name, + ]); + } + + public function isAccountPendingDeletion(int $accountId): bool + { + return Cache::remember( + key: $this->getPendingDeletionCacheKey($accountId), + ttl: self::PENDING_DELETION_CACHE_TTL_SECONDS, + callback: fn () => $this->findActiveRequest($accountId) !== null, + ); + } + + public function getCannotDeleteReason(int $accountId): ?string + { + if ($this->findActiveRequest($accountId) !== null) { + return __('Account deletion has already been requested.'); + } + + $blockingEvents = $this->eventRepository->getUpcomingEventsWithCompletedOrders($accountId); + + if ($blockingEvents->isNotEmpty()) { + return __('The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.', [ + 'events' => $blockingEvents + ->map(fn (EventDomainObject $event) => $event->getTitle()) + ->implode(', '), + ]); + } + + return null; + } + + public function determineOutcome(int $accountId): AccountDeletionOutcome + { + return $this->orderRepository->accountHasCompletedOrders($accountId) + ? AccountDeletionOutcome::ANONYMIZE + : AccountDeletionOutcome::HARD_DELETE; + } + + /** + * @throws CannotDeleteEntityException + * @throws Throwable + */ + public function requestDeletion( + int $accountId, + int $requestedByUserId, + AccountDeletionInitiator $initiator, + ?string $reason = null, + ): AccountDeletionRequestDomainObject { + $deletionRequest = $this->databaseManager->transaction(function () use ($accountId, $requestedByUserId, $initiator, $reason) { + $cannotDeleteReason = $this->getCannotDeleteReason($accountId); + + if ($cannotDeleteReason !== null) { + throw new CannotDeleteEntityException($cannotDeleteReason); + } + + /** @var AccountDeletionRequestDomainObject $deletionRequest */ + $deletionRequest = $this->deletionRequestRepository->create([ + AccountDeletionRequestDomainObjectAbstract::ACCOUNT_ID => $accountId, + AccountDeletionRequestDomainObjectAbstract::REQUESTED_BY_USER_ID => $requestedByUserId, + AccountDeletionRequestDomainObjectAbstract::INITIATED_BY => $initiator->name, + AccountDeletionRequestDomainObjectAbstract::REASON => $reason, + AccountDeletionRequestDomainObjectAbstract::STATUS => AccountDeletionRequestStatus::REQUESTED->name, + AccountDeletionRequestDomainObjectAbstract::EXPECTED_OUTCOME => $this->determineOutcome($accountId)->name, + AccountDeletionRequestDomainObjectAbstract::SCHEDULED_DELETION_AT => now()->addDays(self::GRACE_PERIOD_DAYS), + ]); + + $this->eventRepository->updateWhere( + attributes: [EventDomainObjectAbstract::STATUS => EventStatus::DRAFT->name], + where: [ + EventDomainObjectAbstract::ACCOUNT_ID => $accountId, + EventDomainObjectAbstract::STATUS => EventStatus::LIVE->name, + ], + ); + + $account = $this->accountRepository->findById($accountId); + + Mail::to($account->getEmail())->queue(new AccountDeletionRequestedEmail($account, $deletionRequest)); + + return $deletionRequest; + }); + + Cache::forget($this->getPendingDeletionCacheKey($accountId)); + + $this->logger->info('Account deletion requested', [ + 'account_id' => $accountId, + 'requested_by_user_id' => $requestedByUserId, + 'initiated_by' => $initiator->name, + 'scheduled_deletion_at' => $deletionRequest->getScheduledDeletionAt(), + 'expected_outcome' => $deletionRequest->getExpectedOutcome(), + ]); + + return $deletionRequest; + } + + /** + * @throws AccountDeletionRequestNotFoundException + * @throws Throwable + */ + public function cancelDeletion(int $accountId, int $cancelledByUserId): AccountDeletionRequestDomainObject + { + $deletionRequest = $this->databaseManager->transaction(function () use ($accountId, $cancelledByUserId) { + $activeRequest = $this->findActiveRequest($accountId); + + if ($activeRequest === null) { + throw new AccountDeletionRequestNotFoundException( + __('There is no pending deletion request for this account.'), + ); + } + + $cancelledRequest = $this->deletionRequestRepository->updateFromArray($activeRequest->getId(), [ + AccountDeletionRequestDomainObjectAbstract::STATUS => AccountDeletionRequestStatus::CANCELLED->name, + AccountDeletionRequestDomainObjectAbstract::CANCELLED_AT => now(), + AccountDeletionRequestDomainObjectAbstract::CANCELLED_BY_USER_ID => $cancelledByUserId, + ]); + + $account = $this->accountRepository->findById($accountId); + + Mail::to($account->getEmail())->queue(new AccountDeletionCancelledEmail($account)); + + return $cancelledRequest; + }); + + Cache::forget($this->getPendingDeletionCacheKey($accountId)); + + $this->logger->info('Account deletion cancelled', [ + 'account_id' => $accountId, + 'cancelled_by_user_id' => $cancelledByUserId, + ]); + + return $deletionRequest; + } + + /** + * @throws Throwable + */ + public function executeDeletion(int $deletionRequestId): void + { + /** @var AccountDeletionRequestDomainObject $deletionRequest */ + $deletionRequest = $this->deletionRequestRepository->findById($deletionRequestId); + + if ($deletionRequest->getStatus() !== AccountDeletionRequestStatus::REQUESTED->name) { + $this->logger->info('Skipping account deletion execution for inactive request', [ + 'deletion_request_id' => $deletionRequestId, + 'status' => $deletionRequest->getStatus(), + ]); + + return; + } + + $accountId = $deletionRequest->getAccountId(); + $account = $this->accountRepository->findById($accountId); + $recipientEmail = $account->getEmail(); + $recipientName = $account->getName(); + + $outcome = $this->determineOutcome($accountId); + + $manifest = $outcome === AccountDeletionOutcome::HARD_DELETE + ? $this->hardDeletionService->deleteAccount($accountId) + : $this->anonymizationService->anonymizeAccount($accountId); + + $this->deletionRequestRepository->updateFromArray($deletionRequestId, [ + AccountDeletionRequestDomainObjectAbstract::STATUS => AccountDeletionRequestStatus::COMPLETED->name, + AccountDeletionRequestDomainObjectAbstract::OUTCOME => $outcome->name, + AccountDeletionRequestDomainObjectAbstract::COMPLETED_AT => now(), + AccountDeletionRequestDomainObjectAbstract::DELETION_MANIFEST => $manifest, + ]); + + Cache::forget($this->getPendingDeletionCacheKey($accountId)); + + Mail::to($recipientEmail)->queue(new AccountDeletionCompletedEmail( + accountName: $recipientName, + wasAnonymized: $outcome === AccountDeletionOutcome::ANONYMIZE, + )); + + $this->logger->info('Account deletion executed', [ + 'account_id' => $accountId, + 'deletion_request_id' => $deletionRequestId, + 'outcome' => $outcome->name, + ]); + } + + private function getPendingDeletionCacheKey(int $accountId): string + { + return "account:{$accountId}:pending-deletion"; + } +} diff --git a/backend/app/Services/Domain/Account/AccountHardDeletionService.php b/backend/app/Services/Domain/Account/AccountHardDeletionService.php new file mode 100644 index 0000000000..3a928d6e9a --- /dev/null +++ b/backend/app/Services/Domain/Account/AccountHardDeletionService.php @@ -0,0 +1,182 @@ +dataResolver->resolve($accountId); + + $this->logger->info('Hard deleting account', [ + 'account_id' => $accountId, + 'stripe_account_ids' => $context->stripeAccountIds, + ]); + + $manifest = $this->databaseManager->transaction(fn () => $this->deleteAccountData($context)); + + $this->deleteImageFiles($context->imageFiles); + + $this->logger->info('Account hard deleted', [ + 'account_id' => $accountId, + 'manifest' => $manifest, + ]); + + return $manifest; + } + + private function deleteAccountData(AnonymizationContext $context): array + { + $manifest = []; + $connection = $this->databaseManager->connection(); + + if ($context->eventIds !== []) { + $questionIds = $connection->table('questions') + ->whereIn('event_id', $context->eventIds) + ->pluck('id') + ->all(); + + if ($questionIds !== []) { + $manifest['question_answers'] = $connection->table('question_answers') + ->whereIn('question_id', $questionIds) + ->delete(); + } + } + + if ($context->orderIds !== []) { + $manifest['attendee_check_ins'] = $connection->table('attendee_check_ins') + ->whereIn('order_id', $context->orderIds) + ->delete(); + + $manifest['stripe_payments'] = $connection->table('stripe_payments') + ->whereIn('order_id', $context->orderIds) + ->delete(); + + $manifest['orders'] = $connection->table('orders') + ->whereIn('id', $context->orderIds) + ->delete(); + } + + if ($context->eventIds !== []) { + $manifest['order_audit_logs'] = $connection->table('order_audit_logs') + ->whereIn('event_id', $context->eventIds) + ->delete(); + + $manifest['outgoing_messages'] = $connection->table('outgoing_messages') + ->whereIn('event_id', $context->eventIds) + ->delete(); + + $manifest['messages'] = $connection->table('messages') + ->whereIn('event_id', $context->eventIds) + ->delete(); + + $manifest['promo_codes'] = $connection->table('promo_codes') + ->whereIn('event_id', $context->eventIds) + ->delete(); + + $manifest['questions'] = $connection->table('questions') + ->whereIn('event_id', $context->eventIds) + ->delete(); + + foreach ([ + 'event_statistics', + 'event_daily_statistics', + 'event_occurrence_statistics', + 'event_occurrence_daily_statistics', + ] as $statisticsTable) { + $manifest[$statisticsTable] = $connection->table($statisticsTable) + ->whereIn('event_id', $context->eventIds) + ->delete(); + } + + $manifest['events'] = $connection->table('events') + ->whereIn('id', $context->eventIds) + ->delete(); + } + + if ($context->organizerIds !== []) { + $manifest['organizers'] = $connection->table('organizers') + ->whereIn('id', $context->organizerIds) + ->delete(); + } + + $manifest['roles'] = $connection->table('roles') + ->where('account_id', $context->accountId) + ->delete(); + + $manifest['taxes_and_fees'] = $connection->table('taxes_and_fees') + ->where('account_id', $context->accountId) + ->delete(); + + if ($context->stripeAccountIds !== []) { + $manifest['stripe_customers'] = $connection->table('stripe_customers') + ->whereIn('stripe_account_id', $context->stripeAccountIds) + ->delete(); + } + + if ($context->soleUserEmails !== []) { + $manifest['password_resets'] = $connection->table('password_resets') + ->whereIn('email', $context->soleUserEmails) + ->delete(); + + $manifest['password_reset_tokens'] = $connection->table('password_reset_tokens') + ->whereIn('email', $context->soleUserEmails) + ->delete(); + } + + if ($context->soleUserIds !== []) { + $manifest['personal_access_tokens'] = $connection->table('personal_access_tokens') + ->where('tokenable_type', User::class) + ->whereIn('tokenable_id', $context->soleUserIds) + ->delete(); + + $manifest['event_logs'] = $connection->table('event_logs') + ->whereIn('user_id', $context->soleUserIds) + ->delete(); + } + + $manifest['accounts'] = $connection->table('accounts') + ->where('id', $context->accountId) + ->delete(); + + if ($context->soleUserIds !== []) { + $manifest['users'] = $connection->table('users') + ->whereIn('id', $context->soleUserIds) + ->delete(); + } + + return $manifest; + } + + private function deleteImageFiles(array $imageFiles): void + { + foreach ($imageFiles as $imageFile) { + try { + Storage::disk($imageFile['disk'])->delete($imageFile['path']); + } catch (Throwable $exception) { + $this->logger->warning('Failed to delete image file during account deletion', [ + 'disk' => $imageFile['disk'], + 'path' => $imageFile['path'], + 'error' => $exception->getMessage(), + ]); + } + } + } +} diff --git a/backend/app/Services/Domain/Account/Anonymization/AccountAnonymizerInterface.php b/backend/app/Services/Domain/Account/Anonymization/AccountAnonymizerInterface.php new file mode 100644 index 0000000000..a69e37ab1e --- /dev/null +++ b/backend/app/Services/Domain/Account/Anonymization/AccountAnonymizerInterface.php @@ -0,0 +1,11 @@ +databaseManager->connection(); + + $eventIds = $connection->table('events') + ->where('account_id', $accountId) + ->pluck('id') + ->all(); + + $orderIds = $eventIds === [] ? [] : $connection->table('orders') + ->whereIn('event_id', $eventIds) + ->pluck('id') + ->all(); + + $organizerIds = $connection->table('organizers') + ->where('account_id', $accountId) + ->pluck('id') + ->all(); + + $userIds = $connection->table('account_users') + ->where('account_id', $accountId) + ->whereNull('deleted_at') + ->pluck('user_id') + ->unique() + ->all(); + + $sharedUserIds = $userIds === [] ? [] : $connection->table('account_users') + ->whereIn('user_id', $userIds) + ->where('account_id', '!=', $accountId) + ->whereNull('deleted_at') + ->pluck('user_id') + ->unique() + ->all(); + + $soleUserIds = array_values(array_diff($userIds, $sharedUserIds)); + + $soleUserEmails = $soleUserIds === [] ? [] : $connection->table('users') + ->whereIn('id', $soleUserIds) + ->pluck('email') + ->all(); + + $stripeAccountIds = collect() + ->merge($connection->table('accounts')->where('id', $accountId)->pluck('stripe_account_id')) + ->merge($connection->table('account_stripe_platforms')->where('account_id', $accountId)->pluck('stripe_account_id')) + ->merge( + $organizerIds === [] ? [] : $connection->table('organizer_stripe_platforms') + ->whereIn('organizer_id', $organizerIds) + ->pluck('stripe_account_id'), + ) + ->filter() + ->unique() + ->values() + ->all(); + + $imageEntityScopes = array_filter([ + EventDomainObject::class => $eventIds, + OrganizerDomainObject::class => $organizerIds, + UserDomainObject::class => $soleUserIds, + ]); + + $imageFiles = $connection->table('images') + ->where(function ($query) use ($accountId, $imageEntityScopes) { + $query->where('account_id', $accountId); + + foreach ($imageEntityScopes as $entityType => $entityIds) { + $query->orWhere(function ($subQuery) use ($entityType, $entityIds) { + $subQuery->where('entity_type', $entityType) + ->whereIn('entity_id', $entityIds); + }); + } + }) + ->get(['id', 'disk', 'path']) + ->map(fn ($image) => ['id' => $image->id, 'disk' => $image->disk, 'path' => $image->path]) + ->all(); + + return new AnonymizationContext( + accountId: $accountId, + eventIds: $eventIds, + orderIds: $orderIds, + organizerIds: $organizerIds, + soleUserIds: $soleUserIds, + sharedUserIds: $sharedUserIds, + soleUserEmails: $soleUserEmails, + stripeAccountIds: $stripeAccountIds, + imageFiles: $imageFiles, + ); + } +} diff --git a/backend/app/Services/Domain/Account/Anonymization/AnonymizationContext.php b/backend/app/Services/Domain/Account/Anonymization/AnonymizationContext.php new file mode 100644 index 0000000000..f7855d2e0c --- /dev/null +++ b/backend/app/Services/Domain/Account/Anonymization/AnonymizationContext.php @@ -0,0 +1,18 @@ + $strategy) { + $updates[$column] = $this->valueForStrategy($strategy, $context); + } + + return new EntityAnonymizationResult( + entity: $entity, + action: 'scrubbed', + rowCount: $query->update($updates), + columns: array_keys($columnStrategies), + ); + } + + public function delete(Builder $query, string $entity): EntityAnonymizationResult + { + return new EntityAnonymizationResult( + entity: $entity, + action: 'deleted', + rowCount: $query->delete(), + ); + } + + public function softDelete(Builder $query, string $entity): EntityAnonymizationResult + { + return new EntityAnonymizationResult( + entity: $entity, + action: 'soft_deleted', + rowCount: $query->whereNull('deleted_at')->update(['deleted_at' => now()]), + ); + } + + private function valueForStrategy(AnonymizationStrategy $strategy, AnonymizationContext $context): mixed + { + return match ($strategy) { + AnonymizationStrategy::NULLIFY => null, + AnonymizationStrategy::SCRUB_TEXT => 'Anonymized', + AnonymizationStrategy::SCRUB_EMAIL => sprintf('anonymized+account-%d@anonymized.invalid', $context->accountId), + AnonymizationStrategy::SCRUB_EMAIL_UNIQUE => DB::raw("'anonymized+' || id || '@anonymized.invalid'"), + AnonymizationStrategy::RANDOM_TOKEN => DB::raw('md5(random()::text || clock_timestamp()::text || id::text)'), + }; + } +} diff --git a/backend/app/Services/Domain/Account/Anonymization/Anonymizers/AccountAnonymizer.php b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/AccountAnonymizer.php new file mode 100644 index 0000000000..f0fabb58f8 --- /dev/null +++ b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/AccountAnonymizer.php @@ -0,0 +1,55 @@ +executor->scrub( + query: $this->databaseManager->table('account_vat_settings')->where('account_id', $context->accountId), + entity: 'account_vat_settings', + columnStrategies: [ + 'vat_number' => AnonymizationStrategy::NULLIFY, + 'business_name' => AnonymizationStrategy::NULLIFY, + 'business_address' => AnonymizationStrategy::NULLIFY, + ], + context: $context, + ), + $this->executor->delete( + query: $this->databaseManager->table('account_attributions')->where('account_id', $context->accountId), + entity: 'account_attributions', + ), + $this->executor->delete( + query: $this->databaseManager->table('account_stripe_platforms')->where('account_id', $context->accountId), + entity: 'account_stripe_platforms', + ), + $this->executor->scrub( + query: $this->databaseManager->table('accounts')->where('id', $context->accountId), + entity: 'accounts', + columnStrategies: [ + 'name' => AnonymizationStrategy::SCRUB_TEXT, + 'email' => AnonymizationStrategy::SCRUB_EMAIL, + 'stripe_account_id' => AnonymizationStrategy::NULLIFY, + ], + context: $context, + ), + $this->executor->softDelete( + query: $this->databaseManager->table('accounts')->where('id', $context->accountId), + entity: 'accounts', + ), + ]; + } +} diff --git a/backend/app/Services/Domain/Account/Anonymization/Anonymizers/ActivityLogAnonymizer.php b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/ActivityLogAnonymizer.php new file mode 100644 index 0000000000..83a7492d2e --- /dev/null +++ b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/ActivityLogAnonymizer.php @@ -0,0 +1,62 @@ +eventIds !== []) { + $results[] = $this->executor->delete( + query: $this->databaseManager->table('order_audit_logs')->whereIn('event_id', $context->eventIds), + entity: 'order_audit_logs', + ); + + $webhookIds = $this->databaseManager->table('webhooks') + ->where(function ($query) use ($context) { + $query->where('account_id', $context->accountId) + ->orWhereIn('event_id', $context->eventIds); + }) + ->pluck('id') + ->all(); + + if ($webhookIds !== []) { + $results[] = $this->executor->delete( + query: $this->databaseManager->table('webhook_logs')->whereIn('webhook_id', $webhookIds), + entity: 'webhook_logs', + ); + } + } + + $results[] = $this->executor->delete( + query: $this->databaseManager->table('webhooks')->where('account_id', $context->accountId), + entity: 'webhooks', + ); + + $results[] = $this->executor->delete( + query: $this->databaseManager->table('email_templates')->where('account_id', $context->accountId), + entity: 'email_templates', + ); + + if ($context->soleUserIds !== []) { + $results[] = $this->executor->delete( + query: $this->databaseManager->table('event_logs')->whereIn('user_id', $context->soleUserIds), + entity: 'event_logs', + ); + } + + return $results; + } +} diff --git a/backend/app/Services/Domain/Account/Anonymization/Anonymizers/EventContentAnonymizer.php b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/EventContentAnonymizer.php new file mode 100644 index 0000000000..0f9e0ba282 --- /dev/null +++ b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/EventContentAnonymizer.php @@ -0,0 +1,60 @@ +eventIds === []) { + return []; + } + + $questionIds = $this->databaseManager->table('questions') + ->whereIn('event_id', $context->eventIds) + ->pluck('id') + ->all(); + + return array_values(array_filter([ + $questionIds === [] ? null : $this->executor->delete( + query: $this->databaseManager->table('question_answers')->whereIn('question_id', $questionIds), + entity: 'question_answers', + ), + $this->executor->delete( + query: $this->databaseManager->table('outgoing_messages')->whereIn('event_id', $context->eventIds), + entity: 'outgoing_messages', + ), + $this->executor->delete( + query: $this->databaseManager->table('messages')->whereIn('event_id', $context->eventIds), + entity: 'messages', + ), + $this->executor->delete( + query: $this->databaseManager->table('waitlist_entries')->whereIn('event_id', $context->eventIds), + entity: 'waitlist_entries', + ), + $this->executor->scrub( + query: $this->databaseManager->table('event_settings')->whereIn('event_id', $context->eventIds), + entity: 'event_settings', + columnStrategies: [ + 'support_email' => AnonymizationStrategy::NULLIFY, + ], + context: $context, + ), + $this->executor->softDelete( + query: $this->databaseManager->table('events')->where('account_id', $context->accountId), + entity: 'events', + ), + ])); + } +} diff --git a/backend/app/Services/Domain/Account/Anonymization/Anonymizers/ImageAnonymizer.php b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/ImageAnonymizer.php new file mode 100644 index 0000000000..ed38f78baa --- /dev/null +++ b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/ImageAnonymizer.php @@ -0,0 +1,32 @@ +imageFiles, 'id'); + + if ($imageIds === []) { + return []; + } + + return [ + $this->executor->delete( + query: $this->databaseManager->table('images')->whereIn('id', $imageIds), + entity: 'images', + ), + ]; + } +} diff --git a/backend/app/Services/Domain/Account/Anonymization/Anonymizers/OrderAnonymizer.php b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/OrderAnonymizer.php new file mode 100644 index 0000000000..512da48c1b --- /dev/null +++ b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/OrderAnonymizer.php @@ -0,0 +1,54 @@ +orderIds === []) { + return []; + } + + return [ + $this->executor->scrub( + query: $this->databaseManager->table('orders')->whereIn('id', $context->orderIds), + entity: 'orders', + columnStrategies: [ + 'first_name' => AnonymizationStrategy::SCRUB_TEXT, + 'last_name' => AnonymizationStrategy::SCRUB_TEXT, + 'email' => AnonymizationStrategy::SCRUB_EMAIL, + 'address' => AnonymizationStrategy::NULLIFY, + 'session_id' => AnonymizationStrategy::NULLIFY, + 'notes' => AnonymizationStrategy::NULLIFY, + 'point_in_time_data' => AnonymizationStrategy::NULLIFY, + 'public_id' => AnonymizationStrategy::RANDOM_TOKEN, + ], + context: $context, + ), + $this->executor->scrub( + query: $this->databaseManager->table('attendees')->whereIn('order_id', $context->orderIds), + entity: 'attendees', + columnStrategies: [ + 'first_name' => AnonymizationStrategy::SCRUB_TEXT, + 'last_name' => AnonymizationStrategy::SCRUB_TEXT, + 'email' => AnonymizationStrategy::SCRUB_EMAIL, + 'notes' => AnonymizationStrategy::NULLIFY, + 'public_id' => AnonymizationStrategy::RANDOM_TOKEN, + ], + context: $context, + ), + ]; + } +} diff --git a/backend/app/Services/Domain/Account/Anonymization/Anonymizers/OrganizerAnonymizer.php b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/OrganizerAnonymizer.php new file mode 100644 index 0000000000..c51dfc744c --- /dev/null +++ b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/OrganizerAnonymizer.php @@ -0,0 +1,46 @@ +organizerIds === []) { + return []; + } + + return [ + $this->executor->scrub( + query: $this->databaseManager->table('organizers')->whereIn('id', $context->organizerIds), + entity: 'organizers', + columnStrategies: [ + 'email' => AnonymizationStrategy::SCRUB_EMAIL, + 'phone' => AnonymizationStrategy::NULLIFY, + 'website' => AnonymizationStrategy::NULLIFY, + 'description' => AnonymizationStrategy::NULLIFY, + ], + context: $context, + ), + $this->executor->delete( + query: $this->databaseManager->table('organizer_stripe_platforms')->whereIn('organizer_id', $context->organizerIds), + entity: 'organizer_stripe_platforms', + ), + $this->executor->softDelete( + query: $this->databaseManager->table('organizers')->whereIn('id', $context->organizerIds), + entity: 'organizers', + ), + ]; + } +} diff --git a/backend/app/Services/Domain/Account/Anonymization/Anonymizers/PartnerAnonymizer.php b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/PartnerAnonymizer.php new file mode 100644 index 0000000000..c75834582f --- /dev/null +++ b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/PartnerAnonymizer.php @@ -0,0 +1,46 @@ +executor->scrub( + query: $this->databaseManager->table('affiliates')->where('account_id', $context->accountId), + entity: 'affiliates', + columnStrategies: [ + 'name' => AnonymizationStrategy::SCRUB_TEXT, + 'email' => AnonymizationStrategy::SCRUB_EMAIL, + ], + context: $context, + ), + ]; + + if ($context->stripeAccountIds !== []) { + $results[] = $this->executor->scrub( + query: $this->databaseManager->table('stripe_customers')->whereIn('stripe_account_id', $context->stripeAccountIds), + entity: 'stripe_customers', + columnStrategies: [ + 'name' => AnonymizationStrategy::SCRUB_TEXT, + 'email' => AnonymizationStrategy::SCRUB_EMAIL, + ], + context: $context, + ); + } + + return $results; + } +} diff --git a/backend/app/Services/Domain/Account/Anonymization/Anonymizers/UserAnonymizer.php b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/UserAnonymizer.php new file mode 100644 index 0000000000..a605efe8fe --- /dev/null +++ b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/UserAnonymizer.php @@ -0,0 +1,70 @@ +soleUserEmails !== []) { + $results[] = $this->executor->delete( + query: $this->databaseManager->table('password_resets')->whereIn('email', $context->soleUserEmails), + entity: 'password_resets', + ); + + $results[] = $this->executor->delete( + query: $this->databaseManager->table('password_reset_tokens')->whereIn('email', $context->soleUserEmails), + entity: 'password_reset_tokens', + ); + } + + if ($context->soleUserIds !== []) { + $results[] = $this->executor->delete( + query: $this->databaseManager->table('personal_access_tokens') + ->where('tokenable_type', User::class) + ->whereIn('tokenable_id', $context->soleUserIds), + entity: 'personal_access_tokens', + ); + + $results[] = $this->executor->scrub( + query: $this->databaseManager->table('users')->whereIn('id', $context->soleUserIds), + entity: 'users', + columnStrategies: [ + 'first_name' => AnonymizationStrategy::SCRUB_TEXT, + 'last_name' => AnonymizationStrategy::SCRUB_TEXT, + 'email' => AnonymizationStrategy::SCRUB_EMAIL_UNIQUE, + 'pending_email' => AnonymizationStrategy::NULLIFY, + 'password' => AnonymizationStrategy::RANDOM_TOKEN, + 'remember_token' => AnonymizationStrategy::NULLIFY, + ], + context: $context, + ); + + $results[] = $this->executor->softDelete( + query: $this->databaseManager->table('users')->whereIn('id', $context->soleUserIds), + entity: 'users', + ); + } + + $results[] = $this->executor->softDelete( + query: $this->databaseManager->table('account_users')->where('account_id', $context->accountId), + entity: 'account_users', + ); + + return $results; + } +} diff --git a/backend/app/Services/Domain/Account/Anonymization/EntityAnonymizationResult.php b/backend/app/Services/Domain/Account/Anonymization/EntityAnonymizationResult.php new file mode 100644 index 0000000000..39e6dd088c --- /dev/null +++ b/backend/app/Services/Domain/Account/Anonymization/EntityAnonymizationResult.php @@ -0,0 +1,15 @@ + '/checkout/%d/%s/details', 'organizer_order_summary' => '/manage/event/%d/orders#order-%d', 'ticket_lookup' => '/my-tickets/%s', + 'account_danger_zone' => '/account/danger-zone', ], /** diff --git a/backend/database/migrations/2026_07_29_000001_create_account_deletion_requests_table.php b/backend/database/migrations/2026_07_29_000001_create_account_deletion_requests_table.php new file mode 100644 index 0000000000..e0dcaace1b --- /dev/null +++ b/backend/database/migrations/2026_07_29_000001_create_account_deletion_requests_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('account_id'); + $table->unsignedBigInteger('requested_by_user_id'); + $table->string('initiated_by', 40); + $table->text('reason')->nullable(); + $table->string('status', 40)->default(AccountDeletionRequestStatus::REQUESTED->name); + $table->string('expected_outcome', 40)->nullable(); + $table->string('outcome', 40)->nullable(); + $table->timestamp('scheduled_deletion_at'); + $table->timestamp('reminder_sent_at')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->unsignedBigInteger('cancelled_by_user_id')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->jsonb('deletion_manifest')->nullable(); + $table->timestamps(); + + $table->index(['status', 'scheduled_deletion_at']); + $table->index(['account_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('account_deletion_requests'); + } +}; diff --git a/backend/lang/de.json b/backend/lang/de.json index 0a4691ebea..e877005a54 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -663,5 +663,32 @@ "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", "Review Message": "", - "(deactivated)": "(deaktiviert)" -} + "(deactivated)": "(deaktiviert)", + "Account deletion has already been requested.": "Die Kontolöschung wurde bereits beantragt.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Die folgenden bevorstehenden Veranstaltungen haben abgeschlossene Bestellungen: :events. Bitte stornieren und erstatten Sie diese Bestellungen, bevor Sie Ihr Konto löschen.", + "There is no pending deletion request for this account.": "Für dieses Konto liegt kein ausstehender Löschantrag vor.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Dieses Konto ist zur Löschung vorgemerkt. Brechen Sie den Löschantrag ab, um es weiter zu nutzen.", + "Only the account owner can request account deletion.": "Nur der Kontoinhaber kann die Kontolöschung beantragen.", + "The confirmation does not match your account name.": "Die Bestätigung stimmt nicht mit Ihrem Kontonamen überein.", + "Only pending deletion requests can be executed.": "Nur ausstehende Löschanträge können ausgeführt werden.", + "Account deletion has been queued for execution.": "Die Kontolöschung wurde zur Ausführung eingereiht.", + "Your :app_name account is scheduled for deletion": "Ihr :app_name-Konto ist zur Löschung vorgemerkt", + "Your :app_name account deletion has been cancelled": "Die Löschung Ihres :app_name-Kontos wurde abgebrochen", + "Reminder: your :app_name account will be deleted on :date": "Erinnerung: Ihr :app_name-Konto wird am :date gelöscht", + "Your :app_name account has been deleted": "Ihr :app_name-Konto wurde gelöscht", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Wir haben einen Antrag auf Löschung Ihres :appName-Kontos erhalten. Ihr Konto wurde deaktiviert und wird am :date endgültig gelöscht.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Alle Ihre veröffentlichten Veranstaltungen wurden zurückgezogen. Wenn Sie die Löschung abbrechen, müssen Sie sie manuell erneut veröffentlichen.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Da Ihr Konto abgeschlossene Bestellungen hat, werden Transaktionsdaten (Beträge, Daten und Rechnungsdetails) in anonymisierter Form für rechtliche und steuerliche Zwecke aufbewahrt. Alle personenbezogenen Daten werden dauerhaft entfernt.", + "Your account and all of its data will be permanently deleted.": "Ihr Konto und alle zugehörigen Daten werden dauerhaft gelöscht.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Wenn Sie dies nicht beantragt haben oder Ihre Meinung ändern, können Sie die Löschung jederzeit vor dem :date abbrechen:", + "Cancel Account Deletion": "Kontolöschung abbrechen", + "The deletion of your :appName account has been cancelled. Your account is active again.": "Die Löschung Ihres :appName-Kontos wurde abgebrochen. Ihr Konto ist wieder aktiv.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Bitte beachten Sie, dass Ihre Veranstaltungen bei der Beantragung der Löschung zurückgezogen wurden. Sie müssen alle Veranstaltungen, die wieder öffentlich sichtbar sein sollen, erneut veröffentlichen.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Dies ist eine Erinnerung, dass Ihr :appName-Konto am :date endgültig gelöscht wird.", + "If you want to keep your account, cancel the deletion before this date:": "Wenn Sie Ihr Konto behalten möchten, brechen Sie die Löschung vor diesem Datum ab:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Wenn Sie nichts unternehmen, wird die Löschung automatisch durchgeführt und kann nicht rückgängig gemacht werden.", + "Your :appName account has been permanently deleted.": "Ihr :appName-Konto wurde endgültig gelöscht.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Alle personenbezogenen Daten wurden entfernt. Anonymisierte Transaktionsdaten (Beträge, Daten und Rechnungsdetails) wurden gemäß rechtlichen und steuerlichen Anforderungen aufbewahrt.", + "All of your account data has been permanently removed.": "Alle Ihre Kontodaten wurden dauerhaft entfernt.", + "Thank you for using :appName. You are welcome back at any time.": "Vielen Dank, dass Sie :appName genutzt haben. Sie sind jederzeit wieder willkommen." +} \ No newline at end of file diff --git a/backend/lang/el.json b/backend/lang/el.json index 8e5e5e1fc5..c926de8023 100644 --- a/backend/lang/el.json +++ b/backend/lang/el.json @@ -663,5 +663,32 @@ "No completed paid orders on this account": "Δεν υπάρχουν ολοκληρωμένες πληρωμένες παραγγελίες σε αυτόν τον λογαριασμό", "Event was created less than 24 hours ago": "Η εκδήλωση δημιουργήθηκε πριν από λιγότερο από 24 ώρες", "Review Message": "Αξιολόγηση Μηνύματος", - "(deactivated)": "(απενεργοποιημένος)" -} + "(deactivated)": "(απενεργοποιημένος)", + "Account deletion has already been requested.": "Η διαγραφή λογαριασμού έχει ήδη ζητηθεί.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Οι ακόλουθες επερχόμενες εκδηλώσεις έχουν ολοκληρωμένες παραγγελίες: :events. Ακυρώστε και επιστρέψτε τα χρήματα αυτών των παραγγελιών πριν διαγράψετε τον λογαριασμό σας.", + "There is no pending deletion request for this account.": "Δεν υπάρχει εκκρεμές αίτημα διαγραφής για αυτόν τον λογαριασμό.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Αυτός ο λογαριασμός έχει προγραμματιστεί για διαγραφή. Ακυρώστε το αίτημα διαγραφής για να συνεχίσετε να τον χρησιμοποιείτε.", + "Only the account owner can request account deletion.": "Μόνο ο κάτοχος του λογαριασμού μπορεί να ζητήσει διαγραφή λογαριασμού.", + "The confirmation does not match your account name.": "Η επιβεβαίωση δεν ταιριάζει με το όνομα του λογαριασμού σας.", + "Only pending deletion requests can be executed.": "Μόνο εκκρεμή αιτήματα διαγραφής μπορούν να εκτελεστούν.", + "Account deletion has been queued for execution.": "Η διαγραφή λογαριασμού τέθηκε σε ουρά για εκτέλεση.", + "Your :app_name account is scheduled for deletion": "Ο λογαριασμός σας στο :app_name έχει προγραμματιστεί για διαγραφή", + "Your :app_name account deletion has been cancelled": "Η διαγραφή του λογαριασμού σας στο :app_name ακυρώθηκε", + "Reminder: your :app_name account will be deleted on :date": "Υπενθύμιση: ο λογαριασμός σας στο :app_name θα διαγραφεί στις :date", + "Your :app_name account has been deleted": "Ο λογαριασμός σας στο :app_name διαγράφηκε", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Λάβαμε αίτημα διαγραφής του λογαριασμού σας στο :appName. Ο λογαριασμός σας απενεργοποιήθηκε και έχει προγραμματιστεί για οριστική διαγραφή στις :date.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Όλες οι δημοσιευμένες εκδηλώσεις σας αποσύρθηκαν. Εάν ακυρώσετε τη διαγραφή, θα χρειαστεί να τις δημοσιεύσετε ξανά χειροκίνητα.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Επειδή ο λογαριασμός σας έχει ολοκληρωμένες παραγγελίες, τα αρχεία συναλλαγών (ποσά, ημερομηνίες και στοιχεία τιμολογίων) θα διατηρηθούν σε ανωνυμοποιημένη μορφή για νομικούς και φορολογικούς σκοπούς. Όλα τα προσωπικά δεδομένα θα διαγραφούν οριστικά.", + "Your account and all of its data will be permanently deleted.": "Ο λογαριασμός σας και όλα τα δεδομένα του θα διαγραφούν οριστικά.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Εάν δεν το ζητήσατε εσείς ή αλλάξετε γνώμη, μπορείτε να ακυρώσετε τη διαγραφή οποιαδήποτε στιγμή πριν από τις :date:", + "Cancel Account Deletion": "Ακύρωση διαγραφής λογαριασμού", + "The deletion of your :appName account has been cancelled. Your account is active again.": "Η διαγραφή του λογαριασμού σας στο :appName ακυρώθηκε. Ο λογαριασμός σας είναι ξανά ενεργός.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Λάβετε υπόψη ότι οι εκδηλώσεις σας αποσύρθηκαν όταν ζητήθηκε η διαγραφή. Θα χρειαστεί να δημοσιεύσετε ξανά όσες εκδηλώσεις θέλετε να είναι ξανά δημόσια ορατές.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Αυτή είναι μια υπενθύμιση ότι ο λογαριασμός σας στο :appName έχει προγραμματιστεί για οριστική διαγραφή στις :date.", + "If you want to keep your account, cancel the deletion before this date:": "Εάν θέλετε να κρατήσετε τον λογαριασμό σας, ακυρώστε τη διαγραφή πριν από αυτήν την ημερομηνία:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Εάν δεν προβείτε σε καμία ενέργεια, η διαγραφή θα προχωρήσει αυτόματα και δεν μπορεί να αναιρεθεί.", + "Your :appName account has been permanently deleted.": "Ο λογαριασμός σας στο :appName διαγράφηκε οριστικά.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Όλα τα προσωπικά δεδομένα έχουν διαγραφεί. Ανωνυμοποιημένα αρχεία συναλλαγών (ποσά, ημερομηνίες και στοιχεία τιμολογίων) έχουν διατηρηθεί όπως απαιτείται για νομικούς και φορολογικούς σκοπούς.", + "All of your account data has been permanently removed.": "Όλα τα δεδομένα του λογαριασμού σας έχουν διαγραφεί οριστικά.", + "Thank you for using :appName. You are welcome back at any time.": "Ευχαριστούμε που χρησιμοποιήσατε το :appName. Είστε ευπρόσδεκτοι ξανά οποιαδήποτε στιγμή." +} \ No newline at end of file diff --git a/backend/lang/es.json b/backend/lang/es.json index fe09fc0b0d..e021112208 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -708,5 +708,32 @@ "You cannot archive the last active organizer on your account.": "No puedes archivar el último organizador activo de tu cuenta.", "You cannot delete the last organizer on your account.": "No puedes eliminar el último organizador de tu cuenta.", "You must acknowledge your data controller responsibilities before enabling tracking pixels.": "Debes reconocer tus responsabilidades como responsable del tratamiento de datos antes de habilitar los píxeles de seguimiento.", - "(deactivated)": "(desactivado)" -} + "(deactivated)": "(desactivado)", + "Account deletion has already been requested.": "La eliminación de la cuenta ya ha sido solicitada.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Los siguientes eventos próximos tienen pedidos completados: :events. Cancela y reembolsa estos pedidos antes de eliminar tu cuenta.", + "There is no pending deletion request for this account.": "No hay ninguna solicitud de eliminación pendiente para esta cuenta.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Esta cuenta está programada para su eliminación. Cancela la solicitud de eliminación para seguir usándola.", + "Only the account owner can request account deletion.": "Solo el propietario de la cuenta puede solicitar la eliminación de la cuenta.", + "The confirmation does not match your account name.": "La confirmación no coincide con el nombre de tu cuenta.", + "Only pending deletion requests can be executed.": "Solo se pueden ejecutar solicitudes de eliminación pendientes.", + "Account deletion has been queued for execution.": "La eliminación de la cuenta se ha puesto en cola para su ejecución.", + "Your :app_name account is scheduled for deletion": "Tu cuenta de :app_name está programada para su eliminación", + "Your :app_name account deletion has been cancelled": "La eliminación de tu cuenta de :app_name ha sido cancelada", + "Reminder: your :app_name account will be deleted on :date": "Recordatorio: tu cuenta de :app_name se eliminará el :date", + "Your :app_name account has been deleted": "Tu cuenta de :app_name ha sido eliminada", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Hemos recibido una solicitud para eliminar tu cuenta de :appName. Tu cuenta ha sido desactivada y está programada para su eliminación permanente el :date.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Todos tus eventos publicados han sido despublicados. Si cancelas la eliminación, tendrás que volver a publicarlos manualmente.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Debido a que tu cuenta tiene pedidos completados, los registros de transacciones (importes, fechas y detalles de facturas) se conservarán de forma anonimizada por motivos legales y fiscales. Toda la información personal se eliminará de forma permanente.", + "Your account and all of its data will be permanently deleted.": "Tu cuenta y todos sus datos se eliminarán de forma permanente.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Si no solicitaste esto, o cambias de opinión, puedes cancelar la eliminación en cualquier momento antes del :date:", + "Cancel Account Deletion": "Cancelar eliminación de la cuenta", + "The deletion of your :appName account has been cancelled. Your account is active again.": "La eliminación de tu cuenta de :appName ha sido cancelada. Tu cuenta está activa de nuevo.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Ten en cuenta que tus eventos se despublicaron cuando se solicitó la eliminación. Tendrás que volver a publicar los eventos que quieras que vuelvan a ser visibles públicamente.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Este es un recordatorio de que tu cuenta de :appName está programada para su eliminación permanente el :date.", + "If you want to keep your account, cancel the deletion before this date:": "Si quieres conservar tu cuenta, cancela la eliminación antes de esta fecha:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Si no haces nada, la eliminación se realizará automáticamente y no se podrá deshacer.", + "Your :appName account has been permanently deleted.": "Tu cuenta de :appName ha sido eliminada permanentemente.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Toda la información personal ha sido eliminada. Los registros de transacciones anonimizados (importes, fechas y detalles de facturas) se han conservado según lo exigido por motivos legales y fiscales.", + "All of your account data has been permanently removed.": "Todos los datos de tu cuenta han sido eliminados permanentemente.", + "Thank you for using :appName. You are welcome back at any time.": "Gracias por usar :appName. Eres bienvenido de nuevo en cualquier momento." +} \ No newline at end of file diff --git a/backend/lang/fr.json b/backend/lang/fr.json index ed79d7db1c..ee23787422 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -663,5 +663,32 @@ "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", "Review Message": "", - "(deactivated)": "(désactivé)" -} + "(deactivated)": "(désactivé)", + "Account deletion has already been requested.": "La suppression du compte a déjà été demandée.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Les événements à venir suivants ont des commandes finalisées : :events. Veuillez annuler et rembourser ces commandes avant de supprimer votre compte.", + "There is no pending deletion request for this account.": "Il n'y a aucune demande de suppression en attente pour ce compte.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Ce compte est programmé pour suppression. Annulez la demande de suppression pour continuer à l'utiliser.", + "Only the account owner can request account deletion.": "Seul le propriétaire du compte peut demander la suppression du compte.", + "The confirmation does not match your account name.": "La confirmation ne correspond pas au nom de votre compte.", + "Only pending deletion requests can be executed.": "Seules les demandes de suppression en attente peuvent être exécutées.", + "Account deletion has been queued for execution.": "La suppression du compte a été mise en file d'attente pour exécution.", + "Your :app_name account is scheduled for deletion": "Votre compte :app_name est programmé pour suppression", + "Your :app_name account deletion has been cancelled": "La suppression de votre compte :app_name a été annulée", + "Reminder: your :app_name account will be deleted on :date": "Rappel : votre compte :app_name sera supprimé le :date", + "Your :app_name account has been deleted": "Votre compte :app_name a été supprimé", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Nous avons reçu une demande de suppression de votre compte :appName. Votre compte a été désactivé et sera définitivement supprimé le :date.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Tous vos événements publiés ont été dépubliés. Si vous annulez la suppression, vous devrez les republier manuellement.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Comme votre compte comporte des commandes finalisées, les enregistrements de transactions (montants, dates et détails de factures) seront conservés sous forme anonymisée à des fins légales et fiscales. Toutes les informations personnelles seront définitivement supprimées.", + "Your account and all of its data will be permanently deleted.": "Votre compte et toutes ses données seront définitivement supprimés.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Si vous n'êtes pas à l'origine de cette demande ou si vous changez d'avis, vous pouvez annuler la suppression à tout moment avant le :date :", + "Cancel Account Deletion": "Annuler la suppression du compte", + "The deletion of your :appName account has been cancelled. Your account is active again.": "La suppression de votre compte :appName a été annulée. Votre compte est de nouveau actif.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Veuillez noter que vos événements ont été dépubliés lors de la demande de suppression. Vous devrez republier les événements que vous souhaitez rendre à nouveau visibles publiquement.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Ceci est un rappel : votre compte :appName sera définitivement supprimé le :date.", + "If you want to keep your account, cancel the deletion before this date:": "Si vous souhaitez conserver votre compte, annulez la suppression avant cette date :", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Si vous ne faites rien, la suppression se poursuivra automatiquement et ne pourra pas être annulée.", + "Your :appName account has been permanently deleted.": "Votre compte :appName a été définitivement supprimé.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Toutes les informations personnelles ont été supprimées. Des enregistrements de transactions anonymisés (montants, dates et détails de factures) ont été conservés conformément aux exigences légales et fiscales.", + "All of your account data has been permanently removed.": "Toutes les données de votre compte ont été définitivement supprimées.", + "Thank you for using :appName. You are welcome back at any time.": "Merci d'avoir utilisé :appName. Vous êtes le bienvenu à tout moment." +} \ No newline at end of file diff --git a/backend/lang/hu.json b/backend/lang/hu.json index 7217c84594..dc0f0a6bdc 100644 --- a/backend/lang/hu.json +++ b/backend/lang/hu.json @@ -663,5 +663,32 @@ "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", "Review Message": "", - "(deactivated)": "(deaktivált)" -} + "(deactivated)": "(deaktivált)", + "Account deletion has already been requested.": "A fiók törlését már kérelmezték.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "A következő közelgő eseményekhez teljesített rendelések tartoznak: :events. A fiók törlése előtt mondja le és térítse vissza ezeket a rendeléseket.", + "There is no pending deletion request for this account.": "Ehhez a fiókhoz nem tartozik függőben lévő törlési kérelem.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Ez a fiók törlésre van ütemezve. A további használathoz szakítsa meg a törlési kérelmet.", + "Only the account owner can request account deletion.": "Csak a fiók tulajdonosa kérheti a fiók törlését.", + "The confirmation does not match your account name.": "A megerősítés nem egyezik a fiók nevével.", + "Only pending deletion requests can be executed.": "Csak függőben lévő törlési kérelmek hajthatók végre.", + "Account deletion has been queued for execution.": "A fiók törlése végrehajtásra ütemezve.", + "Your :app_name account is scheduled for deletion": "Az Ön :app_name fiókja törlésre van ütemezve", + "Your :app_name account deletion has been cancelled": "Az Ön :app_name fiókjának törlése megszakítva", + "Reminder: your :app_name account will be deleted on :date": "Emlékeztető: az Ön :app_name fiókja :date napján törlésre kerül", + "Your :app_name account has been deleted": "Az Ön :app_name fiókja törölve lett", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Kérelmet kaptunk az Ön :appName fiókjának törlésére. Fiókja deaktiválva lett, és :date napján véglegesen törlésre kerül.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Minden közzétett eseménye visszavonásra került. Ha megszakítja a törlést, manuálisan kell újra közzétennie őket.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Mivel fiókjához teljesített rendelések tartoznak, a tranzakciós adatok (összegek, dátumok és számlaadatok) anonimizált formában megőrzésre kerülnek jogi és adózási célokból. Minden személyes adat véglegesen törlésre kerül.", + "Your account and all of its data will be permanently deleted.": "Fiókja és annak minden adata véglegesen törlésre kerül.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Ha nem Ön kérte, vagy meggondolja magát, a törlést bármikor megszakíthatja :date előtt:", + "Cancel Account Deletion": "Fióktörlés megszakítása", + "The deletion of your :appName account has been cancelled. Your account is active again.": "Az Ön :appName fiókjának törlése megszakítva. Fiókja újra aktív.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Felhívjuk figyelmét, hogy eseményei a törlési kérelem benyújtásakor visszavonásra kerültek. Újra közzé kell tennie azokat az eseményeket, amelyeket ismét nyilvánosan láthatóvá szeretne tenni.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Emlékeztetjük, hogy az Ön :appName fiókja :date napján véglegesen törlésre kerül.", + "If you want to keep your account, cancel the deletion before this date:": "Ha meg szeretné tartani fiókját, szakítsa meg a törlést e dátum előtt:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Ha nem tesz semmit, a törlés automatikusan megtörténik, és nem vonható vissza.", + "Your :appName account has been permanently deleted.": "Az Ön :appName fiókja véglegesen törölve lett.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Minden személyes adat törlésre került. Az anonimizált tranzakciós adatok (összegek, dátumok és számlaadatok) jogi és adózási követelményeknek megfelelően megőrzésre kerültek.", + "All of your account data has been permanently removed.": "Fiókjának minden adata véglegesen törlésre került.", + "Thank you for using :appName. You are welcome back at any time.": "Köszönjük, hogy a :appName szolgáltatást használta. Bármikor szívesen látjuk újra." +} \ No newline at end of file diff --git a/backend/lang/it.json b/backend/lang/it.json index 0e968fb12f..8a114f7f39 100644 --- a/backend/lang/it.json +++ b/backend/lang/it.json @@ -664,5 +664,32 @@ "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", "Review Message": "", - "(deactivated)": "(disattivato)" -} + "(deactivated)": "(disattivato)", + "Account deletion has already been requested.": "L'eliminazione dell'account è già stata richiesta.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "I seguenti eventi imminenti hanno ordini completati: :events. Annulla e rimborsa questi ordini prima di eliminare il tuo account.", + "There is no pending deletion request for this account.": "Non ci sono richieste di eliminazione in sospeso per questo account.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Questo account è pianificato per l'eliminazione. Annulla la richiesta di eliminazione per continuare a usarlo.", + "Only the account owner can request account deletion.": "Solo il proprietario dell'account può richiedere l'eliminazione dell'account.", + "The confirmation does not match your account name.": "La conferma non corrisponde al nome del tuo account.", + "Only pending deletion requests can be executed.": "Solo le richieste di eliminazione in sospeso possono essere eseguite.", + "Account deletion has been queued for execution.": "L'eliminazione dell'account è stata messa in coda per l'esecuzione.", + "Your :app_name account is scheduled for deletion": "Il tuo account :app_name è pianificato per l'eliminazione", + "Your :app_name account deletion has been cancelled": "L'eliminazione del tuo account :app_name è stata annullata", + "Reminder: your :app_name account will be deleted on :date": "Promemoria: il tuo account :app_name sarà eliminato il :date", + "Your :app_name account has been deleted": "Il tuo account :app_name è stato eliminato", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Abbiamo ricevuto una richiesta di eliminazione del tuo account :appName. Il tuo account è stato disattivato ed è pianificato per l'eliminazione permanente il :date.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Tutti i tuoi eventi pubblicati sono stati rimossi dalla pubblicazione. Se annulli l'eliminazione, dovrai ripubblicarli manualmente.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Poiché il tuo account ha ordini completati, i record delle transazioni (importi, date e dettagli delle fatture) saranno conservati in forma anonimizzata per scopi legali e fiscali. Tutte le informazioni personali saranno rimosse in modo permanente.", + "Your account and all of its data will be permanently deleted.": "Il tuo account e tutti i suoi dati saranno eliminati in modo permanente.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Se non hai richiesto tu questa operazione, o cambi idea, puoi annullare l'eliminazione in qualsiasi momento prima del :date:", + "Cancel Account Deletion": "Annulla eliminazione account", + "The deletion of your :appName account has been cancelled. Your account is active again.": "L'eliminazione del tuo account :appName è stata annullata. Il tuo account è di nuovo attivo.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Tieni presente che i tuoi eventi sono stati rimossi dalla pubblicazione quando è stata richiesta l'eliminazione. Dovrai ripubblicare gli eventi che desideri rendere di nuovo visibili pubblicamente.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Questo è un promemoria che il tuo account :appName è pianificato per l'eliminazione permanente il :date.", + "If you want to keep your account, cancel the deletion before this date:": "Se vuoi mantenere il tuo account, annulla l'eliminazione prima di questa data:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Se non intervieni, l'eliminazione procederà automaticamente e non potrà essere annullata.", + "Your :appName account has been permanently deleted.": "Il tuo account :appName è stato eliminato in modo permanente.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Tutte le informazioni personali sono state rimosse. I record delle transazioni anonimizzati (importi, date e dettagli delle fatture) sono stati conservati come richiesto per scopi legali e fiscali.", + "All of your account data has been permanently removed.": "Tutti i dati del tuo account sono stati rimossi in modo permanente.", + "Thank you for using :appName. You are welcome back at any time.": "Grazie per aver usato :appName. Sarai sempre il benvenuto." +} \ No newline at end of file diff --git a/backend/lang/nl.json b/backend/lang/nl.json index c702833781..152295f4b5 100644 --- a/backend/lang/nl.json +++ b/backend/lang/nl.json @@ -663,5 +663,32 @@ "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", "Review Message": "", - "(deactivated)": "(gedeactiveerd)" -} + "(deactivated)": "(gedeactiveerd)", + "Account deletion has already been requested.": "Accountverwijdering is al aangevraagd.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "De volgende aankomende evenementen hebben voltooide bestellingen: :events. Annuleer en restitueer deze bestellingen voordat je je account verwijdert.", + "There is no pending deletion request for this account.": "Er is geen openstaand verwijderingsverzoek voor dit account.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Dit account staat gepland voor verwijdering. Annuleer het verwijderingsverzoek om het te blijven gebruiken.", + "Only the account owner can request account deletion.": "Alleen de accounteigenaar kan accountverwijdering aanvragen.", + "The confirmation does not match your account name.": "De bevestiging komt niet overeen met je accountnaam.", + "Only pending deletion requests can be executed.": "Alleen openstaande verwijderingsverzoeken kunnen worden uitgevoerd.", + "Account deletion has been queued for execution.": "Accountverwijdering is in de wachtrij gezet voor uitvoering.", + "Your :app_name account is scheduled for deletion": "Je :app_name-account staat gepland voor verwijdering", + "Your :app_name account deletion has been cancelled": "De verwijdering van je :app_name-account is geannuleerd", + "Reminder: your :app_name account will be deleted on :date": "Herinnering: je :app_name-account wordt verwijderd op :date", + "Your :app_name account has been deleted": "Je :app_name-account is verwijderd", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "We hebben een verzoek ontvangen om je :appName-account te verwijderen. Je account is gedeactiveerd en wordt permanent verwijderd op :date.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Al je gepubliceerde evenementen zijn gedepubliceerd. Als je de verwijdering annuleert, moet je ze handmatig opnieuw publiceren.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Omdat je account voltooide bestellingen heeft, worden transactiegegevens (bedragen, datums en factuurgegevens) in geanonimiseerde vorm bewaard voor juridische en fiscale doeleinden. Alle persoonlijke gegevens worden permanent verwijderd.", + "Your account and all of its data will be permanently deleted.": "Je account en alle bijbehorende gegevens worden permanent verwijderd.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Als je dit niet hebt aangevraagd, of je van gedachten verandert, kun je de verwijdering op elk moment vóór :date annuleren:", + "Cancel Account Deletion": "Accountverwijdering annuleren", + "The deletion of your :appName account has been cancelled. Your account is active again.": "De verwijdering van je :appName-account is geannuleerd. Je account is weer actief.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Houd er rekening mee dat je evenementen zijn gedepubliceerd toen de verwijdering werd aangevraagd. Je moet evenementen die je weer openbaar zichtbaar wilt maken opnieuw publiceren.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Dit is een herinnering dat je :appName-account gepland staat voor permanente verwijdering op :date.", + "If you want to keep your account, cancel the deletion before this date:": "Als je je account wilt behouden, annuleer dan de verwijdering vóór deze datum:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Als je geen actie onderneemt, wordt de verwijdering automatisch uitgevoerd en kan deze niet ongedaan worden gemaakt.", + "Your :appName account has been permanently deleted.": "Je :appName-account is permanent verwijderd.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Alle persoonlijke gegevens zijn verwijderd. Geanonimiseerde transactiegegevens (bedragen, datums en factuurgegevens) zijn bewaard zoals vereist voor juridische en fiscale doeleinden.", + "All of your account data has been permanently removed.": "Alle gegevens van je account zijn permanent verwijderd.", + "Thank you for using :appName. You are welcome back at any time.": "Bedankt voor het gebruik van :appName. Je bent altijd weer welkom." +} \ No newline at end of file diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 8f11077795..8b94c451bd 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -663,5 +663,32 @@ "No completed paid orders on this account": "Brak zakończonych płatnych zamówień na tym koncie", "Event was created less than 24 hours ago": "Wydarzenie zostało utworzone mniej niż 24 godziny temu", "Review Message": "Przejrzyj wiadomość", - "(deactivated)": "(dezaktywowany)" -} + "(deactivated)": "(dezaktywowany)", + "Account deletion has already been requested.": "Usunięcie konta zostało już zgłoszone.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Następujące nadchodzące wydarzenia mają zrealizowane zamówienia: :events. Anuluj te zamówienia i zwróć środki przed usunięciem konta.", + "There is no pending deletion request for this account.": "Brak oczekującego żądania usunięcia dla tego konta.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "To konto jest zaplanowane do usunięcia. Anuluj żądanie usunięcia, aby nadal z niego korzystać.", + "Only the account owner can request account deletion.": "Tylko właściciel konta może zażądać usunięcia konta.", + "The confirmation does not match your account name.": "Potwierdzenie nie pasuje do nazwy Twojego konta.", + "Only pending deletion requests can be executed.": "Można wykonać tylko oczekujące żądania usunięcia.", + "Account deletion has been queued for execution.": "Usunięcie konta zostało dodane do kolejki wykonania.", + "Your :app_name account is scheduled for deletion": "Twoje konto :app_name jest zaplanowane do usunięcia", + "Your :app_name account deletion has been cancelled": "Usunięcie Twojego konta :app_name zostało anulowane", + "Reminder: your :app_name account will be deleted on :date": "Przypomnienie: Twoje konto :app_name zostanie usunięte dnia :date", + "Your :app_name account has been deleted": "Twoje konto :app_name zostało usunięte", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Otrzymaliśmy żądanie usunięcia Twojego konta :appName. Twoje konto zostało dezaktywowane i zostanie trwale usunięte dnia :date.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Wszystkie Twoje opublikowane wydarzenia zostały wycofane. Jeśli anulujesz usunięcie, będziesz musiał opublikować je ponownie ręcznie.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Ponieważ Twoje konto ma zrealizowane zamówienia, zapisy transakcji (kwoty, daty i szczegóły faktur) zostaną zachowane w formie zanonimizowanej do celów prawnych i podatkowych. Wszystkie dane osobowe zostaną trwale usunięte.", + "Your account and all of its data will be permanently deleted.": "Twoje konto i wszystkie jego dane zostaną trwale usunięte.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Jeśli to nie Ty złożyłeś to żądanie lub zmienisz zdanie, możesz anulować usunięcie w dowolnym momencie przed :date:", + "Cancel Account Deletion": "Anuluj usunięcie konta", + "The deletion of your :appName account has been cancelled. Your account is active again.": "Usunięcie Twojego konta :appName zostało anulowane. Twoje konto jest znowu aktywne.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Pamiętaj, że Twoje wydarzenia zostały wycofane w momencie zgłoszenia żądania usunięcia. Musisz ponownie opublikować wydarzenia, które mają być znowu widoczne publicznie.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "To przypomnienie, że Twoje konto :appName zostanie trwale usunięte dnia :date.", + "If you want to keep your account, cancel the deletion before this date:": "Jeśli chcesz zachować konto, anuluj usunięcie przed tą datą:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Jeśli nie podejmiesz żadnych działań, usunięcie nastąpi automatycznie i nie będzie można go cofnąć.", + "Your :appName account has been permanently deleted.": "Twoje konto :appName zostało trwale usunięte.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Wszystkie dane osobowe zostały usunięte. Zanonimizowane zapisy transakcji (kwoty, daty i szczegóły faktur) zostały zachowane zgodnie z wymogami prawnymi i podatkowymi.", + "All of your account data has been permanently removed.": "Wszystkie dane Twojego konta zostały trwale usunięte.", + "Thank you for using :appName. You are welcome back at any time.": "Dziękujemy za korzystanie z :appName. Zapraszamy ponownie w każdej chwili." +} \ No newline at end of file diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index 1662004fc9..0a7c4e0ee1 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -663,5 +663,32 @@ "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", "Review Message": "", - "(deactivated)": "(desativado)" -} + "(deactivated)": "(desativado)", + "Account deletion has already been requested.": "A exclusão da conta já foi solicitada.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Os seguintes eventos futuros possuem pedidos concluídos: :events. Cancele e reembolse esses pedidos antes de excluir sua conta.", + "There is no pending deletion request for this account.": "Não há solicitação de exclusão pendente para esta conta.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Esta conta está agendada para exclusão. Cancele a solicitação de exclusão para continuar a usá-la.", + "Only the account owner can request account deletion.": "Apenas o proprietário da conta pode solicitar a exclusão da conta.", + "The confirmation does not match your account name.": "A confirmação não corresponde ao nome da sua conta.", + "Only pending deletion requests can be executed.": "Apenas solicitações de exclusão pendentes podem ser executadas.", + "Account deletion has been queued for execution.": "A exclusão da conta foi colocada na fila para execução.", + "Your :app_name account is scheduled for deletion": "Sua conta :app_name está agendada para exclusão", + "Your :app_name account deletion has been cancelled": "A exclusão da sua conta :app_name foi cancelada", + "Reminder: your :app_name account will be deleted on :date": "Lembrete: sua conta :app_name será excluída em :date", + "Your :app_name account has been deleted": "Sua conta :app_name foi excluída", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Recebemos uma solicitação para excluir sua conta :appName. Sua conta foi desativada e está agendada para exclusão permanente em :date.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Todos os seus eventos publicados foram despublicados. Se você cancelar a exclusão, precisará republicá-los manualmente.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Como sua conta possui pedidos concluídos, os registros de transações (valores, datas e detalhes de faturas) serão mantidos de forma anonimizada para fins legais e fiscais. Todas as informações pessoais serão removidas permanentemente.", + "Your account and all of its data will be permanently deleted.": "Sua conta e todos os seus dados serão excluídos permanentemente.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Se você não solicitou isso, ou mudar de ideia, pode cancelar a exclusão a qualquer momento antes de :date:", + "Cancel Account Deletion": "Cancelar exclusão da conta", + "The deletion of your :appName account has been cancelled. Your account is active again.": "A exclusão da sua conta :appName foi cancelada. Sua conta está ativa novamente.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Observe que seus eventos foram despublicados quando a exclusão foi solicitada. Você precisará republicar os eventos que deseja tornar publicamente visíveis novamente.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Este é um lembrete de que sua conta :appName está agendada para exclusão permanente em :date.", + "If you want to keep your account, cancel the deletion before this date:": "Se você quiser manter sua conta, cancele a exclusão antes desta data:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Se você não tomar nenhuma ação, a exclusão prosseguirá automaticamente e não poderá ser desfeita.", + "Your :appName account has been permanently deleted.": "Sua conta :appName foi excluída permanentemente.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Todas as informações pessoais foram removidas. Os registros de transações anonimizados (valores, datas e detalhes de faturas) foram mantidos conforme exigido para fins legais e fiscais.", + "All of your account data has been permanently removed.": "Todos os dados da sua conta foram removidos permanentemente.", + "Thank you for using :appName. You are welcome back at any time.": "Obrigado por usar o :appName. Você é bem-vindo de volta a qualquer momento." +} \ No newline at end of file diff --git a/backend/lang/pt.json b/backend/lang/pt.json index 496fe45871..28eb82a674 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -663,5 +663,32 @@ "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", "Review Message": "", - "(deactivated)": "(desativado)" -} + "(deactivated)": "(desativado)", + "Account deletion has already been requested.": "A eliminação da conta já foi pedida.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Os seguintes eventos futuros têm encomendas concluídas: :events. Cancele e reembolse estas encomendas antes de eliminar a sua conta.", + "There is no pending deletion request for this account.": "Não existe nenhum pedido de eliminação pendente para esta conta.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Esta conta está agendada para eliminação. Cancele o pedido de eliminação para continuar a utilizá-la.", + "Only the account owner can request account deletion.": "Apenas o proprietário da conta pode pedir a eliminação da conta.", + "The confirmation does not match your account name.": "A confirmação não corresponde ao nome da sua conta.", + "Only pending deletion requests can be executed.": "Apenas pedidos de eliminação pendentes podem ser executados.", + "Account deletion has been queued for execution.": "A eliminação da conta foi colocada em fila para execução.", + "Your :app_name account is scheduled for deletion": "A sua conta :app_name está agendada para eliminação", + "Your :app_name account deletion has been cancelled": "A eliminação da sua conta :app_name foi cancelada", + "Reminder: your :app_name account will be deleted on :date": "Lembrete: a sua conta :app_name será eliminada em :date", + "Your :app_name account has been deleted": "A sua conta :app_name foi eliminada", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Recebemos um pedido para eliminar a sua conta :appName. A sua conta foi desativada e está agendada para eliminação permanente em :date.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Todos os seus eventos publicados foram despublicados. Se cancelar a eliminação, terá de os republicar manualmente.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Como a sua conta tem encomendas concluídas, os registos de transações (montantes, datas e detalhes de faturas) serão mantidos de forma anonimizada para fins legais e fiscais. Todas as informações pessoais serão removidas permanentemente.", + "Your account and all of its data will be permanently deleted.": "A sua conta e todos os seus dados serão eliminados permanentemente.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Se não pediu isto, ou mudar de ideias, pode cancelar a eliminação a qualquer momento antes de :date:", + "Cancel Account Deletion": "Cancelar eliminação da conta", + "The deletion of your :appName account has been cancelled. Your account is active again.": "A eliminação da sua conta :appName foi cancelada. A sua conta está novamente ativa.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Tenha em atenção que os seus eventos foram despublicados quando a eliminação foi pedida. Terá de republicar os eventos que pretende tornar novamente visíveis publicamente.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Este é um lembrete de que a sua conta :appName está agendada para eliminação permanente em :date.", + "If you want to keep your account, cancel the deletion before this date:": "Se quiser manter a sua conta, cancele a eliminação antes desta data:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Se não fizer nada, a eliminação prosseguirá automaticamente e não poderá ser anulada.", + "Your :appName account has been permanently deleted.": "A sua conta :appName foi eliminada permanentemente.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Todas as informações pessoais foram removidas. Os registos de transações anonimizados (montantes, datas e detalhes de faturas) foram mantidos conforme exigido para fins legais e fiscais.", + "All of your account data has been permanently removed.": "Todos os dados da sua conta foram removidos permanentemente.", + "Thank you for using :appName. You are welcome back at any time.": "Obrigado por utilizar o :appName. Será sempre bem-vindo de volta." +} \ No newline at end of file diff --git a/backend/lang/se.json b/backend/lang/se.json index 07a8324500..f7201353cc 100644 --- a/backend/lang/se.json +++ b/backend/lang/se.json @@ -491,5 +491,32 @@ "Reply to :name": "Svara till :name", "This message was sent via your organizer contact form.": "Detta meddelande skickades via ditt kontaktformulär för arrangörer.", "Your email confirmation code is:": "Din e-postbekräftelsekod är:", - "(deactivated)": "(avaktiverad)" -} + "(deactivated)": "(avaktiverad)", + "Account deletion has already been requested.": "Kontoradering har redan begärts.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Följande kommande evenemang har slutförda beställningar: :events. Avboka och återbetala dessa beställningar innan du raderar ditt konto.", + "There is no pending deletion request for this account.": "Det finns ingen väntande raderingsbegäran för detta konto.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Detta konto är schemalagt för radering. Avbryt raderingsbegäran för att fortsätta använda det.", + "Only the account owner can request account deletion.": "Endast kontoägaren kan begära kontoradering.", + "The confirmation does not match your account name.": "Bekräftelsen matchar inte ditt kontonamn.", + "Only pending deletion requests can be executed.": "Endast väntande raderingsbegäranden kan verkställas.", + "Account deletion has been queued for execution.": "Kontoradering har köats för verkställande.", + "Your :app_name account is scheduled for deletion": "Ditt :app_name-konto är schemalagt för radering", + "Your :app_name account deletion has been cancelled": "Raderingen av ditt :app_name-konto har avbrutits", + "Reminder: your :app_name account will be deleted on :date": "Påminnelse: ditt :app_name-konto raderas den :date", + "Your :app_name account has been deleted": "Ditt :app_name-konto har raderats", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Vi har mottagit en begäran om att radera ditt :appName-konto. Ditt konto har inaktiverats och raderas permanent den :date.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Alla dina publicerade evenemang har avpublicerats. Om du avbryter raderingen måste du publicera dem igen manuellt.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Eftersom ditt konto har slutförda beställningar kommer transaktionsuppgifter (belopp, datum och fakturadetaljer) att bevaras i anonymiserad form av juridiska och skattemässiga skäl. All personlig information tas bort permanent.", + "Your account and all of its data will be permanently deleted.": "Ditt konto och all dess data raderas permanent.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Om du inte begärde detta, eller ändrar dig, kan du avbryta raderingen när som helst före :date:", + "Cancel Account Deletion": "Avbryt kontoradering", + "The deletion of your :appName account has been cancelled. Your account is active again.": "Raderingen av ditt :appName-konto har avbrutits. Ditt konto är aktivt igen.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Observera att dina evenemang avpublicerades när raderingen begärdes. Du måste publicera om de evenemang du vill göra offentligt synliga igen.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Detta är en påminnelse om att ditt :appName-konto är schemalagt för permanent radering den :date.", + "If you want to keep your account, cancel the deletion before this date:": "Om du vill behålla ditt konto, avbryt raderingen före detta datum:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Om du inte gör något kommer raderingen att genomföras automatiskt och kan inte ångras.", + "Your :appName account has been permanently deleted.": "Ditt :appName-konto har raderats permanent.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "All personlig information har tagits bort. Anonymiserade transaktionsuppgifter (belopp, datum och fakturadetaljer) har bevarats enligt juridiska och skattemässiga krav.", + "All of your account data has been permanently removed.": "All din kontodata har tagits bort permanent.", + "Thank you for using :appName. You are welcome back at any time.": "Tack för att du använde :appName. Du är välkommen tillbaka när som helst." +} \ No newline at end of file diff --git a/backend/lang/sk.json b/backend/lang/sk.json index 526ca4c38d..624e4c844e 100644 --- a/backend/lang/sk.json +++ b/backend/lang/sk.json @@ -663,5 +663,32 @@ "No completed paid orders on this account": "Žiadne dokončené platené objednávky na tomto účte", "Event was created less than 24 hours ago": "Udalosť bola vytvorená pred menej ako 24 hodinami", "Review Message": "Skontrolovať správu", - "(deactivated)": "(deaktivovaný)" -} + "(deactivated)": "(deaktivovaný)", + "Account deletion has already been requested.": "O vymazanie účtu už bolo požiadané.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Nasledujúce nadchádzajúce podujatia majú dokončené objednávky: :events. Pred vymazaním účtu tieto objednávky zrušte a vráťte peniaze.", + "There is no pending deletion request for this account.": "Pre tento účet neexistuje žiadna čakajúca žiadosť o vymazanie.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Tento účet je naplánovaný na vymazanie. Ak ho chcete ďalej používať, zrušte žiadosť o vymazanie.", + "Only the account owner can request account deletion.": "O vymazanie účtu môže požiadať iba vlastník účtu.", + "The confirmation does not match your account name.": "Potvrdenie sa nezhoduje s názvom vášho účtu.", + "Only pending deletion requests can be executed.": "Vykonať možno iba čakajúce žiadosti o vymazanie.", + "Account deletion has been queued for execution.": "Vymazanie účtu bolo zaradené na vykonanie.", + "Your :app_name account is scheduled for deletion": "Váš účet :app_name je naplánovaný na vymazanie", + "Your :app_name account deletion has been cancelled": "Vymazanie vášho účtu :app_name bolo zrušené", + "Reminder: your :app_name account will be deleted on :date": "Pripomienka: váš účet :app_name bude vymazaný dňa :date", + "Your :app_name account has been deleted": "Váš účet :app_name bol vymazaný", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Dostali sme žiadosť o vymazanie vášho účtu :appName. Váš účet bol deaktivovaný a je naplánovaný na trvalé vymazanie dňa :date.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Všetky vaše zverejnené podujatia boli stiahnuté. Ak vymazanie zrušíte, budete ich musieť znova zverejniť manuálne.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Keďže váš účet má dokončené objednávky, záznamy o transakciách (sumy, dátumy a údaje faktúr) budú uchované v anonymizovanej podobe na právne a daňové účely. Všetky osobné údaje budú natrvalo odstránené.", + "Your account and all of its data will be permanently deleted.": "Váš účet a všetky jeho údaje budú natrvalo vymazané.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Ak ste o to nepožiadali alebo si to rozmyslíte, vymazanie môžete kedykoľvek zrušiť pred :date:", + "Cancel Account Deletion": "Zrušiť vymazanie účtu", + "The deletion of your :appName account has been cancelled. Your account is active again.": "Vymazanie vášho účtu :appName bolo zrušené. Váš účet je opäť aktívny.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Upozorňujeme, že vaše podujatia boli stiahnuté pri podaní žiadosti o vymazanie. Podujatia, ktoré chcete znova verejne sprístupniť, budete musieť znova zverejniť.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Toto je pripomienka, že váš účet :appName je naplánovaný na trvalé vymazanie dňa :date.", + "If you want to keep your account, cancel the deletion before this date:": "Ak si chcete účet ponechať, zrušte vymazanie pred týmto dátumom:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Ak nepodniknete žiadne kroky, vymazanie prebehne automaticky a nebude ho možné vrátiť späť.", + "Your :appName account has been permanently deleted.": "Váš účet :appName bol natrvalo vymazaný.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Všetky osobné údaje boli odstránené. Anonymizované záznamy o transakciách (sumy, dátumy a údaje faktúr) boli uchované podľa právnych a daňových požiadaviek.", + "All of your account data has been permanently removed.": "Všetky údaje vášho účtu boli natrvalo odstránené.", + "Thank you for using :appName. You are welcome back at any time.": "Ďakujeme, že ste používali :appName. Kedykoľvek sa k nám môžete vrátiť." +} \ No newline at end of file diff --git a/backend/lang/tr.json b/backend/lang/tr.json index d609b4e87b..31b36e32a6 100644 --- a/backend/lang/tr.json +++ b/backend/lang/tr.json @@ -678,5 +678,32 @@ "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", "Review Message": "", - "(deactivated)": "(devre dışı)" -} + "(deactivated)": "(devre dışı)", + "Account deletion has already been requested.": "Hesap silme talebi zaten yapıldı.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Aşağıdaki yaklaşan etkinliklerde tamamlanmış siparişler var: :events. Hesabınızı silmeden önce lütfen bu siparişleri iptal edip iade edin.", + "There is no pending deletion request for this account.": "Bu hesap için bekleyen bir silme talebi yok.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Bu hesap silinmek üzere planlandı. Kullanmaya devam etmek için silme talebini iptal edin.", + "Only the account owner can request account deletion.": "Yalnızca hesap sahibi hesap silme talebinde bulunabilir.", + "The confirmation does not match your account name.": "Onay, hesap adınızla eşleşmiyor.", + "Only pending deletion requests can be executed.": "Yalnızca bekleyen silme talepleri yürütülebilir.", + "Account deletion has been queued for execution.": "Hesap silme işlemi yürütülmek üzere sıraya alındı.", + "Your :app_name account is scheduled for deletion": ":app_name hesabınız silinmek üzere planlandı", + "Your :app_name account deletion has been cancelled": ":app_name hesabınızın silinmesi iptal edildi", + "Reminder: your :app_name account will be deleted on :date": "Hatırlatma: :app_name hesabınız :date tarihinde silinecek", + "Your :app_name account has been deleted": ":app_name hesabınız silindi", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": ":appName hesabınızı silme talebi aldık. Hesabınız devre dışı bırakıldı ve :date tarihinde kalıcı olarak silinmek üzere planlandı.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Yayınlanan tüm etkinlikleriniz yayından kaldırıldı. Silmeyi iptal ederseniz bunları manuel olarak yeniden yayınlamanız gerekir.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Hesabınızda tamamlanmış siparişler bulunduğundan, işlem kayıtları (tutarlar, tarihler ve fatura ayrıntıları) yasal ve vergisel amaçlarla anonimleştirilmiş olarak saklanacaktır. Tüm kişisel bilgiler kalıcı olarak kaldırılacaktır.", + "Your account and all of its data will be permanently deleted.": "Hesabınız ve tüm verileri kalıcı olarak silinecektir.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Bunu siz talep etmediyseniz veya fikrinizi değiştirirseniz, silme işlemini :date tarihinden önce istediğiniz zaman iptal edebilirsiniz:", + "Cancel Account Deletion": "Hesap Silmeyi İptal Et", + "The deletion of your :appName account has been cancelled. Your account is active again.": ":appName hesabınızın silinmesi iptal edildi. Hesabınız yeniden etkin.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Silme talep edildiğinde etkinliklerinizin yayından kaldırıldığını lütfen unutmayın. Yeniden herkese görünür yapmak istediğiniz etkinlikleri yeniden yayınlamanız gerekecektir.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Bu, :appName hesabınızın :date tarihinde kalıcı olarak silinmek üzere planlandığına dair bir hatırlatmadır.", + "If you want to keep your account, cancel the deletion before this date:": "Hesabınızı korumak istiyorsanız, bu tarihten önce silme işlemini iptal edin:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Herhangi bir işlem yapmazsanız, silme otomatik olarak gerçekleşecek ve geri alınamayacaktır.", + "Your :appName account has been permanently deleted.": ":appName hesabınız kalıcı olarak silindi.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Tüm kişisel bilgiler kaldırıldı. Anonimleştirilmiş işlem kayıtları (tutarlar, tarihler ve fatura ayrıntıları) yasal ve vergisel amaçlar için gerektiği şekilde saklandı.", + "All of your account data has been permanently removed.": "Tüm hesap verileriniz kalıcı olarak kaldırıldı.", + "Thank you for using :appName. You are welcome back at any time.": ":appName kullandığınız için teşekkürler. İstediğiniz zaman tekrar bekleriz." +} \ No newline at end of file diff --git a/backend/lang/vi.json b/backend/lang/vi.json index 0ada850e10..f228084e63 100644 --- a/backend/lang/vi.json +++ b/backend/lang/vi.json @@ -627,5 +627,32 @@ "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", "Review Message": "", - "(deactivated)": "(đã hủy kích hoạt)" -} + "(deactivated)": "(đã hủy kích hoạt)", + "Account deletion has already been requested.": "Yêu cầu xóa tài khoản đã được gửi trước đó.", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Các sự kiện sắp tới sau có đơn hàng đã hoàn tất: :events. Vui lòng hủy và hoàn tiền các đơn hàng này trước khi xóa tài khoản.", + "There is no pending deletion request for this account.": "Không có yêu cầu xóa nào đang chờ xử lý cho tài khoản này.", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Tài khoản này đã được lên lịch xóa. Hủy yêu cầu xóa để tiếp tục sử dụng.", + "Only the account owner can request account deletion.": "Chỉ chủ tài khoản mới có thể yêu cầu xóa tài khoản.", + "The confirmation does not match your account name.": "Xác nhận không khớp với tên tài khoản của bạn.", + "Only pending deletion requests can be executed.": "Chỉ có thể thực hiện các yêu cầu xóa đang chờ xử lý.", + "Account deletion has been queued for execution.": "Việc xóa tài khoản đã được đưa vào hàng đợi thực thi.", + "Your :app_name account is scheduled for deletion": "Tài khoản :app_name của bạn đã được lên lịch xóa", + "Your :app_name account deletion has been cancelled": "Việc xóa tài khoản :app_name của bạn đã bị hủy", + "Reminder: your :app_name account will be deleted on :date": "Nhắc nhở: tài khoản :app_name của bạn sẽ bị xóa vào :date", + "Your :app_name account has been deleted": "Tài khoản :app_name của bạn đã bị xóa", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Chúng tôi đã nhận được yêu cầu xóa tài khoản :appName của bạn. Tài khoản của bạn đã bị vô hiệu hóa và được lên lịch xóa vĩnh viễn vào :date.", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Tất cả các sự kiện đã xuất bản của bạn đã bị gỡ. Nếu bạn hủy việc xóa, bạn sẽ cần xuất bản lại chúng theo cách thủ công.", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Vì tài khoản của bạn có các đơn hàng đã hoàn tất, hồ sơ giao dịch (số tiền, ngày tháng và chi tiết hóa đơn) sẽ được lưu giữ ở dạng ẩn danh cho các mục đích pháp lý và thuế. Tất cả thông tin cá nhân sẽ bị xóa vĩnh viễn.", + "Your account and all of its data will be permanently deleted.": "Tài khoản của bạn và tất cả dữ liệu của nó sẽ bị xóa vĩnh viễn.", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Nếu bạn không yêu cầu điều này, hoặc bạn đổi ý, bạn có thể hủy việc xóa bất cứ lúc nào trước :date:", + "Cancel Account Deletion": "Hủy xóa tài khoản", + "The deletion of your :appName account has been cancelled. Your account is active again.": "Việc xóa tài khoản :appName của bạn đã bị hủy. Tài khoản của bạn đã hoạt động trở lại.", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Xin lưu ý rằng các sự kiện của bạn đã bị gỡ khi yêu cầu xóa được gửi. Bạn sẽ cần xuất bản lại các sự kiện bạn muốn hiển thị công khai trở lại.", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Đây là lời nhắc rằng tài khoản :appName của bạn được lên lịch xóa vĩnh viễn vào :date.", + "If you want to keep your account, cancel the deletion before this date:": "Nếu bạn muốn giữ tài khoản, hãy hủy việc xóa trước ngày này:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "Nếu bạn không làm gì, việc xóa sẽ tự động tiến hành và không thể hoàn tác.", + "Your :appName account has been permanently deleted.": "Tài khoản :appName của bạn đã bị xóa vĩnh viễn.", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Tất cả thông tin cá nhân đã bị xóa. Hồ sơ giao dịch ẩn danh (số tiền, ngày tháng và chi tiết hóa đơn) đã được lưu giữ theo yêu cầu cho các mục đích pháp lý và thuế.", + "All of your account data has been permanently removed.": "Tất cả dữ liệu tài khoản của bạn đã bị xóa vĩnh viễn.", + "Thank you for using :appName. You are welcome back at any time.": "Cảm ơn bạn đã sử dụng :appName. Chúng tôi luôn chào đón bạn quay lại." +} \ No newline at end of file diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index 3a9a7f9457..fe3ba62981 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -663,5 +663,32 @@ "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", "Review Message": "", - "(deactivated)": "(已停用)" -} + "(deactivated)": "(已停用)", + "Account deletion has already been requested.": "已请求删除账户。", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "以下即将举行的活动存在已完成的订单::events。请在删除账户前取消这些订单并退款。", + "There is no pending deletion request for this account.": "此账户没有待处理的删除请求。", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "此账户已计划删除。取消删除请求以继续使用。", + "Only the account owner can request account deletion.": "只有账户所有者才能请求删除账户。", + "The confirmation does not match your account name.": "确认内容与您的账户名称不匹配。", + "Only pending deletion requests can be executed.": "只能执行待处理的删除请求。", + "Account deletion has been queued for execution.": "账户删除已加入执行队列。", + "Your :app_name account is scheduled for deletion": "您的 :app_name 账户已计划删除", + "Your :app_name account deletion has been cancelled": "您的 :app_name 账户删除已取消", + "Reminder: your :app_name account will be deleted on :date": "提醒:您的 :app_name 账户将于 :date 删除", + "Your :app_name account has been deleted": "您的 :app_name 账户已被删除", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "我们收到了删除您 :appName 账户的请求。您的账户已停用,并计划于 :date 永久删除。", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "您所有已发布的活动均已取消发布。如果您取消删除,需要手动重新发布这些活动。", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "由于您的账户存在已完成的订单,交易记录(金额、日期和发票详情)将以匿名形式保留,用于法律和税务目的。所有个人信息将被永久删除。", + "Your account and all of its data will be permanently deleted.": "您的账户及其所有数据将被永久删除。", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "如果这不是您发起的请求,或者您改变了主意,可以在 :date 之前随时取消删除:", + "Cancel Account Deletion": "取消账户删除", + "The deletion of your :appName account has been cancelled. Your account is active again.": "您的 :appName 账户删除已取消。您的账户已重新激活。", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "请注意,在请求删除时您的活动已被取消发布。您需要重新发布希望再次公开可见的活动。", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "提醒您,您的 :appName 账户计划于 :date 永久删除。", + "If you want to keep your account, cancel the deletion before this date:": "如果您想保留账户,请在此日期之前取消删除:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "如果您不采取任何行动,删除将自动进行且无法撤消。", + "Your :appName account has been permanently deleted.": "您的 :appName 账户已被永久删除。", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "所有个人信息已被删除。匿名化的交易记录(金额、日期和发票详情)已按法律和税务要求保留。", + "All of your account data has been permanently removed.": "您的所有账户数据已被永久删除。", + "Thank you for using :appName. You are welcome back at any time.": "感谢您使用 :appName。随时欢迎您回来。" +} \ No newline at end of file diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json index c0ad789ddf..43a8e9620e 100644 --- a/backend/lang/zh-hk.json +++ b/backend/lang/zh-hk.json @@ -663,5 +663,32 @@ "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", "Review Message": "", - "(deactivated)": "(已停用)" -} + "(deactivated)": "(已停用)", + "Account deletion has already been requested.": "已請求刪除帳戶。", + "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "以下即將舉行的活動有已完成的訂單::events。請在刪除帳戶前取消這些訂單並退款。", + "There is no pending deletion request for this account.": "此帳戶沒有待處理的刪除請求。", + "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "此帳戶已計劃刪除。取消刪除請求以繼續使用。", + "Only the account owner can request account deletion.": "只有帳戶擁有者才能請求刪除帳戶。", + "The confirmation does not match your account name.": "確認內容與您的帳戶名稱不符。", + "Only pending deletion requests can be executed.": "只能執行待處理的刪除請求。", + "Account deletion has been queued for execution.": "帳戶刪除已加入執行佇列。", + "Your :app_name account is scheduled for deletion": "您的 :app_name 帳戶已計劃刪除", + "Your :app_name account deletion has been cancelled": "您的 :app_name 帳戶刪除已取消", + "Reminder: your :app_name account will be deleted on :date": "提醒:您的 :app_name 帳戶將於 :date 刪除", + "Your :app_name account has been deleted": "您的 :app_name 帳戶已被刪除", + "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "我們收到了刪除您 :appName 帳戶的請求。您的帳戶已停用,並計劃於 :date 永久刪除。", + "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "您所有已發佈的活動均已取消發佈。如果您取消刪除,需要手動重新發佈這些活動。", + "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "由於您的帳戶有已完成的訂單,交易記錄(金額、日期及發票詳情)將以匿名形式保留,用於法律及稅務目的。所有個人資料將被永久刪除。", + "Your account and all of its data will be permanently deleted.": "您的帳戶及其所有資料將被永久刪除。", + "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "如果這不是您發起的請求,或者您改變了主意,可以在 :date 之前隨時取消刪除:", + "Cancel Account Deletion": "取消帳戶刪除", + "The deletion of your :appName account has been cancelled. Your account is active again.": "您的 :appName 帳戶刪除已取消。您的帳戶已重新啟用。", + "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "請注意,在請求刪除時您的活動已被取消發佈。您需要重新發佈希望再次公開可見的活動。", + "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "提醒您,您的 :appName 帳戶計劃於 :date 永久刪除。", + "If you want to keep your account, cancel the deletion before this date:": "如果您想保留帳戶,請在此日期之前取消刪除:", + "If you take no action, the deletion will proceed automatically and cannot be undone.": "如果您不採取任何行動,刪除將自動進行且無法復原。", + "Your :appName account has been permanently deleted.": "您的 :appName 帳戶已被永久刪除。", + "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "所有個人資料已被刪除。匿名化的交易記錄(金額、日期及發票詳情)已按法律及稅務要求保留。", + "All of your account data has been permanently removed.": "您的所有帳戶資料已被永久刪除。", + "Thank you for using :appName. You are welcome back at any time.": "感謝您使用 :appName。隨時歡迎您回來。" +} \ No newline at end of file diff --git a/backend/resources/views/emails/account/deletion-cancelled.blade.php b/backend/resources/views/emails/account/deletion-cancelled.blade.php new file mode 100644 index 0000000000..c76dbd1445 --- /dev/null +++ b/backend/resources/views/emails/account/deletion-cancelled.blade.php @@ -0,0 +1,12 @@ +@php /** @var \HiEvents\DomainObjects\AccountDomainObject $account */ @endphp + + +{{ __('Hi :name', ['name' => $account->getName()]) }}, + +{{ __('The deletion of your :appName account has been cancelled. Your account is active again.', ['appName' => config('app.name')]) }} + +{{ __('Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.') }} + +{{ __('Best Regards,') }}
+{{ __('The :appName Team', ['appName' => config('app.name')]) }} +
diff --git a/backend/resources/views/emails/account/deletion-completed.blade.php b/backend/resources/views/emails/account/deletion-completed.blade.php new file mode 100644 index 0000000000..cf766e2651 --- /dev/null +++ b/backend/resources/views/emails/account/deletion-completed.blade.php @@ -0,0 +1,19 @@ +@php /** @var string $accountName */ @endphp +@php /** @var bool $wasAnonymized */ @endphp + + +{{ __('Hi :name', ['name' => $accountName]) }}, + +{{ __('Your :appName account has been permanently deleted.', ['appName' => config('app.name')]) }} + +@if ($wasAnonymized) +{{ __('All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.') }} +@else +{{ __('All of your account data has been permanently removed.') }} +@endif + +{{ __('Thank you for using :appName. You are welcome back at any time.', ['appName' => config('app.name')]) }} + +{{ __('Best Regards,') }}
+{{ __('The :appName Team', ['appName' => config('app.name')]) }} +
diff --git a/backend/resources/views/emails/account/deletion-reminder.blade.php b/backend/resources/views/emails/account/deletion-reminder.blade.php new file mode 100644 index 0000000000..a7d17031b8 --- /dev/null +++ b/backend/resources/views/emails/account/deletion-reminder.blade.php @@ -0,0 +1,20 @@ +@php /** @var string $accountName */ @endphp +@php /** @var string $scheduledDeletionDate */ @endphp +@php /** @var string $cancelLink */ @endphp + + +{{ __('Hi :name', ['name' => $accountName]) }}, + +{{ __('This is a reminder that your :appName account is scheduled for permanent deletion on :date.', ['appName' => config('app.name'), 'date' => $scheduledDeletionDate]) }} + +{{ __('If you want to keep your account, cancel the deletion before this date:') }} + + + {{ __('Cancel Account Deletion') }} + + +{{ __('If you take no action, the deletion will proceed automatically and cannot be undone.') }} + +{{ __('Best Regards,') }}
+{{ __('The :appName Team', ['appName' => config('app.name')]) }} +
diff --git a/backend/resources/views/emails/account/deletion-requested.blade.php b/backend/resources/views/emails/account/deletion-requested.blade.php new file mode 100644 index 0000000000..112b759a2f --- /dev/null +++ b/backend/resources/views/emails/account/deletion-requested.blade.php @@ -0,0 +1,27 @@ +@php /** @var \HiEvents\DomainObjects\AccountDomainObject $account */ @endphp +@php /** @var string $scheduledDeletionDate */ @endphp +@php /** @var bool $willBeAnonymized */ @endphp +@php /** @var string $cancelLink */ @endphp + + +{{ __('Hi :name', ['name' => $account->getName()]) }}, + +{{ __('We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.', ['appName' => config('app.name'), 'date' => $scheduledDeletionDate]) }} + +{{ __('All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.') }} + +@if ($willBeAnonymized) +{{ __('Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.') }} +@else +{{ __('Your account and all of its data will be permanently deleted.') }} +@endif + +{{ __('If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:', ['date' => $scheduledDeletionDate]) }} + + + {{ __('Cancel Account Deletion') }} + + +{{ __('Best Regards,') }}
+{{ __('The :appName Team', ['appName' => config('app.name')]) }} +
diff --git a/backend/routes/api.php b/backend/routes/api.php index 84c9c51539..ca0a1d6ad3 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,9 @@ post('/announcements/{announcement_id}/dismiss', DismissAnnouncementAction::class); // Accounts + $router->post('/accounts/deletion-request', RequestAccountDeletionAction::class); + $router->delete('/accounts/deletion-request', CancelAccountDeletionAction::class); + $router->get('/accounts/deletion-request', GetAccountDeletionStatusAction::class); $router->get('/accounts/{account_id?}', GetAccountAction::class); $router->put('/accounts/{account_id?}', UpdateAccountAction::class); @@ -562,6 +572,12 @@ function (Router $router): void { $router->get('/messaging-tiers', GetMessagingTiersAction::class); $router->put('/accounts/{account_id}/messaging-tier', UpdateAccountMessagingTierAction::class); + // Account Deletion Requests + $router->get('/deletion-requests', GetAllAccountDeletionRequestsAction::class); + $router->post('/accounts/{account_id}/deletion-request', AdminRequestAccountDeletionAction::class); + $router->delete('/deletion-requests/{deletion_request_id}', AdminCancelAccountDeletionAction::class); + $router->post('/deletion-requests/{deletion_request_id}/execute', AdminExecuteAccountDeletionAction::class); + // System Info $router->get('/system-info', GetSystemInfoAction::class); } diff --git a/backend/tests/Feature/Services/Domain/Account/AccountDeletionExecutionTest.php b/backend/tests/Feature/Services/Domain/Account/AccountDeletionExecutionTest.php new file mode 100644 index 0000000000..42ed19f8c2 --- /dev/null +++ b/backend/tests/Feature/Services/Domain/Account/AccountDeletionExecutionTest.php @@ -0,0 +1,313 @@ +seedAccountGraph(); + } + + public function test_anonymization_scrubs_pii_and_retains_financial_records(): void + { + $manifest = $this->app->make(AccountAnonymizationService::class) + ->anonymizeAccount($this->account->id); + + $order = $this->order->fresh(); + $this->assertSame('Anonymized', $order->first_name); + $this->assertStringContainsString('@anonymized.invalid', $order->email); + $this->assertNull($order->address); + $this->assertNull($order->session_id); + $this->assertNotSame('pub-order-1', $order->public_id); + $this->assertSame('25.00', (string) number_format((float) $order->total_gross, 2)); + $this->assertSame('COMPLETED', $order->status); + $this->assertNull($order->deleted_at); + + $attendee = $this->attendee->fresh(); + $this->assertSame('Anonymized', $attendee->first_name); + $this->assertNotSame('pub-att-1', $attendee->public_id); + + $this->assertDatabaseMissing('question_answers', ['question_id' => $this->question->id]); + $this->assertDatabaseCount('question_answers', 1); + + $soleUser = $this->soleUser->fresh(); + $this->assertSame('Anonymized', $soleUser->first_name); + $this->assertStringContainsString('@anonymized.invalid', $soleUser->email); + $this->assertNotNull($soleUser->deleted_at); + + $sharedUser = $this->sharedUser->fresh(); + $this->assertSame('shared@example.com', $sharedUser->email); + $this->assertNull($sharedUser->deleted_at); + + $this->assertSoftDeleted('account_users', [ + 'account_id' => $this->account->id, + 'user_id' => $this->sharedUser->id, + ]); + $this->assertDatabaseHas('account_users', [ + 'account_id' => $this->otherAccount->id, + 'user_id' => $this->sharedUser->id, + 'deleted_at' => null, + ]); + + $account = $this->account->fresh(); + $this->assertSame('Anonymized', $account->name); + $this->assertNotNull($account->deleted_at); + + $this->assertDatabaseHas('stripe_customers', [ + 'stripe_account_id' => 'acct_deltest123', + 'name' => 'Anonymized', + ]); + + $this->assertNotEmpty($manifest); + $this->assertContains('orders', array_column($manifest, 'entity')); + } + + public function test_hard_deletion_removes_account_graph_and_preserves_shared_users(): void + { + $this->app->make(AccountHardDeletionService::class) + ->deleteAccount($this->account->id); + + $this->assertDatabaseMissing('accounts', ['id' => $this->account->id]); + $this->assertDatabaseMissing('events', ['id' => $this->event->id]); + $this->assertDatabaseMissing('orders', ['id' => $this->order->id]); + $this->assertDatabaseMissing('attendees', ['id' => $this->attendee->id]); + $this->assertDatabaseMissing('questions', ['event_id' => $this->event->id]); + $this->assertDatabaseMissing('users', ['id' => $this->soleUser->id]); + $this->assertDatabaseMissing('stripe_customers', ['stripe_account_id' => 'acct_deltest123']); + + $this->assertDatabaseHas('users', ['id' => $this->sharedUser->id]); + $this->assertDatabaseHas('accounts', ['id' => $this->otherAccount->id]); + $this->assertDatabaseCount('question_answers', 1); + } + + private function seedAccountGraph(): void + { + $this->account = Account::forceCreate([ + 'name' => 'Doomed Account', + 'email' => 'owner@example.com', + 'timezone' => 'UTC', + 'currency_code' => 'USD', + 'short_id' => 'acc_doomed', + 'stripe_account_id' => 'acct_deltest123', + ]); + + $this->otherAccount = Account::forceCreate([ + 'name' => 'Surviving Account', + 'email' => 'other@example.com', + 'timezone' => 'UTC', + 'currency_code' => 'USD', + 'short_id' => 'acc_survivor', + ]); + + $this->soleUser = User::forceCreate([ + 'email' => 'sole@example.com', + 'first_name' => 'Sole', + 'last_name' => 'User', + 'password' => 'hash', + 'timezone' => 'UTC', + ]); + + $this->sharedUser = User::forceCreate([ + 'email' => 'shared@example.com', + 'first_name' => 'Shared', + 'last_name' => 'User', + 'password' => 'hash', + 'timezone' => 'UTC', + ]); + + AccountUser::forceCreate([ + 'account_id' => $this->account->id, + 'user_id' => $this->soleUser->id, + 'role' => 'ADMIN', + 'status' => 'ACTIVE', + 'is_account_owner' => true, + ]); + AccountUser::forceCreate([ + 'account_id' => $this->account->id, + 'user_id' => $this->sharedUser->id, + 'role' => 'ADMIN', + 'status' => 'ACTIVE', + ]); + AccountUser::forceCreate([ + 'account_id' => $this->otherAccount->id, + 'user_id' => $this->sharedUser->id, + 'role' => 'ADMIN', + 'status' => 'ACTIVE', + ]); + + $organizer = Organizer::forceCreate([ + 'name' => 'Doomed Organizer', + 'account_id' => $this->account->id, + 'email' => 'organizer@example.com', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'status' => 'LIVE', + ]); + + $this->event = $this->seedEvent('Past Event', $this->account->id, $organizer->id, $this->soleUser->id, 'evt_doomed'); + + $product = Product::forceCreate([ + 'event_id' => $this->event->id, + 'title' => 'Ticket', + 'type' => 'PAID', + 'product_type' => 'TICKET', + 'order' => 1, + ]); + + $productPrice = ProductPrice::forceCreate([ + 'product_id' => $product->id, + 'price' => 25, + 'order' => 1, + ]); + + $this->order = Order::forceCreate([ + 'event_id' => $this->event->id, + 'short_id' => 'ord_doomed', + 'public_id' => 'pub-order-1', + 'first_name' => 'Jane', + 'last_name' => 'Buyer', + 'email' => 'jane@example.com', + 'address' => ['line1' => '1 Main St'], + 'session_id' => 'sess-123', + 'total_before_additions' => 25, + 'total_gross' => 25, + 'currency' => 'USD', + 'status' => 'COMPLETED', + ]); + + $this->attendee = Attendee::forceCreate([ + 'order_id' => $this->order->id, + 'event_id' => $this->event->id, + 'product_id' => $product->id, + 'product_price_id' => $productPrice->id, + 'email' => 'jane@example.com', + 'first_name' => 'Jane', + 'last_name' => 'Buyer', + 'status' => 'ACTIVE', + 'public_id' => 'pub-att-1', + 'short_id' => 'att_doomed', + ]); + + $this->question = Question::forceCreate([ + 'event_id' => $this->event->id, + 'title' => 'Dietary requirements', + 'type' => 'SINGLE_LINE_TEXT', + 'belongs_to' => 'ORDER', + 'required' => false, + 'is_hidden' => false, + ]); + + QuestionAnswer::forceCreate([ + 'question_id' => $this->question->id, + 'order_id' => $this->order->id, + 'answer' => ['text' => 'Nut allergy'], + ]); + + $otherOrganizer = Organizer::forceCreate([ + 'name' => 'Surviving Organizer', + 'account_id' => $this->otherAccount->id, + 'email' => 'surviving@example.com', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'status' => 'LIVE', + ]); + + $otherEvent = $this->seedEvent('Surviving Event', $this->otherAccount->id, $otherOrganizer->id, $this->sharedUser->id, 'evt_survivor'); + + $otherQuestion = Question::forceCreate([ + 'event_id' => $otherEvent->id, + 'title' => 'Other question', + 'type' => 'SINGLE_LINE_TEXT', + 'belongs_to' => 'ORDER', + 'required' => false, + 'is_hidden' => false, + ]); + + $otherOrder = Order::forceCreate([ + 'event_id' => $otherEvent->id, + 'short_id' => 'ord_survivor', + 'public_id' => 'pub-order-2', + 'first_name' => 'Sam', + 'last_name' => 'Survivor', + 'email' => 'sam@example.com', + 'total_before_additions' => 10, + 'total_gross' => 10, + 'currency' => 'USD', + 'status' => 'COMPLETED', + ]); + + QuestionAnswer::forceCreate([ + 'question_id' => $otherQuestion->id, + 'order_id' => $otherOrder->id, + 'answer' => ['text' => 'Should survive'], + ]); + + StripeCustomer::forceCreate([ + 'name' => 'Jane Buyer', + 'email' => 'jane@example.com', + 'stripe_customer_id' => 'cus_deltest1', + 'stripe_account_id' => 'acct_deltest123', + ]); + + DB::table('question_answers')->whereNotIn('question_id', [$this->question->id, $otherQuestion->id])->delete(); + } + + private function seedEvent(string $title, int $accountId, int $organizerId, int $userId, string $shortId): Event + { + $eventId = DB::table('events')->insertGetId([ + 'title' => $title, + 'account_id' => $accountId, + 'organizer_id' => $organizerId, + 'user_id' => $userId, + 'short_id' => $shortId, + 'status' => 'DRAFT', + 'timezone' => 'UTC', + 'currency' => 'USD', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return Event::findOrFail($eventId); + } +} diff --git a/backend/tests/Unit/Services/Domain/Account/AccountDeletionServiceTest.php b/backend/tests/Unit/Services/Domain/Account/AccountDeletionServiceTest.php new file mode 100644 index 0000000000..bbb12a0cf7 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Account/AccountDeletionServiceTest.php @@ -0,0 +1,300 @@ +deletionRequestRepository = Mockery::mock(AccountDeletionRequestRepositoryInterface::class); + $this->accountRepository = Mockery::mock(AccountRepositoryInterface::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->anonymizationService = Mockery::mock(AccountAnonymizationService::class); + $this->hardDeletionService = Mockery::mock(AccountHardDeletionService::class); + + $databaseManager = Mockery::mock(DatabaseManager::class); + $databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn (callable $callback) => $callback()); + + $this->service = new AccountDeletionService( + $this->deletionRequestRepository, + $this->accountRepository, + $this->eventRepository, + $this->orderRepository, + $this->anonymizationService, + $this->hardDeletionService, + $databaseManager, + new NullLogger, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_cannot_delete_reason_is_null_when_no_blockers_exist(): void + { + $this->mockNoActiveRequest(); + $this->mockUpcomingEventsWithCompletedOrders([]); + + $this->assertNull($this->service->getCannotDeleteReason(1)); + } + + public function test_cannot_delete_when_request_already_exists(): void + { + $this->mockActiveRequest(); + + $this->assertSame( + 'Account deletion has already been requested.', + $this->service->getCannotDeleteReason(1), + ); + } + + public function test_cannot_delete_when_upcoming_events_have_completed_orders(): void + { + $this->mockNoActiveRequest(); + $this->mockUpcomingEventsWithCompletedOrders(['Summer Festival', 'Winter Gala']); + + $reason = $this->service->getCannotDeleteReason(1); + + $this->assertStringContainsString('Summer Festival, Winter Gala', $reason); + } + + public function test_outcome_is_hard_delete_when_account_has_no_completed_orders(): void + { + $this->orderRepository->shouldReceive('accountHasCompletedOrders')->with(1)->andReturnFalse(); + + $this->assertSame(AccountDeletionOutcome::HARD_DELETE, $this->service->determineOutcome(1)); + } + + public function test_outcome_is_anonymize_when_account_has_completed_orders(): void + { + $this->orderRepository->shouldReceive('accountHasCompletedOrders')->with(1)->andReturnTrue(); + + $this->assertSame(AccountDeletionOutcome::ANONYMIZE, $this->service->determineOutcome(1)); + } + + public function test_request_deletion_creates_request_and_unpublishes_events(): void + { + $this->mockNoActiveRequest(); + $this->mockUpcomingEventsWithCompletedOrders([]); + $this->orderRepository->shouldReceive('accountHasCompletedOrders')->andReturnFalse(); + $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($this->makeAccount()); + + $this->deletionRequestRepository->shouldReceive('create') + ->once() + ->withArgs(function (array $attributes) { + return $attributes['account_id'] === 1 + && $attributes['requested_by_user_id'] === 5 + && $attributes['initiated_by'] === AccountDeletionInitiator::ACCOUNT_OWNER->name + && $attributes['status'] === AccountDeletionRequestStatus::REQUESTED->name + && $attributes['expected_outcome'] === AccountDeletionOutcome::HARD_DELETE->name; + }) + ->andReturn($this->makeDeletionRequest()); + + $this->eventRepository->shouldReceive('updateWhere') + ->once() + ->with( + ['status' => EventStatus::DRAFT->name], + ['account_id' => 1, 'status' => EventStatus::LIVE->name], + ) + ->andReturn(1); + + $result = $this->service->requestDeletion(1, 5, AccountDeletionInitiator::ACCOUNT_OWNER); + + $this->assertInstanceOf(AccountDeletionRequestDomainObject::class, $result); + Mail::assertQueued(AccountDeletionRequestedEmail::class); + } + + public function test_request_deletion_throws_when_gated(): void + { + $this->mockActiveRequest(); + + $this->expectException(CannotDeleteEntityException::class); + + $this->service->requestDeletion(1, 5, AccountDeletionInitiator::ACCOUNT_OWNER); + } + + public function test_cancel_deletion_updates_request_and_sends_email(): void + { + $this->mockActiveRequest(); + $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($this->makeAccount()); + + $this->deletionRequestRepository->shouldReceive('updateFromArray') + ->once() + ->withArgs(function (int $id, array $attributes) { + return $id === 10 + && $attributes['status'] === AccountDeletionRequestStatus::CANCELLED->name + && $attributes['cancelled_by_user_id'] === 5; + }) + ->andReturn($this->makeDeletionRequest(AccountDeletionRequestStatus::CANCELLED)); + + $result = $this->service->cancelDeletion(1, 5); + + $this->assertSame(AccountDeletionRequestStatus::CANCELLED->name, $result->getStatus()); + Mail::assertQueued(AccountDeletionCancelledEmail::class); + } + + public function test_cancel_deletion_throws_when_no_active_request(): void + { + $this->mockNoActiveRequest(); + + $this->expectException(AccountDeletionRequestNotFoundException::class); + + $this->service->cancelDeletion(1, 5); + } + + public function test_execute_deletion_skips_inactive_requests(): void + { + $this->deletionRequestRepository->shouldReceive('findById') + ->with(10) + ->andReturn($this->makeDeletionRequest(AccountDeletionRequestStatus::CANCELLED)); + + $this->hardDeletionService->shouldNotReceive('deleteAccount'); + $this->anonymizationService->shouldNotReceive('anonymizeAccount'); + + $this->service->executeDeletion(10); + + Mail::assertNothingQueued(); + } + + public function test_execute_deletion_hard_deletes_when_no_completed_orders(): void + { + $this->deletionRequestRepository->shouldReceive('findById') + ->with(10) + ->andReturn($this->makeDeletionRequest()); + $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($this->makeAccount()); + $this->orderRepository->shouldReceive('accountHasCompletedOrders')->with(1)->andReturnFalse(); + + $this->hardDeletionService->shouldReceive('deleteAccount')->with(1)->once()->andReturn(['accounts' => 1]); + $this->anonymizationService->shouldNotReceive('anonymizeAccount'); + + $this->deletionRequestRepository->shouldReceive('updateFromArray') + ->once() + ->withArgs(function (int $id, array $attributes) { + return $id === 10 + && $attributes['status'] === AccountDeletionRequestStatus::COMPLETED->name + && $attributes['outcome'] === AccountDeletionOutcome::HARD_DELETE->name + && $attributes['deletion_manifest'] === ['accounts' => 1]; + }) + ->andReturn($this->makeDeletionRequest(AccountDeletionRequestStatus::COMPLETED)); + + $this->service->executeDeletion(10); + + Mail::assertQueued(AccountDeletionCompletedEmail::class); + } + + public function test_execute_deletion_anonymizes_when_completed_orders_exist(): void + { + $this->deletionRequestRepository->shouldReceive('findById') + ->with(10) + ->andReturn($this->makeDeletionRequest()); + $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($this->makeAccount()); + $this->orderRepository->shouldReceive('accountHasCompletedOrders')->with(1)->andReturnTrue(); + + $this->anonymizationService->shouldReceive('anonymizeAccount')->with(1)->once()->andReturn([]); + $this->hardDeletionService->shouldNotReceive('deleteAccount'); + + $this->deletionRequestRepository->shouldReceive('updateFromArray') + ->once() + ->withArgs(fn (int $id, array $attributes) => $attributes['outcome'] === AccountDeletionOutcome::ANONYMIZE->name) + ->andReturn($this->makeDeletionRequest(AccountDeletionRequestStatus::COMPLETED)); + + $this->service->executeDeletion(10); + + Mail::assertQueued(AccountDeletionCompletedEmail::class); + } + + private function mockNoActiveRequest(): void + { + $this->deletionRequestRepository->shouldReceive('findFirstWhere')->andReturnNull(); + } + + private function mockActiveRequest(): void + { + $this->deletionRequestRepository->shouldReceive('findFirstWhere') + ->andReturn($this->makeDeletionRequest()); + } + + private function mockUpcomingEventsWithCompletedOrders(array $titles): void + { + $this->eventRepository->shouldReceive('getUpcomingEventsWithCompletedOrders') + ->andReturn(collect($titles)->map(function (string $title) { + $event = new EventDomainObject; + $event->setTitle($title); + + return $event; + })); + } + + private function makeDeletionRequest( + AccountDeletionRequestStatus $status = AccountDeletionRequestStatus::REQUESTED, + ): AccountDeletionRequestDomainObject { + $request = new AccountDeletionRequestDomainObject; + $request->setId(10); + $request->setAccountId(1); + $request->setStatus($status->name); + $request->setExpectedOutcome(AccountDeletionOutcome::HARD_DELETE->name); + $request->setScheduledDeletionAt(now()->addDays(30)->toDateTimeString()); + + return $request; + } + + private function makeAccount(): AccountDomainObject + { + $account = new AccountDomainObject; + $account->setId(1); + $account->setName('Test Account'); + $account->setEmail('owner@example.com'); + $account->setTimezone('UTC'); + + return $account; + } +} diff --git a/e2e/api/api-client.ts b/e2e/api/api-client.ts index 738ecd3112..9de65071d5 100644 --- a/e2e/api/api-client.ts +++ b/e2e/api/api-client.ts @@ -98,8 +98,14 @@ export async function confirmEmailWithCode( export class ApiClient { constructor(private readonly request: APIRequestContext) {} - getAccount(): Promise<{ id: number }> { - return unwrap<{ id: number }>(this.request.get('accounts', { headers: jsonHeaders })); + getAccount(): Promise<{ id: number; name: string }> { + return unwrap<{ id: number; name: string }>(this.request.get('accounts', { headers: jsonHeaders })); + } + + requestAccountDeletion(confirmation: string): Promise<{ id: number; status: string }> { + return unwrap<{ id: number; status: string }>( + this.request.post('accounts/deletion-request', { headers: jsonHeaders, data: { confirmation } }), + ); } createOrganizer(name: string, opts: { email?: string; currency?: string; timezone?: string } = {}): Promise { diff --git a/e2e/tests/management/account-deletion.spec.ts b/e2e/tests/management/account-deletion.spec.ts new file mode 100644 index 0000000000..6c05908ba4 --- /dev/null +++ b/e2e/tests/management/account-deletion.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '../../fixtures'; + +test.describe('account deletion', () => { + test('an owner requests account deletion and cancels it', async ({ freshAccount }) => { + const account = await freshAccount.api.getAccount(); + const page = await freshAccount.newAuthedPage(); + + await page.goto('/account/danger-zone'); + await page.getByTestId('delete-account-button').click(); + await page.getByLabel('Account name').fill(account.name); + await page.getByTestId('confirm-account-deletion-button').click(); + + await expect(page.getByText('This account is scheduled for deletion', { exact: true })).toBeVisible(); + await expect(page.getByTestId('pending-deletion-banner-button')).toBeVisible(); + + await page.getByTestId('cancel-account-deletion-button').click(); + + await expect(page.getByTestId('delete-account-button')).toBeVisible(); + await expect(page.getByTestId('pending-deletion-banner-button')).toHaveCount(0); + }); + + test('a superadmin sees a pending deletion request and cancels it', async ({ freshAccount, superAdminPage }) => { + const account = await freshAccount.api.getAccount(); + await freshAccount.api.requestAccountDeletion(account.name); + + await superAdminPage.goto('/admin/deletion-requests'); + const row = superAdminPage.getByRole('row').filter({ hasText: freshAccount.email }); + await expect(row).toBeVisible(); + + await row.getByTestId('admin-cancel-deletion-button').click(); + await superAdminPage.getByTestId('admin-confirm-deletion-action-button').click(); + + await expect(superAdminPage.getByText('Deletion request cancelled')).toBeVisible(); + await expect(row).toHaveCount(0); + }); + + test('the confirm button stays disabled until the account name matches', async ({ freshAccount }) => { + const page = await freshAccount.newAuthedPage(); + + await page.goto('/account/danger-zone'); + await page.getByTestId('delete-account-button').click(); + + await expect(page.getByTestId('confirm-account-deletion-button')).toBeDisabled(); + + await page.getByLabel('Account name').fill('Wrong Name'); + await expect(page.getByTestId('confirm-account-deletion-button')).toBeDisabled(); + }); +}); diff --git a/frontend/src/api/account.client.ts b/frontend/src/api/account.client.ts index 31e4288e65..cffd999c5a 100644 --- a/frontend/src/api/account.client.ts +++ b/frontend/src/api/account.client.ts @@ -1,5 +1,5 @@ import {api} from "./client.ts"; -import {Account, GenericDataResponse, User} from "../types.ts"; +import {Account, AccountDeletionRequest, AccountDeletionStatus, GenericDataResponse, User} from "../types.ts"; interface CreateAccountRequest { first_name: string; @@ -21,4 +21,16 @@ export const accountClient = { const response = await api.put>('accounts', account); return response.data; }, + getDeletionStatus: async () => { + const response = await api.get>('accounts/deletion-request'); + return response.data; + }, + requestDeletion: async (payload: { confirmation: string; reason?: string }) => { + const response = await api.post>('accounts/deletion-request', payload); + return response.data; + }, + cancelDeletion: async () => { + const response = await api.delete>('accounts/deletion-request'); + return response.data; + }, } diff --git a/frontend/src/api/admin.client.ts b/frontend/src/api/admin.client.ts index 2bff604899..2b732edced 100644 --- a/frontend/src/api/admin.client.ts +++ b/frontend/src/api/admin.client.ts @@ -214,6 +214,31 @@ export interface GetAllAccountsParams { search?: string; } +export interface GetAllDeletionRequestsParams { + page?: number; + per_page?: number; + search?: string; + status?: string; +} + +export interface AdminDeletionRequest { + id: IdParam; + status: 'REQUESTED' | 'CANCELLED' | 'COMPLETED'; + initiated_by: 'ACCOUNT_OWNER' | 'ADMIN'; + reason: string | null; + expected_outcome: 'HARD_DELETE' | 'ANONYMIZE' | null; + outcome: 'HARD_DELETE' | 'ANONYMIZE' | null; + scheduled_deletion_at: string; + reminder_sent_at: string | null; + cancelled_at: string | null; + completed_at: string | null; + requested_at: string; + deletion_manifest: Record[] | Record | null; + account: { id: IdParam; name: string; email: string } | null; + requested_by_user: { id: IdParam; full_name: string; email: string } | null; + cancelled_by_user: { id: IdParam; full_name: string } | null; +} + export interface GetAllEventsParams { page?: number; per_page?: number; @@ -586,4 +611,31 @@ export const adminClient = { const response = await api.get>('admin/messaging-tiers'); return response.data; }, + + getDeletionRequests: async (params: GetAllDeletionRequestsParams = {}) => { + const response = await api.get>('admin/deletion-requests', { + params: { + page: params.page || 1, + per_page: params.per_page || 20, + search: params.search || undefined, + status: params.status || undefined, + } + }); + return response.data; + }, + + requestAccountDeletion: async (accountId: IdParam, reason?: string) => { + const response = await api.post(`admin/accounts/${accountId}/deletion-request`, {reason}); + return response.data; + }, + + cancelDeletionRequest: async (deletionRequestId: IdParam) => { + const response = await api.delete(`admin/deletion-requests/${deletionRequestId}`); + return response.data; + }, + + executeDeletionRequest: async (deletionRequestId: IdParam) => { + const response = await api.post(`admin/deletion-requests/${deletionRequestId}/execute`); + return response.data; + }, }; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e6dcf2b476..f69f47d8f6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -44,6 +44,13 @@ api.interceptors.response.use( const isManageEventPath = currentPath.startsWith('/manage/event/'); const isAuthError = status === 401 || status === 403; + if (status === 403 && error.response.data?.error_code === 'ACCOUNT_PENDING_DELETION') { + if (!currentPath.startsWith('/account')) { + window?.location?.replace('/account/danger-zone'); + } + return Promise.reject(error); + } + if (isAuthError && (!isAllowedUnauthenticatedPath || isManageEventPath)) { // Store the current URL before redirecting to the login page window?.localStorage?.setItem(PREVIOUS_URL_KEY, window?.location.href); diff --git a/frontend/src/components/common/PendingDeletionBanner/PendingDeletionBanner.module.scss b/frontend/src/components/common/PendingDeletionBanner/PendingDeletionBanner.module.scss new file mode 100644 index 0000000000..e58537e71d --- /dev/null +++ b/frontend/src/components/common/PendingDeletionBanner/PendingDeletionBanner.module.scss @@ -0,0 +1,58 @@ +.banner { + position: sticky; + top: 0; + z-index: 11; + background: linear-gradient(135deg, #e53935 0%, #c62828 100%); + color: white; + padding: 0.75rem 1.5rem; + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + border-bottom: 2px solid #b71c1c; + + @media (max-width: 768px) { + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 1rem; + } +} + +.content { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; + + @media (max-width: 768px) { + width: 100%; + justify-content: center; + } +} + +.icon { + flex-shrink: 0; +} + +.text { + font-weight: 500; + font-size: 0.9375rem; + + @media (max-width: 768px) { + font-size: 0.875rem; + text-align: center; + } +} + +.date { + font-weight: 600; +} + +.button { + flex-shrink: 0; + + @media (max-width: 768px) { + width: 100%; + } +} diff --git a/frontend/src/components/common/PendingDeletionBanner/index.tsx b/frontend/src/components/common/PendingDeletionBanner/index.tsx new file mode 100644 index 0000000000..9b731b3101 --- /dev/null +++ b/frontend/src/components/common/PendingDeletionBanner/index.tsx @@ -0,0 +1,43 @@ +import {Button} from "@mantine/core"; +import {t, Trans} from "@lingui/macro"; +import {IconTrash} from "@tabler/icons-react"; +import {useNavigate} from "react-router"; +import {useGetAccount} from "../../../queries/useGetAccount.ts"; +import {prettyDate} from "../../../utilites/dates.ts"; +import classes from "./PendingDeletionBanner.module.scss"; + +const PendingDeletionBanner = () => { + const {data: account} = useGetAccount(); + const navigate = useNavigate(); + + if (!account?.deletion_request) { + return null; + } + + const scheduledDate = prettyDate(account.deletion_request.scheduled_deletion_at, account.timezone || 'UTC'); + + return ( +
+
+ + + + This account is scheduled for deletion on {scheduledDate} + + +
+ +
+ ); +}; + +export default PendingDeletionBanner; diff --git a/frontend/src/components/layouts/Admin/index.tsx b/frontend/src/components/layouts/Admin/index.tsx index 0a2e428241..4cee97e9bf 100644 --- a/frontend/src/components/layouts/Admin/index.tsx +++ b/frontend/src/components/layouts/Admin/index.tsx @@ -1,4 +1,4 @@ -import {IconUsers, IconBuildingBank, IconLayoutDashboard, IconCalendar, IconReceipt, IconSettings, IconChartBar, IconAlertTriangle, IconMail, IconSpeakerphone} from "@tabler/icons-react"; +import {IconUsers, IconBuildingBank, IconLayoutDashboard, IconCalendar, IconReceipt, IconSettings, IconChartBar, IconAlertTriangle, IconMail, IconSpeakerphone, IconTrash} from "@tabler/icons-react"; import {t} from "@lingui/macro"; import {NavItem, BreadcrumbItem} from "../AppLayout/types"; import AppLayout from "../AppLayout"; @@ -18,6 +18,7 @@ const AdminLayout = () => { {link: 'messages', label: t`Messages`, icon: IconMail}, {link: 'announcements', label: t`Announcements`, icon: IconSpeakerphone}, {link: 'attribution', label: t`UTM Analytics`, icon: IconChartBar}, + {link: 'deletion-requests', label: t`Deletion Requests`, icon: IconTrash}, {link: 'failed-jobs', label: t`Failed Jobs`, icon: IconAlertTriangle}, {link: 'configurations', label: t`Configurations`, icon: IconSettings}, ]; diff --git a/frontend/src/components/layouts/AppLayout/index.tsx b/frontend/src/components/layouts/AppLayout/index.tsx index 8ee266adc0..fc66068c6b 100644 --- a/frontend/src/components/layouts/AppLayout/index.tsx +++ b/frontend/src/components/layouts/AppLayout/index.tsx @@ -8,6 +8,7 @@ import {IconLayoutSidebar} from "@tabler/icons-react"; import {UnstyledButton, VisuallyHidden} from "@mantine/core"; import {t} from "@lingui/macro"; import ImpersonationBanner from "../../common/ImpersonationBanner"; +import PendingDeletionBanner from "../../common/PendingDeletionBanner"; import AnnouncementDisplay from "../../common/AnnouncementDisplay"; interface AppLayoutProps { @@ -82,6 +83,7 @@ const AppLayout: React.FC = ({ return ( <> +
diff --git a/frontend/src/components/layouts/DefaultLayout/index.tsx b/frontend/src/components/layouts/DefaultLayout/index.tsx index 40133167de..a3c24d37c9 100644 --- a/frontend/src/components/layouts/DefaultLayout/index.tsx +++ b/frontend/src/components/layouts/DefaultLayout/index.tsx @@ -3,12 +3,14 @@ import {Header} from "../../common/Header"; import {Container} from "@mantine/core"; import {GlobalMenu} from "../../common/GlobalMenu"; import ImpersonationBanner from "../../common/ImpersonationBanner"; +import PendingDeletionBanner from "../../common/PendingDeletionBanner"; import AnnouncementDisplay from "../../common/AnnouncementDisplay"; const DefaultLayout = () => { return ( <> +
}/> diff --git a/frontend/src/components/routes/account/ManageAccount/index.tsx b/frontend/src/components/routes/account/ManageAccount/index.tsx index 082a3c89ac..4dc66df6a4 100644 --- a/frontend/src/components/routes/account/ManageAccount/index.tsx +++ b/frontend/src/components/routes/account/ManageAccount/index.tsx @@ -1,7 +1,7 @@ import {Card} from "../../../common/Card"; import {Tabs} from "@mantine/core"; import classes from "./ManageAccount.module.scss"; -import {IconAdjustmentsCog, IconReceiptTax, IconUsers} from "@tabler/icons-react"; +import {IconAdjustmentsCog, IconAlertTriangle, IconReceiptTax, IconUsers} from "@tabler/icons-react"; import {Outlet, useLocation, useNavigate} from "react-router"; import {t} from "@lingui/macro"; import {useIsCurrentUserAdmin} from "../../../../hooks/useIsCurrentUserAdmin.ts"; @@ -30,6 +30,12 @@ export const ManageAccount = () => { {t`Users`} )} + + {isUserAdmin && ( + } data-testid="danger-zone-tab"> + {t`Danger Zone`} + + )} diff --git a/frontend/src/components/routes/account/ManageAccount/sections/DangerZone/DangerZone.module.scss b/frontend/src/components/routes/account/ManageAccount/sections/DangerZone/DangerZone.module.scss new file mode 100644 index 0000000000..24c1312bfe --- /dev/null +++ b/frontend/src/components/routes/account/ManageAccount/sections/DangerZone/DangerZone.module.scss @@ -0,0 +1,14 @@ +.card { + display: flex; + flex-direction: column; + gap: var(--hi-spacing-sm); + align-items: flex-start; + + p { + margin: 0; + } +} + +.modalText { + margin-top: 0; +} diff --git a/frontend/src/components/routes/account/ManageAccount/sections/DangerZone/index.tsx b/frontend/src/components/routes/account/ManageAccount/sections/DangerZone/index.tsx new file mode 100644 index 0000000000..4a66eacdf1 --- /dev/null +++ b/frontend/src/components/routes/account/ManageAccount/sections/DangerZone/index.tsx @@ -0,0 +1,192 @@ +import {Alert, Button, Modal, Textarea, TextInput} from "@mantine/core"; +import {useForm} from "@mantine/form"; +import {useDisclosure} from "@mantine/hooks"; +import {IconAlertTriangle, IconTrash} from "@tabler/icons-react"; +import {t, Trans} from "@lingui/macro"; +import {useGetAccount} from "../../../../../../queries/useGetAccount.ts"; +import {useGetAccountDeletionStatus} from "../../../../../../queries/useGetAccountDeletionStatus.ts"; +import {useRequestAccountDeletion} from "../../../../../../mutations/useRequestAccountDeletion.ts"; +import {useCancelAccountDeletion} from "../../../../../../mutations/useCancelAccountDeletion.ts"; +import {useGetMe} from "../../../../../../queries/useGetMe.ts"; +import {showError, showSuccess} from "../../../../../../utilites/notifications.tsx"; +import {useFormErrorResponseHandler} from "../../../../../../hooks/useFormErrorResponseHandler.tsx"; +import {prettyDate} from "../../../../../../utilites/dates.ts"; +import {Card} from "../../../../../common/Card"; +import {HeadingCard} from "../../../../../common/HeadingCard"; +import {LoadingMask} from "../../../../../common/LoadingMask"; +import classes from "./DangerZone.module.scss"; + +const DangerZone = () => { + const accountQuery = useGetAccount(); + const deletionStatusQuery = useGetAccountDeletionStatus(); + const {data: me} = useGetMe(); + const requestDeletionMutation = useRequestAccountDeletion(); + const cancelDeletionMutation = useCancelAccountDeletion(); + const formErrorHandler = useFormErrorResponseHandler(); + const [modalOpened, {open: openModal, close: closeModal}] = useDisclosure(false); + + const form = useForm({ + initialValues: { + confirmation: '', + reason: '', + } + }); + + const account = accountQuery.data; + const deletionStatus = deletionStatusQuery.data; + const isOwner = !!me?.is_account_owner; + + if (!account || !deletionStatus) { + return ; + } + + const pendingRequest = deletionStatus.deletion_request; + const willBeAnonymized = deletionStatus.expected_outcome === 'ANONYMIZE'; + + const handleRequestDeletion = (values: { confirmation: string; reason: string }) => { + requestDeletionMutation.mutate({ + confirmation: values.confirmation, + reason: values.reason || undefined, + }, { + onSuccess: () => { + closeModal(); + showSuccess(t`Your account is now scheduled for deletion.`); + }, + onError: (error: any) => { + if (error?.response?.status === 409) { + closeModal(); + showError(error.response.data.message); + return; + } + formErrorHandler(form, error); + } + }); + }; + + const handleCancelDeletion = () => { + cancelDeletionMutation.mutate(undefined, { + onSuccess: () => { + showSuccess(t`Account deletion has been cancelled.`); + }, + onError: (error: any) => { + showError(error?.response?.data?.message || t`Something went wrong. Please try again.`); + } + }); + }; + + return ( + <> + + + + {pendingRequest && ( + } + title={t`This account is scheduled for deletion`} + > +

+ + Your account has been deactivated and will be permanently deleted + on {prettyDate(pendingRequest.scheduled_deletion_at, account.timezone || 'UTC')}. + +

+ +
+ )} + + {!pendingRequest && !deletionStatus.can_request_deletion && ( + } title={t`Account deletion is blocked`}> + {deletionStatus.cannot_delete_reason} + + )} + + {!pendingRequest && deletionStatus.can_request_deletion && ( + <> +

+ + Deleting your account deactivates it immediately: all published events are + unpublished and access is disabled. After a 30-day grace period, the deletion + becomes permanent. You can cancel at any time during those 30 days. + +

+

+ {willBeAnonymized + ? + Because this account has completed orders, transaction records (amounts, + dates, and invoice details) will be retained in an anonymized form for legal + and tax purposes. All personal information will be permanently removed. + + : + This account has no completed orders, so all of its data will be permanently + deleted. + } +

+ {!isOwner && ( + }> + {t`Only the account owner can request account deletion.`} + + )} + {isOwner && ( + + )} + + )} +
+ + +
+

+ + This will deactivate your account immediately and permanently delete it after 30 + days. To confirm, type your account name: {account.name} + +

+ +