'boolean', 'date_of_birth' => 'date', 'email_verified_at' => 'datetime', 'principles_terms_accepted' => 'array', 'inactive_at' => 'datetime', 'deleted_at' => 'datetime', ]; /** * The name of the guard used by this model. * This is used to determine the default guard for authentication. */ protected string $guard_name = 'web'; protected function getDefaultGuardName(): string { return $this->guard_name; } /** * Get the index name for the model. * * @return string */ public function searchableAs() { return 'users_index'; } /** * Convert this model to a searchable array. * * @return array */ public function toSearchableArray() { // Prepare eager loaded relationships $this->load( 'languages', 'locations.district.translations', 'locations.city.translations', 'locations.division.translations', 'locations.country.translations', 'tags.contexts.tags', 'tags.contexts.tags.locale', 'tags.contexts.category.ancestorsAndSelf', ); return [ 'id' => $this->id, 'name' => $this->name, //TODO: Update to multilang database structure in future, this ElasticSearch index is already prepared for it 'about_nl' => $this->about, 'about_en' => $this->about, 'about_fr' => $this->about, 'about_de' => $this->about, 'about_es' => $this->about, 'about_short_nl' => $this->about_short, 'about_short_en' => $this->about_short, 'about_short_fr' => $this->about_short, 'about_short_de' => $this->about_short, 'about_short_es' => $this->about_short, 'motivation_nl' => $this->motivation, 'motivation_en' => $this->motivation, 'motivation_fr' => $this->motivation, 'motivation_de' => $this->motivation, 'motivation_es' => $this->motivation, 'cyclos_skills' => $this->cyclos_skills, // Legacy column, will not be used in the future 'website' => $this->website, 'last_login_at' => $this->last_login_at, 'lang_preference' => $this->lang_preference, 'languages' => $this->languages->map(function ($language) { // map() as languages is a collection return [ 'id' => $language->id, 'name' => $language->name, 'lang_code' => $language->lang_code, ]; }), 'locations' => $this->locations->map(function ($location) { // map() as locations is a collection return [ 'id' => $location->id, 'district' => $location->district ? $location->district->translations->map(function ($translation) { // map() as translations is a collection return $translation->name; })->toArray() : [], 'city' => $location->city ? $location->city->translations->map(function ($translation) { // map() as translations is a collection return $translation->name; })->toArray() : [], 'division' => $location->division ? $location->division->translations->map(function ($translation) { // map() as translations is a collection return $translation->name; })->toArray() : [], 'country' => $location->country ? $location->country->translations->map(function ($translation) { // map() as translations is a collection return $translation->name; })->toArray() : [], ]; }), 'tags' => $this->tags->map(function ($tag) { return [ 'contexts' => $tag->contexts ->map(function ($context) { return [ 'tags' => $context->tags->map(function ($tag) { // Include the locale in the field name for tags return [ 'name_' . $tag->locale->locale => StringHelper::DutchTitleCase($tag->normalized), ]; }), 'categories' => Category::with(['translations' => function ($query) { $query->select('category_id', 'locale', 'name');}]) ->find([ $context->category->ancestorsAndSelf()->get()->flatMap(function ($related) { $categoryPath = explode('.', $related->path); return $categoryPath; }) ->unique()->values()->toArray() ])->map(function ($category) { // Include the locale in the field name for categories return $category->translations->mapWithKeys(function ($translation) { return ['name_' . $translation->locale => StringHelper::DutchTitleCase($translation->name)]; }); }), ]; }), ]; }) ]; } /** * Get the user's profile. * One-to-one */ public function profile() { return $this->hasOne(Profile::class); } /** * Get the user's organization(s). * Many-to-many. */ public function organizations() { return $this->belongsToMany(Organization::class); } /** * Get the user's bank(s) that it can manage. * Many-to-many. */ public function banksManaged() { return $this->belongsToMany(Bank::class, 'bank_user', 'user_id', 'bank_id'); } /** * Determine if the user has any non-personal profiles. * * Checks whether the user is associated with any organizations, manages any banks, * or holds any administrative assignments. Returns true if at least one of these * related profiles exists; otherwise returns false. * * @return bool True if the user has organization, bank-managed, or admin profiles; false otherwise. */ public function hasOtherProfiles() { return $this->organizations()->exists() || $this->banksManaged()->exists() || $this->admins()->exists(); } public function banksClient() { return $this->morphToMany(Bank::class, 'client', 'bank_clients') ->wherePivot('relationship_type', 'local'); } public function attachBankClient($bank, $relationshipType = null) { // Set default relationship type if not provided $relationshipType = $relationshipType ?? 'local'; // Accept either ID or Bank model $bankId = $bank instanceof Bank ? $bank->id : $bank; // Verify existence if numeric ID was provided if (is_numeric($bankId)) { if (!Bank::where('id', $bankId)->exists()) { throw new \Exception("Bank with ID {$bankId} does not exist"); } } $this->banksClient()->sync([$bankId => [ 'relationship_type' => $relationshipType, 'created_at' => now(), 'updated_at' => now() ]]); return $this; } public function detachBankClient($bank, $relationshipType = null) { // Set default relationship type if not provided $relationshipType = $relationshipType ?? 'local'; // Accept either ID or Bank model $bankId = $bank instanceof Bank ? $bank->id : $bank; // Verify existence if numeric ID was provided if (is_numeric($bankId)) { if (!Bank::where('id', $bankId)->exists()) { throw new \Exception("Bank with ID {$bankId} does not exist"); } } // Detach with additional pivot conditions $this->banksClient()->wherePivot('relationship_type', $relationshipType) ->detach($bankId); return $this; } /** * Get the user's admin(s) that it can manage. * Many-to-many. */ public function admins() { return $this->belongsToMany(Admin::class); } /** * Get all related accounts of the user. * One-to-many polymorphic. */ public function accounts() { return $this->morphMany(Account::class, 'accountable'); } /** * Check if the user has any associated accounts. * * @return bool Returns true if the user has accounts, false otherwise. */ public function hasAccounts() { $accountsExists = DB::table('accounts') ->where('accountable_id', $this->id) ->where('accountable_type', 'App\Models\User') ->exists(); return $accountsExists; } /** * Get all related languages of the user. * Many-to-many polymorphic. */ public function languages() { return $this->morphToMany(Language::class, 'languagable')->withPivot('competence'); } /** * Get all related socials of the user. * Many-to-many polymorphic. */ public function socials() { return $this->morphToMany(Social::class, 'sociable')->withPivot('id', 'user_on_social', 'server_of_social'); } /** * Get all related the locations of the user. * One-to-many polymorph. */ public function locations() { return $this->morphMany(Location::class, 'locatable'); } /** * Get all of the user's message settings. */ public function message_settings() { return $this->morphOne(MessageSetting::class, 'message_settingable'); } /** * Configure the activity log options for the User model. * * This method sets up the logging to: * - Only log changes to the 'name', 'password', and 'last_login_ip' attributes. * - Log only when these attributes have been modified (dirty). * - Prevent submission of empty logs. * - Use 'user' as the log name. * * @return \Spatie\Activitylog\LogOptions */ public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() ->logOnly([ 'name', 'last_login_ip', 'inactive_at', 'deleted_at', ]) ->logOnlyDirty() // Only log attributes that have been changed ->dontSubmitEmptyLogs() ->useLogName('User'); } /** * Get all of the User's posts. */ public function posts() { return $this->morphMany(Post::class, 'postable'); } /** * Get all post translations updated by the user. */ public function post_translations_updated() { return $this->hasMany(PostTranslation::class, 'updated_by_user_id'); } /** * Get all of the User's categories. */ public function categories() { return $this->morphMany(Category::class, 'categoryable'); } public function sendEmailVerificationNotification() { \Mail::to($this->email)->send(new \App\Mail\VerifyProfileEmailMailable($this)); } /** * Send the password reset notification. * * @param string $token * @return void */ public function sendPasswordResetNotification($token) { $locale = $this->lang_preference ?? config('app.fallback_locale'); $this->notify(new \App\Notifications\UserPasswordResetNotification($token, $locale)); } /** * Wirechat: Returns the URL for the user's cover image (avatar). * Adjust the 'avatar_url' field to your database setup. */ public function getCoverUrlAttribute(): ?string { return Storage::url($this->profile_photo_path) ?? null; } /** * Wirechat:Returns the URL for the user's profile page. * Adjust the 'profile' route as needed for your setup. */ public function getProfileUrlAttribute(): ?string { return route('profile.show_by_type_and_id', ['type' => __('user'), 'id' => $this->id]); } /** * Wirechat: Returns the display name for the user. * Modify this to use your preferred name field. */ public function getDisplayNameAttribute(): ?string { if ($this->full_name !== $this->name) { return $this->full_name . ' (' . $this->name . ')'; } elseif ($this->name) { return $this->name; } else { return __('User'); } } /** * Search for users when creating a new chat or adding members to a group. * Customize the search logic to limit results, such as restricting to friends or eligible users only. */ public function searchChatables(string $query): ?\Illuminate\Support\Collection { $searchableFields = ['name', 'full_name']; // Search across all profile types $users = User::where(function ($queryBuilder) use ($searchableFields, $query) { foreach ($searchableFields as $field) { $queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%'); } })->limit(6)->get(); $organizations = \App\Models\Organization::where(function ($queryBuilder) use ($searchableFields, $query) { foreach ($searchableFields as $field) { $queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%'); } })->limit(6)->get(); $banks = \App\Models\Bank::where(function ($queryBuilder) use ($searchableFields, $query) { foreach ($searchableFields as $field) { $queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%'); } })->limit(6)->get(); $admins = \App\Models\Admin::where(function ($queryBuilder) use ($searchableFields, $query) { foreach ($searchableFields as $field) { $queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%'); } })->limit(6)->get(); // Combine all results into a base Collection to avoid serialization issues $results = collect() ->merge($users->all()) ->merge($organizations->all()) ->merge($banks->all()) ->merge($admins->all()); // Filter out profiles based on configuration return $results->filter(function ($profile) { // Check inactive profiles if (timebank_config('profile_inactive.messenger_hidden') && !$profile->isActive()) { return false; } // Check email unverified profiles if (timebank_config('profile_email_unverified.messenger_hidden') && !$profile->isEmailVerified()) { return false; } // Check incomplete profiles if ( timebank_config('profile_incomplete.messenger_hidden') && method_exists($profile, 'hasIncompleteProfile') && $profile->hasIncompleteProfile($profile) ) { return false; } return true; })->take(6)->values(); } /** * Wirechat: Determine if the user can create new groups. */ public function canCreateGroups(): bool { return timebank_config('profiles.user.messenger_can_create_groups'); } /** * Check if the user has any transactions with another model (User, Organization, Bank). * * @param \Illuminate\Database\Eloquent\Model $model * @return bool */ public function hasTransactionsWith($model): bool { $modelClass = get_class($model); $modelId = $model->id; // Check if this user is involved in any transaction where the other model is either the sender or receiver return Transaction::where(function ($query) use ($modelClass, $modelId) { $query->whereHas('accountFrom.accountable', function ($q) use ($modelClass, $modelId) { $q->where('accountable_type', $modelClass) ->where('accountable_id', $modelId); }) ->orWhereHas('accountTo.accountable', function ($q) use ($modelClass, $modelId) { $q->where('accountable_type', $modelClass) ->where('accountable_id', $modelId); }); }) ->where(function ($query) { $query->whereHas('accountFrom.accountable', function ($q) { $q->where('accountable_type', User::class) ->where('accountable_id', $this->id); }) ->orWhereHas('accountTo.accountable', function ($q) { $q->where('accountable_type', User::class) ->where('accountable_id', $this->id); }); }) ->exists(); } /** * Get the message settings for this user */ public function messageSettings() { return $this->morphMany(MessageSetting::class, 'message_settingable'); } /** * Check if the user has accepted the principles. * * @return bool */ public function hasAcceptedPrinciples(): bool { return !is_null($this->principles_terms_accepted); } /** * Check if the user needs to re-accept principles due to a newer version. * Compares the stored version with the current active principles post. * * @return bool */ public function needsToReacceptPrinciples(): bool { // First, check if there's a current principles post available // This must come BEFORE checking user acceptance status to avoid redirect loops $currentPost = Post::with(['translations' => function ($query) { $locale = app()->getLocale(); $query->where('locale', 'like', $locale . '%') ->whereDate('from', '<=', now()) ->where(function ($query) { $query->whereDate('till', '>', now())->orWhereNull('till'); }) ->orderBy('updated_at', 'desc') ->limit(1); }]) ->whereHas('category', function ($query) { $query->where('type', 'SiteContents\Static\Principles'); }) ->first(); // Failsafe: If no current principles post exists, no need to accept/re-accept if (!$currentPost || !$currentPost->translations->first()) { return false; } // If user hasn't accepted at all, they need to accept if (!$this->hasAcceptedPrinciples()) { return true; } $currentTranslation = $currentPost->translations->first(); $acceptedData = $this->principles_terms_accepted; // Compare the updated_at timestamp if (isset($acceptedData['updated_at'])) { $acceptedUpdatedAt = \Carbon\Carbon::parse($acceptedData['updated_at']); $currentUpdatedAt = \Carbon\Carbon::parse($currentTranslation->updated_at); return $currentUpdatedAt->isAfter($acceptedUpdatedAt); } return true; // If no timestamp stored, require re-acceptance } }