Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View File

@@ -0,0 +1,825 @@
<?php
namespace App\Http\Livewire\Profiles;
use App\Events\Auth\RegisteredByAdmin;
use App\Http\Livewire\Traits\RequiresAdminAuthorization;
use App\Models\Account;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Language;
use App\Models\Locations\Country;
use App\Models\Locations\Location;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
class Create extends Component
{
use WireUiActions, RequiresAdminAuthorization;
public bool $showCreateModal = false;
// #[Validate] // Add this attribute
public $createProfile = [
'name' => null,
'full_name' => null,
'email' => null,
'password' => null,
'password_confirmation' => null,
'lang_preference' => null,
'type' => null,
];
public $profileTypeOptions = [];
public $profileTypeSelected;
public $linkBankOptions = [];
public $linkBankSelected;
public $linkUserOptions = [];
public $linkUserSelected;
public bool $generateRandomPassword = true;
private ?string $generatedPlainTextPassword = null; // Add property to store plain password
public $country;
public $division;
public $city;
public $district;
// Keep track of whether validation is needed
public $validateCountry = false;
public $validateDivision = false;
public $validateCity = false;
public $localeOptions;
protected $listeners = ['countryToParent', 'divisionToParent', 'cityToParent', 'districtToParent'];
public function mount()
{
// CRITICAL: Authorize admin access for profile creation component
$this->authorizeAdminAccess();
}
protected function rules()
{
$rules = [
'createProfile' => 'array',
'createProfile.type' => ['required', 'string'],
'country' => 'required_if:validateCountry,true',
'division' => 'required_if:validateDivision,true',
'city' => 'required_if:validateCity,true',
'district' => 'sometimes',
];
// Dynamically add rules based on type
if (!empty($this->createProfile['type'])) {
$typeKey = 'profile_' . strtolower(class_basename($this->createProfile['type']));
$isUserType = $this->createProfile['type'] === \App\Models\User::class;
$isOrgType = $this->createProfile['type'] === \App\Models\Organization::class;
$isAdminType = $this->createProfile['type'] === \App\Models\Admin::class;
$isBankType = $this->createProfile['type'] === \App\Models\Bank::class;
// --- Add rules for common fields like name, full_name, email ---
$rules['createProfile.name'] = Rule::when(
fn ($input) => isset($input['createProfile']['name']),
timebank_config("rules.{$typeKey}.name", []),
[]
);
$rules['createProfile.full_name'] = Rule::when(
fn ($input) => isset($input['createProfile']['full_name']),
timebank_config("rules.{$typeKey}.full_name", []),
[]
);
$rules['createProfile.email'] = Rule::when(
fn ($input) => isset($input['createProfile']['email']),
timebank_config("rules.{$typeKey}.email", []),
[]
);
// --- Conditional Password Rules (Only for User type) ---
//TODO NEXT: fix manual password confirmation
if ($isUserType) {
$rules['createProfile.password'] = Rule::when(
!$this->generateRandomPassword,
// Explicitly add 'confirmed' here for the final validation
// Merge with rules from config, ensuring 'confirmed' is present
timebank_config("rules.{$typeKey}.password" // Get base rules
),
['nullable', 'string'] // Rules when generating random password
);
$rules['createProfile.lang_preference'] = ['string', 'max:3'];
} else {
$rules['createProfile.password'] = ['nullable', 'string'];
$rules['createProfile.password_confirmation'] = ['nullable', 'string'];
}
// --- Conditional Link Rules ---
// Link Bank is required for User and Organization
if ($isUserType || $isOrgType) {
$rules['createProfile.linkBank'] = ['required', 'integer'];
} else {
// Ensure it's not required if not rendered
$rules['createProfile.linkBank'] = ['nullable', 'integer'];
}
// Link User is required for Organization, Admin, and Bank
if ($isOrgType || $isAdminType || $isBankType) {
$rules['createProfile.linkUser'] = ['required', 'integer'];
} else {
// Ensure it's not required if not rendered
$rules['createProfile.linkUser'] = ['nullable', 'integer'];
}
} else {
// Default rules if type is not yet selected (optional, but good practice)
$rules['createProfile.linkBank'] = ['nullable', 'integer'];
$rules['createProfile.linkUser'] = ['nullable', 'integer'];
}
return $rules;
}
public function openCreateModal()
{
$this->resetErrorBag();
$this->showCreateModal = true;
$appLocale = app()->getLocale();
// Get all optional profiles from config
$this->profileTypeOptions = collect(timebank_config('profiles'))
->map(function ($data, $key) {
return [
'name' => ucfirst($key),
'value' => 'App\Models\\'. ucfirst($key),
];
})
->values()
->toArray();
$this->generateRandomPassword = true; // Ensure it's checked on open
$this->generateAndSetPassword(); // Generate initial password
$this->localeOptions = Language::all()->filter(function ($lang) {
return ($lang->lang_code);
})->map(function ($lang) {
return [
'lang_code' => $lang->lang_code,
'label' => $lang->flag . ' ' . trans('messages.' . $lang->name),
];
})->toArray();
$this->resetErrorBag(['country', 'division', 'city', 'district']); // Clear location errors
}
public function updatedCreateProfileType()
{
$selectedType = $this->createProfile['type'] ?? null;
$optionsCollection = collect(); // Initialize empty collection
if ($selectedType === \App\Models\User::class || $selectedType === \App\Models\Organization::class) {
// Banks higher than level 1 are non-system banks
$optionsBankCollection = Bank::where('level', '>=', 2)->get(['id', 'name', 'full_name', 'email', 'profile_photo_path']);
// Make email visible if it's hidden and needed for description
$optionsBankCollection->each(fn ($item) => $item->makeVisible('email'));
// Same procedure for User model
$optionsUserCollection = User::get(['id', 'name', 'full_name', 'email', 'profile_photo_path']);
$optionsUserCollection->each(fn ($item) => $item->makeVisible('email'));
// Map to a plain array structure, needed for the wireUi user-option template
$this->linkBankOptions = $optionsBankCollection->map(function ($item) {
return [
'id' => $item->id,
'name' => $item->name,
'email' => $item->email,
'profile_photo_url' => $item->profile_photo_url,
];
})->toArray();
$this->linkUserOptions = $optionsUserCollection->map(function ($item) {
return [
'id' => $item->id,
'name' => $item->name,
'email' => $item->email,
'profile_photo_url' => $item->profile_photo_url,
];
})->toArray();
} elseif ($selectedType) {
$optionsUserCollection = User::get(['id', 'name', 'full_name', 'email', 'profile_photo_path']);
$optionsUserCollection->each(fn ($item) => $item->makeVisible('email'));
$this->linkUserOptions = $optionsUserCollection->map(function ($item) {
return [
'id' => $item->id,
'name' => $item->name,
'email' => $item->email,
'profile_photo_url' => $item->profile_photo_url,
];
})->toArray();
}
}
public function updated($propertyName)
{
// Only validate createProfile.* fields when they themselves change
if (str_starts_with($propertyName, 'createProfile.')
&& $propertyName !== 'createProfile.type') {
$this->validateOnly($propertyName);
}
// If the 'type' field specifically was updated, handle that separately
if ($propertyName === 'createProfile.type') {
$this->resetErrorBag(['createProfile.name', 'createProfile.full_name']);
$this->validateOnly('createProfile.name');
$this->validateOnly('createProfile.full_name');
$this->updatedCreateProfileType();
}
}
// Method called when checkbox state changes
public function updatedGenerateRandomPassword(bool $value)
{
if ($value) {
// Checkbox is CHECKED - Generate password
$this->generateAndSetPassword();
} else {
// Checkbox is UNCHECKED - Clear password fields for manual input
$this->createProfile['password'] = null;
$this->createProfile['password_confirmation'] = null;
// Reset validation errors for password fields
$this->resetErrorBag(['createProfile.password', 'createProfile.password_confirmation']);
}
}
// Helper function to generate and set password
private function generateAndSetPassword()
{
$password = Str::password(12, true, true, true, false);
$this->generatedPlainTextPassword = $password; // Store plain text
$this->createProfile['password'] = $password; // Set for validation/hashing
$this->createProfile['passwordConfirmation'] = null;
$this->resetErrorBag(['createProfile.password', 'createProfile.password_confirmation']);
}
public function emitLocationToChildren()
{
$this->dispatch('countryToChildren', $this->country);
$this->dispatch('divisionToChildren', $this->division);
$this->dispatch('cityToChildren', $this->city);
$this->dispatch('districtToChildren', $this->district);
}
// --- Listener methods ---
// When a location value changes (from child), update the property,
// recalculate validation requirements, and trigger validation for that specific field.
public function countryToParent($value)
{
$this->country = $value;
if ($value) {
// Look up language preference by country, if available
$countryLanguage = DB::table('country_languages')->where('country_id', $this->country)->pluck('code');
count($countryLanguage) === 1 ? $this->createProfile['lang_preference'] = $countryLanguage->first() : $this->createProfile['lang_preference'] = null;
}
$this->setLocationValidationOptions();
$this->validateOnly('country'); // Validate country immediately
// Also re-validate division/city as their requirement might change
$this->validateOnly('division');
$this->validateOnly('city');
}
public function divisionToParent($value)
{
$this->division = $value;
$this->setLocationValidationOptions(); // Recalculate requirements
$this->validateOnly('division'); // Validate division immediately
}
public function cityToParent($value)
{
$this->city = $value;
$this->setLocationValidationOptions(); // Recalculate requirements
$this->validateOnly('city'); // Validate city immediately
}
// District doesn't usually affect others, just validate itself
public function districtToParent($value)
{
$this->district = $value;
$this->validateOnly('district');
}
// --- End Listener methods ---
public function setLocationValidationOptions()
{
// Store previous state to check if requirements changed
$oldValidateDivision = $this->validateDivision;
$oldValidateCity = $this->validateCity;
// Default to true, then adjust based on country data
$this->validateCountry = true; // Country is always potentially required initially
$this->validateDivision = true;
$this->validateCity = true;
if ($this->country) {
$countryModel = Country::find($this->country);
if ($countryModel) {
$countDivisions = $countryModel->divisions()->count();
$countCities = $countryModel->cities()->count();
// Logic based on available sub-locations for the selected country
if ($countDivisions > 0 && $countCities < 1) {
$this->validateDivision = true;
$this->validateCity = false; // City not needed if none exist for country
} elseif ($countDivisions < 1 && $countCities > 0) {
$this->validateDivision = false; // Division not needed if none exist
$this->validateCity = true;
} elseif ($countDivisions < 1 && $countCities < 1) {
$this->validateDivision = false; // Neither needed if none exist
$this->validateCity = false;
} elseif ($countDivisions > 0 && $countCities > 0) {
// Prefer City over Division if both exist
$this->validateDivision = false; // Assuming city is the primary choice here
$this->validateCity = true;
}
} else {
// Invalid country selected, potentially keep validation? Or reset?
// For now, keep defaults (true) as the country rule itself will fail.
}
} else {
// No country selected, only country is required.
$this->validateCountry = true;
$this->validateDivision = false;
$this->validateCity = false;
}
// --- Re-validate if requirements changed ---
// If the requirement for division/city changed, re-trigger their validation
// This helps clear errors if they become non-required.
if ($this->validateDivision !== $oldValidateDivision) {
$this->validateOnly('division');
}
if ($this->validateCity !== $oldValidateCity) {
$this->validateOnly('city');
}
// --- End Re-validation ---
}
/**
* Handles the save button of the create profile modal.
*
* @return void
*/
public function create()
{
// CRITICAL: Authorize admin access for creating profiles
$this->authorizeAdminAccess();
// --- If a user, bank, admin profile will be created, determine the plain password that will be emailed ---
if ($this->createProfile['type'] === \App\Models\User::class
|| $this->createProfile['type'] === \App\Models\Bank::class
|| $this->createProfile['type'] === \App\Models\Admin::class ) {
if ($this->generateRandomPassword) {
$this->generateAndSetPassword();
} elseif (!empty($this->createProfile['password'])) {
// Capture manually entered password (after trimming)
$this->generatedPlainTextPassword = trim($this->createProfile['password']);
} else {
// Manual mode, but password field is empty
$this->generatedPlainTextPassword = null;
}
} else {
// Not a profile type with password, ensure plain password is null
$this->generatedPlainTextPassword = null;
// Also nullify password fields before validation if not User
$this->createProfile['password'] = null;
$this->createProfile['passwordConfirmation'] = null;
}
// Trim password fields if they exist (important for validation)
if (isset($this->createProfile['password'])) {
$this->createProfile['password'] = trim($this->createProfile['password']);
}
if (isset($this->createProfile['passwordConfirmation'])) {
$this->createProfile['passwordConfirmation'] = trim($this->createProfile['passwordConfirmation']);
}
// Validate all fields based on current rules
$validatedData = $this->validate();
$profileData = $validatedData['createProfile']; // Get the nested profile data
// Remove password confirmation if it exists
unset($profileData['password_confirmation']);
// Add location data to the profile data array for helper methods
$profileData['country_id'] = $validatedData['country'] ?? null;
$profileData['division_id'] = $validatedData['division'] ?? null;
$profileData['city_id'] = $validatedData['city'] ?? null;
$profileData['district_id'] = $validatedData['district'] ?? null;
$newProfile = null;
try {
// Use a transaction for creating the new profile and related models
DB::transaction(function () use ($profileData, &$newProfile) {
switch ($profileData['type']) {
case \App\Models\User::class:
$newProfile = $this->createUserProfile($profileData);
break;
case \App\Models\Organization::class:
$newProfile = $this->createOrganizationProfile($profileData);
break;
case \App\Models\Bank::class:
$newProfile = $this->createBankProfile($profileData);
break;
case \App\Models\Admin::class:
$newProfile = $this->createAdminProfile($profileData);
break;
default:
throw new \Exception("Unknown profile type: " . $profileData['type']);
}
// Common logic after profile creation (if any) can go here
// e.g., creating a default location if not handled in helpers
if ($newProfile && !$newProfile->locations()->exists()) {
$this->createDefaultLocation($newProfile, $profileData);
}
}); // End of transaction
if ($newProfile) {
// Dispatch RegisteredByAdmin event to send email confirmation / password / welcome
event(new RegisteredByAdmin($newProfile, $this->generatedPlainTextPassword));
}
// Success
$this->notification()->success(
__('Profile Created'),
__('The profile has been successfully created.')
);
$this->showCreateModal = false;
$this->dispatch('profileCreated');
$this->resetForm();
} catch (Throwable $e) {
// --- Failure ---
Log::error('Profile creation failed: ' . $e->getMessage(), [
'profile_data' => $profileData, // Log data for debugging
'exception' => $e
]);
$this->notification()->error(
__('Error'),
// Provide a generic error message to the user
__('Failed to create profile. Please check the details and try again. If the problem persists, contact support.')
);
// Keep the modal open for correction
}
}
private function createUserProfile(array $data): User
{
// Hash password
$data['password'] = Hash::make($data['password']);
// Add default values from config
$data['profile_photo_path'] = timebank_config('profiles.user.profile_photo_path_new');
$data['limit_min'] = timebank_config('profiles.user.limit_min');
$data['limit_max'] = timebank_config('profiles.user.limit_max');
$data['lang_preference'] = $data['lang_preference'] ?? null;
// Create the User
$profile = User::create($data);
// Attach to Bank
if (!empty($data['linkBank'])) {
$profile->attachBankClient($data['linkBank']);
}
// Create Account
$this->createDefaultAccount($profile, 'user');
// Create Location
$this->createDefaultLocation($profile, $data);
// TODO: Replace commented rtippin messenger logic with wirechat logic
// Attach to Messenger
// Messenger::getProviderMessenger($profile);
return $profile;
}
private function createOrganizationProfile(array $data): Organization
{
// Add default values from config
$data['profile_photo_path'] = timebank_config('profiles.organization.profile_photo_path_new');
$data['limit_min'] = timebank_config('profiles.organization.limit_min');
$data['limit_max'] = timebank_config('profiles.organization.limit_max');
$data['lang_preference'] = $data['lang_preference'] ?? null;
// Create the profile
$profile = Organization::create($data);
// Attach to Bank
if (!empty($data['linkBank'])) {
$profile->attachBankClient($data['linkBank']);
}
// Attach to profile manager
if (!empty($data['linkUser'])) {
$profile->managers()->attach($data['linkUser']);
// Send email notifications to linked user about the new organization
$linkedUser = User::find($data['linkUser']);
if ($linkedUser) {
$this->sendProfileLinkNotifications($profile, $linkedUser);
}
}
// Create Account
$this->createDefaultAccount($profile, 'organization');
// Create Location
$this->createDefaultLocation($profile, $data);
// TODO: Replace commented rtippin messenger logic with wirechat logic
// Attach to Messenger
// Messenger::getProviderMessenger($profile);
return $profile;
}
private function createBankProfile(array $data): Bank
{
// Hash password
$data['password'] = Hash::make($data['password']);
// Add default values from config
$data['profile_photo_path'] = timebank_config('profiles.bank.profile_photo_path_new');
$data['level'] = $data['level'] ?? timebank_config('profiles.bank.level', 1);
$data['limit_min'] = timebank_config('profiles.bank.limit_min');
$data['limit_max'] = timebank_config('profiles.bank.limit_max');
$data['lang_preference'] = $data['lang_preference'] ?? null;
// Create the profile
$profile = Bank::create($data);
// Attach to profile manager
if (!empty($data['linkUser'])) {
$profile->managers()->attach($data['linkUser']);
// Send email notifications to linked user about the new bank
$linkedUser = User::find($data['linkUser']);
if ($linkedUser) {
$this->sendProfileLinkNotifications($profile, $linkedUser);
}
}
// Create Account
$this->createDefaultAccount($profile, 'bank');
// Create debit account for level 0 (source) banks
if ($profile->level === 0) {
$this->createDefaultAccount($profile, 'debit');
}
// Create Location
$this->createDefaultLocation($profile, $data);
// TODO: Replace commented rtippin messenger logic with wirechat logic
// Attach to Messenger
// Messenger::getProviderMessenger($profile);
return $profile;
}
private function createAdminProfile(array $data): Admin
{
// Hash password
$data['password'] = Hash::make($data['password']);
// Add default values from config
$data['profile_photo_path'] = timebank_config('profiles.admin.profile_photo_path_new');
$data['limit_min'] = timebank_config('profiles.admin.limit_min');
$data['limit_max'] = timebank_config('profiles.admin.limit_max');
$data['lang_preference'] = $data['lang_preference'] ?? null;
// Create the profile
$profile = Admin::create($data);
// Attach to User
if (!empty($data['linkUser'])) {
$profile->users()->attach($data['linkUser']);
$linkedUser = User::find($data['linkUser']);
$linkedUser->assignRole('admin');
// Create and assign scoped Admin role (required by getCanManageProfiles())
$scopedRoleName = "Admin\\{$profile->id}\\admin";
$scopedRole = \Spatie\Permission\Models\Role::findOrCreate($scopedRoleName, 'web');
$scopedRole->syncPermissions(\Spatie\Permission\Models\Permission::all());
$linkedUser->assignRole($scopedRoleName);
// Send email notifications to linked user about the new admin profile
$this->sendProfileLinkNotifications($profile, $linkedUser);
}
// Create Location
$this->createDefaultLocation($profile, $data);
// TODO: Replace commented rtippin messenger logic with wirechat logic
// Attach to Messenger
// Messenger::getProviderMessenger($profile);
return $profile;
}
// --- Helper function to create Default Location ---
private function createDefaultLocation($profileModel, array $data): void
{
if (empty($data['country_id'])) {
return;
} // Don't create if no country
$location = new Location();
$location->name = __('Default location');
$location->country_id = $data['country_id'];
$location->division_id = $data['division_id'] ?? null;
$location->city_id = $data['city_id'] ?? null;
$location->district_id = $data['district_id'] ?? null;
$profileModel->locations()->save($location);
}
// --- Helper function to create Default Account ---
private function createDefaultAccount($profileModel, string $type): void
{
// Check if accounts are enabled for this type and config exists
$accountConfig = timebank_config("accounts.{$type}");
if (!$accountConfig) {
Log::info("Account creation skipped for type '{$type}': No config found.");
return;
}
$account = new Account();
$account->name = __(timebank_config("accounts.{$type}.name", 'default Account'));
$account->limit_min = timebank_config("accounts.{$type}.limit_min", 0);
$account->limit_max = timebank_config("accounts.{$type}.limit_max", 0);
// Associate account with the profile model (assuming polymorphic relation 'accounts')
$profileModel->accounts()->save($account);
}
/**
* Resets the form fields to their initial state.
*/
public function resetForm()
{
$this->showCreateModal = false;
// Reset the main profile data array
$this->createProfile = [
'name' => null,
'full_name' => null,
'email' => null,
'password' => null,
'password_confirmation' => null,
'phone' => null,
'comment' => null,
'lang_preference' => null,
'type' => 'user', // Reset type back to default
'linkBank' => null, // Add linkBank if it's part of the array
'linkUser' => null, // Add linkUser if it's part of the array
];
// Reset select options and selections
$this->profileTypeOptions = [];
$this->profileTypeSelected = null;
$this->linkBankOptions = [];
$this->linkBankSelected = null;
$this->linkUserOptions = [];
$this->linkUserSelected = null;
// Reset password generation flag
$this->generateRandomPassword = true;
$this->generatedPlainTextPassword = null; // Clear stored password
// Reset location properties
$this->country = null;
$this->division = null;
$this->city = null;
$this->district = null;
// Reset location validation flags
$this->validateCountry = true;
$this->validateDivision = true;
$this->validateCity = true;
// Clear validation errors
$this->resetErrorBag();
// Re-fetch initial options if needed when resetting
$this->updatedCreateProfileType();
}
/**
* Send profile link notification emails to both the profile and the linked user
*
* @param mixed $profile The newly created profile (Organization/Bank/Admin)
* @param User $linkedUser The user being linked to the profile
* @return void
*/
private function sendProfileLinkNotifications($profile, User $linkedUser): void
{
Log::info('ProfileLinkCreated: Sending email notifications', [
'profile_id' => $profile->id,
'profile_type' => get_class($profile),
'profile_name' => $profile->name,
'linked_user_id' => $linkedUser->id,
'linked_user_email' => $linkedUser->email ?? 'NO EMAIL',
]);
// Send email to the linked user
$userMessageSetting = \App\Models\MessageSetting::where('message_settingable_id', $linkedUser->id)
->where('message_settingable_type', get_class($linkedUser))
->first();
$sendUserEmail = $userMessageSetting ? $userMessageSetting->system_message : true;
Log::info('ProfileLinkCreated: Linked user message setting check', [
'has_message_setting' => $userMessageSetting ? 'yes' : 'no',
'system_message' => $userMessageSetting ? $userMessageSetting->system_message : 'default:true',
'will_send_email' => $sendUserEmail ? 'yes' : 'no',
]);
if ($sendUserEmail) {
\App\Jobs\SendProfileLinkChangedMail::dispatch($linkedUser, $profile, 'attached');
Log::info('ProfileLinkCreated: Dispatched email to linked user', [
'recipient_email' => $linkedUser->email,
'profile_name' => $profile->name,
]);
} else {
Log::info('ProfileLinkCreated: Skipped email to linked user (system_message disabled)');
}
// Send email to the newly created 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('ProfileLinkCreated: 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, $linkedUser, 'attached');
Log::info('ProfileLinkCreated: Dispatched email to profile', [
'recipient_email' => $profile->email,
'linked_user_name' => $linkedUser->name,
]);
} else {
Log::info('ProfileLinkCreated: Skipped email to profile (system_message disabled)');
}
}
}