2155 lines
89 KiB
PHP
2155 lines
89 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\Component;
|
||
use Livewire\WithPagination;
|
||
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;
|
||
|
||
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 $selectedProfile;
|
||
public int $selectedProfileId; // Needed for the modals
|
||
|
||
public $deleteProfileData = null;
|
||
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 $initAttachProfile = [];
|
||
public $editAttachProfile = [];
|
||
public $newTypesAvailable = [];
|
||
public bool $editAttachProfileChanged = false;
|
||
|
||
|
||
public $initAccounts = [];
|
||
public $editAccounts = [];
|
||
public $editAccountsMessages = [];
|
||
public bool $editAccountsChanged = false;
|
||
|
||
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',
|
||
'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 or central bank permissions
|
||
if ($profile instanceof \App\Models\Admin) {
|
||
// Admin access OK
|
||
} elseif ($profile instanceof \App\Models\Bank) {
|
||
// Only central bank (level 0) can access profile management
|
||
if ($profile->level !== 0) {
|
||
abort(403, __('Central bank access required for profile management'));
|
||
}
|
||
} 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),
|
||
'authenticated_guard' => \Auth::getDefaultDriver(),
|
||
'ip_address' => request()->ip(),
|
||
]);
|
||
}
|
||
|
||
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'),
|
||
|
||
'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:organization-manager,organization-coordinator,bank-manager,bank-coordinator'],
|
||
'editAttachProfile.newProfile.role' => ['nullable', 'string', 'in:organization-manager,organization-coordinator,bank-manager,bank-coordinator,administrator'],
|
||
|
||
'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]
|
||
];
|
||
|
||
|
||
public function openEditAccountsModal($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['location'] = $profile->getLocationFirst()['name'];
|
||
$this->editAccounts['accounts'] = $this->getAccountsInfo($modelName, $profileId)->toArray(); // Use the AccountInfoTrait
|
||
|
||
$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 openEditProfileModal($profileId, $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
|
||
}
|
||
$this->initProfile = $this->editProfile; // Store the initial profile data for comparison later
|
||
$this->modalEditProfile = true;
|
||
}
|
||
|
||
|
||
public function openAttachProfilesModal($profileId, $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'] = 'administrator';
|
||
$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', or the manager default.
|
||
*/
|
||
protected function resolveLinkedUserRole($user, string $parentType, int $parentId): string
|
||
{
|
||
$coordinatorMap = [
|
||
'Organization' => 'organization-coordinator',
|
||
'Bank' => 'bank-coordinator',
|
||
];
|
||
$managerMap = [
|
||
'Organization' => 'organization-manager',
|
||
'Bank' => 'bank-manager',
|
||
];
|
||
|
||
$coordinatorRole = isset($coordinatorMap[$parentType])
|
||
? "{$parentType}\\{$parentId}\\{$coordinatorMap[$parentType]}"
|
||
: null;
|
||
|
||
if ($coordinatorRole && $user->hasRole($coordinatorRole)) {
|
||
return $coordinatorMap[$parentType];
|
||
}
|
||
|
||
return $managerMap[$parentType] ?? 'organization-manager';
|
||
}
|
||
|
||
public function selectedProfile($profile)
|
||
{
|
||
if ($profile) {
|
||
$profile['role'] = null;
|
||
$this->editAttachProfile['newProfile'] = $profile;
|
||
} else {
|
||
$this->editAttachProfile['newProfile'] = null;
|
||
}
|
||
|
||
$this->syncAttachProfileChangedState();
|
||
}
|
||
|
||
|
||
|
||
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 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()
|
||
{
|
||
// CRITICAL: Authorize admin access for updating profile
|
||
$this->authorizeAdminAccess();
|
||
|
||
$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->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 {
|
||
if (empty($allRulesToValidate)) {
|
||
$this->notification()->info(__('No Changes'), __('No changes requiring validation were made.'));
|
||
$this->modalEditProfile = false;
|
||
return;
|
||
}
|
||
|
||
$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;
|
||
}
|
||
}
|
||
$profile->save();
|
||
|
||
// 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,
|
||
]);
|
||
|
||
$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',
|
||
]);
|
||
|
||
if ($sendEmail && !empty($changedFields)) {
|
||
\App\Jobs\SendProfileEditedByAdminMail::dispatch($profile, $changedFields);
|
||
Log::info('ProfileEdited: Dispatched email notification', [
|
||
'recipient_email' => $profile->email ?? 'NO EMAIL',
|
||
'changed_fields' => $changedFields,
|
||
]);
|
||
} else {
|
||
Log::info('ProfileEdited: Skipped email notification', [
|
||
'reason' => $sendEmail ? 'no_fields_changed' : 'system_message_disabled',
|
||
]);
|
||
}
|
||
|
||
// Apply pending email suppression toggle if set
|
||
if (isset($this->editProfile['email_suppression_pending'])) {
|
||
$emailToToggle = $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]);
|
||
}
|
||
}
|
||
}
|
||
|
||
$this->resetForm();
|
||
$this->notification()->success(__('Saved'), __('The profile has been saved successfully!'));
|
||
$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->authorizeAdminAccess();
|
||
|
||
$current = $this->editProfile['email_suppression_pending'] ?? null;
|
||
|
||
if ($current === null) {
|
||
// First toggle: flip the live DB state
|
||
$email = $this->editProfile['email'] ?? null;
|
||
$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->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->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 = $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()
|
||
{
|
||
// CRITICAL: Authorize admin access for attaching profile
|
||
$this->authorizeAdminAccess();
|
||
|
||
$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;
|
||
$roleRequired = !in_array($selectedProfileModel, ['User', 'Admin']);
|
||
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' => ['admin'],
|
||
];
|
||
|
||
// Allowed roles per profile type (for validation).
|
||
// Admin is intentionally excluded: admins always receive the 'admin' role
|
||
// regardless of any client-submitted role value (safe by design).
|
||
$allowedRolesMap = [
|
||
'Organization' => ['organization-manager', 'organization-coordinator'],
|
||
'Bank' => ['bank-manager', 'bank-coordinator'],
|
||
];
|
||
|
||
$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 any previous coordinator/manager role before assigning new one
|
||
if (isset($allowedRolesMap[$type])) {
|
||
foreach ($allowedRolesMap[$type] as $oldBase) {
|
||
$oldRoleName = "{$type}\\{$attachedId}\\{$oldBase}";
|
||
if ($profile->hasRole($oldRoleName)) {
|
||
$profile->removeRole($oldRoleName);
|
||
}
|
||
}
|
||
}
|
||
|
||
$roleName = "{$type}\\{$attachedId}\\{$baseRole}";
|
||
$role = Role::findOrCreate($roleName, 'web');
|
||
$permissionName = 'manage ' . strtolower($type) . 's';
|
||
$role->givePermissionTo($permissionName);
|
||
$roles[] = $roleName;
|
||
if ($baseRole === 'admin') {
|
||
$role->syncPermissions(\Spatie\Permission\Models\Permission::all());
|
||
}
|
||
}
|
||
}
|
||
} 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');
|
||
$permissionName = 'manage ' . strtolower($parentType) . 's';
|
||
$role->givePermissionTo($permissionName);
|
||
if ($baseRole === 'admin') {
|
||
$role->syncPermissions(\Spatie\Permission\Models\Permission::all());
|
||
}
|
||
// 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 (manager + coordinator)
|
||
$allRolesMap = [
|
||
'Organization' => ['organization-manager', 'organization-coordinator'],
|
||
'Bank' => ['bank-manager', 'bank-coordinator'],
|
||
'Admin' => ['admin'],
|
||
];
|
||
|
||
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());
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
{
|
||
// CRITICAL: Authorize admin access for deleting profile
|
||
$this->authorizeAdminAccess();
|
||
|
||
$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()
|
||
{
|
||
// CRITICAL: Authorize admin access for restoring profile
|
||
$this->authorizeAdminAccess();
|
||
|
||
$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()
|
||
{
|
||
// CRITICAL: Authorize admin access for bulk deleting profiles
|
||
$this->authorizeAdminAccess();
|
||
|
||
$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',
|
||
'confirmString',
|
||
'buttonDisabled',
|
||
'reActivate',
|
||
'deleteProfileData',
|
||
'restoreProfileData',
|
||
'adminPassword',
|
||
'balanceHandlingOption',
|
||
'donationAccountId',
|
||
'donationExceedsLimit',
|
||
'donationLimitError',
|
||
]);
|
||
|
||
$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
|
||
{
|
||
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');
|
||
|
||
// --- 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',
|
||
]
|
||
);
|
||
|
||
return view('livewire.profiles.manage', [
|
||
'profiles' => $profilesPaginator,
|
||
]);
|
||
}
|
||
}
|