Files

3583 lines
158 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Livewire\Profiles;
use App\Http\Livewire\Traits\RequiresAdminAuthorization;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use App\Traits\AccountInfoTrait;
use App\Traits\DateTimeTrait;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithPagination;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
use Namu\WireChat\Events\NotifyParticipant;
use Spatie\Permission\Models\Role;
use WireUi\Traits\WireUiActions;
class Manage extends Component
{
use WithPagination;
use WireUiActions;
use DateTimeTrait;
use AccountInfoTrait;
use RequiresAdminAuthorization;
/**
* When set, the active profile is a non-central bank-manager. All
* queries/actions must be scoped to this bank's own clients, and only
* the accounts-related actions (view/edit accounts, reversal) are
* reachable — everything else (delete/restore/attach/edit-profile,
* convert user<->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()
);
}
}
}