Initial commit
This commit is contained in:
566
app/Actions/Jetstream/DeleteUser.php
Normal file
566
app/Actions/Jetstream/DeleteUser.php
Normal file
@@ -0,0 +1,566 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user