organization) stays out of reach. */ public ?int $scopedToBankId = null; public string $search = ''; public ?string $typeFilter = null; public ?string $activeFilter = null; public ?string $emailVerifiedFilter = null; public bool $showModal = false; public $createTranslation; public int $profileId; public bool $buttonDisabled = true; public string $confirmString; public int $categoryId; public int $profile; public bool $modalEditAccounts = false; public bool $modalEditProfile = false; public bool $modalAttachProfile = false; public bool $modalDeleteProfile = false; public bool $modalRestoreProfile = false; public bool $modalConvertToUser = false; public $convertToUserData = null; public bool $modalConvertToOrganization = false; public $convertToOrganizationData = null; public $convertToOrganizationSelectedManager = null; // ['id' => ..., 'name' => ..., 'type' => ...] #[Locked] public $selectedProfile; #[Locked] public int $selectedProfileId; // Needed for the modals #[Locked] public $deleteProfileData = null; #[Locked] public $restoreProfileData = null; public string $balanceHandlingOption = 'delete'; public $donationAccountId = null; public string $adminPassword = ''; public bool $donationExceedsLimit = false; public $donationLimitError = null; public $initProfile = []; public $editProfile = []; public bool $editProfileChangedName = false; public $editProfileMessages = []; public bool $editProfileChanged = false; public bool $reActivate = false; public $initMessageSettings = []; public $editMessageSettings = []; public bool $editMessageSettingsChanged = false; public $initAttachProfile = []; public $editAttachProfile = []; public $newTypesAvailable = []; public bool $editAttachProfileChanged = false; public $initAccounts = []; public $editAccounts = []; public $editAccountsMessages = []; public bool $editAccountsChanged = false; public bool $modalReversal = false; public $reversalLookupId = ''; public $reversalLookupError = null; public $reversalSelectedTransactionId = null; public $reversalAmount = null; public $reversalDescription = ''; public string $reversalConfirmString = ''; public $reversalSenderName = null; public $reversalRecipientName = null; public $reversalDirection = null; public $reversalCreatedAt = null; public $reversalRequesterOptions = []; public $reversalRequestedBy = null; public string $sortField = 'created_at'; // Default sort field public string $sortDirection = 'desc'; // Default sort direction public $perPage = 10; // default 10 results per page public $page; protected $listeners = [ 'profileCreated' => '$refresh', // $refresh is a magic method that forces a re-render 'selectedProfile', 'selectedProfileForConvertToOrganization', 'toAccountId' => 'setDonationAccountId' ]; public function mount() { // Admin Authorization - Prevent IDOR attacks and cross-guard access $activeProfileType = session('activeProfileType'); $activeProfileId = session('activeProfileId'); if (!$activeProfileType || !$activeProfileId) { abort(403, __('No active profile selected')); } $profile = $activeProfileType::find($activeProfileId); if (!$profile) { abort(403, __('Active profile not found')); } // Validate profile ownership using ProfileAuthorizationHelper (prevents cross-guard attacks) \App\Helpers\ProfileAuthorizationHelper::authorize($profile); // Verify admin, central bank, or scoped bank-manager permissions if ($profile instanceof \App\Models\Admin) { // Admin access OK — full, unscoped access } elseif ($profile instanceof \App\Models\Bank) { if ($profile->level === 0) { // Central bank: full, unscoped access (existing behavior) } elseif ($this->isActiveBankManager()) { // Regular bank-manager: scoped to this bank's own clients, // accounts-related actions only (see authorizeAccountsScopeAction()). // Gated on the bank-manager role itself (not bank-coordinator), // matching getCanCreatePayments()'s convention — bank-scoped roles // created via Roles\Manage.php only carry 'org *' style capability // permissions, never the Admin-style 'manage accounts' permission. $this->scopedToBankId = $profile->id; } else { abort(403, __('Central bank or bank-manager access required')); } } else { abort(403, __('Admin or central bank access required')); } // Log admin access for security monitoring \Log::info('Profiles management access', [ 'component' => 'Profiles\\Manage', 'profile_id' => $profile->id, 'profile_type' => get_class($profile), 'scoped_to_bank_id' => $this->scopedToBankId, 'authenticated_guard' => \Auth::getDefaultDriver(), 'ip_address' => request()->ip(), ]); } /** * True when the currently authenticated user holds the `bank-manager` * scoped role (not `bank-coordinator`) for the active Bank profile. * Mirrors getCanCreatePayments()'s convention: bank-scoped roles created * via Roles\Manage.php only ever carry 'org *' style capability * permissions (never the Admin-style 'manage accounts' permission * string), so role membership itself is the correct gate here. */ protected function isActiveBankManager(): bool { $user = Auth::guard('web')->user(); $activeType = session('activeProfileType'); $activeId = session('activeProfileId'); if (!$user || $activeType !== \App\Models\Bank::class || !$activeId) { return false; } return $user->hasRole("Bank\\{$activeId}\\bank-manager"); } /** * Enforce the scoped-bank-manager restrictions for account-related actions: * the active profile must actually hold the bank-manager role (re-checked, * not just trusted from mount()) and the target profile must be one of the * bank's own clients (via bank_clients pivot). * * No-op when the active profile is Admin or the central bank (unscoped). */ protected function authorizeAccountsScopeAction(int $profileId, string $modelName): void { if ($this->scopedToBankId === null) { return; } if (!$this->isActiveBankManager()) { abort(403, __('You do not have permission to perform this action.')); } if (!in_array($modelName, [\App\Models\User::class, \App\Models\Organization::class], true)) { abort(403, __('You do not have permission to perform this action.')); } $isClient = DB::table('bank_clients') ->where('bank_id', $this->scopedToBankId) ->where('client_type', $modelName) ->where('client_id', $profileId) ->exists(); if (!$isClient) { abort(403, __('This profile is not a client of your bank.')); } } /** * True when the given profile is a client of the active scoped bank. * Always true (no-op) for Admin/central-bank (unscoped) access. */ protected function isClientOfActiveBank(int $profileId, string $modelName): bool { if ($this->scopedToBankId === null) { return true; } return DB::table('bank_clients') ->where('bank_id', $this->scopedToBankId) ->where('client_type', $modelName) ->where('client_id', $profileId) ->exists(); } /** * Authorize a reversal against the original sender/recipient pair: a * scoped bank-manager may act as long as EITHER party is one of their * bank's own clients (not necessarily both) — matching the fact that a * reversal moves money on behalf of whichever side is their client. */ protected function authorizeReversalScopeAction( int $senderProfileId, string $senderModelClass, int $recipientProfileId, string $recipientModelClass ): void { if ($this->scopedToBankId === null) { return; } if (!$this->isActiveBankManager()) { abort(403, __('You do not have permission to perform this action.')); } $senderInScope = in_array($senderModelClass, [\App\Models\User::class, \App\Models\Organization::class], true) && $this->isClientOfActiveBank($senderProfileId, $senderModelClass); $recipientInScope = in_array($recipientModelClass, [\App\Models\User::class, \App\Models\Organization::class], true) && $this->isClientOfActiveBank($recipientProfileId, $recipientModelClass); if (!$senderInScope && !$recipientInScope) { abort(403, __('Neither party of this transaction is a client of your bank.')); } } /** * True when only the accounts-related actions should be shown/reachable * (i.e. active profile is a scoped, non-central bank-manager). */ public function getIsAccountsOnlyScopeProperty(): bool { return $this->scopedToBankId !== null; } /** * Authorize account-editing actions (view/edit accounts, reversal). * Scoped bank-managers are gated by bank-manager role membership instead * of the Admin-only 'update profiles' permission; Admins/central-bank * keep the original check. */ protected function authorizeAccountsPermission(): void { if ($this->scopedToBankId !== null) { if (!$this->isActiveBankManager()) { abort(403, __('You do not have permission to perform this action.')); } return; } $this->authorizePermission('update profiles'); } /** * Block every non-accounts action (profile edit/delete/restore/attach, * convert user<->organization) for scoped, non-central bank-managers. */ protected function denyIfScopedToBank(): void { if ($this->scopedToBankId !== null) { abort(403, __('You do not have permission to perform this action.')); } } public function setDonationAccountId($accountId) { $this->donationAccountId = $accountId; $this->checkDonationLimits(); } /** * Check if the donation would exceed the receiving account's limits. * * @return void */ protected function checkDonationLimits() { // Reset error state $this->donationExceedsLimit = false; $this->donationLimitError = null; // If no donation account selected or no balance to donate, skip check if (!$this->donationAccountId || !isset($this->deleteProfileData['totalBalance']) || $this->deleteProfileData['totalBalance'] <= 0) { return; } // Get the donation account $donationAccount = \App\Models\Account::find($this->donationAccountId); if (!$donationAccount) { return; } // Clear cache for fresh balance \Cache::forget("account_balance_{$donationAccount->id}"); // Get current balance of the receiving account $currentBalance = $donationAccount->balance; // Calculate the maximum receivable amount // limitMaxTo = limit_max - limit_min (similar to Pay.php logic) $limitMaxTo = $donationAccount->limit_max - $donationAccount->limit_min; // Calculate available budget for receiving $transferBudgetTo = $limitMaxTo - $currentBalance; // Check if donation amount exceeds the receiving account's budget if ($this->deleteProfileData['totalBalance'] > $transferBudgetTo) { $this->donationExceedsLimit = true; // Check if the receiving account holder's balance is public $holderType = $donationAccount->accountable_type; $balancePublic = timebank_config('account_info.' . strtolower(class_basename($holderType)) . '.balance_public', false); if ($balancePublic) { $this->donationLimitError = __('The selected organization account can only receive up to :amount more. Please select a different organization or delete the balance instead.', [ 'amount' => tbFormat($transferBudgetTo) ]); } else { $this->donationLimitError = __('The selected organization account cannot receive this donation amount due to account limits. Please select a different organization or delete the balance instead.'); } } } /** * Called when balanceHandlingOption is updated. * * @return void */ public function updatedBalanceHandlingOption() { $this->checkDonationLimits(); } protected function rules() { $modelKey = $this->editProfile['model'] ?? 'user'; // Default or handle if not set $baseRules = [ 'editProfile.inactive_at' => ['nullable', 'date'], 'editProfile.name' => timebank_config("rules.profile_{$modelKey}.name"), 'editProfile.full_name' => timebank_config("rules.profile_{$modelKey}.full_name"), 'editProfile.email' => timebank_config("rules.profile_{$modelKey}.email"), 'editProfile.about_short' => timebank_config("rules.profile_{$modelKey}.about_short"), 'editProfile.about' => timebank_config("rules.profile_{$modelKey}.about"), 'editProfile.motivation' => timebank_config("rules.profile_{$modelKey}.motivation"), 'editProfile.phone' => timebank_config('rules.phone'), 'editProfile.website' => timebank_config("rules.profile_{$modelKey}.website"), 'editProfile.comment' => timebank_config('rules.comment'), 'editAccounts.profileLimitMin' => ['required', 'integer', 'min:-16666', 'max:0'], 'editAccounts.profileLimitMax' => ['required', 'integer', 'min:0', 'max:16666'], 'editAccounts.accounts.*.rawName' => ['required', 'string', 'max:50'], 'editAccounts.accounts.*.editLimitMin' => ['required', 'integer', 'min:-16666', 'max:0'], 'editAccounts.accounts.*.editLimitMax' => ['required', 'integer', 'min:0', 'max:16666'], 'editAccounts.accounts.*.markInactive' => ['boolean'], 'editAttachProfile.comment' => timebank_config('rules.comment'), 'editAttachProfile.profile' => ['required', 'array'], 'editAttachProfile.profile.id' => ['required', 'integer'], 'editAttachProfile.profile.model' => ['required', 'string'], 'editAttachProfile.profiles.*.role' => ['nullable', 'string', 'in:' . implode(',', array_merge(\App\Models\OrganizationRole::pluck('name')->toArray(), \App\Models\AdminRole::pluck('name')->toArray()))], 'editAttachProfile.newProfile.role' => ['nullable', 'string', 'in:' . implode(',', array_merge(\App\Models\OrganizationRole::pluck('name')->toArray(), \App\Models\AdminRole::pluck('name')->toArray()))], 'confirmString' => ['required', function (string $attribute, $value, $fail) { $expected = strtolower(__('messages.confirm_input_string')); if (strtolower($value) !== $expected) { $fail(__('The confirmation keyword is incorrect.')); } }, ], ]; // Add level validation for Bank models only $modelClass = $this->editProfile['modelClass'] ?? null; if ($modelClass === \App\Models\Bank::class) { $baseRules['editProfile.level'] = ['required', 'integer', 'in:1,2']; } // Ensure all loaded rules are arrays and handle nulls from config foreach ($baseRules as $key => $ruleSet) { if (is_string($ruleSet)) { $baseRules[$key] = explode('|', $ruleSet); } elseif (is_null($ruleSet)) { $baseRules[$key] = []; // Default to empty array if config key is missing or null } } return $baseRules; } protected $queryString = [ 'search' => ['except' => ''], 'typeFilter' => ['except' => ''], 'activeFilter' => ['except' => ''], 'emailVerifiedFilter' => ['except' => ''], 'sortField' => ['except' => 'created_at'], // Fixed to match actual default 'sortDirection' => ['except' => 'desc'], 'perPage' => ['except' => 10], 'page' => ['except' => 1] ]; private function validateModelName(string $modelName): void { $allowed = [ \App\Models\User::class, \App\Models\Organization::class, \App\Models\Bank::class, \App\Models\Admin::class, ]; if (!in_array($modelName, $allowed, true)) { abort(422, 'Invalid model type'); } } public function openEditAccountsModal($profileId, $modelName) { $this->authorizeAccountsPermission(); $this->validateModelName($modelName); $this->authorizeAccountsScopeAction((int) $profileId, $modelName); $this->confirmString = ''; $profile = $modelName::with('accounts')->find($profileId); if (!$profile) { $modelBasename = class_basename($modelName); throw new \Exception("{$modelBasename} profile with ID {$profileId} not found."); } $this->editAccounts = $profile->makeVisible([ 'id', 'name', 'full_name', 'email', 'email_verified_at', 'limit_min', 'limit_max', 'lang_preference', 'last_login_at', 'last_login_ip', 'inactive_at', 'deleted_at', 'updated_at', 'created_at', ])->toArray(); $this->editAccounts['model'] = strtolower(class_basename($modelName)); $this->editAccounts['modelClass'] = $modelName; $this->editAccounts['profileLimitMin'] = (int) round(($profile->limit_min ?? 0) / 60); $this->editAccounts['profileLimitMax'] = (int) round(($profile->limit_max ?? 0) / 60); $this->editAccounts['location'] = $profile->getLocationFirst()['name']; \Illuminate\Support\Facades\Cache::forget("accounts_info_{$modelName}_{$profileId}"); $this->editAccounts['accounts'] = $this->getAccountsInfo($modelName, $profileId)->toArray(); // Use the AccountInfoTrait // Add raw (DB) name and editable fields to each account $rawAccounts = $profile->accounts()->orderBy('id')->get(['id', 'name', 'limit_min', 'limit_max', 'inactive_at']); $accountIds = $rawAccounts->pluck('id')->toArray(); $txCounts = DB::table('transactions') ->whereIn('from_account_id', $accountIds) ->orWhereIn('to_account_id', $accountIds) ->selectRaw('from_account_id as account_id, COUNT(*) as cnt') ->groupBy('from_account_id') ->unionAll( DB::table('transactions') ->whereIn('to_account_id', $accountIds) ->selectRaw('to_account_id as account_id, COUNT(*) as cnt') ->groupBy('to_account_id') ) ->get() ->groupBy('account_id') ->map(fn($rows) => $rows->sum('cnt')); foreach ($this->editAccounts['accounts'] as $index => $account) { $raw = $rawAccounts->firstWhere('id', $account['id']); $this->editAccounts['accounts'][$index]['rawName'] = $raw?->name ?? ''; $this->editAccounts['accounts'][$index]['editLimitMin'] = (int) round(($raw?->limit_min ?? 0) / 60); $this->editAccounts['accounts'][$index]['editLimitMax'] = (int) round(($raw?->limit_max ?? 0) / 60); $this->editAccounts['accounts'][$index]['markInactive'] = false; $this->editAccounts['accounts'][$index]['markActivate'] = false; $this->editAccounts['accounts'][$index]['markDelete'] = false; $this->editAccounts['accounts'][$index]['hasTransactions'] = ($txCounts->get($account['id'], 0) > 0); } $this->editAccounts['totals'] = $this->getAccountsTotals($modelName, $profileId); // Use the AccountInfoTrait, include totals for past 12 months $this->editAccounts['totalsPastYear'] = $this->getAccountsTotals($modelName, $profileId, timebank_config('account_info.account_totals.countTransfersSince')); // Use the AccountInfoTrait, include totals for past 12 months $this->initAccounts = $this->editAccounts; // Store the initial profile data for comparison later $this->modalEditAccounts = true; } public function addNewAccount(): void { $this->authorizeAccountsPermission(); $profileType = $this->editAccounts['model']; // e.g. 'user', 'organization', 'bank' $configKey = 'accounts.' . $profileType; $defaults = timebank_config($configKey, []); $limitMin = isset($defaults['limit_min']) ? (int) round(($defaults['limit_min'] ?? 0) / 60) : 0; $limitMax = isset($defaults['limit_max']) ? (int) round(($defaults['limit_max'] ?? 0) / 60) : 0; $this->editAccounts['accounts'][] = [ 'id' => null, // new account — no DB id yet 'rawName' => $defaults['name'] ?? 'personal', 'balance' => 0, 'editLimitMin' => $limitMin, 'editLimitMax' => $limitMax, 'limitMin' => $defaults['limit_min'] ?? 0, 'limitMax' => $defaults['limit_max'] ?? 0, 'limitReceivable' => 0, 'inactive' => false, 'inactiveAt' => null, 'removed' => false, 'deletedAt' => null, 'markInactive' => false, 'isNew' => true, ]; } public function updateAccounts(): void { $this->authorizeAccountsPermission(); $this->validateModelName($this->editAccounts['modelClass'] ?? ''); $this->authorizeAccountsScopeAction((int) $this->editAccounts['id'], $this->editAccounts['modelClass']); $this->buttonDisabled = true; // Re-fetch legitimate account IDs from DB to prevent IDOR via tampered Livewire state $modelClass = $this->editAccounts['modelClass']; $profileInstance = $modelClass::find($this->editAccounts['id']); if (!$profileInstance) { abort(403, 'Profile not found'); } $legitimateIds = $profileInstance->accounts()->pluck('id')->toArray(); foreach ($this->editAccounts['accounts'] as $account) { if (empty($account['isNew']) && !in_array($account['id'], $legitimateIds, true)) { abort(403, 'Account does not belong to this profile'); } } $canEditMinLimit = timebank_config('admin_settings.admin_min_limit_edits', false); $profileType = $this->editAccounts['model'] ?? 'user'; // e.g. 'user', 'organization', 'bank' $configMaxMinutes = timebank_config("profiles.{$profileType}.limit_max"); $configMinMinutes = timebank_config("profiles.{$profileType}.limit_min", 0); // Config values are in minutes; validation compares in hours (UI unit) $maxHours = $configMaxMinutes !== null ? (int) round($configMaxMinutes / 60) : 999999; $minHours = $configMinMinutes !== null ? (int) round($configMinMinutes / 60) : -999999; $accountRules = [ 'editAccounts.profileLimitMax' => ['required', 'integer', 'min:0', "max:{$maxHours}"], ]; if ($canEditMinLimit) { $accountRules['editAccounts.profileLimitMin'] = ['required', 'integer', "min:{$minHours}", 'max:0']; } foreach ($this->editAccounts['accounts'] as $index => $account) { $accountRules["editAccounts.accounts.{$index}.rawName"] = ['required', 'string', 'max:50']; $accountRules["editAccounts.accounts.{$index}.editLimitMax"] = ['required', 'integer', 'min:0', "max:{$maxHours}"]; if ($canEditMinLimit) { $accountRules["editAccounts.accounts.{$index}.editLimitMin"] = ['required', 'integer', "min:{$minHours}", 'max:0']; } $accountRules["editAccounts.accounts.{$index}.markInactive"] = ['boolean']; $accountRules["editAccounts.accounts.{$index}.markActivate"] = ['boolean']; $accountRules["editAccounts.accounts.{$index}.markDelete"] = ['boolean']; } $allBaseRules = $this->rules(); $allRulesToValidate = array_merge($accountRules, [ 'confirmString' => $allBaseRules['confirmString'], ]); $validationMessages = [ 'editAccounts.profileLimitMin.min' => __('The profile min. limit cannot be lower than :min hours.', ['min' => $minHours]), 'editAccounts.profileLimitMin.max' => __('The profile min. limit must be 0 or negative.'), 'editAccounts.profileLimitMax.min' => __('The profile max. limit must be 0 or positive.'), 'editAccounts.profileLimitMax.max' => __('The profile max. limit cannot exceed :max hours.', ['max' => $maxHours]), ]; foreach ($this->editAccounts['accounts'] as $index => $account) { $label = $account['rawName']; $validationMessages["editAccounts.accounts.{$index}.editLimitMin.max"] = __('The min. limit for account ":name" must be 0 or negative.', ['name' => $label]); $validationMessages["editAccounts.accounts.{$index}.editLimitMax.min"] = __('The max. limit for account ":name" must be 0 or positive.', ['name' => $label]); } $validator = \Illuminate\Support\Facades\Validator::make($this->all(), $allRulesToValidate, $validationMessages); if ($validator->fails()) { $this->setErrorBag($validator->errors()); $this->notification()->warning(__('Validation Error'), __('Please correct the errors in the form.')); return; } // Pre-save check: ensure accounts marked for deletion still have 0 transactions foreach ($this->editAccounts['accounts'] as $account) { if (empty($account['isNew']) && !empty($account['markDelete'])) { $txCount = DB::table('transactions') ->where('from_account_id', $account['id']) ->orWhere('to_account_id', $account['id']) ->count(); if ($txCount > 0) { $this->notification()->error( __('Cannot delete account'), __('Account :name (ID :id) now has transactions and cannot be deleted.', [ 'name' => $account['rawName'], 'id' => $account['id'], ]) ); $this->buttonDisabled = false; return; } } } try { DB::transaction(function () use ($canEditMinLimit) { // Save profile-level limits $modelClass = $this->editAccounts['modelClass']; $profileLimitUpdate = ['limit_max' => (int) $this->editAccounts['profileLimitMax'] * 60]; if ($canEditMinLimit) { $profileLimitUpdate['limit_min'] = (int) $this->editAccounts['profileLimitMin'] * 60; } $modelClass::where('id', $this->editAccounts['id'])->update($profileLimitUpdate); foreach ($this->editAccounts['accounts'] as $account) { $accountData = [ 'name' => $account['rawName'], 'limit_max' => (int) $account['editLimitMax'] * 60, ]; if ($canEditMinLimit) { $accountData['limit_min'] = (int) $account['editLimitMin'] * 60; } if (!empty($account['isNew'])) { // Insert new account $modelClass = $this->editAccounts['modelClass']; DB::table('accounts')->insert(array_merge($accountData, [ 'accountable_type' => $modelClass, 'accountable_id' => $this->editAccounts['id'], 'created_at' => now(), 'updated_at' => now(), ])); } elseif (!empty($account['markDelete'])) { // Hard delete — only reaches here if transaction check above passed DB::table('accounts')->where('id', $account['id'])->delete(); } else { // Only set inactive_at if the checkbox is checked and it's not already inactive if (!empty($account['markInactive']) && empty($account['inactiveAt'])) { $accountData['inactive_at'] = now(); } // Activate: clear inactive_at — verify DB state to prevent spoofing if (!empty($account['markActivate'])) { $currentInactiveAt = DB::table('accounts') ->where('id', $account['id']) ->value('inactive_at'); if ($currentInactiveAt !== null) { $accountData['inactive_at'] = null; } } DB::table('accounts')->where('id', $account['id'])->update($accountData); } } }); \Illuminate\Support\Facades\Cache::forget("accounts_info_{$this->editAccounts['modelClass']}_{$this->editAccounts['id']}"); // Send email notification to profile owner $modelClass = $this->editAccounts['modelClass']; $profile = $modelClass::find($this->editAccounts['id']); if ($profile) { $messageSetting = \App\Models\MessageSetting::where('message_settingable_id', $profile->id) ->where('message_settingable_type', get_class($profile)) ->first(); $sendEmail = $messageSetting ? $messageSetting->system_message : true; if ($sendEmail) { // Build a summary of what changed (as translation key + params, translated in the Mailable using the recipient's locale) $changedFields = []; $initAccountsById = collect($this->initAccounts['accounts'] ?? [])->keyBy('id'); foreach ($this->editAccounts['accounts'] as $account) { if (!empty($account['isNew'])) { $changedFields[] = ['key' => 'New account added: :name', 'params' => ['name' => $account['rawName']]]; } elseif (!empty($account['markDelete'])) { $changedFields[] = ['key' => 'Account deleted: :name', 'params' => ['name' => $account['rawName']]]; } elseif (!empty($account['markInactive'])) { $changedFields[] = ['key' => 'Account set to inactive: :name', 'params' => ['name' => $account['rawName']]]; } elseif (!empty($account['markActivate'])) { $changedFields[] = ['key' => 'Account reactivated: :name', 'params' => ['name' => $account['rawName']]]; } else { $init = $initAccountsById->get($account['id']); if ($init) { $limitMinChanged = (int) $account['editLimitMin'] !== (int) ($init['editLimitMin'] ?? 0); $limitMaxChanged = (int) $account['editLimitMax'] !== (int) ($init['editLimitMax'] ?? 0); if ($limitMinChanged || $limitMaxChanged) { $changedFields[] = ['key' => 'Account :name: limit changed to min. :min and max. :max', 'params' => [ 'name' => $account['rawName'], 'min' => tbFormat((int) $account['editLimitMin'] * 60), 'max' => tbFormat((int) $account['editLimitMax'] * 60), ]]; } } } } // Profile-level limits $initLimitMin = $this->initAccounts['profileLimitMin'] ?? null; $initLimitMax = $this->initAccounts['profileLimitMax'] ?? null; if ((int) $this->editAccounts['profileLimitMin'] !== (int) $initLimitMin || (int) $this->editAccounts['profileLimitMax'] !== (int) $initLimitMax) { $changedFields[] = ['key' => 'Profile balance limits changed to min. :min and max. :max', 'params' => [ 'min' => tbFormat((int) $this->editAccounts['profileLimitMin'] * 60), 'max' => tbFormat((int) $this->editAccounts['profileLimitMax'] * 60), ]]; } \App\Jobs\SendAccountsEditedByAdminMail::dispatch($profile, $changedFields); Log::info('AccountsEdited: Dispatched email notification', [ 'recipient_email' => $profile->email ?? 'NO EMAIL', 'changed_fields' => $changedFields, ]); } } $this->notification()->success(__('Saved'), __('The accounts have been updated successfully!')); $this->resetForm(); } catch (\Exception $e) { $this->notification()->error(__('Error!'), __('Oops, we have an error: the accounts were not saved!') . ' ' . $e->getMessage()); } } /** * Open the "Issue reversal" modal for the profile currently open in the * accounts-edit surface. Only reachable from within that surface * (Admin/central-bank unscoped, or a scoped bank-manager acting on one of * their own bank's clients). The bank-manager then looks up a specific * transaction by ID rather than browsing a per-account list — this also * sidesteps the case where a profile has more than one account of the * same type (the lookup checks ALL of the profile's accounts, not one). */ public function openReversalModal(): void { $this->authorizeAccountsPermission(); $modelClass = $this->editAccounts['modelClass'] ?? null; $profileId = $this->editAccounts['id'] ?? null; if (!$modelClass || !$profileId) { $this->notification()->error(__('Error!'), __('Profile not found.')); return; } $this->authorizeAccountsScopeAction((int) $profileId, $modelClass); $this->reversalLookupId = ''; $this->reversalLookupError = null; $this->reversalSelectedTransactionId = null; $this->reversalAmount = null; $this->reversalDescription = ''; $this->reversalConfirmString = ''; $this->reversalSenderName = null; $this->reversalRecipientName = null; $this->reversalDirection = null; $this->reversalCreatedAt = null; $this->modalReversal = true; } /** * Look up a transaction by ID and show its details, provided it touches * one of the accounts belonging to the profile currently open in the * accounts-edit surface, and hasn't already been reversed. */ public function lookupReversalTransaction(): void { $this->authorizeAccountsPermission(); $this->reversalLookupError = null; $this->reversalSelectedTransactionId = null; $this->reversalAmount = null; $this->reversalDescription = ''; $this->reversalSenderName = null; $this->reversalRecipientName = null; $this->reversalDirection = null; $this->reversalCreatedAt = null; $this->reversalRequesterOptions = []; $this->reversalRequestedBy = null; $modelClass = $this->editAccounts['modelClass'] ?? null; $profileId = $this->editAccounts['id'] ?? null; if (!$modelClass || !$profileId) { $this->notification()->error(__('Error!'), __('Profile not found.')); return; } $this->authorizeAccountsScopeAction((int) $profileId, $modelClass); $lookupId = trim((string) $this->reversalLookupId); if ($lookupId === '' || !ctype_digit($lookupId)) { $this->reversalLookupError = __('Please enter a valid transaction ID.'); return; } $profileAccountIds = \App\Models\Account::where('accountable_type', $modelClass) ->where('accountable_id', $profileId) ->pluck('id'); $transaction = \App\Models\Transaction::where('id', (int) $lookupId) ->where(function ($q) use ($profileAccountIds) { $q->whereIn('from_account_id', $profileAccountIds) ->orWhereIn('to_account_id', $profileAccountIds); }) ->where(function ($q) { // NULL-safe: a transaction with no type at all is still eligible. $q->whereNull('transaction_type_id')->orWhere('transaction_type_id', '!=', 8); }) ->first(['id', 'from_account_id', 'to_account_id', 'amount', 'description', 'created_at']); if (!$transaction) { $this->reversalLookupError = __('No reversible transaction with that ID was found for this profile.'); return; } $alreadyReversed = \App\Models\Transaction::where('transaction_type_id', 8) ->where(function ($q) use ($transaction) { $q->where('from_reference', (string) $transaction->id) ->orWhere('to_reference', (string) $transaction->id); }) ->exists(); if ($alreadyReversed) { $this->reversalLookupError = __('This transaction has already been reversed.'); return; } $isOutgoing = $profileAccountIds->contains($transaction->from_account_id); $this->reversalSelectedTransactionId = $transaction->id; $this->reversalAmount = (int) $transaction->amount; $this->reversalDescription = __('Reversal for transaction #:id', ['id' => $transaction->id]); $this->reversalDirection = $isOutgoing ? __('sent') : __('received'); $this->reversalCreatedAt = optional($transaction->created_at)->format('Y-m-d H:i'); // A reversal claws the amount back from whoever RECEIVED it and // restores it to whoever SENT it — which may differ from the profile // currently open. Show both parties clearly before the bank-manager // confirms. $senderAccount = \App\Models\Account::find($transaction->from_account_id); $recipientAccount = \App\Models\Account::find($transaction->to_account_id); $sender = $senderAccount?->accountable; $recipient = $recipientAccount?->accountable; $this->reversalSenderName = $sender ? ($sender->full_name ?? $sender->name) : null; $this->reversalRecipientName = $recipient ? ($recipient->full_name ?? $recipient->name) : null; // "Reverse requested by" — the bank-manager records which of the two // involved profiles actually asked for the correction. The select // binds to the scalar "key" (WireUI's x-select only supports scalar // option values); the full option is resolved back from this list. // Uses the class BASENAME (e.g. "Organization"), not the FQCN — a // backslash-containing value like "App\Models\Organization:3" gets // mangled somewhere in the select's JS/JSON round-trip, silently // dropping the backslashes client-side. $this->reversalRequesterOptions = collect([ $sender ? ['key' => class_basename($sender) . ':' . $sender->id, 'type' => get_class($sender), 'id' => $sender->id, 'name' => $sender->name, 'full_name' => $sender->full_name ?? $sender->name] : null, $recipient ? ['key' => class_basename($recipient) . ':' . $recipient->id, 'type' => get_class($recipient), 'id' => $recipient->id, 'name' => $recipient->name, 'full_name' => $recipient->full_name ?? $recipient->name] : null, ])->filter()->values()->toArray(); } /** * Issue a reversal correction for the selected transaction: claws the * amount back from whoever originally RECEIVED it (recipient -> bank), * then restores it to whoever originally SENT it (bank -> sender) — both * legs in one DB transaction, via the bank as an audit-trail intermediary. * The original transaction is never modified (transactions are immutable * at the DB user grant level); the link back to it is kept in the new * transactions' description/reference fields only. */ public function issueReversal(): void { $this->authorizeAccountsPermission(); $viewedModelClass = $this->editAccounts['modelClass'] ?? null; $viewedProfileId = $this->editAccounts['id'] ?? null; if (!$viewedModelClass || !$viewedProfileId) { abort(403, 'Profile not found'); } $this->authorizeAccountsScopeAction((int) $viewedProfileId, $viewedModelClass); if (!$this->reversalSelectedTransactionId) { $this->notification()->error(__('Error!'), __('Please look up a transaction to reverse.')); return; } // Re-verify the transaction still belongs to one of the viewed // profile's accounts (defends against tampered Livewire state — the // lookup already checked this, but issueReversal() re-checks // independently rather than trusting client-held state). $profileAccountIds = \App\Models\Account::where('accountable_type', $viewedModelClass) ->where('accountable_id', $viewedProfileId) ->pluck('id'); $originalTransaction = \App\Models\Transaction::find((int) $this->reversalSelectedTransactionId); if (!$originalTransaction || (!$profileAccountIds->contains($originalTransaction->from_account_id) && !$profileAccountIds->contains($originalTransaction->to_account_id))) { abort(403, 'Transaction does not belong to this profile'); } $amountMinutes = (int) $originalTransaction->amount; // Idempotency guard: a transaction can only be reversed once. Without // this, a double-click / resubmission would create a second reversal // pair that flows in the opposite direction of the first, silently // undoing the original reversal instead of being a no-op. $alreadyReversed = \App\Models\Transaction::where('transaction_type_id', 8) ->where(function ($q) use ($originalTransaction) { $q->where('from_reference', (string) $originalTransaction->id) ->orWhere('to_reference', (string) $originalTransaction->id); }) ->exists(); if ($alreadyReversed) { $this->notification()->error(__('Error!'), __('This transaction has already been reversed.')); return; } // A reversal must undo the actual money movement: pull the amount back // from whoever RECEIVED it in the original transaction, and return it // to whoever SENT it — not necessarily the account whose accounts // modal happened to be open. Correction/notification is scoped to the // sender (they are the one being made whole). $senderAccount = \App\Models\Account::find($originalTransaction->from_account_id); $recipientAccount = \App\Models\Account::find($originalTransaction->to_account_id); if (!$senderAccount || !$recipientAccount) { $this->notification()->error(__('Error!'), __('Original sender or recipient account not found.')); return; } $modelClass = $senderAccount->accountable_type; $profileId = $senderAccount->accountable_id; $recipientModelClass = $recipientAccount->accountable_type; $recipientProfileId = $recipientAccount->accountable_id; // A scoped bank-manager may act as long as EITHER the sender or the // recipient of the original transaction is one of their bank's own // clients — not necessarily both. $this->authorizeReversalScopeAction( (int) $profileId, $modelClass, (int) $recipientProfileId, $recipientModelClass ); $validator = \Illuminate\Support\Facades\Validator::make([ 'confirmString' => $this->reversalConfirmString, ], [ 'confirmString' => $this->rules()['confirmString'], ]); if ($validator->fails()) { $this->setErrorBag($validator->errors()); $this->notification()->warning(__('Validation Error'), __('Please correct the errors in the form.')); return; } // "Reverse requested by" must be one of the transaction's two actual // parties (sender/recipient) — never trust the client-held selection // alone, re-derive the requester option from the DB-verified pair. // The select is bound to a scalar "type:id" key (WireUI's x-select // only supports scalar option values), resolved back to the option here. $requesterOption = collect($this->reversalRequesterOptions) ->firstWhere('key', $this->reversalRequestedBy); $validRequesterTypes = [$modelClass, $recipientModelClass]; $validRequesterIds = [(int) $profileId, (int) $recipientProfileId]; if (!$requesterOption || !in_array($requesterOption['type'], $validRequesterTypes, true) || !in_array((int) $requesterOption['id'], $validRequesterIds, true)) { $this->notification()->error(__('Error!'), __('Please select who requested this reversal.')); return; } $requesterModelClass = $requesterOption['type']; $requester = $requesterModelClass::find($requesterOption['id']); if (!$requester) { $this->notification()->error(__('Error!'), __('The selected requester profile was not found.')); return; } $requesterName = $requester->full_name ?? $requester->name; // Active profile must be the Bank (scoped or central) that owns the bank account used below. $bank = getActiveProfile(); if (!$bank instanceof \App\Models\Bank) { abort(403, __('Only a bank profile can issue a reversal.')); } $bankAccount = $bank->accounts()->notRemoved()->first(); if (!$bankAccount) { $this->notification()->error(__('Error!'), __('Your bank has no active account to issue the reversal from.')); return; } $originalTransactionId = $originalTransaction->id; try { [$legATransaction, $legBTransaction] = DB::transaction(function () use ($senderAccount, $recipientAccount, $bankAccount, $amountMinutes, $originalTransactionId, $requesterName) { // Leg A: recipient -> bank (claw back what they wrongly received) $legA = \App\Models\Transaction::create([ 'from_account_id' => $recipientAccount->id, 'to_account_id' => $bankAccount->id, 'amount' => $amountMinutes, 'transaction_type_id' => 8, // Reversal 'description' => __('Reversal of transaction #:id, requested by :name', ['id' => $originalTransactionId, 'name' => $requesterName]), 'from_reference' => (string) $originalTransactionId, 'creator_user_id' => Auth::guard('web')->id(), ]); // Leg B: bank -> sender (restore what they originally paid out) $legB = \App\Models\Transaction::create([ 'from_account_id' => $bankAccount->id, 'to_account_id' => $senderAccount->id, 'amount' => $amountMinutes, 'transaction_type_id' => 8, // Reversal 'description' => __('Reversal for transaction #:id, requested by :name', ['id' => $originalTransactionId, 'name' => $requesterName]), 'to_reference' => (string) $originalTransactionId, 'creator_user_id' => Auth::guard('web')->id(), ]); return [$legA, $legB]; }); $profile = $modelClass::find($profileId); if ($profile) { $messageSetting = \App\Models\MessageSetting::where('message_settingable_id', $profile->id) ->where('message_settingable_type', get_class($profile)) ->first(); $sendEmail = $messageSetting ? $messageSetting->payment_received : true; if ($sendEmail) { \App\Jobs\SendReversalIssuedMail::dispatch($profile, $bank, $amountMinutes, $originalTransactionId, $this->reversalDescription); } } // Notify both parties involved in the original transaction via // chat, mentioning who executed the reversal and who requested it // — each in THEIR OWN preferred language, not the acting // bank-manager's current browsing locale. $senderProfile = $modelClass::find($profileId); $recipientProfile = $recipientModelClass::find($recipientProfileId); foreach (array_filter([$senderProfile, $recipientProfile]) as $participant) { try { $participantLocale = $participant->lang_preference ?? $bank->lang_preference ?? config('app.fallback_locale'); if (!Lang::has('A reversal (transaction #:id, :amount) was executed by :bank on request by :requester.', $participantLocale)) { $participantLocale = config('app.fallback_locale'); } $chatMessage = __('A reversal (transaction #:id, :amount) was executed by :bank on request by :requester.', [ 'id' => $originalTransactionId, 'amount' => tbFormat($amountMinutes), 'bank' => $bank->full_name ?? $bank->name, 'requester' => $requesterName, ], $participantLocale); $conversation = $bank->createConversationWith($participant); $message = $bank->sendMessageTo($participant, $chatMessage); broadcast(new \Namu\WireChat\Events\MessageCreated($message)); \Namu\WireChat\Jobs\NotifyParticipants::dispatch($conversation, $message); } catch (\Throwable $e) { Log::warning('Reversal: failed to send chat notification', [ 'participant_type' => get_class($participant), 'participant_id' => $participant->id, 'error' => $e->getMessage(), ]); } } // Also send the standard "payment received" chat notification // (text + QR attachment) for each leg, same as a normal transfer — // the bank is always the WireChat sender of these messages, since // it's the one executing the reversal on both legs. Pass the // actual Account (not just the profile) so the message names the // specific account the money moved into/out of. $this->sendPaymentChatNotification($bank, $recipientAccount, $amountMinutes, $legATransaction->id, withdrawal: true); $this->sendPaymentChatNotification($bank, $senderAccount, $amountMinutes, $legBTransaction->id); Log::notice('Reversal issued', [ 'bank_id' => $bank->id, 'client_type' => $modelClass, 'client_id' => $profileId, 'sender_account_id' => $senderAccount->id, 'recipient_account_id' => $recipientAccount->id, 'amount' => $amountMinutes, 'original_transaction_id' => $originalTransactionId, 'issued_by_user_id' => Auth::guard('web')->id(), ]); $this->notification()->success(__('Saved'), __('The reversal has been issued successfully!')); $this->modalReversal = false; $this->reversalLookupId = ''; $this->reversalLookupError = null; $this->reversalSelectedTransactionId = null; $this->reversalAmount = null; $this->reversalDescription = ''; $this->reversalConfirmString = ''; $this->reversalSenderName = null; $this->reversalRecipientName = null; $this->reversalDirection = null; $this->reversalCreatedAt = null; $this->reversalRequesterOptions = []; $this->reversalRequestedBy = null; // Refresh the accounts modal for the profile that was being viewed // (may be the sender or the recipient of the original transaction). $this->openEditAccountsModal($viewedProfileId, $viewedModelClass); } catch (\Exception $e) { $this->notification()->error(__('Error!'), __('Oops, we have an error: the reversal was not issued!') . ' ' . $e->getMessage()); } } /** * Send the standard "payment received" chat notification (text message + * QR code attachment linking to the transaction statement) — identical in * form to the notification Pay.php sends after a normal transfer — for * one leg of a reversal. The BANK is always the WireChat sender (it's the * one executing the reversal), regardless of which direction the money * actually moved in this particular leg — $withdrawal picks the wording. * * $counterpartyAccount is the actual Account the money moved into/out of * (not just the owning profile) — its own name (e.g. "personal", "gift") * is what the message references, matching Pay.php's convention of using * the account's name rather than the profile's name. */ private function sendPaymentChatNotification($bank, \App\Models\Account $counterpartyAccount, int $amountMinutes, int $transactionId, bool $withdrawal = false): void { try { $counterparty = $counterpartyAccount->accountable; $messageLocale = $counterparty->lang_preference ?? $bank->lang_preference; $payChatMessageKey = $withdrawal ? 'messages.reversal_withdrawal_chat_message' : payChatMessageKey($bank); if (!Lang::has($payChatMessageKey, $messageLocale)) { $messageLocale = config('app.fallback_locale'); } $accountName = __(ucfirst(strtolower($counterpartyAccount->name)), [], $messageLocale); $chatMessage = __($payChatMessageKey, [ 'amount' => tbFormat($amountMinutes), 'account_name' => $accountName, ], $messageLocale); $conversation = $bank->createConversationWith($counterparty); $message = $bank->sendMessageTo($counterparty, $chatMessage); broadcast(new \Namu\WireChat\Events\MessageCreated($message)); \Namu\WireChat\Jobs\NotifyParticipants::dispatch($conversation, $message); $qrStatementUrl = route('transaction.show', ['transactionId' => $transactionId]); $qrSvg = \SimpleSoftwareIO\QrCode\Facades\QrCode::size(150)->errorCorrection('L')->color(17, 24, 39)->generate($qrStatementUrl); $storageDisk = config('wirechat.attachments.storage_disk', 'public'); $storageFolder = config('wirechat.attachments.storage_folder', 'attachments'); $qrFileName = 'qr-transaction-' . $transactionId . '-' . time() . '.svg'; $qrFilePath = $storageFolder . '/' . $qrFileName; \Illuminate\Support\Facades\Storage::disk($storageDisk)->put($qrFilePath, $qrSvg); $chatTransactionStatement = LaravelLocalization::getURLFromRouteNameTranslated($messageLocale, 'routes.statement', ['transactionId' => $transactionId]); $qrMessage = \Namu\WireChat\Models\Message::create([ 'conversation_id' => $conversation->id, 'sendable_type' => $bank->getMorphClass(), 'sendable_id' => $bank->id, 'body' => $chatTransactionStatement, 'type' => \Namu\WireChat\Enums\MessageType::ATTACHMENT, ]); $qrMessage->attachment()->create([ 'file_path' => $qrFilePath, 'file_name' => $qrFileName, 'original_name' => $qrFileName, 'mime_type' => 'image/svg+xml', 'url' => \Illuminate\Support\Facades\Storage::disk($storageDisk)->url($qrFilePath), ]); $conversation->updated_at = now(); $conversation->save(); broadcast(new \Namu\WireChat\Events\MessageCreated($qrMessage)); \Namu\WireChat\Jobs\NotifyParticipants::dispatch($conversation, $qrMessage); } catch (\Throwable $e) { Log::warning('Reversal: failed to send payment-style chat notification', [ 'bank_id' => $bank->id, 'counterparty_account_id' => $counterpartyAccount->id, 'transaction_id' => $transactionId, 'error' => $e->getMessage(), ]); } } public function openEditProfileModal($profileId, $modelName) { $this->denyIfScopedToBank(); $this->authorizePermission('update profiles'); $this->validateModelName($modelName); $this->confirmString = ''; $profile = $modelName::find($profileId); if (!$profile) { $modelBasename = class_basename($modelName); throw new \Exception("{$modelBasename} profile with ID {$profileId} not found."); } // $this->confirmString = strtolower(__('messages.confirm_input_string')); $visibleFields = [ 'id', 'name', 'full_name', 'email', 'email_verified_at', 'profile_photo_path', 'about', 'about_short', 'motivation', 'website', 'phone', 'phone_public', 'limit_min', 'limit_max', 'comment', 'lang_preference', 'last_login_at', 'last_login_ip', 'inactive_at', 'deleted_at', 'updated_at', 'created_at', ]; // Add 'level' field only for Bank models if ($modelName === \App\Models\Bank::class) { $visibleFields[] = 'level'; } $this->editProfile = $profile->makeVisible($visibleFields)->only($visibleFields); $this->editProfile['model'] = strtolower(class_basename($modelName)); $this->editProfile['modelClass'] = $modelName; // Store full class name for type checking $this->editProfile['location'] = $profile->getLocationFirst()['name']; // Cast level to string for WireUI select compatibility if (isset($this->editProfile['level'])) { $this->editProfile['level'] = (string) $this->editProfile['level']; } if ($this->editProfile['inactive_at']) { $this->reActivate = false; // If the profile is inactive, set reActivate to true } else { $this->reActivate = true; // Otherwise, set it to false } // Load message settings $messageSetting = \App\Models\MessageSetting::where('message_settingable_id', $profileId) ->where('message_settingable_type', $modelName) ->first(); if ($messageSetting) { $this->editMessageSettings = [ 'system_message' => (bool) $messageSetting->system_message, 'payment_received' => (bool) $messageSetting->payment_received, 'star_received' => (bool) $messageSetting->star_received, 'local_newsletter' => (bool) $messageSetting->local_newsletter, 'general_newsletter' => (bool) $messageSetting->general_newsletter, 'personal_chat' => (bool) $messageSetting->personal_chat, 'group_chat' => (bool) $messageSetting->group_chat, 'chat_unread_delay' => (int) $messageSetting->chat_unread_delay, ]; } else { // Use defaults if no message setting exists $this->editMessageSettings = [ 'system_message' => true, 'payment_received' => true, 'star_received' => true, 'local_newsletter' => true, 'general_newsletter' => true, 'personal_chat' => true, 'group_chat' => true, 'chat_unread_delay' => timebank_config('messenger.default_unread_mail_delay', 0), ]; } $this->initMessageSettings = $this->editMessageSettings; // Store initial settings for comparison $this->editMessageSettingsChanged = false; $this->initProfile = $this->editProfile; // Store the initial profile data for comparison later $this->modalEditProfile = true; } public function openAttachProfilesModal($profileId, $modelName) { $this->denyIfScopedToBank(); $this->authorizePermission('create profiles'); $this->validateModelName($modelName); $this->editAttachProfile['newProfile'] = null; $this->initAttachProfile['newProfile'] = null; $this->confirmString = ''; $profile = $modelName::find($profileId); if (!$profile) { $modelBasename = class_basename($modelName); throw new \Exception("{$modelBasename} profile with ID {$profileId} not found."); } // $this->confirmString = strtolower(__('messages.confirm_input_string')); $this->editAttachProfile = $profile->makeVisible([ 'id', 'full_name', 'comment', 'lang_preference', 'last_login_at', 'last_login_ip', 'inactive_at', 'deleted_at', 'updated_at', 'created_at', ]) ->toArray(); $this->editAttachProfile['model'] = strtolower(class_basename($modelName)); $this->editAttachProfile['location'] = $profile->getLocationFirst()['name']; $profiles = []; // Map and merge all attached profiles into a single array if (class_basename($modelName) === 'User') { $this->newTypesAvailable = [Organization::class, Bank::class, Admin::class]; $profileWithRelations = $modelName::with(['organizations', 'banksManaged', 'admins'])->find($profileId); foreach ($profileWithRelations->organizations as $org) { $arr = $org->toArray(); $arr['typeName'] = __('organization'); $arr['username'] = $arr['name'] ?? ''; $arr['inactiveAt'] = $arr['inactive_at'] ?? null; $arr['type'] = 'Organization'; $arr['role'] = $this->resolveLinkedUserRole($profileWithRelations, 'Organization', $org->id); $profiles[] = $arr; } foreach ($profileWithRelations->banksManaged as $bank) { $arr = $bank->toArray(); $arr['typeName'] = __('bank'); $arr['username'] = $arr['name'] ?? ''; $arr['inactiveAt'] = $arr['inactive_at'] ?? null; $arr['type'] = 'Bank'; $arr['role'] = $this->resolveLinkedUserRole($profileWithRelations, 'Bank', $bank->id); $profiles[] = $arr; } foreach ($profileWithRelations->admins as $admin) { $arr = $admin->toArray(); $arr['typeName'] = __('admin'); $arr['username'] = $arr['name'] ?? ''; $arr['inactiveAt'] = $arr['inactive_at'] ?? null; $arr['type'] = 'Admin'; $arr['role'] = $this->resolveLinkedUserRole($profileWithRelations, 'Admin', $admin->id); $profiles[] = $arr; } } elseif (class_basename($modelName) === 'Organization' || class_basename($modelName) === 'Admin') { $this->newTypesAvailable = [User::class]; $profileWithRelations = $modelName::with(['users'])->find($profileId); $parentType = class_basename($modelName); foreach ($profileWithRelations->users as $user) { $arr = $user->toArray(); $arr['typeName'] = __('user'); $arr['username'] = $arr['name'] ?? ''; $arr['inactiveAt'] = $arr['inactive_at'] ?? null; $arr['type'] = 'User'; $arr['role'] = $this->resolveLinkedUserRole($user, $parentType, $profileId); $profiles[] = $arr; } } elseif (class_basename($modelName) === 'Bank') { $this->newTypesAvailable = [User::class]; $profileWithRelations = $modelName::with(['managers'])->find($profileId); foreach ($profileWithRelations->managers as $manager) { $arr = $manager->toArray(); $arr['username'] = $arr['name'] ?? ''; $arr['inactiveAt'] = $arr['inactive_at'] ?? null; $arr['type'] = 'User'; $arr['role'] = $this->resolveLinkedUserRole($manager, 'Bank', $profileId); $profiles[] = $arr; } } $this->editAttachProfile['profiles'] = $profiles; $this->initAttachProfile['profiles'] = $profiles; $this->initAttachProfile = $this->editAttachProfile; $this->modalAttachProfile = true; } /** * Resolve the current role of a linked user for a given parent profile type/id. * Returns 'organization-manager', 'organization-coordinator', 'bank-manager', 'bank-coordinator', * 'site-editor', or the default for the given parent type. */ protected function resolveLinkedUserRole($user, string $parentType, int $parentId): string { // For Admin parent: check scoped roles from AdminRole table if ($parentType === 'Admin') { $adminRoles = \App\Models\AdminRole::where('is_system', false)->get(); foreach ($adminRoles as $adminRole) { $scopedName = "Admin\\{$parentId}\\{$adminRole->name}"; if ($user->hasRole($scopedName)) { return $adminRole->name; } } return 'administrator'; // default } $prefix = strtolower($parentType) === 'bank' ? 'bank-' : 'organization-'; $orgRoles = \App\Models\OrganizationRole::where('name', 'like', $prefix . '%')->pluck('name'); foreach ($orgRoles as $roleName) { if ($user->hasRole("{$parentType}\\{$parentId}\\{$roleName}")) { return $roleName; } } // Default: first role with 'manager' in its name, or the first available role $default = $orgRoles->first(fn($n) => str_contains($n, 'manager')) ?? $orgRoles->first(); return $default ?? ($prefix . 'manager'); } public function selectedProfile($profile) { if ($profile) { $profile['role'] = null; $this->editAttachProfile['newProfile'] = $profile; } else { $this->editAttachProfile['newProfile'] = null; } $this->syncAttachProfileChangedState(); } public function selectedProfileForConvertToOrganization($profile) { $this->convertToOrganizationSelectedManager = $profile ?: null; } public function updatedReActivate() { // If reActivate is set, we assume the user wants to reactivate the profile if ($this->reActivate) { $this->editProfile['inactive_at'] = null; // Clear the inactive date } else { $this->editProfile['inactive_at'] = $this->initProfile['inactive_at']; // Restore the inactive date } $this->syncEditProfileChangedState(); } public function updatedConfirmString($value) { // always assume “bad” until proven good $this->buttonDisabled = true; // explicitly grab the confirmString rules from rules() $ruleSet = ['confirmString' => $this->rules()['confirmString']]; try { // validateOnly needs the keyed map $this->validateOnly('confirmString', $ruleSet); // if no exception, it’s good $this->buttonDisabled = false; // clear out any existing validation error for that field $this->resetErrorBag('confirmString'); } catch (\Illuminate\Validation\ValidationException $e) { // leave it disabled $this->buttonDisabled = true; } } public function updatedEditProfile($newValue, $fieldKey) // $fieldKey is 'name', 'email' etc. { $fieldPath = 'editProfile.' . $fieldKey; // Handle specific field side-effects (e.g., messages) if ($fieldKey === 'email') { if ($newValue !== ($this->initProfile['email'] ?? null)) { $this->editProfileMessages['email'] = __('The email address will be marked as unverified.'); } else { unset($this->editProfileMessages['email']); } } // Real-time validation for the updated field $rules = $this->getProcessedRulesForField($fieldPath, true); if (! empty($rules)) { // ← wrap the rules in a map keyed by the field $this->validateOnly($fieldPath, [ $fieldPath => $rules ]); } $this->syncEditProfileChangedState(); } /** * Recalculate the overall "changed" state. * This method iterates over the list of fields that can be modified. */ protected function syncEditProfileChangedState(): void { $keysToCheck = [ 'inactive_at', 'name', 'full_name', 'email', 'about_short', 'about', 'motivation', 'website', 'phone', 'comment', 'level', // Include level for Bank models ]; $this->editProfileChanged = false; // assume no changes initially foreach ($keysToCheck as $key) { // If a key does not exist in one of the arrays, consider them different. $current = $this->editProfile[$key] ?? null; $initial = $this->initProfile[$key] ?? null; if ($current !== $initial) { $this->editProfileChanged = true; break; } } // Also consider pending email suppression toggle as a change if (!$this->editProfileChanged && isset($this->editProfile['email_suppression_pending'])) { $this->editProfileChanged = true; } } public function updatedEditMessageSettings() { // Check if any message setting has changed $this->editMessageSettingsChanged = false; $keysToCheck = [ 'system_message', 'payment_received', 'star_received', 'local_newsletter', 'general_newsletter', 'personal_chat', 'group_chat', 'chat_unread_delay', ]; foreach ($keysToCheck as $key) { $current = $this->editMessageSettings[$key] ?? null; $initial = $this->initMessageSettings[$key] ?? null; // Compare as strings to handle type coercion issues if ((string)$current !== (string)$initial) { $this->editMessageSettingsChanged = true; Log::debug('MessageSettingsChanged detected', [ 'field' => $key, 'current' => $current, 'initial' => $initial, ]); break; } } } public function updatedEditAccounts($newValue, $fieldKey) { $this->editAccountsChanged = true; } public function updatedEditAttachProfile($newValue, $fieldKey) // $fieldKey is 'name', 'email' etc. { $fieldPath = 'editAttachProfile.' . $fieldKey; $rules = $this->getProcessedRulesForField($fieldPath, true); if (! empty($rules)) { $this->validateOnly($fieldPath, [ $fieldPath => $rules ]); } $this->syncAttachProfileChangedState(); } /** * Recalculate the overall "changed" state. * This method iterates over the list of fields that can be modified. */ protected function syncAttachProfileChangedState(): void { $keysToCheck = ['comment', 'newProfile', 'profiles']; $this->editAttachProfileChanged = false; foreach ($keysToCheck as $key) { $current = $this->editAttachProfile[$key] ?? null; $initial = $this->initAttachProfile[$key] ?? null; if (is_array($current) || is_array($initial)) { if (json_encode($current) !== json_encode($initial)) { $this->editAttachProfileChanged = true; break; } } else { if ($current !== $initial) { $this->editAttachProfileChanged = true; break; } } } } /** * Processes and returns the validation rules for a given field path. * Removes the unique rule when the field has not been updated. * * @param string $fieldPath The dot-notated path to the field (e.g., 'editProfile.email'). * @param bool $isUpdateContext Whether the rules are being processed in an update context. * @return array The processed validation rules for the field. */ protected function getProcessedRulesForField(string $fieldPath, bool $isUpdateContext = false): array { $allBaseRules = $this->rules(); if (!isset($allBaseRules[$fieldPath])) { return []; } $fieldRulesArray = $allBaseRules[$fieldPath]; if ($isUpdateContext && isset($this->editProfile['id']) && isset($this->editProfile['model'])) { $profileId = $this->editProfile['id']; $modelKey = $this->editProfile['model']; $modelClassString = 'App\\Models\\' . Str::studly($modelKey); if (!class_exists($modelClassString)) { return $fieldRulesArray; } $table = (new $modelClassString())->getTable(); $actualFieldName = Str::after($fieldPath, 'editProfile.'); $processedRules = []; $uniqueRuleForCurrentTableAdded = false; foreach ($fieldRulesArray as $rule) { if (is_string($rule) && Str::startsWith(trim($rule), 'unique:')) { // If it's the unique rule for the current table and field, mark it to be replaced if (preg_match("/^unique:{$table},{$actualFieldName}(,|$)/", trim($rule))) { // We will add the ->ignore() version later, so skip this string version $uniqueRuleForCurrentTableAdded = true; // Mark that we've handled this specific one continue; } else { // Keep unique rules for OTHER tables (cross-table uniqueness checks) $processedRules[] = $rule; } } else { $processedRules[] = $rule; // Keep non-unique rules } } // Only add unique rule if there was a unique rule in the original rules if ($uniqueRuleForCurrentTableAdded) { $processedRules[] = Rule::unique($table, $actualFieldName)->ignore($profileId); } return array_values(array_unique($processedRules, SORT_REGULAR)); // Remove duplicates } return $fieldRulesArray; } /** * Update a profile * * @param mixed $translationId * @return void */ public function updateProfile() { $this->denyIfScopedToBank(); $this->authorizePermission('update profiles'); $this->buttonDisabled = true; // Disable the button until validation is done $profileId = $this->editProfile['id'] ?? null; $modelKey = $this->editProfile['model'] ?? null; if (!$profileId || !$modelKey) { $this->notification()->error(__('Error!'), __('Profile data is incomplete.')); return; } if (!$this->editProfileChanged && !$this->editMessageSettingsChanged) { $this->notification()->info(__('No changes'), __('No changes were saved to the profile.')); $this->modalEditProfile = false; return; } $rulesForProfileFields = []; $allBaseRules = $this->rules(); // Get all defined base rules, including for 'confirmString' // Identify changed fields in editProfile and prepare their rules for validation foreach ($this->editProfile as $field => $currentValue) { if (!array_key_exists($field, $this->initProfile) || $currentValue !== $this->initProfile[$field]) { $fieldPath = 'editProfile.' . $field; if (isset($allBaseRules[$fieldPath])) { $processedRules = $this->getProcessedRulesForField($fieldPath, true); if (!empty($processedRules)) { $rulesForProfileFields[$fieldPath] = $processedRules; } } } } // Prepare all rules to be validated in one go. $allRulesToValidate = $rulesForProfileFields; if (isset($allBaseRules['confirmString'])) { $allRulesToValidate['confirmString'] = $allBaseRules['confirmString']; } try { // Only validate if there are profile field rules if (!empty($allRulesToValidate)) { $this->validate($allRulesToValidate); } // If validation passes (including confirmString), proceed with DB transaction DB::transaction(function () use ($modelKey, $rulesForProfileFields) { // Pass only profile field rules for DB update logic $modelClass = 'App\\Models\\' . Str::studly($modelKey); $profile = $modelClass::find($this->editProfile['id']); if ($profile) { $changedFields = []; // Iterate over the keys for fields that were actually part of $this->editProfile and had rules foreach (array_keys($rulesForProfileFields) as $validatedFieldPath) { $actualFieldKey = Str::after($validatedFieldPath, 'editProfile.'); if (array_key_exists($actualFieldKey, $this->editProfile)) { if ($actualFieldKey === 'email' && $profile->email !== $this->editProfile['email']) { $profile->email_verified_at = null; } $profile->{$actualFieldKey} = $this->editProfile[$actualFieldKey]; $changedFields[] = $actualFieldKey; } } // Save profile if there are profile changes (even if no rules matched) if (!empty($changedFields)) { $profile->save(); } // Determine if we should send notification // Only if profile fields changed (not just admin comments) OR message settings changed $shouldNotify = !empty($changedFields) || $this->editMessageSettingsChanged; // Filter out 'comment' field from changedFields for notification purposes $notificationFields = array_filter($changedFields, function($field) { return $field !== 'comment'; }); // Send email notification to profile owner about the changes Log::info('ProfileEdited: Checking if email should be sent', [ 'profile_id' => $profile->id, 'profile_type' => get_class($profile), 'changed_fields' => $changedFields, 'notification_fields' => $notificationFields, 'message_settings_changed' => $this->editMessageSettingsChanged, ]); $messageSetting = \App\Models\MessageSetting::where('message_settingable_id', $profile->id) ->where('message_settingable_type', get_class($profile)) ->first(); $sendEmail = $messageSetting ? $messageSetting->system_message : true; Log::info('ProfileEdited: Message setting check', [ 'has_message_setting' => $messageSetting ? 'yes' : 'no', 'system_message' => $messageSetting ? $messageSetting->system_message : 'default:true', 'will_send_email' => $sendEmail ? 'yes' : 'no', ]); // Track if notification was sent $notificationSent = false; // Send notification if there are relevant field changes (excluding comments) or message settings changed if ($sendEmail && ($shouldNotify && (!empty($notificationFields) || $this->editMessageSettingsChanged))) { \App\Jobs\SendProfileEditedByAdminMail::dispatch($profile, array_values($notificationFields)); $notificationSent = true; Log::info('ProfileEdited: Dispatched email notification', [ 'recipient_email' => $profile->email ?? 'NO EMAIL', 'changed_fields' => $notificationFields, 'message_settings_changed' => $this->editMessageSettingsChanged, ]); } else { Log::info('ProfileEdited: Skipped email notification', [ 'reason' => $sendEmail ? 'only_admin_comments_changed' : 'system_message_disabled', ]); } // Apply pending email suppression toggle if set if (isset($this->editProfile['email_suppression_pending'])) { $emailToToggle = strtolower(trim($profile->email ?? '')); if ($emailToToggle) { if ($this->editProfile['email_suppression_pending']) { \App\Models\MailingBounce::suppressEmail($emailToToggle, 'Manually suppressed by admin'); } else { \App\Models\MailingBounce::where('email', $emailToToggle) ->update(['is_suppressed' => false, 'bounce_type' => 'unsuppressed']); } } } // Save message settings if they've changed if ($this->editMessageSettingsChanged) { $messageSetting = \App\Models\MessageSetting::where('message_settingable_id', $profile->id) ->where('message_settingable_type', get_class($profile)) ->first(); if (!$messageSetting) { // Create new message setting if it doesn't exist $messageSetting = new \App\Models\MessageSetting(); $messageSetting->message_settingable_id = $profile->id; $messageSetting->message_settingable_type = get_class($profile); } // Update all message settings fields $messageSetting->system_message = $this->editMessageSettings['system_message']; $messageSetting->payment_received = $this->editMessageSettings['payment_received']; $messageSetting->star_received = $this->editMessageSettings['star_received']; $messageSetting->local_newsletter = $this->editMessageSettings['local_newsletter']; $messageSetting->general_newsletter = $this->editMessageSettings['general_newsletter']; $messageSetting->personal_chat = $this->editMessageSettings['personal_chat']; $messageSetting->group_chat = $this->editMessageSettings['group_chat']; $messageSetting->chat_unread_delay = $this->editMessageSettings['chat_unread_delay']; $messageSetting->save(); Log::info('MessageSettingsUpdated', [ 'profile_id' => $profile->id, 'profile_type' => get_class($profile), 'settings' => $this->editMessageSettings, ]); } $this->resetForm(); $this->notification()->success(__('Saved'), __('The profile has been saved successfully!')); // Only show notification about profile owner being informed if email was actually sent if ($notificationSent) { $this->notification()->info(__('Notification sent'), __('The profile owner will be notified about this update')); } $this->modalEditProfile = false; } else { $this->notification()->error(__('Error!'), __('Profile not found.')); } }); } catch (\Illuminate\Validation\ValidationException $e) { $this->notification()->warning(__('Validation Error'), __('Please correct the errors in the form.')); } catch (\Exception $e) { $this->notification()->error(__('Error!'), __('Oops, we have an error: the profile was not saved!') . ' ' . $e->getMessage()); } } public function toggleEmailSuppression(): void { $this->authorizePermission('update profiles'); $current = $this->editProfile['email_suppression_pending'] ?? null; if ($current === null) { // First toggle: read live DB state and flip it $email = strtolower(trim($this->editProfile['email'] ?? '')); $liveState = $email ? \App\Models\MailingBounce::isSuppressed($email) : false; $this->editProfile['email_suppression_pending'] = !$liveState; } else { // Subsequent toggles: flip the pending state $this->editProfile['email_suppression_pending'] = !$current; } $this->syncEditProfileChangedState(); } public function openDeleteProfileModal($profileId, $modelName) { $this->denyIfScopedToBank(); $this->adminPassword = ''; $this->balanceHandlingOption = 'delete'; $this->donationAccountId = null; $profile = $modelName::find($profileId); if (!$profile) { $this->notification()->error(__('Error!'), __('Profile not found.')); return; } $this->selectedProfile = $profile; $this->selectedProfileId = $profileId; // Load account information $accounts = collect(); $totalBalance = 0; $hasNegativeBalance = false; if (method_exists($profile, 'accounts')) { $userAccounts = $profile->accounts()->active()->notRemoved()->get(); foreach ($userAccounts as $account) { \Cache::forget("account_balance_{$account->id}"); $balance = $account->balance; $accounts->push([ 'id' => $account->id, 'name' => __('messages.' . $account->name . '_account'), 'balance' => $balance, 'balanceFormatted' => tbFormat($balance), ]); $totalBalance += $balance; if ($balance < 0) { $hasNegativeBalance = true; } } } // Check if central bank $isCentralBank = ($profile instanceof \App\Models\Bank && isset($profile->level) && $profile->level == 0); // Check if final admin $isFinalAdmin = false; if ($profile instanceof \App\Models\Admin) { $activeAdminCount = \App\Models\Admin::whereNull('deleted_at')->count(); if ($activeAdminCount <= 1) { $isFinalAdmin = true; } } $this->deleteProfileData = [ 'name' => $profile->name, 'full_name' => $profile->full_name ?? '', 'email' => $profile->email, 'type' => class_basename($profile), 'accounts' => $accounts->toArray(), 'totalBalance' => $totalBalance, 'hasNegativeBalance' => $hasNegativeBalance, 'isCentralBank' => $isCentralBank, 'isFinalAdmin' => $isFinalAdmin, 'showBalanceOptions' => timebank_config('delete_profile.account_balances.donate_balances_to_organization_account_specified', false), ]; $this->modalDeleteProfile = true; } public function openRestoreProfileModal($profileId, $modelName) { $this->denyIfScopedToBank(); $this->adminPassword = ''; // Models don't use SoftDeletes trait, so we can query directly $profile = $modelName::find($profileId); if (!$profile) { $this->notification()->error(__('Error!'), __('Profile not found.')); return; } if (!$profile->deleted_at) { $this->notification()->error(__('Error!'), __('Profile is not deleted.')); return; } // Check if profile can be restored if (!$this->isProfileRestorable($profile)) { $this->notification()->error(__('Error!'), __('Profile permanently deleted - cannot be restored')); return; } $this->selectedProfile = $profile; $this->selectedProfileId = $profileId; // Calculate grace period information $gracePeriodDays = timebank_config('delete_profile.grace_period_days', 30); $deletedAt = \Carbon\Carbon::parse($profile->deleted_at); $gracePeriodExpiry = $deletedAt->copy()->addDays($gracePeriodDays); $now = \Carbon\Carbon::now(); // Calculate time remaining $secondsRemaining = (int) ($now->diffInSeconds($gracePeriodExpiry, false)); if ($secondsRemaining <= 0) { $timeRemaining = __('Expired'); } elseif ($secondsRemaining >= 86400 * 7) { $weeks = round($secondsRemaining / (86400 * 7)); $timeRemaining = trans_choice('weeks_remaining', $weeks, ['count' => $weeks]); } elseif ($secondsRemaining >= 86400) { $days = round($secondsRemaining / 86400); $timeRemaining = trans_choice('days_remaining', $days, ['count' => $days]); } elseif ($secondsRemaining >= 3600) { $hours = round($secondsRemaining / 3600); $timeRemaining = trans_choice('hours_remaining', $hours, ['count' => $hours]); } else { $minutes = max(1, round($secondsRemaining / 60)); $timeRemaining = trans_choice('minutes_remaining', $minutes, ['count' => $minutes]); } $this->restoreProfileData = [ 'name' => $profile->name, 'full_name' => $profile->full_name ?? '', 'email' => $profile->email, 'type' => class_basename($profile), 'deletedAt' => $deletedAt->translatedFormat('j F Y, H:i'), 'gracePeriodExpiry' => $gracePeriodExpiry->translatedFormat('j F Y, H:i'), 'timeRemaining' => $timeRemaining, ]; $this->modalRestoreProfile = true; } /** * Attach a profile * * @param mixed $translationId * @return void */ public function attachProfile() { $this->denyIfScopedToBank(); $this->authorizePermission('create profiles'); $this->buttonDisabled = true; $profileId = $this->editAttachProfile['id'] ?? null; $modelKey = $this->editAttachProfile['model'] ?? null; if (!$profileId || !$modelKey) { $this->notification()->error(__('Error!'), __('Profile data is incomplete.')); return; } if (!$this->editAttachProfileChanged) { $this->notification()->info(__('No changes'), __('No changes were saved to the profile.')); $this->modalAttachProfile = false; return; } // --- ALWAYS add newProfile to profiles array before validation/sync --- if (!empty($this->editAttachProfile['newProfile'])) { $selectedProfile = $this->editAttachProfile['newProfile']; $selectedProfileId = $selectedProfile['id'] ?? null; $selectedProfileModel = class_basename($selectedProfile['type'] ?? ''); if (!$profileId || !$modelKey || !$selectedProfileId || !$selectedProfileModel) { $this->notification()->error(__('Error!'), __('Profile data is incomplete!')); return; } // Validate role selection before proceeding $roleValue = $selectedProfile['role'] ?? null; // User profiles have no role. All other profile types require a role. $roleRequired = $selectedProfileModel !== 'User'; if ($roleRequired && empty($roleValue)) { $this->addError('editAttachProfile.newProfile.role', __('A role is required for the selected profile.')); $this->buttonDisabled = false; return; } $alreadyExists = collect($this->editAttachProfile['profiles'] ?? []) ->contains(function ($profile) use ($selectedProfileId, $selectedProfileModel) { return $profile['id'] == $selectedProfileId && $profile['type'] == $selectedProfileModel; }); if (!$alreadyExists) { $this->editAttachProfile['profiles'][] = [ 'id' => $selectedProfileId, 'type' => $selectedProfileModel, 'role' => $roleValue, ]; } $this->editAttachProfile['newProfile'] = null; } $rulesForProfileFields = []; $allBaseRules = $this->rules(); foreach ($this->editAttachProfile as $field => $currentValue) { if (!array_key_exists($field, $this->initAttachProfile) || $currentValue !== $this->initAttachProfile[$field]) { $fieldPath = 'editAttachProfile.' . $field; if (isset($allBaseRules[$fieldPath])) { $processedRules = $this->getProcessedRulesForField($fieldPath, true); if (!empty($processedRules)) { $rulesForProfileFields[$fieldPath] = $processedRules; } } } } $allRulesToValidate = $rulesForProfileFields; if (isset($allBaseRules['confirmString'])) { $allRulesToValidate['confirmString'] = $allBaseRules['confirmString']; } try { if (empty($allRulesToValidate)) { $this->notification()->info(__('No Changes'), __('No changes requiring validation were made.')); $this->modalAttachProfile = false; return; } $this->validate($allRulesToValidate); DB::transaction(function () use ($modelKey, $rulesForProfileFields) { $modelClass = 'App\\Models\\' . Str::studly($modelKey); $profile = $modelClass::find($this->editAttachProfile['id']); if ($profile) { foreach (array_keys($rulesForProfileFields) as $validatedFieldPath) { $actualFieldKey = Str::after($validatedFieldPath, 'editAttachProfile.'); if (array_key_exists($actualFieldKey, $this->editAttachProfile)) { $profile->{$actualFieldKey} = $this->editAttachProfile[$actualFieldKey]; } } $comment = $this->editAttachProfile['comment'] ?? null; $profile->comment = $comment; $profile->save(); $relationMap = [ 'user' => [ 'Organization' => 'organizations', 'Bank' => 'banksManaged', 'Admin' => 'admins', ], 'organization' => [ 'User' => 'users', ], 'bank' => [ 'User' => 'managers', ], 'admin' => [ 'User' => 'users', ], ]; $roleMap = [ 'Organization' => ['organization-manager'], 'Bank' => ['bank-manager'], 'Admin' => ['administrator'], ]; // Load role names for cleanup and validation $allAdminRoleNames = \App\Models\AdminRole::pluck('name')->toArray(); $allOrgRoleNames = \App\Models\OrganizationRole::where('name', 'like', 'organization-%')->pluck('name')->toArray(); $allBankRoleNames = \App\Models\OrganizationRole::where('name', 'like', 'bank-%')->pluck('name')->toArray(); $allowedRolesMap = [ 'Organization' => $allOrgRoleNames, 'Bank' => $allBankRoleNames, 'Admin' => $allAdminRoleNames, ]; $currentProfiles = collect($this->editAttachProfile['profiles'] ?? []) ->reject(fn($profile) => !empty($profile['removed'])) ->values() ->all(); $initialProfiles = $this->initAttachProfile['profiles'] ?? []; $modelRelations = $relationMap[$modelKey] ?? []; $roles = []; foreach ($modelRelations as $type => $relation) { $currentIds = $this->getIdsByType($currentProfiles, $type); $profile->{$relation}()->sync($currentIds); // Sync attached profiles if ($profile instanceof User) { // For each attached profile, create and assign a profile-specific role foreach ($currentIds as $attachedId) { if (isset($roleMap[$type])) { // Read the role selected in the modal for this linked profile entry $profileEntry = collect($currentProfiles) ->first(fn($p) => $p['id'] == $attachedId && $p['type'] === $type); $selectedRole = $profileEntry['role'] ?? null; if (isset($allowedRolesMap[$type]) && in_array($selectedRole, $allowedRolesMap[$type])) { $baseRole = $selectedRole; } else { $baseRole = $roleMap[$type][0]; // default: manager } // Remove ALL previous scoped roles for this type+id (pattern match) $prefix = "{$type}\\{$attachedId}\\"; foreach ($profile->roles as $existingRole) { if (str_starts_with($existingRole->name, $prefix)) { $profile->removeRole($existingRole->name); } } $roleName = "{$type}\\{$attachedId}\\{$baseRole}"; $role = Role::findOrCreate($roleName, 'web'); if ($type === 'Admin') { // For admin-scoped roles: assign permissions from AdminRole definition $adminRoleDef = \App\Models\AdminRole::where('name', $baseRole)->first(); if ($adminRoleDef) { $role->syncPermissions($adminRoleDef->permissions); } else { // AdminRole definition not found — grant no permissions rather than all \Log::warning("AdminRole definition not found for '{$baseRole}', scoped role created with no permissions."); $role->syncPermissions([]); } } else { $permissionName = 'manage ' . strtolower($type) . 's'; $role->givePermissionTo($permissionName); } $roles[] = $roleName; } } } else { // $profile is Organization, Bank, or Admin $parentType = class_basename($profile); // e.g. 'Organization' $parentId = $profile->id; foreach ($currentIds as $attachedUserId) { $user = \App\Models\User::find($attachedUserId); if ($user) { // Determine selected role from profile entry, fall back to manager default $profileEntry = collect($currentProfiles) ->first(fn($p) => $p['id'] == $attachedUserId && $p['type'] === 'User'); $selectedRole = $profileEntry['role'] ?? null; if (isset($allowedRolesMap[$parentType]) && in_array($selectedRole, $allowedRolesMap[$parentType])) { $baseRole = $selectedRole; } else { $baseRole = $roleMap[$parentType][0] ?? 'organization-manager'; } // Remove any previous coordinator/manager role for this user+profile before assigning new one if (isset($allowedRolesMap[$parentType])) { foreach ($allowedRolesMap[$parentType] as $oldBase) { $oldRoleName = "{$parentType}\\{$parentId}\\{$oldBase}"; if ($user->hasRole($oldRoleName)) { $user->removeRole($oldRoleName); } } } $roleName = "{$parentType}\\{$parentId}\\{$baseRole}"; $role = Role::findOrCreate($roleName, 'web'); if ($parentType === 'Admin') { // For admin-scoped roles: assign permissions from AdminRole definition $adminRoleDef = \App\Models\AdminRole::where('name', $baseRole)->first(); if ($adminRoleDef) { $role->syncPermissions($adminRoleDef->permissions); } else { // Fallback for 'administrator': all permissions $role->syncPermissions(\Spatie\Permission\Models\Permission::all()); } } else { $permissionName = 'manage ' . strtolower($parentType) . 's'; $role->givePermissionTo($permissionName); } // Assign the role to the user $user->assignRole($roleName); } } } } // --- Also assign / revoke / clean-up roles when $profile is a User --- // // Remove duplicate roles $roles = array_unique($roles); // Remember roles that were previously assigned to this profile $previousRoles = $profile->roles->pluck('name')->toArray(); if ($profile instanceof User) { $user = $profile; $willHavePermission = false; // Test: Will the user have 'manage profiles' after sync? // 1. Get all permissions from the roles that will be assigned $newRoles = Role::whereIn('name', $roles)->get(); foreach ($newRoles as $role) { if ($role->hasPermissionTo('manage profiles', 'web')) { $willHavePermission = true; break; } } // 2. If user currently has permission but will lose it, abort if ($user->hasPermissionTo('manage profiles', 'web') && !$willHavePermission && $profile->id === Auth::guard('web')->user()->id) { throw new \Exception(__('This action would remove your own "manage profiles" permission. Login with another account that can manage profiles and try again.')); } // Now sync roles $profile->syncRoles($roles); } // 1. Find detached profiles (present in initialProfiles but not in currentProfiles) $detachedProfiles = collect($initialProfiles) ->reject(function ($prev) use ($currentProfiles) { return collect($currentProfiles)->contains(function ($curr) use ($prev) { return $curr['id'] == $prev['id'] && $curr['type'] == $prev['type']; }); }) ->values() ->all(); // 2. Remove the corresponding role from each detached profile $parentType = class_basename($profile); // e.g. 'Organization' $parentId = $profile->id; // All possible base roles per type — loaded from DB $allRolesMap = [ 'Organization' => \App\Models\OrganizationRole::where('name', 'like', 'organization-%')->pluck('name')->toArray(), 'Bank' => \App\Models\OrganizationRole::where('name', 'like', 'bank-%')->pluck('name')->toArray(), 'Admin' => \App\Models\AdminRole::pluck('name')->toArray(), ]; foreach ($detachedProfiles as $detached) { // Only process if detached profile is a User if (($detached['type'] ?? '') === 'User') { $user = \App\Models\User::find($detached['id']); if ($user && isset($allRolesMap[$parentType])) { foreach ($allRolesMap[$parentType] as $baseRole) { $roleName = "{$parentType}\\{$parentId}\\{$baseRole}"; $user->removeRole($roleName); $role = Role::where('name', $roleName)->first(); if ($role && $role->users()->count() === 0) { $role->delete(); } } } } } // 3. Find roles that were removed and delete any stray roles that do not have a related profile $removedRoles = array_diff($previousRoles, $roles); foreach ($removedRoles as $roleName) { $role = Role::where('name', $roleName)->first(); if ($role && $role->users()->count() === 0) { $role->delete(); } } // Send email notifications for profile link changes Log::info('ProfileLinkChanged: Starting email notification process', [ 'profile_id' => $profile->id, 'profile_type' => get_class($profile), 'profile_name' => $profile->name, ]); // 1. Find newly attached profile(s) $newlyAttached = []; foreach ($modelRelations as $type => $relation) { $currentIds = $this->getIdsByType($currentProfiles, $type); $initialIds = $this->getIdsByType($initialProfiles, $type); // Find IDs that are in current but not in initial (i.e., newly attached) $addedIds = array_diff($currentIds, $initialIds); foreach ($addedIds as $addedId) { $newlyAttached[] = [ 'id' => $addedId, 'type' => $type, ]; } } Log::info('ProfileLinkChanged: Found newly attached profiles', [ 'count' => count($newlyAttached), 'attached' => $newlyAttached, ]); // 2. Send emails for newly attached profiles foreach ($newlyAttached as $attached) { $attachedModelClass = 'App\\Models\\' . $attached['type']; $attachedProfile = $attachedModelClass::find($attached['id']); if ($attachedProfile) { Log::info('ProfileLinkChanged: Processing attached profile', [ 'attached_profile_id' => $attachedProfile->id, 'attached_profile_type' => get_class($attachedProfile), 'attached_profile_name' => $attachedProfile->name, 'attached_profile_email' => $attachedProfile->email ?? 'NO EMAIL', ]); // Send email to the attached profile $messageSetting = \App\Models\MessageSetting::where('message_settingable_id', $attachedProfile->id) ->where('message_settingable_type', get_class($attachedProfile)) ->first(); $sendEmail = $messageSetting ? $messageSetting->system_message : true; Log::info('ProfileLinkChanged: Attached profile message setting check', [ 'has_message_setting' => $messageSetting ? 'yes' : 'no', 'system_message' => $messageSetting ? $messageSetting->system_message : 'default:true', 'will_send_email' => $sendEmail ? 'yes' : 'no', ]); if ($sendEmail) { \App\Jobs\SendProfileLinkChangedMail::dispatch($attachedProfile, $profile, 'attached'); Log::info('ProfileLinkChanged: Dispatched email to attached profile', [ 'recipient_email' => $attachedProfile->email, 'linked_profile_name' => $profile->name, ]); } else { Log::info('ProfileLinkChanged: Skipped email to attached profile (system_message disabled)'); } // Send email to the main profile $profileMessageSetting = \App\Models\MessageSetting::where('message_settingable_id', $profile->id) ->where('message_settingable_type', get_class($profile)) ->first(); $sendProfileEmail = $profileMessageSetting ? $profileMessageSetting->system_message : true; Log::info('ProfileLinkChanged: Main profile message setting check', [ 'has_message_setting' => $profileMessageSetting ? 'yes' : 'no', 'system_message' => $profileMessageSetting ? $profileMessageSetting->system_message : 'default:true', 'will_send_email' => $sendProfileEmail ? 'yes' : 'no', ]); if ($sendProfileEmail) { \App\Jobs\SendProfileLinkChangedMail::dispatch($profile, $attachedProfile, 'attached'); Log::info('ProfileLinkChanged: Dispatched email to main profile', [ 'recipient_email' => $profile->email ?? 'NO EMAIL', 'linked_profile_name' => $attachedProfile->name, ]); } else { Log::info('ProfileLinkChanged: Skipped email to main profile (system_message disabled)'); } } else { Log::warning('ProfileLinkChanged: Attached profile not found', [ 'attached_id' => $attached['id'], 'attached_type' => $attached['type'], ]); } } // 3. Send emails for detached profiles Log::info('ProfileLinkChanged: Found detached profiles', [ 'count' => count($detachedProfiles), 'detached' => $detachedProfiles, ]); foreach ($detachedProfiles as $detached) { $detachedModelClass = 'App\\Models\\' . $detached['type']; $detachedProfile = $detachedModelClass::find($detached['id']); if ($detachedProfile) { Log::info('ProfileLinkChanged: Processing detached profile', [ 'detached_profile_id' => $detachedProfile->id, 'detached_profile_type' => get_class($detachedProfile), 'detached_profile_name' => $detachedProfile->name, 'detached_profile_email' => $detachedProfile->email ?? 'NO EMAIL', ]); // Send email to the detached profile $messageSetting = \App\Models\MessageSetting::where('message_settingable_id', $detachedProfile->id) ->where('message_settingable_type', get_class($detachedProfile)) ->first(); $sendEmail = $messageSetting ? $messageSetting->system_message : true; Log::info('ProfileLinkChanged: Detached profile message setting check', [ 'has_message_setting' => $messageSetting ? 'yes' : 'no', 'system_message' => $messageSetting ? $messageSetting->system_message : 'default:true', 'will_send_email' => $sendEmail ? 'yes' : 'no', ]); if ($sendEmail) { \App\Jobs\SendProfileLinkChangedMail::dispatch($detachedProfile, $profile, 'detached'); Log::info('ProfileLinkChanged: Dispatched email to detached profile', [ 'recipient_email' => $detachedProfile->email, 'linked_profile_name' => $profile->name, ]); } else { Log::info('ProfileLinkChanged: Skipped email to detached profile (system_message disabled)'); } // Send email to the main profile $profileMessageSetting = \App\Models\MessageSetting::where('message_settingable_id', $profile->id) ->where('message_settingable_type', get_class($profile)) ->first(); $sendProfileEmail = $profileMessageSetting ? $profileMessageSetting->system_message : true; Log::info('ProfileLinkChanged: Main profile message setting check (detached)', [ 'has_message_setting' => $profileMessageSetting ? 'yes' : 'no', 'system_message' => $profileMessageSetting ? $profileMessageSetting->system_message : 'default:true', 'will_send_email' => $sendProfileEmail ? 'yes' : 'no', ]); if ($sendProfileEmail) { \App\Jobs\SendProfileLinkChangedMail::dispatch($profile, $detachedProfile, 'detached'); Log::info('ProfileLinkChanged: Dispatched email to main profile (detached)', [ 'recipient_email' => $profile->email ?? 'NO EMAIL', 'unlinked_profile_name' => $detachedProfile->name, ]); } else { Log::info('ProfileLinkChanged: Skipped email to main profile (system_message disabled)'); } } else { Log::warning('ProfileLinkChanged: Detached profile not found', [ 'detached_id' => $detached['id'], 'detached_type' => $detached['type'], ]); } } $this->resetForm(); $this->notification()->success(__('Saved'), __('The profile has been saved successfully!')); // Uncomment to send wirechat message to the new Profile when it is added to the $profile // This block is commented because currently no other models than User could chat with chat. // // 1. Find newly attached profile(s) // $newlyAttached = []; // foreach ($modelRelations as $type => $relation) { // $currentIds = $this->getIdsByType($currentProfiles, $type); // $initialIds = $this->getIdsByType($initialProfiles, $type); // // Find IDs that are in current but not in initial (i.e., newly attached) // $addedIds = array_diff($currentIds, $initialIds); // foreach ($addedIds as $addedId) { // $newlyAttached[] = [ // 'id' => $addedId, // 'type' => $type, // ]; // } // } // // 2. Get the model instance for the first newly attached profile (if any) // if (!empty($newlyAttached)) { // $recipientInfo = $newlyAttached[0]; // $recipientModelClass = 'App\\Models\\' . $recipientInfo['type']; // $recipient = $recipientModelClass::find($recipientInfo['id']); // if ($recipient) { // $sender = $profile; // $messageLocale = $recipient->lang_preference ?? $sender->lang_preference; // if (!Lang::has('messages.manage_profiles.attached_profile_chat_message', $messageLocale)) { // $messageLocale = config('app.fallback_locale'); // } // $chatMessage = __('messages.manage_profiles.attached_profile_chat_message', [], $messageLocale); // // Send Wirechat message // $message = $sender->sendMessageTo($recipient, $chatMessage); // // Broadcast the NotifyParticipant event to wirechat messenger // broadcast(new NotifyParticipant($recipient, $message)); // } // } $this->modalAttachProfile = false; } else { $this->notification()->error(__('Error!'), __('Profile not found.')); } }); } catch (\Illuminate\Validation\ValidationException $e) { $this->notification()->warning(__('Validation Error'), __('Please correct the errors in the form.')); } catch (\Exception $e) { $this->notification()->error(__('Error!'), __('Oops, we have an error: the profile was not saved!') . ' ' . $e->getMessage()); } } // Returns system role names for a given profile type private function systemRolesFor(string $type): array { return match ($type) { 'Admin' => \App\Models\AdminRole::where('is_system', true)->pluck('name')->toArray(), 'Organization' => \App\Models\OrganizationRole::where('is_system', true)->where('name', 'like', 'organization-%')->pluck('name')->toArray(), 'Bank' => \App\Models\OrganizationRole::where('is_system', true)->where('name', 'like', 'bank-%')->pluck('name')->toArray(), default => [], }; } // Helper function for attachProfile method private function getIdsByType($profiles, $type) { return collect($profiles) ->where('type', $type) ->pluck('id') ->map(fn($id) => (int)$id) ->unique() ->values() ->toArray(); } public function removeAttachedProfile($id, $type) { $this->editAttachProfile['profiles'] = array_map(function ($profile) use ($id, $type) { if ( isset($profile['id'], $profile['type']) && $profile['id'] == $id && strtolower($profile['type']) == strtolower($type) ) { $profile['removed'] = true; // Mark as removed } return $profile; }, $this->editAttachProfile['profiles']); $this->syncAttachProfileChangedState(); } /** * Delete the tag * * @param mixed $translationId * @return void */ public function deleteProfile() { $this->denyIfScopedToBank(); $this->authorizePermission('delete profiles'); $this->validate([ 'adminPassword' => ['required', 'string'], ]); // Check if donation would exceed limits if ($this->balanceHandlingOption === 'donate' && $this->donationExceedsLimit) { $this->addError('donationAccountId', $this->donationLimitError ?? __('The selected organization account cannot receive this donation amount.')); return; } try { $profile = $this->selectedProfile; if (!$profile) { throw new \Exception('Profile not found.'); } // Verify admin password $admin = getActiveProfile(); if (!$admin instanceof \App\Models\Admin) { throw new \Exception('Only administrators can delete profiles.'); } if (!\Hash::check($this->adminPassword, $admin->password)) { $this->addError('adminPassword', __('This password does not match our records.')); return; } // Get the profile's updated_at timestamp for the email $profileTable = $profile->getTable(); $time = DB::table($profileTable) ->where('id', $profile->id) ->pluck('updated_at') ->first(); $time = \Carbon\Carbon::parse($time); // Get the admin username who is deleting the profile $deletedByUsername = \Auth::guard('web')->user()->name ?? null; // Use the DeleteUser action $deleter = app(\Laravel\Jetstream\Contracts\DeletesUsers::class); $result = $deleter->delete( $profile->fresh(), $this->balanceHandlingOption, $this->donationAccountId, false, // isAutoDeleted = false $deletedByUsername // admin username ); if ($result['status'] === 'success') { // Prepare email data $result['time'] = $time->translatedFormat('j F Y, H:i'); $result['deletedUser'] = $profile; $result['mail'] = $profile->email; $result['balanceHandlingOption'] = $this->balanceHandlingOption; $result['totalBalance'] = $this->deleteProfileData['totalBalance'] ?? 0; $result['donationAccountId'] = $this->donationAccountId; // Get donation account details if donated if ($this->balanceHandlingOption === 'donate' && $this->donationAccountId) { $donationAccount = \App\Models\Account::find($this->donationAccountId); if ($donationAccount && $donationAccount->accountable) { $result['donationAccountName'] = $donationAccount->name; $result['donationOrganizationName'] = $donationAccount->accountable->name; } } // Send confirmation email Log::notice('Profile deleted by admin: ' . $profile->name); \Mail::to($profile->email)->queue(new \App\Mail\UserDeletedMail($result)); $this->notification()->success( __('Deleted'), __('Profile was deleted successfully!') ); $this->resetForm(); $this->resetPage(); $this->dispatch('scroll-to-top'); } else { $this->notification()->error( __('Error!'), __('There was an error deleting the profile: ') . $result['message'] ); } } catch (\Exception $e) { $this->notification()->error( __('Error!'), __('Could not delete the profile: ') . $e->getMessage() ); Log::error('Admin profile deletion failed', [ 'profile_id' => $this->selectedProfileId, 'error' => $e->getMessage() ]); } $this->modalDeleteProfile = false; } /** * Restore a deleted profile * * @return void */ public function restoreProfile() { $this->denyIfScopedToBank(); $this->authorizePermission('update profiles'); $this->validate([ 'adminPassword' => ['required', 'string'], ]); try { $profile = $this->selectedProfile; if (!$profile) { throw new \Exception('Profile not found.'); } // Verify admin password $admin = getActiveProfile(); if (!$admin instanceof \App\Models\Admin) { throw new \Exception('Only administrators can restore profiles.'); } if (!\Hash::check($this->adminPassword, $admin->password)) { $this->addError('adminPassword', __('This password does not match our records.')); return; } // Use the RestoreProfile action $restorer = new \App\Actions\Jetstream\RestoreProfile(); $result = $restorer->restore($profile->fresh()); if ($result['status'] === 'success') { $this->notification()->success( __('Restored'), __('Profile was restored successfully!') ); Log::notice('Profile restored by admin: ' . $profile->name, [ 'profile_id' => $profile->id, 'profile_type' => get_class($profile), 'admin_id' => $admin->id, 'admin_name' => $admin->name, ]); $this->resetForm(); $this->resetPage(); $this->dispatch('scroll-to-top'); } else { $this->notification()->error( __('Error!'), __('There was an error restoring the profile: ') . $result['message'] ); } } catch (\Exception $e) { $this->notification()->error( __('Error!'), __('Could not restore the profile: ') . $e->getMessage() ); Log::error('Admin profile restoration failed', [ 'profile_id' => $this->selectedProfileId, 'error' => $e->getMessage() ]); } $this->modalRestoreProfile = false; } public function deleteSelected() { $this->denyIfScopedToBank(); $this->authorizePermission('delete profiles'); $this->validateOnly('confirmString'); try { $tags = User::whereIn('id', $this->bulkSelected); if ($tags) { $tags->delete(); $this->notification()->success( $title = __('Deleted'), $description = __('Selected tags were deleted successfully!') ); } else { $this->notification()->error( $title = __('Error!'), $description = __('The selected tags were not found.') ); } } catch (\Exception $e) { $this->notification()->error( $title = __('Error!'), $description = __('Oops, could not delete the selected tags') . '!' . $e->getMessage() ); } $this->resetPage(); $this->dispatch('scroll-to-top'); $this->confirmString = ''; } public function resetForm() { $this->reset([ 'selectedProfileId', 'selectedProfile', 'modalEditProfile', 'modalAttachProfile', 'modalEditAccounts', 'modalDeleteProfile', 'modalRestoreProfile', 'initProfile', 'initAttachProfile', 'initAccounts', 'editProfile', 'editAttachProfile', 'editAccounts', 'editProfileChanged', 'editAttachProfileChanged', 'editAccountsChanged', 'editProfileMessages', 'editAccountsMessages', 'initMessageSettings', 'editMessageSettings', 'editMessageSettingsChanged', 'confirmString', 'buttonDisabled', 'reActivate', 'deleteProfileData', 'restoreProfileData', 'adminPassword', 'balanceHandlingOption', 'donationAccountId', 'donationExceedsLimit', 'donationLimitError', 'modalConvertToUser', 'convertToUserData', 'modalConvertToOrganization', 'convertToOrganizationData', 'convertToOrganizationSelectedManager', 'modalReversal', 'reversalLookupId', 'reversalLookupError', 'reversalSelectedTransactionId', 'reversalAmount', 'reversalDescription', 'reversalConfirmString', 'reversalSenderName', 'reversalRecipientName', 'reversalDirection', 'reversalCreatedAt', 'reversalRequesterOptions', 'reversalRequestedBy', ]); $this->resetErrorBag(); } public function sortBy(string $field): void { if ($this->sortField === $field) { $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; } else { $this->sortDirection = 'asc'; } $this->sortField = $field; $this->resetPage(); $this->dispatch('scroll-to-top'); } public function searchProfiles() { $this->resetPage(); $this->dispatch('scroll-to-top'); } public function handleSearchEnter() { if (!$this->showModal) { $this->searchProfiles(); } } public function updatingSearch() { $this->resetPage(); } public function resetSearch() { $this->search = ''; $this->searchProfiles(); $this->dispatch('scroll-to-top'); } public function updatedPage() { $this->dispatch('scroll-to-top'); } public function updatingPerPage() { $this->resetPage(); $this->dispatch('scroll-to-top'); } public function updatedTypeFilter() { $this->resetPage(); $this->dispatch('scroll-to-top'); } public function updatedActiveFilter() { $this->resetPage(); $this->dispatch('scroll-to-top'); } public function updatedEmailVerifiedFilter() { $this->resetPage(); $this->dispatch('scroll-to-top'); } /** * Get available profile type options for the filter dropdown. * * @return array */ public function getTypeOptionsProperty(): array { if ($this->scopedToBankId !== null) { // Scoped bank-managers only ever see their own Users/Organizations clients. return [ ['id' => 'user', 'name' => __('User')], ['id' => 'organization', 'name' => __('Organization')], ]; } return [ ['id' => 'user', 'name' => __('User')], ['id' => 'organization', 'name' => __('Organization')], ['id' => 'bank', 'name' => __('Bank')], ['id' => 'admin', 'name' => __('Admin')], ]; } /** * Get available active status options for the filter dropdown. * * @return array */ public function getActiveOptionsProperty(): array { return [ ['id' => 'active', 'name' => __('Active')], ['id' => 'inactive', 'name' => __('Inactive')], ['id' => 'deleted', 'name' => __('Deleted')], ]; } /** * Get available email verification options for the filter dropdown. * * @return array */ public function getEmailVerifiedOptionsProperty(): array { return [ ['id' => 'verified', 'name' => __('Verified')], ['id' => 'unverified', 'name' => __('Not verified')], ['id' => 'blocked', 'name' => __('Blocked')], ]; } /** * Check if a deleted profile can be restored. * * @param mixed $profile * @return bool */ protected function isProfileRestorable($profile) { // If profile is not deleted, it can't be restored if (!$profile->deleted_at) { return false; } // Check if profile has been permanently deleted (anonymized email) if (str_starts_with($profile->email, 'removed-') && str_ends_with($profile->email, '@remove.ed')) { return false; } // Check if password is empty (permanently deleted) if (empty($profile->password)) { return false; } // Check if grace period has expired $gracePeriodDays = timebank_config('delete_profile.grace_period_days', 30); $gracePeriodExpiry = \Carbon\Carbon::parse($profile->deleted_at)->addDays($gracePeriodDays); if (now()->isAfter($gracePeriodExpiry)) { return false; } return true; } public function render() { // --- 1. Define Base Queries --- $usersQuery = User::query() ->select('id', 'name', 'full_name', 'email', 'password', 'profile_photo_path', 'comment', 'inactive_at', 'email_verified_at', 'last_login_at', 'deleted_at', 'created_at', 'updated_at'); $organizationsQuery = Organization::query() ->select('id', 'name', 'full_name', 'email', 'password', 'profile_photo_path', 'comment', 'inactive_at', 'email_verified_at', 'last_login_at', 'deleted_at', 'created_at', 'updated_at'); $banksQuery = Bank::query() ->select('id', 'name', 'full_name', 'email', 'password', 'profile_photo_path', 'comment', 'inactive_at', 'email_verified_at', 'last_login_at', 'deleted_at', 'created_at', 'updated_at'); $adminsQuery = Admin::query() ->select('id', 'name', 'full_name', 'email', 'password', 'profile_photo_path', 'comment', 'inactive_at', 'email_verified_at', 'last_login_at', 'deleted_at', 'created_at', 'updated_at'); // --- 1b. Scope to the active bank's own clients only, for non-central bank-managers --- // bank_clients only links Users/Organizations to a bank, so Banks and Admins are excluded entirely. if ($this->scopedToBankId !== null) { $clientUserIds = DB::table('bank_clients') ->where('bank_id', $this->scopedToBankId) ->where('client_type', User::class) ->pluck('client_id'); $clientOrgIds = DB::table('bank_clients') ->where('bank_id', $this->scopedToBankId) ->where('client_type', Organization::class) ->pluck('client_id'); $usersQuery->whereIn('id', $clientUserIds); $organizationsQuery->whereIn('id', $clientOrgIds); $banksQuery->whereRaw('1 = 0'); $adminsQuery->whereRaw('1 = 0'); } // --- 2. Apply Search --- if ($this->search) { $searchTerm = '%' . $this->search . '%'; $usersQuery->where(function (Builder $query) use ($searchTerm) { $query->where('name', 'like', $searchTerm) ->orWhere('full_name', 'like', $searchTerm) ->orWhere('email', 'like', $searchTerm) ->orWhere('comment', 'like', $searchTerm) ->orWhere('id', 'like', $searchTerm); }); $organizationsQuery->where(function (Builder $query) use ($searchTerm) { $query->where('name', 'like', $searchTerm) ->orWhere('full_name', 'like', $searchTerm) ->orWhere('email', 'like', $searchTerm) ->orWhere('comment', 'like', $searchTerm) ->orWhere('id', 'like', $searchTerm); }); $banksQuery->where(function (Builder $query) use ($searchTerm) { $query->where('name', 'like', $searchTerm) ->orWhere('full_name', 'like', $searchTerm) ->orWhere('email', 'like', $searchTerm) ->orWhere('comment', 'like', $searchTerm) ->orWhere('id', 'like', $searchTerm); }); $adminsQuery->where(function (Builder $query) use ($searchTerm) { $query->where('name', 'like', $searchTerm) ->orWhere('full_name', 'like', $searchTerm) ->orWhere('email', 'like', $searchTerm) ->orWhere('comment', 'like', $searchTerm) ->orWhere('id', 'like', $searchTerm); }); } // --- 3. Fetch Data (conditionally based on type filter) --- $users = collect(); $organizations = collect(); $banks = collect(); $admins = collect(); if (!$this->typeFilter || $this->typeFilter === 'user') { $users = $usersQuery->get(); } if (!$this->typeFilter || $this->typeFilter === 'organization') { $organizations = $organizationsQuery->get(); } if (!$this->typeFilter || $this->typeFilter === 'bank') { $banks = $banksQuery->get(); } if (!$this->typeFilter || $this->typeFilter === 'admin') { $admins = $adminsQuery->get(); } // --- 4. Combine Collections & Add Type --- // Pre-fetch all suppressed emails in one query to avoid N+1 $allEmails = collect([$users, $organizations, $banks, $admins]) ->flatten() ->pluck('email') ->filter() ->unique() ->values(); $suppressedEmails = \App\Models\MailingBounce::whereIn('email', $allEmails) ->where('is_suppressed', true) ->pluck('email') ->flip(); // flip for O(1) lookup $combined = new Collection(); foreach ($users as $user) { $user->type = __('User'); $user->model = 'App\Models\User'; $user->inactive = $this->dateStatus($user->inactive_at); $user->email_suppressed = isset($suppressedEmails[$user->email]); $user->email_verif = $user->email_suppressed ? __('Blocked') : $this->dateStatus($user->email_verified_at); $user->deleted = $this->dateStatus($user->deleted_at); $user->is_restorable = $this->isProfileRestorable($user); $combined->push($user); } foreach ($organizations as $org) { $org->type = __('Organization'); $org->model = 'App\Models\Organization'; $org->inactive = $this->dateStatus($org->inactive_at); $org->email_suppressed = isset($suppressedEmails[$org->email]); $org->email_verif = $org->email_suppressed ? __('Blocked') : $this->dateStatus($org->email_verified_at); $org->deleted = $this->dateStatus($org->deleted_at); $org->is_restorable = $this->isProfileRestorable($org); $combined->push($org); } foreach ($banks as $bank) { $bank->type = __('Bank'); $bank->model = 'App\Models\Bank'; $bank->inactive = $this->dateStatus($bank->inactive_at); $bank->email_suppressed = isset($suppressedEmails[$bank->email]); $bank->email_verif = $bank->email_suppressed ? __('Blocked') : $this->dateStatus($bank->email_verified_at); $bank->deleted = $this->dateStatus($bank->deleted_at); $bank->is_restorable = $this->isProfileRestorable($bank); $combined->push($bank); } foreach ($admins as $admin) { $admin->type = __('Admin'); $admin->model = 'App\Models\Admin'; $admin->inactive = $this->dateStatus($admin->inactive_at); $admin->email_suppressed = isset($suppressedEmails[$admin->email]); $admin->email_verif = $admin->email_suppressed ? __('Blocked') : $this->dateStatus($admin->email_verified_at); $admin->deleted = $this->dateStatus($admin->deleted_at); $admin->is_restorable = $this->isProfileRestorable($admin); $combined->push($admin); } // --- 5. Apply Active and Email Verified Filters --- if ($this->activeFilter) { $combined = $combined->filter(function ($profile) { if ($this->activeFilter === 'active') { return !$profile->inactive_at && !$profile->deleted_at; } elseif ($this->activeFilter === 'inactive') { return $profile->inactive_at && !$profile->deleted_at; } elseif ($this->activeFilter === 'deleted') { return $profile->deleted_at !== null; } return true; }); } if ($this->emailVerifiedFilter) { $combined = $combined->filter(function ($profile) { if ($this->emailVerifiedFilter === 'verified') { return $profile->email_verified_at !== null && !$profile->email_suppressed; } elseif ($this->emailVerifiedFilter === 'unverified') { return $profile->email_verified_at === null && !$profile->email_suppressed; } elseif ($this->emailVerifiedFilter === 'blocked') { return $profile->email_suppressed; } return true; }); } // --- 6. Sort Combined Collection --- $sortField = $this->sortField; $sortDirection = $this->sortDirection === 'asc' ? false : true; if (in_array($sortField, ['name', 'inactive', 'email_verif', 'created_at', 'updated_at', 'type', 'id', 'last_login_at', 'comment'])) { $combined = $combined->sortBy($sortField, SORT_REGULAR, $sortDirection); } else { $combined = $combined->sortBy('created_at', SORT_REGULAR, $sortDirection); } // --- 7. Use proper Livewire pagination --- // Get current page using the correct method $currentPage = $this->getPage(); $perPage = $this->perPage; $currentPageItems = $combined->slice(($currentPage - 1) * $perPage, $perPage)->values(); $profilesPaginator = new LengthAwarePaginator( $currentPageItems, $combined->count(), $perPage, $currentPage, [ 'path' => request()->url(), 'pageName' => 'page', ] ); // Build role options for the attach-profile modal $adminRoleOptions = \App\Models\AdminRole::all()->map(fn($r) => [ 'id' => $r->name, 'name' => __($r->label), 'is_system' => (bool) $r->is_system, ])->toArray(); $translateRoleName = function (string $name): string { [$prefix, $suffix] = array_pad(explode('-', $name, 2), 2, ''); return __(ucfirst($prefix)) . ' ' . __(ucfirst($suffix)); }; $orgRoleOptions = \App\Models\OrganizationRole::where('name', 'like', 'organization-%') ->orderBy('name') ->get() ->map(fn($r) => ['id' => $r->name, 'name' => $translateRoleName($r->name), 'is_system' => (bool) $r->is_system]) ->toArray(); $bankRoleOptions = \App\Models\OrganizationRole::where('name', 'like', 'bank-%') ->orderBy('name') ->get() ->map(fn($r) => ['id' => $r->name, 'name' => $translateRoleName($r->name), 'is_system' => (bool) $r->is_system]) ->toArray(); return view('livewire.profiles.manage', [ 'profiles' => $profilesPaginator, 'adminRoleOptions' => $adminRoleOptions, 'orgRoleOptions' => $orgRoleOptions, 'bankRoleOptions' => $bankRoleOptions, ]); } public function openConvertToOrganizationModal(int $profileId, string $modelName): void { $this->denyIfScopedToBank(); $this->authorizePermission('update profiles'); if (class_basename($modelName) !== 'User') { return; } $user = User::find($profileId); if (!$user) { return; } $blockingErrors = []; $warnings = []; if (Organization::where('name', $user->name)->exists()) { $blockingErrors[] = __('An organization with name :name already exists.', ['name' => $user->name]); } if (Organization::where('email', $user->email)->exists()) { $blockingErrors[] = __('An organization with email :email already exists.', ['email' => $user->email]); } try { if (method_exists($user, 'isOnline') && $user->isOnline()) { $blockingErrors[] = __('User is currently online — wait for them to go offline before converting.'); } } catch (\Exception $e) { // Skip online check if presence system is not available } if ($user->hasRole('Super Admin') || $user->hasRole('super-admin')) { $warnings[] = __('User has Super Admin role — all admin privileges will be removed.'); } if (!empty($user->two_factor_secret)) { $warnings[] = __('User has two-factor authentication enabled — this will be lost after conversion.'); } try { if (method_exists($user, 'banksManaged') && $user->banksManaged()->count() > 0) { $warnings[] = __('User is managing :count bank(s). This management will be removed after conversion.', ['count' => $user->banksManaged()->count()]); } if (method_exists($user, 'organizations') && $user->organizations()->count() > 0) { $warnings[] = __('User is managing :count organization(s). This management will be removed after conversion.', ['count' => $user->organizations()->count()]); } } catch (\Exception $e) { // Skip if relationships don't exist } $this->convertToOrganizationData = [ 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, 'accountCount' => $user->accounts()->count(), 'blockingErrors' => $blockingErrors, 'warnings' => $warnings, ]; $this->modalConvertToOrganization = true; } public function convertToOrganization(): void { $this->denyIfScopedToBank(); $this->authorizePermission('update profiles'); if (!$this->convertToOrganizationData || !empty($this->convertToOrganizationData['blockingErrors'])) { return; } if (empty($this->convertToOrganizationSelectedManager)) { $this->addError('convertToOrganization', __('Please select a user to become the manager of this organization.')); return; } $userId = filter_var($this->convertToOrganizationData['id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) ?: null; $user = $userId ? User::find($userId) : null; if (!$user) { $this->resetForm(); $this->notification()->error(__('Conversion failed'), __('User not found.')); return; } $managerId = filter_var($this->convertToOrganizationSelectedManager['id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) ?: null; try { $exitCode = \Illuminate\Support\Facades\Artisan::call('migrate:user-to-organization', [ 'user_id' => $user->id, '--force' => true, ]); $this->resetForm(); if ($exitCode !== 0) { $this->notification()->error( __('Conversion failed'), __('The user could not be converted to an organization profile.') ); return; } $this->notification()->success( __('Conversion successful'), __('The user has been converted to an organization profile.') ); $organization = \App\Models\Organization::where('name', $user->name)->latest('id')->first(); // Attach the selected manager to the organization (if not already linked) $manager = $managerId ? \App\Models\User::find($managerId) : null; if ($organization && $manager && $manager->id !== $user->id) { \Illuminate\Support\Facades\DB::table('organization_user')->insertOrIgnore([ 'organization_id' => $organization->id, 'user_id' => $manager->id, 'created_at' => now(), 'updated_at' => now(), ]); } // Email the organization about the conversion (passing full manager model) if ($organization && $organization->email) { \App\Jobs\SendProfileConvertedByAdminMail::dispatch($organization, 'organization', $manager); } // Email the selected manager about being linked to the new organization if ($manager && $organization) { \App\Jobs\SendProfileLinkChangedMail::dispatch($manager, $organization, 'attached'); } $this->dispatch('scroll-to-top'); } catch (\Exception $e) { $this->resetForm(); $this->notification()->error( __('Conversion failed'), $e->getMessage() ); } } public function openConvertToUserModal(int $profileId, string $modelName): void { $this->denyIfScopedToBank(); $this->authorizePermission('update profiles'); if (class_basename($modelName) !== 'Organization') { return; } $organization = Organization::find($profileId); if (!$organization) { return; } // Run the same validations as the Artisan command $blockingErrors = []; $warnings = []; if (User::where('name', $organization->name)->exists()) { $blockingErrors[] = __('A user with name :name already exists.', ['name' => $organization->name]); } if (User::where('email', $organization->email)->exists()) { $blockingErrors[] = __('A user with email :email already exists.', ['email' => $organization->email]); } $highValueAccount = $organization->accounts()->where('limit_max', '>', timebank_config('profiles.user.limit_max', 6000))->first(); if ($highValueAccount) { $warnings[] = __('Organization has account(s) with limits higher than the user maximum — limits will be reduced.'); } $accountCount = $organization->accounts()->count(); if ($accountCount > 3) { $warnings[] = __('Organization has :count accounts — this might be a complex organization.', ['count' => $accountCount]); } try { if (method_exists($organization, 'banksManaged') && $organization->banksManaged()->count() > 0) { $warnings[] = __('Organization is managing :count bank(s). This management will be removed after conversion.', ['count' => $organization->banksManaged()->count()]); } } catch (\Exception $e) { // Skip if relationship doesn't exist } $this->convertToUserData = [ 'id' => $organization->id, 'name' => $organization->name, 'email' => $organization->email, 'accountCount' => $accountCount, 'blockingErrors' => $blockingErrors, 'warnings' => $warnings, ]; $this->modalConvertToUser = true; } public function convertToUser(): void { $this->denyIfScopedToBank(); $this->authorizePermission('update profiles'); if (!$this->convertToUserData || !empty($this->convertToUserData['blockingErrors'])) { return; } $orgId = filter_var($this->convertToUserData['id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) ?: null; $organization = $orgId ? Organization::find($orgId) : null; if (!$organization) { $this->addError('convertToUser', __('Organization not found.')); return; } try { $exitCode = \Illuminate\Support\Facades\Artisan::call('migrate:organization-to-user', [ 'organization_id' => $organization->id, '--force' => true, ]); $this->resetForm(); if ($exitCode !== 0) { $this->notification()->error( __('Conversion failed'), __('The organization could not be converted to a user profile.') ); return; } $this->notification()->success( __('Conversion successful'), __('The organization has been converted to a user profile.') ); $user = \App\Models\User::where('name', $organization->name)->latest('id')->first(); if ($user && $user->email) { \App\Jobs\SendProfileConvertedByAdminMail::dispatch($user, 'user'); } $this->dispatch('scroll-to-top'); } catch (\Exception $e) { $this->resetForm(); $this->notification()->error( __('Conversion failed'), $e->getMessage() ); } } }