Files
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

567 lines
22 KiB
PHP

<?php
namespace App\Actions\Jetstream;
use App\Models\User;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\DB;
use Laravel\Jetstream\Contracts\DeletesUsers;
use Throwable;
class DeleteUser implements DeletesUsers
{
/**
* Delete the given user.
*
* @param mixed $user
* @param string $balanceHandlingOption
* @param int|null $donationAccountId
* @param bool $isAutoDeleted
* @param string|null $deletedByUsername
* @return void
*/
public function delete($user, $balanceHandlingOption = 'delete', $donationAccountId = null, $isAutoDeleted = false, $deletedByUsername = null)
{
try {
// Use a transaction for deleting the user
// START
DB::transaction(function () use ($user, $balanceHandlingOption, $donationAccountId, $isAutoDeleted, $deletedByUsername): void {
// Check for negative balances before proceeding with deletion
$userAccounts = $user->accounts()->active()->notRemoved()->get();
foreach ($userAccounts as $account) {
\Cache::forget("account_balance_{$account->id}");
if ($account->balance < 0) {
\Log::error('Profile deletion blocked: negative balance detected', [
'user_id' => $user->id,
'account_id' => $account->id,
'account_name' => $account->name,
'balance' => $account->balance
]);
throw new \Exception('Cannot delete profile with negative balance. Please settle all debts before deleting your profile.');
}
}
// Store balance handling preferences in cache for later use
// This will be used by permanentlyDelete() after grace period
// Fallback: if cache is lost, currency will be destroyed (transferred to debit account)
$balanceHandlingData = [
'option' => $balanceHandlingOption,
'donation_account_id' => $donationAccountId,
'stored_at' => now()->toDateTimeString(),
];
// Store in cache with TTL = grace period + 7 days buffer
$gracePeriodDays = timebank_config('delete_profile.grace_period_days', 30);
$cacheKey = 'balance_handling_' . get_class($user) . '_' . $user->id;
\Cache::put($cacheKey, $balanceHandlingData, now()->addDays($gracePeriodDays + 7));
// Set human-readable comment (always in English for database storage)
if ($isAutoDeleted) {
// Auto-deletion due to inactivity
$daysInactive = timebank_config('profile_inactive.days_not_logged_in') + timebank_config('delete_profile.days_after_inactive.run_delete');
$user->comment = 'Profile automatically deleted after ' . $daysInactive . ' days of inactivity.';
} elseif ($deletedByUsername) {
// Admin/manager deletion
$user->comment = 'Profile deleted by ' . $deletedByUsername;
} else {
// Self-deletion by profile owner
$user->comment = 'Profile deleted by self-deletion';
}
// Mark profile as deleted (soft delete with grace period)
// Balances will be handled after grace period by scheduled command
// Accounts remain active during grace period to allow restoration
$user->deleted_at = now();
$user->save();
// Delete tokens to force logout
if ($user instanceof \App\Models\User) {
$user->tokens->each->delete();
}
});
// STOP
// End of transaction
return ['status' => 'success'];
} catch (Throwable $e) {
return ['status' => 'error', 'message' => $e->getMessage()];
}
}
/**
* Donate user's account balances to an organization.
*
* @param mixed $user
* @param int $donationAccountId
* @return void
*/
protected function donateBalancesToOrganization($user, $donationAccountId)
{
\Log::info('Starting balance donation', [
'user_id' => $user->id,
'donation_account_id' => $donationAccountId
]);
// Get the donation target account
$toAccount = \App\Models\Account::find($donationAccountId);
if (!$toAccount) {
\Log::error('Donation account not found', ['donation_account_id' => $donationAccountId]);
throw new \Exception('Donation account not found.');
}
\Log::info('Donation target account found', [
'account_id' => $toAccount->id,
'account_name' => $toAccount->name,
'accountable_type' => $toAccount->accountable_type
]);
// Verify the target account is an organization
if ($toAccount->accountable_type !== 'App\\Models\\Organization') {
\Log::error('Target account is not an organization', [
'accountable_type' => $toAccount->accountable_type
]);
throw new \Exception('The selected account is not an organization account.');
}
// Get all active accounts belonging to the user with positive balances
$userAccounts = $user->accounts()
->active()
->notRemoved()
->get();
// Clear balance cache for all accounts to ensure we get current values
foreach ($userAccounts as $account) {
\Cache::forget("account_balance_{$account->id}");
}
\Log::info('User accounts found', [
'count' => $userAccounts->count(),
'accounts' => $userAccounts->map(function($acc) {
return [
'id' => $acc->id,
'name' => $acc->name,
'balance' => $acc->balance
];
})
]);
$totalTransferred = 0;
$transactionsCreated = 0;
foreach ($userAccounts as $fromAccount) {
// Calculate the current balance
$balance = $fromAccount->balance;
\Log::info('Processing account', [
'account_id' => $fromAccount->id,
'balance' => $balance
]);
// Only create transaction if there's a positive balance
if ($balance > 0) {
try {
// Create a donation transaction
$transaction = \App\Models\Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'transaction_type_id' => 3, // Donation type
'amount' => $balance,
'description' => 'Balance donation from deleted profile ' . $user->name,
'created_at' => now(),
'updated_at' => now(),
]);
$totalTransferred += $balance;
$transactionsCreated++;
\Log::info('Transaction created successfully', [
'transaction_id' => $transaction->id,
'amount' => $balance
]);
} catch (\Exception $e) {
\Log::error('Failed to create transaction', [
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => $balance,
'error' => $e->getMessage()
]);
throw new \Exception('Failed to create donation transaction: ' . $e->getMessage());
}
}
}
\Log::info('Balance donation completed', [
'transactions_created' => $transactionsCreated,
'total_transferred' => $totalTransferred
]);
// Mark associated accounts inactive
$user->accounts()->update(['inactive_at' => now(), 'deleted_at' => now()]);
}
/**
* Transfer user's account balances to a bank the profile was a client of.
*
* @param mixed $user
* @return void
*/
protected function transferBalancesToBankClient($user)
{
// Get all active accounts belonging to the user with positive balances
$userAccounts = $user->accounts()
->active()
->notRemoved()
->get();
// Find banks the user was a client of
// This would need to be implemented based on your bank-client relationship structure
// For now, this is a placeholder for the implementation
foreach ($userAccounts as $fromAccount) {
$balance = $fromAccount->balance;
if ($balance > 0) {
// Transfer to bank account logic would go here
// You would need to determine which bank account to use
}
}
}
/**
* Transfer user's account balances to a specific account ID.
*
* @param mixed $user
* @param int $accountId
* @return void
*/
protected function transferBalancesToSpecificAccount($user, $accountId)
{
// Get the target account
$toAccount = \App\Models\Account::find($accountId);
if (!$toAccount) {
throw new \Exception('Target account not found.');
}
// Get all active accounts belonging to the user with positive balances
$userAccounts = $user->accounts()
->active()
->notRemoved()
->get();
foreach ($userAccounts as $fromAccount) {
$balance = $fromAccount->balance;
if ($balance > 0) {
// Create a donation transaction to the specified account
\App\Models\Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'transaction_type_id' => 3, // Donation type
'amount' => $balance,
'description' => 'Balance transfer from deleted profile ' . $user->name,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
// Mark associated accounts inactive
$user->accounts()->update(['inactive_at' => now(), 'deleted_at' => now()]);
}
/**
* Transfer user's account balances to debit account (remove currency from circulation).
*
* @param mixed $user
* @return void
*/
protected function transferBalancesToDebitAccount($user)
{
// Get all active accounts belonging to the user with positive balances
$userAccounts = $user->accounts()
->active()
->notRemoved()
->get();
// Find the debit account (typically a system account for currency removal)
$debitAccount = \App\Models\Account::where('name', 'debit')
->whereHasMorph('accountable', [\App\Models\Bank::class])
->first();
if (!$debitAccount) {
throw new \Exception('Debit account not found for currency removal.');
}
foreach ($userAccounts as $fromAccount) {
$balance = $fromAccount->balance;
if ($balance > 0) {
// Create a currency removal transaction
\App\Models\Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $debitAccount->id,
'transaction_type_id' => 5, // Currency removal type
'amount' => $balance,
'description' => 'Currency removal from deleted profile ' . $user->name,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
// Mark associated accounts inactive
$user->accounts()->update(['inactive_at' => now(), 'deleted_at' => now()]);
}
/**
* Permanently delete a profile by handling balances and anonymizing all data.
* Called by scheduled command after grace period expires.
*
* @param mixed $user
* @return array
*/
public function permanentlyDelete($user)
{
try {
DB::transaction(function () use ($user): void {
$profileType = get_class($user);
$profileTypeName = class_basename($profileType);
// Retrieve balance handling preferences from cache
$cacheKey = 'balance_handling_' . get_class($user) . '_' . $user->id;
$balanceHandlingData = \Cache::get($cacheKey);
// Fallback: try to parse from comment field if it's JSON (old format compatibility)
if (!$balanceHandlingData && $user->comment && str_starts_with($user->comment, '{')) {
$balanceHandlingData = json_decode($user->comment, true);
}
// Handle balances before anonymization
if ($balanceHandlingData && isset($balanceHandlingData['option'])) {
$option = $balanceHandlingData['option'];
$donationAccountId = $balanceHandlingData['donation_account_id'] ?? null;
// Execute balance handling based on stored option
if ($option === 'donate' && $donationAccountId) {
$this->donateBalancesToOrganization($user, $donationAccountId);
} elseif ($option === 'delete') {
// User chose to delete balance - destroy currency
$this->transferBalancesToDebitAccount($user);
}
} else {
// FALLBACK: Cache lost or no data stored
// Destroy currency (transfer to debit account) as safe default
\Log::warning('Balance handling cache lost for profile deletion', [
'user_id' => $user->id,
'user_name' => $user->name,
'fallback' => 'destroying_currency'
]);
$this->transferBalancesToDebitAccount($user);
}
// Handle WireChat kept messages to prevent orphaned data
// This is ALWAYS done when profile is permanently deleted (not optional)
if (timebank_config('wirechat.profile_deletion.release_kept_messages', true)) {
$releasedCount = \DB::table('wirechat_messages')
->where('sendable_id', $user->id)
->where('sendable_type', get_class($user))
->whereNotNull('kept_at')
->update([
'kept_at' => null,
'updated_at' => now()
]);
if ($releasedCount > 0) {
\Log::info('WireChat kept messages released for deleted profile', [
'profile_id' => $user->id,
'profile_type' => get_class($user),
'profile_name' => $user->name,
'messages_released' => $releasedCount
]);
}
}
// Detach all relationships that do not need any historic record
// User-specific relationships
if ($user instanceof \App\Models\User) {
if (method_exists($user, 'locations')) {
$user->locations()->delete();
}
if (method_exists($user, 'languages')) {
$user->languages()->detach();
}
if (method_exists($user, 'socials')) {
$user->socials()->detach();
}
if (method_exists($user, 'organizations')) {
$user->organizations()->detach();
}
if (method_exists($user, 'bankClients')) {
$user->bankClients()->detach();
}
if (method_exists($user, 'banksManaged')) {
$user->banksManaged()->detach();
}
if (method_exists($user, 'admins')) {
$user->admins()->detach();
}
}
// Organization/Bank/Admin specific relationships
if ($user instanceof \App\Models\Organization) {
if (method_exists($user, 'users')) {
$user->users()->detach();
}
}
if ($user instanceof \App\Models\Bank) {
if (method_exists($user, 'managers')) {
$user->managers()->detach();
}
}
if ($user instanceof \App\Models\Admin) {
if (method_exists($user, 'users')) {
$user->users()->detach();
}
}
// Common relationships for all profile types
if (method_exists($user, 'locations')) {
$user->locations()->delete();
}
// Anonymize profile
$anonymousId = $this->generateAnonymousId($profileType);
$user->name = 'Removed ' . strtolower($profileTypeName) . ' ' . $anonymousId;
$user->full_name = 'Removed ' . strtolower($profileTypeName) . ' ' . $anonymousId;
$user->email = 'removed-' . $anonymousId . '@remove.ed';
$user->email_verified_at = null;
$user->password = "";
if (property_exists($user, 'two_factor_secret')) {
$user->two_factor_secret = null;
}
if (property_exists($user, 'two_factor_recovery_codes')) {
$user->two_factor_recovery_codes = null;
}
if (property_exists($user, 'two_factor_confirmed_at')) {
$user->two_factor_confirmed_at = null;
}
$user->deleteProfilePhoto();
$user->profile_photo_path = 'app-images/profile-user-removed.svg';
$user->about = null;
$user->about_short = null;
$user->motivation = null;
if (property_exists($user, 'date_of_birth')) {
$user->date_of_birth = null;
}
$user->website = null;
$user->phone = null;
$user->phone_public = 0;
if (property_exists($user, 'remember_token')) {
$user->remember_token = null;
}
if (property_exists($user, 'current_team_id')) {
$user->current_team_id = null;
}
if (property_exists($user, 'cyclos_id')) {
$user->cyclos_id = null;
}
if (property_exists($user, 'cyclos_salt')) {
$user->cyclos_salt = null;
}
if (property_exists($user, 'cyclos_skills')) {
$user->cyclos_skills = null;
}
$user->limit_min = 0;
$user->limit_max = 0;
$user->comment = null;
$user->lang_preference = null;
if (property_exists($user, 'principles_terms_accepted')) {
$user->principles_terms_accepted = null;
}
$user->last_login_ip = null;
$user->save();
// Unreact all Laravel-love reactions
if (!($user instanceof \App\Models\Admin)) {
$reacterFacade = $user->getloveReacter();
$reactions = $reacterFacade->getReactions()->load(['reactant', 'type']);
foreach ($reactions as $reaction) {
if ($reaction->reactant && $reaction->type) {
$reacterFacade->unReactTo($reaction->reactant, $reaction->type);
}
}
$reactantFacade = $user->getloveReactant();
$receivedReactions = $reactantFacade->getReactions()->load(['reacter', 'type']);
foreach ($receivedReactions as $reaction) {
if ($reaction->reacter && $reaction->type) {
$reaction->reacter->unReactTo($reaction->reactant, $reaction->type);
}
}
}
// Remove all taggable skills
if (!($user instanceof \App\Models\Admin)) {
$user->detag();
}
// Clear the balance handling cache
$cacheKey = 'balance_handling_' . get_class($user) . '_' . $user->id;
\Cache::forget($cacheKey);
});
return ['status' => 'success'];
} catch (Throwable $e) {
return ['status' => 'error', 'message' => $e->getMessage()];
}
}
/**
* Generate a short, anonymous, unique identifier for deleted profiles.
*
* @param string $profileType The profile model class name
* @return string 8-character alphanumeric ID
*/
protected function generateAnonymousId($profileType)
{
$attempts = 0;
$maxAttempts = 100;
do {
// Generate 8-character random alphanumeric string (lowercase for consistency)
$anonymousId = strtolower(substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789'), 0, 8));
// Check if this ID is already used in name or email fields
$nameExists = $profileType::where('name', 'like', '%' . $anonymousId . '%')->exists();
$emailExists = $profileType::where('email', 'like', '%' . $anonymousId . '%')->exists();
$attempts++;
if ($attempts >= $maxAttempts) {
// Fallback to timestamp-based ID if we can't find a unique random one
$anonymousId = strtolower(substr(md5(microtime()), 0, 8));
break;
}
} while ($nameExists || $emailExists);
return $anonymousId;
}
}