Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Models;
use App\Models\Transaction;
use App\Traits\AccountInfoTrait;
use App\Traits\ActiveStatesTrait;
use App\Traits\ProfilePermissionTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Account extends Model
{
use HasFactory;
use AccountInfoTrait;
use ActiveStatesTrait;
use ProfilePermissionTrait;
protected $fillable = [];
protected $table = 'accounts';
/**
* Accessor for balance attribute.
* Calculates the balance by calling getBalance() method from AccountInfoTrait.
*
* @return float|int
*/
public function getBalanceAttribute()
{
return $this->getBalance($this->id);
}
// Get all of the owning accountable tables: users and organizations
// One-to-many polymorphic
public function accountable()
{
return $this->morphTo();
}
// Define the relationship for transactions where the account is the sender
public function transactionsFrom()
{
return $this->hasMany(Transaction::class, 'from_account_id');
}
// Define the relationship for transactions where the account is the receiver
public function transactionsTo()
{
return $this->hasMany(Transaction::class, 'to_account_id');
}
// Define a combined relationship for all transactions involving the account
public function transactions()
{
return $this->transactionsFrom()->union($this->transactionsTo());
}
public static function accountsCyclosMember($cyclos_id)
{
return Account::with('accountable')
->whereHas('accountable', function ($query) use ($cyclos_id) {
$query->where('cyclos_id', $cyclos_id)
->whereNull('inactive_at');
})
->pluck('name', 'id');
}
//get all accounts owned by the same accountable
public function getAccountsBySameAccountable()
{
return Account::where('accountable_id', $this->accountable_id)
->where('accountable_type', $this->accountable_type)
->get();
}
}
+322
View File
@@ -0,0 +1,322 @@
<?php
namespace App\Models;
use App\Models\Locations\Location;
use App\Models\User;
use App\Notifications\NonUserPasswordResetNotification;
use App\Notifications\VerifyProfileEmail;
use App\Traits\ActiveStatesTrait;
use App\Traits\HasPresence;
use App\Traits\LocationTrait;
use App\Traits\TaggableWithLocale;
use Illuminate\Auth\MustVerifyEmail as AuthMustVerifyEmail;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage;
use Laravel\Jetstream\HasProfilePhoto;
use Namu\WireChat\Traits\Chatable;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Permission\Traits\HasRoles;
class Admin extends Authenticatable implements MustVerifyEmail, CanResetPasswordContract
{
use HasFactory;
use AuthMustVerifyEmail;
use Notifiable;
use HasRoles;
use LogsActivity; // Spatie Activity Log
use HasProfilePhoto;
use TaggableWithLocale;
use LocationTrait;
use CanResetPassword; // Trait
use Chatable;
use ActiveStatesTrait;
use HasPresence;
/**
* The attributes that should be hidden for serialization.
* BEWARE: API's CAN POTENTIALLY EXPOSE ALL VISIBLE FIELDS
*
* @var array
*/
protected $hidden = [
'email',
'email_verified_at',
'full_name',
'password',
'remember_token',
'phone',
'cyclos_id',
'cyclos_salt',
'two_factor_confirmed_at',
'two_factor_recovery_codes',
'two_factor_secret',
'limit_min',
'limit_max',
'lang_preference',
'created_at',
'updated_at',
'last_login_at',
'inactive_at',
'deleted_at',
];
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'name',
'full_name',
'email',
'profile_photo_path',
'about',
'about_short',
'motivation',
'website',
'phone',
'phone_public',
'password',
'limit_min',
'limit_max',
'lang_preference',
'last_login_at',
'last_login_ip'
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'phone_public' => 'boolean',
'email_verified_at' => 'datetime',
'inactive_at' => 'datetime',
'deleted_at' => 'datetime',
];
/**
* Get the admin's user(s) that can manage admin profiles.
* Many-to-many.
*/
public function users()
{
return $this->belongsToMany(User::class);
}
/**
* Get all related languages of the admin.
* Many-to-many polymorphic.
*/
public function languages()
{
return $this->morphToMany(Language::class, 'languagable')->withPivot('competence');
}
/**
* Get all related the locations of the admin.
* One-to-many polymorph.
*/
public function locations()
{
return $this->morphMany(Location::class, 'locatable');
}
/**
* Get all related accounts of the user.
* One-to-many polymorphic.
* NOTE: At the moment admins do not use accounts, but to keep methods consistent
* with the other profile models, we keep this method.
* Removing this method will break generic methods that will are used on all profile models!
*/
public function accounts()
{
return $this->morphMany(Account::class, 'accountable');
}
/**
* Get the admin's message settings.
*/
public function message_settings()
{
return $this->morphOne(MessageSetting::class, 'message_settingable');
}
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)
{
// Construct the full reset URL using the named route
// The route 'non-user.password.reset' expects 'profileType' and 'token' parameters.
// It's also good practice to include the email in the query string for the reset form.
$resetUrl = route('non-user.password.reset', [
'profileType' => 'admin',
'token' => $token,
'email' => $this->getEmailForPasswordReset(),
]);
// Pass the fully constructed URL to your notification
$this->notify(new NonUserPasswordResetNotification($resetUrl, $this));
}
/**
* 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('Admin');
}
/**
* 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' => __('admin'), '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 __('Admin');
}
}
/**
* Wirechat: 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 = \App\Models\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 = 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.bank.messenger_can_create_groups');
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Permission\Models\Role;
class AdminRole extends Model
{
protected $fillable = ['name', 'label', 'permissions'];
protected $casts = [
'permissions' => 'array',
'is_system' => 'boolean',
];
public function getPermissionsListAttribute(): array
{
return $this->permissions ?? [];
}
public function scopeNotSystem($query)
{
return $query->where('is_system', false);
}
/**
* The role a user linked to a new admin profile gets: the platform setting
* admin_settings.default_admin_role, 'administrator' when it is not set.
*/
public static function defaultName(): string
{
return (string) (timebank_config('admin_settings.default_admin_role') ?: 'administrator');
}
/**
* Give a user this role on one admin profile: the scoped Spatie role Admin\{adminId}\{name}, created
* when missing and synced with this definition's permissions.
*/
public function assignScopedTo(User $user, int $adminId): Role
{
$role = Role::findOrCreate("Admin\\{$adminId}\\{$this->name}", 'web');
$role->syncPermissions($this->permissions ?? []);
if (!$user->hasRole($role)) {
$user->assignRole($role);
}
return $role;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Article extends Model
{
use HasFactory;
}
+541
View File
@@ -0,0 +1,541 @@
<?php
namespace App\Models;
use App\Helpers\StringHelper;
use App\Models\Locations\Location;
use App\Models\User;
use App\Notifications\NonUserPasswordResetNotification;
use App\Notifications\VerifyProfileEmail;
use App\Traits\ActiveStatesTrait;
use App\Traits\HasPresence;
use App\Traits\LocationTrait;
use App\Traits\ProfileTrait;
use App\Traits\TaggableWithLocale;
use Cog\Contracts\Love\Reactable\Models\Reactable as ReactableInterface;
use Cog\Contracts\Love\Reacterable\Models\Reacterable as ReacterableInterface;
use Cog\Laravel\Love\Reactable\Models\Traits\Reactable;
use Cog\Laravel\Love\Reacterable\Models\Traits\Reacterable;
use Illuminate\Auth\MustVerifyEmail as AuthMustVerifyEmail;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Scout\Searchable;
use Namu\WireChat\Traits\Chatable;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Permission\Traits\HasRoles;
class Bank extends Authenticatable implements ReacterableInterface, ReactableInterface, MustVerifyEmail, CanResetPasswordContract
{
use HasFactory;
use HasProfilePhoto;
use AuthMustVerifyEmail;
use Notifiable;
use HasRoles;
use LogsActivity; // Spatie Activity Log
use TaggableWithLocale;
use Reacterable; // cybercog/laravel-love
use Reactable; // cybercog/laravel-love
use Searchable; // laravel/scout with ElasticSearch
use LocationTrait;
use CanResetPassword; // Trait
use Chatable;
use ActiveStatesTrait;
use HasPresence;
use ProfileTrait;
/**
* The attributes that should be hidden for serialization.
* BEWARE: API'S CAN POTENTIALLY EXPOSE ALL VISIBLE FIELDS
*
* @var array
*/
protected $hidden = [
'email',
'email_verified_at',
'full_name',
'password',
'remember_token',
'phone',
'cyclos_id',
'cyclos_salt',
'two_factor_confirmed_at',
'two_factor_recovery_codes',
'two_factor_secret',
'limit_min',
'limit_max',
'lang_preference',
'created_at',
'updated_at',
'last_login_at',
'inactive_at',
'incomplete_at',
'deleted_at',
];
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'name',
'full_name',
'email',
'profile_photo_path',
'about',
'about_short',
'motivation',
'website',
'phone',
'phone_public',
'password',
'level',
'limit_min',
'limit_max',
'lang_preference',
'last_login_at',
'last_login_ip'
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'phone_public' => 'boolean',
'date_of_birth' => 'date',
'email_verified_at' => 'datetime',
'inactive_at' => 'datetime',
'incomplete_at' => 'datetime',
'deleted_at' => 'datetime',
];
protected $guard_name = 'web';
/**
* Get the index name for the model.
*
* @return string
*/
public function searchableAs()
{
return 'banks_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
'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,
'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 bank's related admin's.
* Many-to-many.
*/
public function admins()
{
return $this->belongsToMany(Admin::class);
}
/**
* Get all of the bank's accounts.
* One-to-many polymorphic.
*
* @return void
*/
public function accounts()
{
return $this->morphMany(Account::class, 'accountable');
}
/**
* Get the bank's manager(s) that can manage bank profiles.
* Many-to-many.
*/
public function managers()
{
return $this->belongsToMany(User::class, 'bank_user', 'bank_id', 'user_id');
}
/**
* Retrieve all related bank clients.
*
* @return \Illuminate\Support\Collection
*/
public function clients()
{
return $this->userClients->merge($this->organizationClients);
}
public function userClients()
{
return $this->morphedByMany(User::class, 'client', 'bank_clients')
->withPivot('relationship_type');
}
public function organizationClients()
{
return $this->morphedByMany(Organization::class, 'client', 'bank_clients')
->withPivot('relationship_type');
}
/**
* Get all related the locations of the bank.
* One-to-many polymorph.
*/
public function locations()
{
return $this->morphMany(Location::class, 'locatable');
}
/**
* Get all of the languages for the organization.
* Many-to-many polymorphic.
*/
public function languages()
{
return $this->morphToMany(Language::class, 'languagable')->withPivot('competence');
}
/**
* Get all of the social for the organization.
* Many-to-many polymorphic.
*/
public function socials()
{
return $this->morphToMany(Social::class, 'sociable')->withPivot('id', 'user_on_social', 'server_of_social');
}
/**
* Get the bank's message settings.
*/
public function message_settings()
{
return $this->morphOne(MessageSetting::class, 'message_settingable');
}
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)
{
// Construct the full reset URL using the named route
// The route 'non-user.password.reset' expects 'profileType' and 'token' parameters.
$resetUrl = route('non-user.password.reset', [
'profileType' => 'bank', // Hardcode 'admin' for the Admin model
'token' => $token,
'email' => $this->getEmailForPasswordReset(), // Method from CanResetPassword trait
]);
// Pass the fully constructed URL to your notification
$this->notify(new NonUserPasswordResetNotification($resetUrl, $this));
}
/**
* 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',
'incomplete_at',
'deleted_at',
])
->logOnlyDirty() // Only log attributes that have been changed
->dontSubmitEmptyLogs()
->useLogName('Bank');
}
/**
* 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' => __('bank'), '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 __('Bank');
}
}
/**
* Wirechat: 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 = \App\Models\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 = 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;
}
// Hide banks by level for the current viewer type
if ($profile instanceof Bank && !canViewerSeeBankLevel($profile->level, 'messenger_visible')) {
return false;
}
return true;
})->take(6)->values();
}
/**
* Wirechat: Determine if the user can create new groups.
*/
public function canCreateGroups(): bool
{
return timebank_config('profiles.bank.messenger_can_create_groups');
}
/**
* Check if the Bank 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 Bank 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', Bank::class)
->where('accountable_id', $this->id);
})
->orWhereHas('accountTo.accountable', function ($q) {
$q->where('accountable_type', Bank::class)
->where('accountable_id', $this->id);
});
})
->exists();
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\Models;
use App\Models\Locations\Location;
use Cog\Contracts\Love\Reactable\Models\Reactable as ReactableInterface;
use Cog\Laravel\Love\Reactable\Models\Traits\Reactable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable;
class Call extends Model implements ReactableInterface
{
use HasFactory;
use SoftDeletes;
use Searchable;
use Reactable;
protected $fillable = [
'callable_id',
'callable_type',
'tag_id',
'location_id',
'from',
'till',
'is_public',
'is_suppressed',
'is_paused',
];
protected $casts = [
'from' => 'datetime',
'till' => 'datetime',
'is_public' => 'boolean',
'is_suppressed' => 'boolean',
'is_paused' => 'boolean',
];
public function callable()
{
return $this->morphTo();
}
public function translations()
{
return $this->hasMany(CallTranslation::class);
}
public function tag()
{
return $this->belongsTo(Tag::class, 'tag_id', 'tag_id');
}
public function location()
{
return $this->belongsTo(Location::class);
}
public function searchableAs(): string
{
return 'calls_index';
}
public function shouldBeSearchable(): bool
{
// Only index active (non-deleted, non-expired, non-blocked) calls
if ($this->trashed()) {
return false;
}
if ($this->till !== null && $this->till->isPast()) {
return false;
}
if ($this->is_suppressed) {
return false;
}
if ($this->is_paused) {
return false;
}
return true;
}
public function toSearchableArray(): array
{
$tag = $this->tag;
$tagNames = [];
if ($tag) {
foreach (['en', 'nl', 'fr', 'de', 'es'] as $locale) {
$trans = $tag->translations()->firstWhere('locale', $locale);
$tagNames['name_' . $locale] = $trans ? $trans->name : $tag->name;
}
}
$location = $this->location;
$locationData = [
'city' => $location?->city?->translations->map(fn($t) => $t->name)->toArray() ?? [],
'division' => $location?->division?->translations->map(fn($t) => $t->name)->toArray() ?? [],
'country' => $location?->country?->translations->map(fn($t) => $t->name)->toArray() ?? [],
];
return [
'id' => $this->id,
'__class_name' => self::class,
'callable' => [
'id' => $this->callable?->id,
'name' => $this->callable?->name,
],
'tag' => array_merge(
['tag_id' => $tag?->tag_id, 'color' => $tag?->contexts->first()?->category?->relatedColor ?? 'gray'],
$tagNames
),
'location' => $locationData,
'from' => $this->from?->utc()->format('Y-m-d H:i:s'),
'till' => $this->till?->utc()->format('Y-m-d H:i:s'),
'is_public' => (bool) $this->is_public,
'call_translations' => $this->translations->mapWithKeys(fn($t) => [
'content_' . $t->locale => $t->content,
])->toArray(),
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class CallTranslation extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = [
'call_id',
'locale',
'content',
];
public function call()
{
return $this->belongsTo(Call::class);
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Models;
use App\Models\CategoryTranslation;
use App\Models\TaggableContext;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App;
use Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships;
class Category extends Model
{
use HasFactory;
use HasRecursiveRelationships;
protected $appends = ['translation', 'relatedPathTranslation', 'relatedPathExSelfTranslation', 'relatedColor'];
protected $fillable = ['type', 'country_id', 'division_id', 'city_id', 'district_id', 'parent_id', 'color', 'categoryable_id', 'categoryable_type'];
/**
* Get the related posts for this category
*
* @return void
*/
public function posts()
{
return $this->hasMany(Post::class);
}
/**
* Get the related translations of this category
*
* @return void
*/
public function translations()
{
return $this->hasMany(CategoryTranslation::class);
}
/**
* Get the related posts for this category
*
* @return void
*/
public function tags()
{
return $this->hasMany(TaggableContext::class);
}
/**
* Get the translation attribute for the category.
*
* This method attempts to retrieve the translation for the category
* in the current locale. If a translation in the current locale is
* not found, it falls back to the base locale defined in the timebank-cc config.
*
* @return \App\Models\Translation|null The translation object for the category in the current or base locale, or null if not found.
*/
public function getTranslationAttribute()
{
$locale = App::getLocale();
$baseLocale = timebank_config('base_language');
// Attempt to get the translation in the current locale
$translation = $this->translations->firstWhere('locale', $locale);
// Fallback to base locale if translation not found
if (!$translation) {
$translation = $this->translations->firstWhere('locale', $baseLocale);
}
return $translation;
}
public function related()
{
return $this->ancestorsAndSelf()->get();
}
public function getRelatedPathTranslationAttribute()
{
$categoryPathIds = $this->ancestors->pluck('id');
$categoryPath = implode(
' > ',
CategoryTranslation::whereIn('category_id', $categoryPathIds)
->where('locale', App::getLocale())
->pluck('name')
->toArray()
);
return $categoryPath;
}
public function getRelatedPathExSelfTranslationAttribute()
{
$categoryPathIds = $this->ancestors->pluck('id');
$categoryPath = implode(
' > ',
CategoryTranslation::whereIn('category_id', $categoryPathIds)
->where('locale', App::getLocale())
->pluck('name')
->toArray()
);
return $categoryPath;
}
public function getRelatedColorAttribute()
{
$categoryColor = $this->rootAncestor ? $this->rootAncestor->color : $this->color;
return $categoryColor;
}
/**
* Get the polymorph relation of this type of category (i.e. division, user, organization).
*
* @return void
*/
public function categoryable()
{
return $this->morphTo();
}
public function tagContexts()
{
return $this->hasMany(TaggableContext::class);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CategoryTranslation extends Model
{
use HasFactory;
protected $fillable = ['category_id', 'locale', 'slug', 'name'];
/**
* Get related category for this translation
*
* @return void
*/
public function category()
{
return $this->belongsTo(Category::class);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use App\Models\Post;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
class Image extends Model
{
use HasFactory;
protected $fillable = ['path','position','caption'];
/**
* Get the related posts of the images
*
* @return void
*/
public function posts(): MorphToMany
{
return $this->morphedByMany(Post:: class, 'imageable');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Language extends Model
{
use HasFactory;
protected $fillable = [
'name',
'lang_code',
'flag'
];
/**
* Get all users that are assigned to this language.
*/
public function users()
{
return $this->morphedByMany(User::class, 'languagable');
}
/**
* Get all organizations that are assigned to this language.
*/
public function organizations()
{
return $this->morphedByMany(Organization::class, 'languagable');
}
/**
* Get all posts that are assigned to this language.
*/
public function posts()
{
return $this->morphedByMany(Post::class, 'languagable');
}
}
+208
View File
@@ -0,0 +1,208 @@
<?php
namespace App\Models\Locations;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Locations\CityLocale;
use App\Models\Locations\Country;
use App\Models\Locations\DistrictLocale;
use App\Models\Locations\Division;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App;
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
class City extends Model
{
use HasRelationships;
use HasFactory;
/**
* The database table doesn't use 'created_at' and 'updated_at' so we disable it from Inserts/Updates.
*
* @var bool
*/
public $timestamps = false;
/**
* Return all available locales of the city.
*
* @return void
*/
public function translations()
{
return $this->hasMany(CityLocale::class, 'city_id');
}
/**
* Get all of the users of the cities.
* Many-to-many polymorphic.
* @return void
*/
public function users()
{
return $this->morphedByMany(User::class, 'districtable', 'districtables');
}
/**
* Get all of the organizations of the cities.
* Many-to-many polymorphic.
* @return void
*/
public function organizations()
{
return $this->morphedByMany(Organization::class, 'cityable', 'cityables');
}
/**
* Get all of the banks of the cities.
* Many-to-many polymorphic.
* @return void
*/
public function banks()
{
return $this->morphedByMany(Bank::class, 'cityable', 'cityables');
}
/**
* Get all of the cityables of the cities.
* Many-to-many polymorph
* @return void
*/
public function cityables()
{
return $this->morphedByMany(City::class, 'cityable', 'cityables');
// cityable refers to pivot columns and cityables refers to pivot table
}
/**
* Get all the related districts of the city.
* @return void
*/
public function districtsRelation()
{
return $this->hasManyThrough(DistrictLocale::class, District::class, 'city_id', 'district_id');
}
/**
* Get all related locations of the division.
* One-to-many.
* @return void
*/
public function locations()
{
return $this->hasMany(Location::class);
}
/**
* Get the districts of the city in the App::getLocale, or if not exists, in the App::getFallbackLocale language.
*
* @return void
*/
public function districts()
{
// $locale = collect(
// $this->hasManyThrough(DistrictLocale::class, District::class, 'city_id', 'district_id')
// ->where('locale', App::getLocale())
// ->get()
// )->keyBy('district_id');
// $fallback = collect(
// $this->hasManyThrough(DistrictLocale::class, District::class, 'city_id', 'district_id')
// ->where('locale', App::getFallbackLocale())
// ->get()
// )->keyBy('district_id');
// $result = $locale
// ->union($fallback)
// ->filter(function ($item) use ($search) {
// return false !== stripos($item->name, $search);
// })
// ->sortBy('name');
return $this->hasMany(District::class);
}
/**
* Get the country of the city in the App::getLocale, or if not exists, in the App::getFallbackLocale language.
*
* @return void
*/
public function country()
{
// $country = $this->belongsTo(Country::class, 'country_id')->pluck('id');
// $result = CountryLocale::where('country_id', $country)
// ->where('locale', App::getLocale());
// if ($result->count() === 0) {
// $result = CountryLocale::where('country_id', $country)
// ->where('locale', App::getFallbackLocale());
// }
// return $result;
return $this->belongsTo(Country::class);
}
/**
* Get the division of the city in the App::getLocale, or if not exists, in the App::getFallbackLocale language.
*
* @return void
*/
public function division()
{
// $division = $this->belongsTo(Division::class, 'division_id')->pluck('id');
// $result = DivisionLocale::where('division_id', $division)
// ->where('locale', App::getLocale());
// if ($result->count() === 0) {
// $result = DivisionLocale::where('division_id', $division)
// ->where('locale', App::getFallbackLocale());
// }
// return $result;
return $this->belongsTo(Division::class);
}
/**
* Get the related children of this model.
*
* @return collection
*/
public function children($search = '')
{
return $this->districts($search);
}
/**
* Get the related parent of this model.
*
* @return void
*/
public function parent()
{
if ($this->division() === null) {
return $this->country();
}
return $this->division();
}
/**
* Get all of the related categories for this model.
*/
public function categories()
{
return $this->morphMany(Category::class, 'categoryable');
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Models\Locations;
use App\Models\Scopes\LocalizeScope;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App;
class CityLocale extends Model
{
/**
* The database table doesn't use 'created_at' and 'updated_at' so we disable it from Inserts/Updates.
*
* @var bool
*/
public $timestamps = false;
protected static function booted()
{
// Scope a query to only include country locales that match the current application locale or fallback locale.
static::addGlobalScope(new LocalizeScope);
}
/**
* return belonged City
*
* @return void
*/
public function city()
{
return $this->belongsTo(City::class, 'city_id');
}
}
+214
View File
@@ -0,0 +1,214 @@
<?php
namespace App\Models\Locations;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Locations\Location;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App;
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
class Country extends Model
{
use HasRelationships;
/**
* The database table doesn't use 'created_at' and 'updated_at' so we disable it from Inserts/Updates.
*
* @var bool
*/
public $timestamps = false;
/**
* Get all related locations of the division.
* One-to-many.
* @return void
*/
public function locations()
{
return $this->hasMany(Location::class);
}
/**
* Return all related locales.
*
* @return void
*/
public function translations()
{
return $this->hasMany(CountryLocale::class, 'country_id');
}
public function translationExists()
{
return $this->hasOne(CountryLocale::class, 'country_id')->where('locale', App::getLocale())->exists();
}
/**
* Get all of the users of the countries.
* Many-to-many polymorphic.
* @return void
*/
public function users()
{
return $this->morphedByMany(User::class, 'countryable', 'countryables');
}
/**
* Get all of the organizations of the countries.
* Many-to-many polymorphic.
* @return void
*/
public function organizations()
{
return $this->morphedByMany(Organization::class, 'countryable', 'countryables');
}
/**
* Get all of the banks of the countries.
* Many-to-many polymorphic.
* @return void
*/
public function banks()
{
return $this->morphedByMany(Bank::class, 'countryable', 'countryables');
}
/**
* Get all the related divisions of the country.
* @return void
*/
public function divisionsRelation()
{
return $this->hasManyThrough(DivisionLocale::class, Division::class, 'country_id', 'division_id');
}
/**
* Get the divisions of the country.
* One-to-many
* @param string
* @return void
*/
public function divisions()
{
return $this->hasMany(Division::class);
}
/**
* Get all the related cities of the country.
* @return void
*/
public function cities()
{
return $this->hasManyThrough(CityLocale::class, City::class, 'country_id');
}
/**
* Get the related cities locale of the country in the App::getLocale, or if not exists, in the App::getFallbackLocale language.
* The optional property will filter the localized city names.
* @return void
*/
public function citiesLocale(string $search = '')
{
$locale = collect(
$this->hasManyThrough(CityLocale::class, City::class, 'country_id')
->where('locale', App::getLocale())
->get()
)->keyBy('city_id');
$fallback = collect(
$this->hasManyThrough(CityLocale::class, City::class, 'country_id')
->where('locale', App::getFallbackLocale())
->get()
)->keyBy('city_id');
$result = $locale
->union($fallback)
->filter(function ($item) use ($search) {
return false !== stripos($item->name, $search);
})
->sortBy('name');
return $result;
}
/**
* Get all the related districts of the country.
*
* @return void
*/
public function districtsRelation()
{
return $this->hasManyDeep(DistrictLocale::class, [City::class, District::class], ['country_id', 'city_id', 'district_id'], ['id', 'id', 'id']);
}
/**
* Get all the districts of the countries.
* Only fallback locale is used when App language is not a country language of the city to prevent double results.
*
* @return void
*/
public function districts(string $search = '')
{
$locale = collect(
$this->hasManyDeep(DistrictLocale::class, [City::class, District::class], ['country_id', 'city_id', 'district_id'], ['id', 'id', 'id'])
->where('locale', App::getLocale())
->get()
)->keyBy('district_id');
$fallback = collect(
$this->hasManyDeep(DistrictLocale::class, [City::class, District::class], ['country_id', 'city_id', 'district_id'], ['id', 'id', 'id'])
->where('locale', App::getFallbackLocale())
->get()
)->keyBy('district_id');
$result = $locale
->union($fallback)
->filter(function ($item) use ($search) {
return false !== stripos($item->name, $search);
})
->sortBy('name');
return $result;
}
/**
* Get the related children of this model.
*
* @return collection
*/
public function children(string $search = '')
{
if ($this->divisions() == true) {
return $this->divisions($search);
}
return $this->cities($search);
}
/**
* Get all of the related categories for this model.
*/
public function categories()
{
return $this->morphMany(Category::class, 'categoryable');
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Models\Locations;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CountryLanguage extends Model
{
use HasFactory;
/**
* The database table doesn't use 'created_at' and 'updated_at' so we disable it from Inserts/Updates.
*
* @var bool
*/
public $timestamps = false;
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models\Locations;
use App\Models\Scopes\LocalizeScope;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App;
class CountryLocale extends Model
{
/**
* The database table doesn't use 'created_at' and 'updated_at' so we disable it from Inserts/Updates.
*
* @var bool
*/
public $timestamps = false;
protected static function booted()
{
// Scope a query to only include country locales that match the current application locale or fallback locale.
static::addGlobalScope(new LocalizeScope);
}
public function country()
{
return $this->belongsTo(Country::class, 'country_id');
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace App\Models\Locations;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Locations\DistrictLocale;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App;
class District extends Model
{
/**
* The database table doesn't use 'created_at' and 'updated_at' so we disable it from Inserts/Updates.
*
* @var bool
*/
public $timestamps = false;
/**
* Return all available locales of the district.
*
* @return void
*/
public function translations()
{
return $this->hasMany(DistrictLocale::class, 'district_id');
}
/**
* Get the local district name.
* In the App::getLocale, or if not exists, in the App::getFallbackLocale language.
* @return void
*/
public function name()
{
$result = $this->hasMany(DistrictLocale::class, 'district_id')
->where('locale', App::getLocale());
if ($result->count() === 0) {
$result = $this->hasMany(DistrictLocale::class, 'district_id')
->where('locale', App::getFallbackLocale());
}
return $result;
}
/**
* Get all of the users of the districts.
* Many-to-many polymorphic.
* @return void
*/
public function users()
{
return $this->morphedByMany(User::class, 'districtable', 'districtables');
}
/**
* Get all of the organizations of the districts.
* Many-to-many polymorphic.
* @return void
*/
public function organizations()
{
return $this->morphedByMany(Organization::class, 'districtable', 'districtables');
}
/**
* Get all of the banks of the districts.
* Many-to-many polymorphic.
* @return void
*/
public function banks()
{
return $this->morphedByMany(Bank::class, 'districtable', 'districtables');
}
/**
* Get all related locations of the division.
* One-to-many.
* @return void
*/
public function locations()
{
return $this->hasMany(Location::class);
}
/**
* Get the related city of the district.
* In the App::getLocale, or if not exists, in the App::getFallbackLocale language.
* @return void
*/
public function city()
{
$city = $this->belongsTo(City::class, 'city_id')->pluck('id');
$result = CityLocale::where('city_id', $city)
->where('locale', App::getLocale());
if ($result->count() === 0) {
$result = CityLocale::where('city_id', $city)
->where('locale', App::getFallbackLocale());
}
return $result;
}
/**
* Get the related country of the district.
* In the App::getLocale, or if not exists, in the App::getFallbackLocale language.
* @return void
*/
public function country()
{
$country = $this->belongsTo(City::class, 'city_id')->pluck('country_id');
$result = CountryLocale::where('country_id', $country)
->where('locale', App::getLocale());
if ($result->count() === 0) {
$result = CountryLocale::where('country_id', $country)
->where('locale', App::getFallbackLocale());
}
return $result;
}
/**
* Get the related division of the district.
* In the App::getLocale, or if not exists, in the App::getFallbackLocale language.
* @return void
*/
public function division()
{
$division = $this->belongsTo(City::class, 'city_id')->pluck('division_id');
$result = DivisionLocale::where('division_id', $division)
->where('locale', App::getLocale());
if ($result->count() === 0) {
$result = DivisionLocale::where('division_id', $division)
->where('locale', App::getFallbackLocale());
}
return $result;
}
/**
* Get the related parent of this model.
*
* @return void
*/
public function parent()
{
// Check if division exists
if ($this->division_id) {
return $this->belongsTo(Division::class, 'division_id');
}
// Otherwise, return the country relationship
return $this->belongsTo(Country::class, 'country_id');
}
/**
* Get all of the related categories for this model.
*/
public function categories()
{
return $this->morphMany(Category::class, 'categoryable');
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models\Locations;
use App\Models\Scopes\LocalizeScope;
use Illuminate\Database\Eloquent\Model;
class DistrictLocale extends Model
{
/**
* The database table doesn't use 'created_at' and 'updated_at' so we disable it from Inserts/Updates.
*
* @var bool
*/
public $timestamps = false;
protected static function booted()
{
// Scope a query to only include country locales that match the current application locale or fallback locale.
static::addGlobalScope(new LocalizeScope);
}
public function district()
{
return $this->belongsTo(District::class, 'district_id');
}
}
+203
View File
@@ -0,0 +1,203 @@
<?php
namespace App\Models\Locations;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Locations\City;
use App\Models\Locations\DivisionLocale;
use App\Models\Locations\Location;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App;
class Division extends Model
{
/**
* The database table doesn't use 'created_at' and 'updated_at' so we disable it from Inserts/Updates.
*
* @var bool
*/
public $timestamps = false;
/**
* Return all available locales of the division.
*
* @return void
*/
public function translations()
{
return $this->hasMany(DivisionLocale::class, 'division_id');
}
/**
* Get the local division name.
* In the App::getLocale, or if not exists, in the App::getFallbackLocale language.
* @return void
*/
public function name()
{
$result = $this->hasMany(DivisionLocale::class, 'division_id')
->whereIn('locale', [App::getLocale()]);
if ($result->count() === 0) {
$result = $this->hasMany(DivisionLocale::class, 'division_id')
->whereIn('locale', [App::getFallbackLocale()]);
}
return $result;
}
/**
* Get all of the users of the divisions.
* Many-to-many polymorphic.
* @return void
*/
public function users()
{
return $this->morphedByMany(User::class, 'divisionable', 'divisionables');
}
/**
* Get all of the organizations of the divisions.
* Many-to-many polymorphic.
* @return void
*/
public function organizations()
{
return $this->morphedByMany(Organization::class, 'divisionable', 'divisionables');
}
/**
* Get all of the banks of the divisions.
* Many-to-many polymorphic.
* @return void
*/
public function banks()
{
return $this->morphedByMany(Bank::class, 'divisionable', 'divisionables');
}
/**
* Get all related locations of the division.
* One-to-many.
* @return void
*/
public function locations()
{
return $this->hasMany(Location::class);
}
/**
* Get the related country of the division.
*
* @return void
*/
public function countryRelation()
{
return $this->belongsTo(Country::class, 'country_id');
}
/**
* Get the country of the division in the App::getLocale, or if not exists, in the App::getFallbackLocale language.
*
* @return void
*/
public function country()
{
// $country = $this->belongsTo(Country::class, 'country_id')->pluck('id');
// $result = CountryLocale::where('country_id', $country)
// ->where('locale', App::getLocale());
// if ($result->count() === 0) {
// $result = CountryLocale::where('country_id', $country)
// ->where('locale', App::getFallbackLocale());
// }
// return $result;
return $this->belongsTo(Country::class, 'country_id');
}
/**
* Get all the related cities of the division.
* @return void
*/
public function citiesRelation()
{
return $this->hasManyThrough(CityLocale::class, City::class, 'division_id', 'city_id');
}
/**
* Get the cities of the division in the App::getLocale, or if not exists, in the App::getFallbackLocale language.
* The optional paramameter will filter the localized city names.
* @param string $search
* @return void
*/
public function cities()
{
// {
// $locale = collect(
// $this->hasManyThrough(CityLocale::class, City::class, 'division_id', 'city_id')
// ->where('locale', App::getLocale())
// ->get()
// )->keyBy('city_id');
// $fallback = collect(
// $this->hasManyThrough(CityLocale::class, City::class, 'division_id', 'city_id')
// ->where('locale', App::getFallbackLocale())
// ->get()
// )->keyBy('city_id');
// $result = $locale
// ->union($fallback)
// ->filter(function ($item) use ($search){
// return false !== stripos($item->name, $search);
// })
// ->sortBy('name');
// return $result;
return $this->hasMany(City::class);
}
/**
* Get the related children of this model.
*
* @param string $search
* @return void
*/
public function children(string $search = '')
{
return $this->cities($search);
}
/**
* Get the related parent of this model.
*
* @return void
*/
public function parent()
{
return $this->country();
}
/**
* Get all of the related categories for this model.
*/
public function categories()
{
return $this->morphMany(Category::class, 'categoryable');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models\Locations;
use App\Models\Scopes\LocalizeScope;
use Illuminate\Database\Eloquent\Model;
class DivisionLocale extends Model
{
/**
* The database table doesn't use 'created_at' and 'updated_at' so we disable it from Inserts/Updates.
*
* @var bool
*/
public $timestamps = false;
protected static function booted()
{
// Scope a query to only include country locales that match the current application locale or fallback locale.
static::addGlobalScope(new LocalizeScope);
}
public function division()
{
return $this->belongsTo(Division::class, 'division_id');
}
}
+305
View File
@@ -0,0 +1,305 @@
<?php
namespace App\Models\Locations;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\District;
use App\Models\Locations\Division;
use App\Models\Meeting;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Location extends Model
{
use HasFactory;
protected $fillable = ['name', 'country_id', 'division_id', 'city_id', 'district_id', 'locatable_id', 'locatable_type'];
/**
* Return related country.
* one-to-many
* @return void
*/
public function country()
{
return $this->belongsTo(Country::class);
}
/**
* Get the most appropriate country for this location.
* Tries multiple sources in order of preference.
* If resolved it syncs the location's missing country_id.
*
* @return Country|null
*/
public function getCountry()
{
// 1. Direct country relationship
if ($this->country_id && $this->country) {
return $this->country;
}
// 2. Country from division
if ($this->division_id && $this->division && $this->division->country_id) {
$this->syncCountryFromDivision();
return $this->fresh()->country;
}
// 3. Country from city
if ($this->city_id && $this->city && $this->city->country_id) {
$this->syncCountryFromCity();
return $this->fresh()->country;
}
// 4. Country from district's city (if district exists)
if ($this->district_id && $this->district && $this->district->city_id) {
// First ensure we have city synced
if (!$this->city_id) {
$this->syncCityFromDistrict();
}
// Now try to get country from city
if ($this->city && $this->city->country_id) {
$this->syncCountryFromCity();
return $this->fresh()->country;
}
}
return null;
}
/**
* Update the location's country_id based on the division's country.
*
* @return bool
*/
public function syncCountryFromDivision()
{
if ($this->division && $this->division->country_id) {
$this->country_id = $this->division->country_id;
return $this->save();
}
return false;
}
/**
* Update the location's country_id based on the city's country.
*
* @return bool
*/
public function syncCountryFromCity()
{
if ($this->city && $this->city->country_id) {
$this->country_id = $this->city->country_id;
return $this->save();
}
return false;
}
/**
* Return all related divisions.
* One-to-many
* @return void
*/
public function division()
{
return $this->belongsTo(Division::class);
}
/**
* Get the most appropriate division for this location.
* Tries multiple sources in order of preference.
* If resolved it syncs the location's missing division_id.
*
* @return Division|null
*/
public function getDivision()
{
// 1. Direct division relationship
if ($this->division_id && $this->division) {
return $this->division;
}
// 2. Division from city
if ($this->city_id && $this->city && $this->city->division_id) {
$this->syncDivisionFromCity();
return $this->fresh()->division; // Fresh instance to get updated division_id
}
// 3. Division from district's city (if district exists)
if ($this->district_id && $this->district && $this->district->city_id) {
// First sync city from district if city_id is missing
if (!$this->city_id) {
$this->syncCityFromDistrict();
}
// Now try to get division from city
if ($this->city && $this->city->division_id) {
$this->syncDivisionFromCity();
return $this->fresh()->division;
}
}
return null;
}
/**
* Update the location's country_id based on the division's country.
*
* @return bool
*/
public function syncCountryFromDistrict()
{
if ($this->division && $this->division->country_id) {
$this->country_id = $this->division->country_id;
return $this->save();
}
return false;
}
/**
* Update the location's division_id based on the city's division.
*
* @return bool
*/
public function syncDivisionFromCity()
{
if ($this->city && $this->city->division_id) {
$this->division_id = $this->city->division_id;
return $this->save();
}
return false;
}
/**
* Update the location's division_id based on the city's division.
*
* @return bool
*/
public function syncCityFromDistrict()
{
if ($this->district && $this->district->city_id) {
$this->city_id = $this->district->city_id;
return $this->save();
}
return false;
}
/**
* Return related city.
* One-to-many
* @return void
*/
public function city()
{
return $this->belongsTo(City::class);
}
/**
* Return related district.
* One-to-many
* @return void
*/
public function district()
{
return $this->belongsTo(District::class);
}
/**
* Sync all missing location hierarchy data.
* This method will populate all missing IDs based on available relationships.
*
* @return array Returns what was synced
*/
public function syncAllLocationData()
{
$synced = [];
// Step 1: Sync city from district if missing
if (!$this->city_id && $this->district_id) {
if ($this->syncCityFromDistrict()) {
$synced[] = 'city_from_district';
}
}
// Step 2: Sync division from city if missing
if (!$this->division_id && $this->city_id) {
if ($this->syncDivisionFromCity()) {
$synced[] = 'division_from_city';
}
}
// Step 3: Sync country from division if missing
if (!$this->country_id && $this->division_id) {
if ($this->syncCountryFromDivision()) {
$synced[] = 'country_from_division';
}
}
// Step 4: Fallback - sync country from city if still missing
if (!$this->country_id && $this->city_id) {
if ($this->syncCountryFromCity()) {
$synced[] = 'country_from_city';
}
}
return $synced;
}
/**
* Get a complete location hierarchy.
* This method ensures all data is synced and returns the complete hierarchy.
*
* @return array
*/
public function getCompleteHierarchy()
{
// Sync all data first
$this->syncAllLocationData();
// Refresh the model to get updated relationships
$location = $this->fresh();
return [
'country' => $location->country,
'division' => $location->getDivision(),
'city' => $location->city,
'district' => $location->district,
];
}
/**
* Return related locatable (i.e. user or organization).
* One-to-many polymorph
* @return void
*/
public function locatable()
{
return $this->morphTo();
}
/**
* Return related meeting.
* one-to-many
* @return void
*/
public function meeting()
{
return $this->hasOne(Meeting::class);
}
}
File diff suppressed because it is too large. Load diff
+327
View File
@@ -0,0 +1,327 @@
<?php
namespace App\Models;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Log;
class MailingBounce extends Model
{
protected $table = 'mailing_bounces';
/**
* Bounce reasons that say the address itself does not exist. These count
* even with a 5.7.x code (Tutanota: "550 5.7.1 ... Recipient not found").
* Used when the platform config has no 'address_bounce_patterns'.
*/
public const ADDRESS_BOUNCE_PATTERNS = [
'user unknown',
'unknown user',
'no such user',
'recipient not found',
'recipient unknown',
'mailbox not found',
'does not exist',
'5.1.1',
'5.1.2',
'5.1.3',
];
/**
* Bounce reasons that reject our server rather than the address.
* Used when the platform config has no 'not_definitive_bounce_patterns'.
*/
public const NOT_DEFINITIVE_BOUNCE_PATTERNS = [
'5.7.',
'block list',
'blocklist',
'blacklist',
'spamhaus',
'spam',
'policy',
'reputation',
];
protected $fillable = [
'email',
'bounce_type',
'bounce_reason',
'mailing_id',
'bounced_at',
'is_suppressed',
];
protected $casts = [
'bounced_at' => 'datetime',
'is_suppressed' => 'boolean',
];
public function mailing(): BelongsTo
{
return $this->belongsTo(Mailing::class);
}
/**
* Normalise an email address: lowercase + trim whitespace.
* Applied consistently before every DB read or write.
*/
private static function normalise(string $email): string
{
return strtolower(trim($email));
}
/**
* Check if an email address should be suppressed from sending
*/
public static function isSuppressed(string $email): bool
{
return self::where('email', self::normalise($email))
->where('is_suppressed', true)
->exists();
}
/**
* Suppress an email address from future mailings
*/
public static function suppressEmail(string $email, string $reason = null): void
{
self::updateOrCreate(
['email' => self::normalise($email)],
[
'bounce_reason' => $reason ?? 'Email suppressed',
'bounced_at' => now(),
'is_suppressed' => true,
'bounce_type' => 'suppressed',
]
);
}
/**
* Record a bounce for an email with threshold-based actions
*/
public static function recordBounce(
string $email,
string $bounceType = 'hard',
string $reason = null,
int $mailingId = null,
?\DateTimeInterface $bouncedAt = null
): self {
$email = self::normalise($email);
// When the bounce happened, not when it was processed: a backlog of
// old bounces must not all fall inside the counting window at once.
$values = [
'bounce_type' => $bounceType,
'bounce_reason' => $reason,
'bounced_at' => $bouncedAt ?? now(),
];
// One row per address and mailing (unique index): a second report for
// the same mailing - a retry, or a soft bounce turning hard - updates
// that row. Inserting it failed and stopped every bounce run for weeks.
$bounce = $mailingId !== null
? self::updateOrCreate(['email' => $email, 'mailing_id' => $mailingId], $values)
: self::create($values + [
'email' => $email,
'mailing_id' => null,
'is_suppressed' => false, // Don't auto-suppress, use threshold-based logic
]);
// Check if this bounce should trigger threshold-based actions
if (self::isDefinitiveHardBounce($bounceType, $reason)) {
self::checkAndApplyThresholds($email, $reason);
}
return $bounce;
}
/**
* Check bounce counts and apply threshold-based actions
*/
protected static function checkAndApplyThresholds(string $email, string $reason): void
{
$config = timebank_config('mailing.bounce_thresholds', []);
$suppressionThreshold = $config['suppression_threshold'] ?? 3;
$verificationResetThreshold = $config['verification_reset_threshold'] ?? 2;
$windowDays = $config['counting_window_days'] ?? 30;
// Count recent definitive hard bounces for this email
$recentBounceCount = self::countRecentDefinitiveBounces($email, $windowDays);
Log::info("Email {$email} has {$recentBounceCount} recent hard bounces (suppression: {$suppressionThreshold}, verification reset: {$verificationResetThreshold})");
// Check verification reset threshold first (lower threshold)
if ($recentBounceCount >= $verificationResetThreshold && !self::hasVerificationBeenReset($email)) {
self::resetEmailVerificationForAddress($email, $reason);
self::markVerificationReset($email);
}
// Check suppression threshold
if ($recentBounceCount >= $suppressionThreshold) {
self::suppressEmailByThreshold($email, $reason, $recentBounceCount);
}
}
/**
* Count recent definitive hard bounces for an email address
*/
protected static function countRecentDefinitiveBounces(string $email, int $windowDays): int
{
$email = self::normalise($email);
$config = timebank_config('mailing.bounce_thresholds', []);
$countedTypes = $config['counted_bounce_types'] ?? ['hard'];
$cutoffDate = now()->subDays($windowDays);
// Matched in PHP so the same rules apply as in isDefinitiveHardBounce(),
// including the exclusion of blocks against our own server.
return self::where('email', $email)
->whereIn('bounce_type', $countedTypes)
->where('bounced_at', '>=', $cutoffDate)
->get(['bounce_type', 'bounce_reason'])
->filter(fn ($bounce) => self::isDefinitiveHardBounce($bounce->bounce_type, $bounce->bounce_reason))
->count();
}
/**
* Check if bounce qualifies as a definitive hard bounce
*/
protected static function isDefinitiveHardBounce(string $bounceType, ?string $reason): bool
{
$config = timebank_config('mailing.bounce_thresholds', []);
$countedTypes = $config['counted_bounce_types'] ?? ['hard'];
$patterns = $config['definitive_hard_bounce_patterns'] ?? [];
if (!in_array($bounceType, $countedTypes)) {
return false;
}
if (empty($reason) || empty($patterns)) {
return false;
}
$reasonLower = strtolower($reason);
$addressGone = $config['address_bounce_patterns'] ?? self::ADDRESS_BOUNCE_PATTERNS;
foreach ($addressGone as $pattern) {
if (strpos($reasonLower, strtolower($pattern)) !== false) {
return true;
}
}
// A block against our sending server (IP on a block list, spam or
// policy rejection, 5.7.x) says nothing about the recipient's address,
// even though it comes with a 550 code.
$notDefinitive = $config['not_definitive_bounce_patterns'] ?? self::NOT_DEFINITIVE_BOUNCE_PATTERNS;
foreach ($notDefinitive as $pattern) {
if (strpos($reasonLower, strtolower($pattern)) !== false) {
return false;
}
}
foreach ($patterns as $pattern) {
if (strpos($reasonLower, strtolower($pattern)) !== false) {
return true;
}
}
return false;
}
/**
* Suppress email due to threshold being reached
*/
protected static function suppressEmailByThreshold(string $email, string $reason, int $bounceCount): void
{
$email = self::normalise($email);
self::where('email', $email)
->where('is_suppressed', false)
->update(['is_suppressed' => true]);
Log::warning("Email {$email} suppressed due to {$bounceCount} hard bounces. Latest reason: {$reason}");
}
/**
* Reset email verification for all profile types using this email
*/
protected static function resetEmailVerificationForAddress(string $email, string $reason): void
{
$updatedProfiles = [];
// Reset verification for all profile types
$userUpdated = User::where('email', $email)->whereNotNull('email_verified_at')->update(['email_verified_at' => null]);
if ($userUpdated) $updatedProfiles[] = "Users: {$userUpdated}";
$orgUpdated = Organization::where('email', $email)->whereNotNull('email_verified_at')->update(['email_verified_at' => null]);
if ($orgUpdated) $updatedProfiles[] = "Organizations: {$orgUpdated}";
$bankUpdated = Bank::where('email', $email)->whereNotNull('email_verified_at')->update(['email_verified_at' => null]);
if ($bankUpdated) $updatedProfiles[] = "Banks: {$bankUpdated}";
$adminUpdated = Admin::where('email', $email)->whereNotNull('email_verified_at')->update(['email_verified_at' => null]);
if ($adminUpdated) $updatedProfiles[] = "Admins: {$adminUpdated}";
if (!empty($updatedProfiles)) {
Log::warning("Reset email verification for {$email} due to hard bounce threshold. Affected: " . implode(', ', $updatedProfiles) . ". Reason: {$reason}");
}
}
/**
* Mark that verification has been reset for this email (to prevent multiple resets)
*/
protected static function markVerificationReset(string $email): void
{
// We could add a verification_reset_at column to track this, but for now
// we'll rely on the email_verified_at being null to prevent duplicate resets
}
/**
* Check if verification has already been reset for this email
*/
protected static function hasVerificationBeenReset(string $email): bool
{
// Check if any profile with this email has null email_verified_at
return User::where('email', $email)->whereNull('email_verified_at')->exists()
|| Organization::where('email', $email)->whereNull('email_verified_at')->exists()
|| Bank::where('email', $email)->whereNull('email_verified_at')->exists()
|| Admin::where('email', $email)->whereNull('email_verified_at')->exists();
}
/**
* Public method for testing pattern matching
*/
public static function testPatternMatching(string $bounceType, ?string $reason): bool
{
return self::isDefinitiveHardBounce($bounceType, $reason);
}
/**
* Get bounce statistics for an email address
*/
public static function getBounceStats(string $email): array
{
$email = self::normalise($email);
$config = timebank_config('mailing.bounce_thresholds', []);
$windowDays = $config['counting_window_days'] ?? 30;
$cutoffDate = now()->subDays($windowDays);
$totalBounces = self::where('email', $email)->count();
$recentBounces = self::where('email', $email)->where('bounced_at', '>=', $cutoffDate)->count();
$recentHardBounces = self::countRecentDefinitiveBounces($email, $windowDays);
$isSuppressed = self::isSuppressed($email);
return [
'email' => $email,
'total_bounces' => $totalBounces,
'recent_bounces' => $recentBounces,
'recent_hard_bounces' => $recentHardBounces,
'is_suppressed' => $isSuppressed,
'window_days' => $windowDays,
];
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Models;
use App\Models\Locations\Location;
use App\Models\Post;
use App\Traits\LocationTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Meeting extends Model
{
use HasFactory, SoftDeletes;
use LocationTrait;
protected $fillable = ['post_id', 'venue', 'address', 'price', 'based_on_quantity', 'transaction_type_id', 'meetingable_id', 'meetingable_type', 'status', 'from', 'till'];
/**
* Get related post for this event
* Ont-to-one relationship
* @return void
*/
public function post()
{
return $this->belongsTo(Post::class);
}
/**
* Get the organizer of the meeting (i.e. user or organization).
*
* @return void
*/
public function meetingable()
{
return $this->morphTo();
}
/**
* Get the organizer of the meeting (i.e. user or organization).
* Accessor and alias of meetingable.
*
* @return void
*/
public function getOrganizerAttribute()
{
return $this->meetingable;
}
public function location()
{
return $this->morphOne(Location::class, 'locatable');
}
/**
* Get the transaction type for this meeting
*/
public function transactionType()
{
return $this->belongsTo(\App\Models\TransactionType::class, 'transaction_type_id');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class MessageSetting extends Model
{
use HasFactory;
protected $fillable = [
'message_settingable_id',
'message_settingable_type',
'system_message',
'payment_received',
'star_received',
'local_newsletter',
'general_newsletter',
'personal_chat',
'group_chat',
'chat_unread_delay',
'call_expiry',
];
/**
* Get the parent message_settingable model (user, organization, or bank).
* This is the instance (bank, organization, admin / alternative profile) where the settings are originally applied for.
* one-to-many polymorphic
*/
public function message_settingable()
{
return $this->morphTo();
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class News extends Model
{
use HasFactory;
}
+600
View File
@@ -0,0 +1,600 @@
<?php
namespace App\Models;
use App\Helpers\StringHelper;
use App\Models\Language;
use App\Models\Locations\Location;
use App\Models\Post;
use App\Models\User;
use App\Notifications\VerifyProfileEmail;
use App\Traits\ActiveStatesTrait;
use App\Traits\HasPresence;
use App\Traits\LocationTrait;
use App\Traits\ProfileTrait;
use App\Traits\TaggableWithLocale;
use Cog\Contracts\Love\Reactable\Models\Reactable as ReactableInterface;
use Cog\Contracts\Love\Reacterable\Models\Reacterable as ReacterableInterface;
use Cog\Laravel\Love\Reactable\Models\Traits\Reactable;
use Cog\Laravel\Love\Reacterable\Models\Traits\Reacterable;
use Illuminate\Auth\MustVerifyEmail as AuthMustVerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Scout\Searchable;
use Namu\WireChat\Traits\Chatable;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Permission\Traits\HasRoles;
class Organization extends Authenticatable implements ReacterableInterface, MustVerifyEmail, ReactableInterface
{
use HasFactory;
use AuthMustVerifyEmail;
use Notifiable;
use HasProfilePhoto;
use HasRoles;
use TaggableWithLocale;
use Reacterable; // cybercog/laravel-love
use Reactable; // cybercog/laravel-love
use Searchable; // laravel/scout with ElasticSearch
use LocationTrait;
use Chatable;
use ActiveStatesTrait;
use LogsActivity; // Spatie Activity Log
use HasPresence;
use ProfileTrait;
/*
* The attributes that should be hidden for serialization.
* WARNING: API'S CAN POTENTIALLY EXPOSE ALL VISIBLE FIELDS
*
* @var array
*/
protected $hidden = [
'email',
'email_verified_at',
'full_name',
'password',
'remember_token',
'phone',
'cyclos_id',
'cyclos_salt',
'cyclos_skills',
'two_factor_confirmed_at',
'two_factor_recovery_codes',
'two_factor_secret',
'limit_min',
'limit_max',
'lang_preference',
'created_at',
'updated_at',
'last_login_at',
'inactive_at',
'incomplete_at',
'deleted_at',
];
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'name',
'full_name',
'email',
'profile_photo_path',
'about',
'about_short',
'motivation',
'website',
'phone',
'phone_public',
'password',
'limit_min',
'limit_max',
'lang_preference',
'last_login_at',
'last_login_ip'
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'phone_public' => 'boolean',
'date_of_birth' => 'date',
'email_verified_at' => 'datetime',
'inactive_at' => 'datetime',
'incomplete_at' => 'datetime',
'deleted_at' => 'datetime',
];
// protected $guard_name = 'web';
/**
* Get the index name for the model.
*
* @return string
*/
public function searchableAs()
{
return 'organizations_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
'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 organization's profile.
* One-to-one
*/
public function profile()
{
return $this->hasOne(Profile::class);
}
/**
* Get all of the organization's accounts.
* One-to-many polymorphic.
*
* @return void
*/
public function accounts()
{
return $this->morphMany(Account::class, 'accountable');
}
/**
* Check if the organization 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 the profile's bank(s) that it can manage.
* Many-to-many.
*/
public function banksManaged()
{
return $this->belongsToMany(Bank::class);
}
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 organization's user(s).
* Many-to-many.
*/
// todo: replace instances to managers() as these are actually profile managers and no organization users. See $this-> manangers()
public function users()
{
return $this->belongsToMany(User::class);
}
/**
* Get the organization's profile manager(s).
* Many-to-many.
*/
public function managers()
{
return $this->belongsToMany(User::class);
}
/**
* Get all of the languages for the organization.
* Many-to-many polymorphic.
*/
public function languages()
{
return $this->morphToMany(Language::class, 'languagable')->withPivot('competence');
}
/**
* Get all of the social for the organization.
* 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 organization.
* One-to-many polymorph.
*/
public function locations()
{
return $this->morphMany(Location::class, 'locatable');
}
/**
* Get the organization's message settings.
*/
public function message_settings()
{
return $this->morphOne(MessageSetting::class, 'message_settingable');
}
/**
* Get all of the Organization's posts.
*/
public function posts()
{
return $this->morphMany(Post::class, 'postable');
}
/**
* Get all of the Organization's posts.
*/
public function categories()
{
return $this->morphMany(Category::class, 'categoryable');
}
public function sendEmailVerificationNotification()
{
\Mail::to($this->email)->send(new \App\Mail\VerifyProfileEmailMailable($this));
}
/**
* 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',
'incomplete_at',
'deleted_at',
])
->logOnlyDirty() // Only log attributes that have been changed
->dontSubmitEmptyLogs()
->useLogName('Organization');
}
/**
* 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' => __('organization'), '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 __('Organization');
}
}
/**
* Wirechat: 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 = \App\Models\User::where(function ($queryBuilder) use ($searchableFields, $query) {
foreach ($searchableFields as $field) {
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
}
})->limit(6)->get();
$organizations = 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.organization.messenger_can_create_groups');
}
/**
* Check if the Organization 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 Organization 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', Organization::class)
->where('accountable_id', $this->id);
})
->orWhereHas('accountTo.accountable', function ($q) {
$q->where('accountable_type', Organization::class)
->where('accountable_id', $this->id);
});
})
->exists();
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class OrganizationRole extends Model
{
protected $table = 'organization_roles';
protected $fillable = ['name', 'label', 'permissions'];
protected $casts = [
'permissions' => 'array',
'is_system' => 'boolean',
];
public function scopeNotSystem($query)
{
return $query->where('is_system', false);
}
}
+335
View File
@@ -0,0 +1,335 @@
<?php
namespace App\Models;
use App\Models\Post;
use App\Services\Stats\LocationFilterResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Poll extends Model
{
use HasFactory, SoftDeletes;
public const STATUS_DRAFT = 'draft';
public const STATUS_OPEN = 'open';
public const STATUS_CLOSED = 'closed';
public const VOTE_SCOPE_PER_PROFILE = 'per_profile';
public const VOTE_SCOPE_PER_USER = 'per_user';
public const MEMBER_SCOPE_ALL_USERS = 'all_users';
public const MEMBER_SCOPE_MANAGERS_COORDINATORS = 'managers_coordinators';
public const RESULTS_NEVER = 'never';
public const RESULTS_AFTER_VOTE = 'after_vote';
public const RESULTS_AFTER_CLOSE = 'after_close';
public const VERIFICATION_OFF = 'off';
public const VERIFICATION_RECEIPTS = 'receipts';
public const VERIFICATION_PUBLIC = 'public';
/** Profile types a poll can target, and their manager/coordinator role names. */
public const PROFILE_TYPES = [User::class, Organization::class, Bank::class, Admin::class];
public const MANAGER_COORDINATOR_ROLES = [
Organization::class => ['organization-manager', 'organization-coordinator'],
Bank::class => ['bank-manager', 'bank-coordinator'],
];
protected $fillable = [
'post_id', 'pollable_id', 'pollable_type',
'status', 'opens_at', 'closes_at',
'vote_scope', 'member_scope', 'allow_vote_change', 'anonymous', 'results_visibility',
'verification', 'sealed_at', 'seal_hash', 'archived_at',
'filter_by_location', 'location_country_ids', 'location_division_ids',
'location_city_ids', 'location_district_ids',
'target_profile_types',
'last_login_from', 'last_login_until', 'registered_from', 'registered_until',
];
protected $casts = [
'archived_at' => 'datetime',
'opens_at' => 'datetime',
'closes_at' => 'datetime',
'sealed_at' => 'datetime',
'allow_vote_change' => 'boolean',
'anonymous' => 'boolean',
'filter_by_location' => 'boolean',
'location_country_ids' => 'array',
'location_division_ids' => 'array',
'location_city_ids' => 'array',
'location_district_ids' => 'array',
'target_profile_types' => 'array',
'last_login_from' => 'date',
'last_login_until' => 'date',
'registered_from' => 'date',
'registered_until' => 'date',
];
/**
* Get related post for this poll
* One-to-one relationship
*/
public function post()
{
return $this->belongsTo(Post::class);
}
/**
* Get the owner of the poll (i.e. user, organization, bank or admin).
*/
public function pollable()
{
return $this->morphTo();
}
public function questions()
{
return $this->hasMany(PollQuestion::class)->orderBy('position');
}
public function responses()
{
return $this->hasMany(PollResponse::class);
}
public function ledger()
{
return $this->hasMany(PollLedgerEntry::class)->orderBy('sequence');
}
/**
* Once someone voted, content stays locked and the poll cannot go back to draft
* (see isContentLocked), so stored answers never point at changed options.
*/
public function hasResponses(): bool
{
return $this->responses()->exists();
}
/**
* Post and questions are read-only once the poll left draft, and always once someone voted:
* voters must all see the same poll.
*/
public function isContentLocked(): bool
{
return $this->status !== self::STATUS_DRAFT || $this->hasResponses();
}
/**
* Open status and inside the opens_at / closes_at window.
*/
public function isOpen(): bool
{
if ($this->status !== self::STATUS_OPEN) {
return false;
}
if ($this->opens_at && $this->opens_at->isFuture()) {
return false;
}
if ($this->closes_at && $this->closes_at->isPast()) {
return false;
}
return true;
}
/**
* Closed by status, or past closes_at.
*/
public function isClosed(): bool
{
return $this->status === self::STATUS_CLOSED || ($this->closes_at && $this->closes_at->isPast());
}
/**
* Whether a voter may see the results (owner and admins always can, on the admin page).
*/
public function resultsVisibleToVoter(bool $hasVoted): bool
{
return match ($this->results_visibility) {
self::RESULTS_AFTER_VOTE => $hasVoted || $this->isClosed(),
self::RESULTS_AFTER_CLOSE => $this->isClosed(),
default => false,
};
}
/**
* Results per question, from the question types' aggregate().
*
* @return array<int, array> keyed by question id
*/
public function questionSummaries(): array
{
$questions = $this->questions()->get();
$answers = PollAnswer::whereIn('poll_question_id', $questions->pluck('id'))->get()->groupBy('poll_question_id');
$summaries = [];
foreach ($questions as $question) {
$summaries[$question->id] = $question->questionType()->aggregate(
$answers->get($question->id, collect()),
$question->settings ?? []
);
}
return $summaries;
}
/**
* Key used by the unique index on poll_responses:
* one vote per profile, or one vote per user behind the profile.
*/
public function dedupeKey(Model $profile, int $userId): string
{
$key = get_class($profile) . ':' . $profile->getKey();
// A User profile always votes for itself, so vote_scope only matters for the other profile types
if ($this->vote_scope === self::VOTE_SCOPE_PER_USER && !$profile instanceof User) {
$key .= ':' . $userId;
}
return $key;
}
/**
* Polls that are open right now (status and opens_at / closes_at window).
*/
public function scopeOpenNow(Builder $query): Builder
{
return $query->where('status', self::STATUS_OPEN)
->where(fn ($q) => $q->whereNull('opens_at')->orWhere('opens_at', '<=', now()))
->where(fn ($q) => $q->whereNull('closes_at')->orWhere('closes_at', '>', now()));
}
/**
* Whether the active profile, voting as $user, may answer this poll right now.
* For Organization / Bank / Admin profiles $user is the logged-in web user behind it.
*/
public function isTargetedAt(Model $profile, ?User $user = null): bool
{
return $this->isOpen()
&& $this->matchesProfile($profile)
&& $this->memberMayVote($profile, $user);
}
/**
* Whether the active profile administers polls: an Admin (or central bank) profile with
* 'access polls'. Same rule as the Polls page and the results export (security audit R4).
*/
public static function viewerIsPollAdmin(): bool
{
$profile = getActiveProfile();
return ($profile instanceof Admin || ($profile instanceof Bank && $profile->level === 0))
&& \Illuminate\Support\Facades\Gate::allows('access polls');
}
/**
* Whether the poll is only for part of the members (any targeting, or only managers
* and coordinators of organizations and banks). Used to warn in the mailing editor.
*/
public function hasLimitedAudience(): bool
{
$types = array_values($this->target_profile_types ?? []);
return ($types && count(array_intersect(self::PROFILE_TYPES, $types)) < count(self::PROFILE_TYPES))
|| $this->filter_by_location
|| $this->last_login_from || $this->last_login_until
|| $this->registered_from || $this->registered_until
|| $this->member_scope === self::MEMBER_SCOPE_MANAGERS_COORDINATORS;
}
/**
* Profile type, location, last login and registration targeting.
*/
public function matchesProfile(Model $profile): bool
{
if (!$this->targetsProfileType(get_class($profile))) {
return false;
}
return $this->applyProfileFilters($profile->newQuery()->whereKey($profile->getKey()))->exists();
}
public function targetsProfileType(string $profileClass): bool
{
return empty($this->target_profile_types) || in_array($profileClass, $this->target_profile_types, true);
}
/**
* Constrain a query on one profile table to the profiles this poll targets
* (location, last login, registration). Profile type is checked by targetsProfileType().
*/
public function applyProfileFilters(Builder $query): Builder
{
if ($this->filter_by_location) {
LocationFilterResolver::apply(
$query,
$this->location_country_ids ?? [],
$this->location_division_ids ?? [],
$this->location_city_ids ?? [],
$this->location_district_ids ?? []
);
}
$table = $query->getModel()->getTable();
if ($this->last_login_from) {
$query->whereDate("$table.last_login_at", '>=', $this->last_login_from->toDateString());
}
if ($this->last_login_until) {
$query->whereDate("$table.last_login_at", '<=', $this->last_login_until->toDateString());
}
if ($this->registered_from) {
$query->whereDate("$table.created_at", '>=', $this->registered_from->toDateString());
}
if ($this->registered_until) {
$query->whereDate("$table.created_at", '<=', $this->registered_until->toDateString());
}
return $query;
}
/**
* member_scope: for Organization / Bank profiles optionally only managers and coordinators
* may vote. A User profile votes for itself; all users linked to an Admin profile may vote.
*/
public function memberMayVote(Model $profile, ?User $user): bool
{
if ($profile instanceof User) {
return $user === null || $user->is($profile);
}
if ($user === null) {
return false;
}
if ($this->member_scope !== self::MEMBER_SCOPE_MANAGERS_COORDINATORS) {
return true;
}
$roles = self::MANAGER_COORDINATOR_ROLES[get_class($profile)] ?? null;
if ($roles === null) {
return true;
}
$prefix = class_basename($profile) . '\\' . $profile->getKey() . '\\';
return $user->hasRole(array_map(fn ($role) => $prefix . $role, $roles));
}
/**
* The existing vote of this profile (or of this user behind it, for per_user polls).
*/
public function responseFor(Model $profile, int $userId): ?PollResponse
{
return $this->responses()->where('dedupe_key', $this->dedupeKey($profile, $userId))->first();
}
/**
* Whether the voter may (still) submit: no vote yet, or changing votes is allowed.
*/
public function canSubmit(Model $profile, int $userId): bool
{
return $this->allow_vote_change || $this->responseFor($profile, $userId) === null;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PollAnswer extends Model
{
use HasFactory;
protected $fillable = ['poll_response_id', 'poll_question_id', 'value', 'text'];
protected $casts = [
'value' => 'array',
];
public function response()
{
return $this->belongsTo(PollResponse::class, 'poll_response_id');
}
public function question()
{
return $this->belongsTo(PollQuestion::class, 'poll_question_id');
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use LogicException;
/**
* One entry in a poll's append-only ballot ledger. Holds no voter identity.
* Entries are only ever inserted, through App\Polls\Ledger.
*/
class PollLedgerEntry extends Model
{
public const CAST = 'cast';
public const CHANGE = 'change';
public const WITHDRAW = 'withdraw';
public const SEAL = 'seal';
protected $table = 'poll_ledger';
public $timestamps = false;
protected $fillable = [
'poll_id', 'sequence', 'format_version', 'entry_type', 'fingerprint', 'refers_to_sequence',
'payload', 'recorded_at', 'previous_hash', 'hash',
];
protected $casts = [
'sequence' => 'integer',
'format_version' => 'integer',
'refers_to_sequence' => 'integer',
'payload' => 'array',
];
protected static function booted(): void
{
static::updating(fn () => throw new LogicException('Poll ledger entries cannot be changed.'));
static::deleting(fn () => throw new LogicException('Poll ledger entries cannot be deleted.'));
}
public function poll()
{
return $this->belongsTo(Poll::class);
}
public function isBallot(): bool
{
return in_array($this->entry_type, [self::CAST, self::CHANGE], true);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use App\Polls\Contracts\QuestionType;
use App\Polls\Localized;
use App\Polls\QuestionTypeRegistry;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PollQuestion extends Model
{
use HasFactory;
protected $fillable = ['poll_id', 'position', 'type', 'label', 'settings', 'required'];
protected $casts = [
'label' => 'array',
'settings' => 'array',
'required' => 'boolean',
];
public function poll()
{
return $this->belongsTo(Poll::class);
}
public function answers()
{
return $this->hasMany(PollAnswer::class);
}
/**
* Label in the given (or current) locale, falling back to the app fallback locale
* and then to the first label that is filled in.
*/
public function labelFor(?string $locale = null): string
{
return Localized::pick($this->label, $locale);
}
/**
* The question type handling this question.
*/
public function questionType(): QuestionType
{
return app(QuestionTypeRegistry::class)->get($this->type);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PollResponse extends Model
{
use HasFactory;
protected $fillable = ['poll_id', 'voter_id', 'voter_type', 'user_id', 'dedupe_key', 'ledger_sequence', 'submitted_at'];
protected $casts = [
'submitted_at' => 'datetime',
'ledger_sequence' => 'integer',
];
public function poll()
{
return $this->belongsTo(Poll::class);
}
/**
* The active profile that voted (user, organization, bank or admin).
*/
public function voter()
{
return $this->morphTo();
}
/**
* The user behind the voting profile.
*/
public function user()
{
return $this->belongsTo(User::class);
}
public function answers()
{
return $this->hasMany(PollAnswer::class);
}
}
+304
View File
@@ -0,0 +1,304 @@
<?php
namespace App\Models;
use App\Helpers\StringHelper;
use App\Models\Category;
use App\Models\Image;
use App\Models\Meeting;
use App\Models\Poll;
use App\Models\PostTranslation;
use Cog\Contracts\Love\Reactable\Models\Reactable as ReactableInterface;
use Cog\Laravel\Love\Reactable\Models\Traits\Reactable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Znck\Eloquent\Traits\BelongsToThrough;
class Post extends Model implements HasMedia, ReactableInterface
{
use HasFactory;
use BelongsToThrough;
use InteractsWithMedia;
use SoftDeletes;
use Searchable { // laravel/scout with ElasticSearch
searchableUsing as scoutSearchableUsing;
}
use Reactable; // cybercog/laravel-love
protected $fillable = ['postable_id', 'postable_type', 'category_id', 'author_id', 'author_model', 'is_locked'];
protected $casts = [
'is_locked' => 'boolean',
];
/**
* Get the index name for the model.
*
* @return string
*/
public function searchableAs()
{
return 'posts_index';
}
/**
* Polls are for logged-in profiles only: never in the search index, whatever the
* search category settings. The daily scout:import also skips them (PullFromSource).
*/
public function shouldBeSearchable(): bool
{
return !$this->isPoll();
}
/**
* Every way into the index ends in searchableUsing()->update(), but Scout only checks
* shouldBeSearchable() in its own model events and imports. The wrapper makes that check
* for every update, so a poll is never indexed (and is removed should it be there).
*/
public function searchableUsing()
{
return new \App\Polls\SearchEngineWithoutPolls($this->scoutSearchableUsing());
}
/**
* Whether this post is a poll (poll category or a poll attached).
*/
public function isPoll(): bool
{
return $this->category?->type === Poll::class || $this->poll()->exists();
}
/**
* Convert the this model to a searchable array.
*
* @return array
*/
public function toSearchableArray()
{
return [
'id' => $this->id,
'category_id' => $this->category_id,
'postable' => [
'id' => $this->postable ? $this->postable->id : '',
'name' => $this->postable ? $this->postable->name : '',
],
'post_translations' => $this->translations->mapWithKeys(function ($translation) {
// mapWithKeys() as translations is a collection
return [
'title_' . $translation->locale => $translation->title,
'excerpt_' . $translation->locale => $translation->excerpt,
'content_' . $translation->locale => $translation->content,
// Add publication fields
'from_' . $translation->locale => $translation->from,
'till_' . $translation->locale => $translation->till,
'deleted_at_' . $translation->locale => $translation->deleted_at,
'status_' . $translation->locale => $translation->status,
];
})->toArray(),
'post_category' => $this->category ? [
'id' => $this->category->id,
'names' => $this->category->translations->mapWithKeys(function ($translation) {
// Include the locale in the field name for categories
return ['name_' . $translation->locale => StringHelper::DutchTitleCase($translation->name)];
})->toArray(),
] : [],
];
}
/**
* Get the creator of the post (i.e. user or organization).
*
* @return void
*/
public function postable()
{
return $this->morphTo();
}
/**
* Get the author of the post (polymorphic: User, Organization, or Bank).
*
* @return void
*/
public function author()
{
return $this->morphTo('author', 'author_model', 'author_id');
}
/**
* Get the related translations of the post
* One-to-many relationship
* @return void
*/
public function translations()
{
return $this->hasMany(PostTranslation::class);
}
/**
* Get the related category of the post
*
* @return void
*/
public function category()
{
return $this->belongsTo(Category::class);
}
/**
* Get the related images of the posts
*
* @return void
*/
public function images()
{
return $this->morphToMany(Image::class, 'imageable');
}
public static function last()
{
return static::all()->last();
}
/**
* Spatie Media Library media conversions
* When updated you can use:
* php artisan media-library:regenerate
* to regenerate media disk and database of all media stored (make sure you restart the queue worker first)
*
* @param mixed $media
* @return void
*/
public function registerMediaConversions(Media $media = null): void
{
$this->addMediaConversion('favicon') //1:1
->focalCrop(32, 32, 50, 50)
->optimize()
->format('gif')
->performOnCollections('posts');
$this->addMediaConversion('logo') //1:1
->focalCrop(160, 160, 50, 50)
->optimize()
->format('webp')
->performOnCollections('posts');
$this->addMediaConversion('thumbnail') // 1:1
->focalCrop(150, 150, 50, 50)
->optimize()
->format('webp')
->performOnCollections('posts');
$this->addMediaConversion('blog') //3:2
->focalCrop(1200, 630, 50, 50)
->withResponsiveImages()
->optimize()
->format('webp')
->performOnCollections('posts');
$this->addMediaConversion('hero') //16:9
->focalCrop(3840, 2160, 50, 50)
->withResponsiveImages()
->optimize()
->format('webp')
->performOnCollections('posts');
$this->addMediaConversion('half_hero') //16:4.5 panoramic
->focalCrop(3840, 1080, 50, 50)
->format('webp')
->performOnCollections('posts');
$this->addMediaConversion('4_3') //4:3
->focalCrop(3072, 2304, 50, 50)
->withResponsiveImages()
->optimize()
->format('webp')
->performOnCollections('posts');
$this->addMediaConversion('email') // For email newsletters - resizes without cropping
->width(800)
->optimize()
->format('jpg')
->quality(85)
->performOnCollections('posts');
}
public function registerMediaCollection(): void
{
$this->addMediaCollection('posts')
->useDisk('media')
->singleFile();
}
/**
* Get the related meeting of the post
* One-to-one relation
*
* @return void
*/
public function meeting()
{
return $this->hasOne(Meeting::class);
}
/**
* Get the related poll of the post
* One-to-one relation
*
* @return void
*/
public function poll()
{
return $this->hasOne(Poll::class);
}
/**
* Get the count of 'like' reactions for this post
*
* @return int
*/
public function getLikeCountAttribute()
{
// Check if post is registered as reactant before getting reactions
if (!$this->isRegisteredAsLoveReactant()) {
return 0;
}
// Get the reactant facade
$reactantFacade = $this->viaLoveReactant();
// Get the reaction counter for 'Like' type
$reactionCounter = $reactantFacade->getReactionCounterOfType('Like');
if (!$reactionCounter) {
return 0;
}
// Return the total count from all users
return $reactionCounter->getCount();
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class PostTranslation extends Model
{
use HasFactory, Sluggable, SoftDeletes;
protected $fillable = ['post_id', 'locale', 'slug', 'title', 'excerpt', 'content', 'status', 'updated_by_user_id', 'from', 'till'];
/**
* Get related post for this translation
* Ont-to-many relationship
* @return void
*/
public function post()
{
return $this->belongsTo(Post::class);
}
/**
* Get the user who last updated the post translation.
*/
public function updated_by_user()
{
return $this->belongsTo(User::class, 'updated_by_user_id');
}
public function sluggable(): array
{
return [
'slug' => [
'source' => 'title'
]
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
// use Laravel\Jetstream\HasProfilePhoto;
class Profile extends Model
{
use HasFactory;
//The attributes that are mass assignable.
// protected $fillable = [
// 'profile_photo_path',
// ];
// protected $appends = [
// 'profile_photo_url',
// ];
// Get all of the owning user
// One-to-one
public function user()
{
return $this->belongsTo(User::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Facades\App;
class LocalizeScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
* Gets the localized name of a model.
* In the App::getLocale, or if not exists, in the App::getFallbackLocale language.
*
* @param \Illuminate\Database\Eloquent\Builder $builder The Eloquent query builder.
* @param \Illuminate\Database\Eloquent\Model $model The Eloquent model.
* @return void
*/
public function apply(Builder $builder, Model $model)
{
$builder->where('locale', App::getLocale())
->orWhere('locale', App::getFallbackLocale())
->orderByRaw("(CASE WHEN locale = ? THEN 1 WHEN locale = ? THEN 2 END)", [App::getLocale(), App::getFallbackLocale()]);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SearchMetric extends Model
{
protected $table = 'search_metrics';
protected $fillable = [
'searched_at',
'search_string',
'category_ids',
'total_results',
'execution_time',
'locale',
'user_type',
'country_id',
'division_id',
'city_id',
'district_id',
];
protected $casts = [
'searched_at' => 'datetime',
'category_ids' => 'array',
'total_results' => 'integer',
'execution_time' => 'float',
];
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SiteContentsPay extends Model
{
use HasFactory;
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Social extends Model
{
use HasFactory;
protected $fillable = [
'name',
'icon',
'url_structure'
];
protected $guarded = [];
/**
* Get all sociables that are assigned to this social.
*/
public function sociables()
{
return $this->morphedByMany(User::class, 'sociable');
}
/**
* Get all users that are assigned to this social.
*/
public function users()
{
return $this->morphedByMany(User::class, 'sociable')->where('sociable_type', User::class);
}
/**
* Get all organizations that are assigned to this social.
*/
public function organizations()
{
return $this->morphedByMany(Organization::class, 'sociable')->where('sociable_type', Organization::class);
;
}
}
+252
View File
@@ -0,0 +1,252 @@
<?php
namespace App\Models;
use App\Models\TaggableContext;
use App\Models\TaggableLocale;
use App\Traits\TaggableWithLocale;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Laravel\Scout\Searchable;
class Tag extends \Cviebrock\EloquentTaggable\Models\Tag
/**
* Class Tag extends \Cviebrock\EloquentTaggable\Models\Tag
*
* This extended the Tag class of cviebrock / eloquent-taggable package.
* It adds an one-to-many relationship to store additional tag context data in the tag_contexts table.
* Use this extension with the trait App\Traits\TaggableWithContext.
*
*/
{
use HasFactory;
use TaggableWithLocale;
use Searchable; // laravel/scout with ElasticSearch
protected $table = 'taggable_tags';
protected $primaryKey = 'tag_id';
protected $appends = ['locales', 'categories'];
/**
* Boot the model and add event listeners.
*
* The 'deleting' event listener performs the following actions:
* - Detaches all relationships with users, organizations, and banks.
* - Deletes all locales and contexts directly tied to the tag.
*
* This listener ensures that when a Tag instance is deleted,
* all related data and associations are properly cleaned up to
* maintain data integrity and avoid orphaned records.
*
* @return void
*/
protected static function boot()
{
parent::boot();
static::deleting(function ($tag) {
// Remove relationships of users, organizations, banks
$tag->users()->detach();
$tag->organizations()->detach();
$tag->banks()->detach();
// Delete locale tied directly to the tag
$tag->locale()->delete();
// Clean up any orphaned contexts (with no related tag models left)
$unusedLocaleContexts = TaggableLocaleContext::doesntHave('tags')->get();
$unusedLocaleContexts->each(fn ($localeContext) => $localeContext->delete());
$unusedContexts = TaggableContext::doesntHave('tags')->get();
$unusedContexts->each(fn ($context) => $context->delete());
});
}
/**
* Get the index name for the model.
*
* @return string
*/
public function searchableAs()
{
return 'tags_index';
}
/**
* Get the value used to index the model.
*/
public function getScoutKey(): mixed
{
return $this->tag_id;
}
/**
* Get the key name used to index the model.
*/
public function getScoutKeyName(): mixed
{
return 'tag_id';
}
public function users()
{
return $this->morphedByMany(User::class, 'taggable', 'taggable_taggables', 'tag_id', 'taggable_id');
}
public function organizations()
{
return $this->morphedByMany(Organization::class, 'taggable', 'taggable_taggables', 'tag_id', 'taggable_id');
}
public function banks()
{
return $this->morphedByMany(Bank::class, 'taggable', 'taggable_taggables', 'tag_id', 'taggable_id');
}
public function locale()
{
return $this->hasOne(TaggableLocale::class, 'taggable_tag_id');
}
public function localeCode()
{
return $this->hasOne(TaggableLocale::class, 'taggable_tag_id')->select('locale');
}
public function contexts()
{
return $this->belongsToMany(TaggableContext::class, 'taggable_locale_context', 'tag_id', 'context_id');
}
public function localeContext()
{
return $this->hasOne(TaggableLocaleContext::class, 'tag_id');
}
/**
* Scope to find tags by name.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param $value
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByLocalName(Builder $query, string $value): Builder
{
$normalized = app(TagService::class)->normalize($value);
return $query->where('normalized', $normalized);
}
/**
* Get all related tags with their locales using a raw query.
* Note: this is not a relationship definition!
*
* @return \Illuminate\Support\Collection
*/
public function translations()
{
return DB::table('taggable_tags as tt')
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
->select('tt.*', 'tl.*', 'tlc.context_id')
->whereIn('tlc.context_id', function ($query) {
$query->select('context_id')
->from('taggable_locale_context')
->where('tag_id', $this->tag_id);
})
->distinct()
->get();
}
public function getLocalesAttribute()
{
// Get context_ids for current tag
$contextIds = DB::table('taggable_locale_context')
->where('tag_id', $this->tag_id)
->pluck('context_id');
// Get tag_ids that share these context_ids
$relatedTagIds = DB::table('taggable_locale_context')
->whereIn('context_id', $contextIds)
->pluck('tag_id');
// Get translations for these tags
return DB::table('taggable_tags as tt')
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
->whereIn('tt.tag_id', $relatedTagIds)
->select('tt.tag_id', 'tt.name', 'tl.*')
->get();
}
public function getCategoriesAttribute()
{
// Get context_ids for current tag
$contextIds = DB::table('taggable_locale_context')
->where('tag_id', $this->tag_id)
->pluck('context_id');
// Get categories for these contexts
$categoryId = DB::table('taggable_contexts as tc')
->join('categories as c', 'tc.category_id', '=', 'c.id')
->whereIn('tc.id', $contextIds)
->pluck('c.id');
// Get the first category ID
$firstCategoryId = $categoryId->first();
// Find the category by its ID
$category = Category::find($firstCategoryId);
$categoryWithRelated = $category ? $category->related() : null;
return $categoryWithRelated;
}
/**
* Get the translation attribute for the tag.
*
* This method attempts to retrieve the translation for the tag
* in the current locale. If a translation in the current locale is
* not found, it falls back to the base locale defined in the timebank-cc config.
*
* @return \App\Models\Translation|null The translation object for the category in the current or base locale, or null if not found.
*/
public function getTranslationAttribute()
{
$baseLocale = timebank_config('base_language');
// Retrieve all translations using the translations() method
$translations = $this->translations();
// Attempt to get the translation in the current locale
$translation = $translations->firstWhere('locale', App::getLocale());
// Fallback to base locale if translation not found
if (!$translation) {
$translation = $translations->firstWhere('locale', $baseLocale);
// Fallback to only available locale if no translation in base locale is not found
if (!$translation) {
return $translations->first(); //We can do first() as tags can only be translated to the base locale. So there are no other translations.
}
}
return $translation;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use App\Traits\TaggableWithLocale;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
class TaggableContext extends Model
{
use HasFactory;
use TaggableWithLocale;
//! TODO: This method is not tested yet, as there's no interface yet for updating taggable_contexts
protected static function boot()
{
parent::boot();
// updating created_by and updated_by when model is created
static::creating(function ($model) {
if (!$model->isDirty('updated_by_user')) {
$model->updated_by_user = Auth::guard('web')->user()->id;
}
});
// updating updated_by when model is updated
static::updating(function ($model) {
if (!$model->isDirty('updated_by_user')) {
$model->updated_by_user = Auth::guard('web')->user()->id;
}
});
}
protected $table = 'taggable_contexts';
protected $guarded = [];
public function tags()
{
return $this->belongsToMany(Tag::class, 'taggable_locale_context', 'context_id', 'tag_id');
}
public function category()
{
return $this->belongsTo(Category::class);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use App\Models\Tag;
use App\Traits\TaggableWithLocale;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
class TaggableLocale extends Model
{
use HasFactory;
use TaggableWithLocale;
protected $table = 'taggable_locales';
protected static function boot()
{
parent::boot();
// updating created_by and updated_by when model is created
static::creating(function ($model) {
if (!$model->isDirty('updated_by_user')) {
$model->updated_by_user = Auth::guard('web')->user()->id;
}
});
// updating updated_by when model is updated
static::updating(function ($model) {
if (!$model->isDirty('updated_by_user')) {
$model->updated_by_user = Auth::guard('web')->user()->id;
}
});
}
protected $guarded = [];
public function tag()
{
return $this->belongsTo(Tag::class, 'taggable_tag_id', 'tag_id');
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TaggableLocaleContext extends Model
{
use HasFactory;
protected $table = 'taggable_locale_context';
protected $guarded = [];
public function tags()
{
return $this->belongsToMany(Tag::class, 'taggable_locale_context', 'context_id', 'tag_id');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class TaggableTaggable extends Model
{
use HasFactory;
public static function boot()
{
Static::updating(function() {
//
});
}
protected $table = 'taggable_taggables';
protected $primaryKey = 'tag_id';
protected $guarded = [];
}
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace App\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
class Transaction extends Model
{
use HasFactory;
use Searchable; // laravel/scout with ElasticSearch
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'from_account_id',
'to_account_id',
'creator_user_id',
'from_authorised_by_user_id',
'from_authorisation_time',
'to_authorised_by_user_id',
'to_authorisation_time',
'amount',
'programmed_time',
'description',
'from_reference',
'to_reference',
'transaction_type_id',
'transaction_status_id',
'cancelled_by_user_id',
'cancelled_time',
'advertisement_id',
];
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'transactions';
/**
* Refuse invalid transactions whichever code path creates them. Transactions are
* immutable once stored, and the table has no foreign keys or check constraints.
*/
protected static function booted(): void
{
static::creating(function (Transaction $transaction) {
if (!is_numeric($transaction->amount) || (int) $transaction->amount != $transaction->amount || $transaction->amount <= 0) {
throw new \InvalidArgumentException('Transaction amount must be a positive whole number of minutes.');
}
if ((int) $transaction->from_account_id === (int) $transaction->to_account_id) {
throw new \InvalidArgumentException('Transaction from and to account must differ.');
}
if (Account::whereKey([$transaction->from_account_id, $transaction->to_account_id])->count() !== 2) {
throw new \InvalidArgumentException('Transaction from and to account must exist.');
}
if (!TransactionType::whereKey($transaction->transaction_type_id)->exists()) {
throw new \InvalidArgumentException('Transaction type must exist.');
}
});
}
/**
* Get the index name for the model.
*
* @return string
*/
public function searchableAs()
{
return 'transactions_index';
}
/**
* Convert the transaction model to a searchable array.
*
* @return array
*/
public function toSearchableArray()
{
return [
'id' => $this->id,
'from_account_id' => $this->from_account_id,
'to_account_id' => $this->to_account_id,
'amount' => $this->amount,
'description' => $this->description ?? '',
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
];
}
public function accountFrom()
{
return $this->belongsTo(Account::class, 'from_account_id');
}
public function accountTo()
{
return $this->belongsTo(Account::class, 'to_account_id');
}
public function creator()
{
return $this->belongsTo(User::class, 'creator_user_id');
}
/**
* Get the transaction type that owns the transaction.
*/
public function transactionType()
{
return $this->belongsTo(TransactionType::class);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TransactionType extends Model
{
use HasFactory;
/**
* Get the transactions for the transaction type.
*/
public function transactions()
{
return $this->hasMany(Transaction::class);
}
}
+702
View File
@@ -0,0 +1,702 @@
<?php
namespace App\Models;
use App\Helpers\StringHelper;
use App\Models\Account;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Language;
use App\Models\Locations\Location;
use App\Models\Organization;
use App\Models\Post;
use App\Notifications\VerifyProfileEmail;
use App\Traits\ActiveStatesTrait;
use App\Traits\HasPresence;
use App\Traits\LocationTrait;
use App\Traits\ProfileTrait;
use App\Traits\TaggableWithLocale;
use Cog\Contracts\Love\Reactable\Models\Reactable as ReactableInterface;
use Cog\Contracts\Love\Reacterable\Models\Reacterable as ReacterableInterface;
use Cog\Laravel\Love\Reactable\Models\Traits\Reactable;
use Cog\Laravel\Love\Reacterable\Models\Traits\Reacterable;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Scout\Searchable;
use Namu\WireChat\Traits\Chatable;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements MustVerifyEmail, ReacterableInterface, ReactableInterface
{
use HasApiTokens;
use HasFactory;
use Notifiable;
use TwoFactorAuthenticatable;
use HasProfilePhoto;
use HasApiTokens; // Laravel Sanctum API support
use HasRoles; // Spatie Permissions
use LogsActivity; // Spatie Activity Log
use TaggableWithLocale;
use Reacterable; // cybercog/laravel-love
use Reactable; // cybercog/laravel-love
use Searchable; // laravel/scout with ElasticSearch
use LocationTrait;
use Chatable;
use ActiveStatesTrait;
use HasPresence;
use ProfileTrait;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'name',
'full_name',
'email',
'profile_photo_path',
'about',
'about_short',
'motivation',
'website',
'phone',
'phone_public',
'password',
'limit_min',
'limit_max',
'lang_preference',
'last_login_at',
'last_login_ip',
'principles_terms_accepted',
'fcm_token',
];
/**
* The attributes that should be hidden for serialization.
* BEWARE: API'S CAN POTENTIALLY EXPOSE ALL VISIBLE FIELDS
*
* @var array
*/
protected $hidden = [
'email',
'email_verified_at',
'full_name',
'password',
'remember_token',
'phone',
'cyclos_id',
'cyclos_salt',
'cyclos_skills',
'two_factor_confirmed_at',
'two_factor_recovery_codes',
'two_factor_secret',
'limit_min',
'limit_max',
'created_at',
'updated_at',
'last_login_at',
'last_login_ip',
'principles_terms_accepted',
'inactive_at',
'incomplete_at',
'deleted_at',
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'phone_public' => 'boolean',
'date_of_birth' => 'date',
'email_verified_at' => 'datetime',
'principles_terms_accepted' => 'array',
'inactive_at' => 'datetime',
'incomplete_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' => $context->category ? 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)];
});
}) : collect(),
];
}),
];
})
];
}
/**
* 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',
'incomplete_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 ?? app()->getLocale() ?? 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();
}
/**
* 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
{
// Find the currently active Principles post (any locale) — must come BEFORE
// checking acceptance to avoid redirect loops when no post exists.
$activeTranslationQuery = function ($query) {
$query->where('from', '<=', now())
->where(function ($q) {
$q->where('till', '>', now())->orWhereNull('till');
});
};
$currentPost = Post::whereHas('category', function ($query) {
$query->where('type', 'SiteContents\Static\Principles');
})
->whereHas('translations', $activeTranslationQuery)
->first();
// Failsafe: no active principles post means no acceptance needed
if (!$currentPost) {
return false;
}
// If user hasn't accepted at all, they need to accept
if (!$this->hasAcceptedPrinciples()) {
return true;
}
$acceptedData = $this->principles_terms_accepted;
// Compare by post_id — re-acceptance is only triggered by a new Principles post
if (isset($acceptedData['post_id'])) {
return (int) $acceptedData['post_id'] !== $currentPost->id;
}
return true; // No post_id stored — require re-acceptance
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Namu\WireChat\Models\Attachment;
/**
* Extends the WireChat Attachment model to override URL generation.
*
* The vendor model calls Storage::disk('local')->url() which throws because
* the local disk has no web-accessible URL. We store files on the local disk
* intentionally — access is gated through WireChatAttachmentController which
* verifies the requester is a conversation participant before streaming the file.
*/
class WireChatAttachment extends Attachment
{
protected function generateUrl(?string $path): ?string
{
if (! $path) {
return null;
}
// Return the authenticated secure route instead of a direct storage URL.
// The controller enforces participant-only access.
$attachment = static::where('file_path', $path)->first();
if (! $attachment) {
return null;
}
return route('wirechat.attachment.serve', ['attachment' => $attachment->id]);
}
}