3583 lines
158 KiB
PHP
3583 lines
158 KiB
PHP
<?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()
|
||
);
|
||
}
|
||
}
|
||
}
|