Initial commit

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

77
app/Models/Account.php Normal file
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
app/Models/Admin.php Normal file
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 all of the admin's message settings.
*/
public function message_settings()
{
return $this->morphMany(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', // 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',
'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');
}
}

11
app/Models/Article.php Normal file
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;
}

533
app/Models/Bank.php Normal file
View File

@@ -0,0 +1,533 @@
<?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',
'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',
'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 all of the bank's message settings.
*/
public function message_settings()
{
return $this->morphMany(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',
'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;
}
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
app/Models/Call.php Normal file
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(),
];
}
}

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
app/Models/Category.php Normal file
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);
}
}

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
app/Models/Image.php Normal file
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
app/Models/Language.php Normal file
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');
}
}

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');
}
}

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');
}
}

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');
}
}

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;
}

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');
}
}

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');
}
}

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');
}
}

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');
}
}

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');
}
}

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);
}
}

589
app/Models/Mailing.php Normal file
View File

@@ -0,0 +1,589 @@
<?php
namespace App\Models;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\MessageSetting;
use App\Models\Organization;
use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class Mailing extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'title', 'type', 'subject', 'content_blocks', 'scheduled_at', 'sent_at',
'status', 'recipients_count', 'sent_count', 'failed_count',
'updated_by_user_id', 'filter_by_location', 'location_country_id',
'location_division_id', 'location_city_id', 'location_district_id',
'filter_by_profile_type', 'selected_profile_types'
];
protected $casts = [
'content_blocks' => 'array',
'subject' => 'array',
'scheduled_at' => 'datetime',
'sent_at' => 'datetime',
'filter_by_location' => 'boolean',
'filter_by_profile_type' => 'boolean',
'selected_profile_types' => 'array',
];
protected $dates = ['scheduled_at', 'sent_at', 'deleted_at'];
/**
* Get the user who last updated the mailing
*/
public function updatedByUser()
{
return $this->belongsTo(\App\Models\User::class, 'updated_by_user_id');
}
/**
* Get the bounces for this mailing
*/
public function bounces()
{
return $this->hasMany(\App\Models\MailingBounce::class, 'mailing_id');
}
/**
* Get the bounced count dynamically from the mailing_bounces table
*/
public function getBouncedCountAttribute()
{
return $this->bounces()->count();
}
/**
* Get posts from content_blocks JSON
*/
public function getSelectedPosts()
{
if (empty($this->content_blocks)) {
return collect();
}
$postIds = collect($this->content_blocks)->pluck('post_id');
return Post::whereIn('id', $postIds)->get()->sortBy(function ($post) {
return array_search($post->id, collect($this->content_blocks)->pluck('post_id')->toArray());
});
}
/**
* Get posts with all their translations
*/
public function getSelectedPostsWithTranslations()
{
return $this->getSelectedPosts()->load('translations');
}
/**
* Get post translation for specific locale with fallback
*/
public function getPostTranslationForLocale($postId, $locale = null)
{
$locale = $locale ?: App::getLocale();
$post = Post::find($postId);
if (!$post) {
return null;
}
// Try preferred locale first
$translation = $post->translations()->where('locale', $locale)->first();
// Only fallback if enabled in configuration
if (!$translation && timebank_config('bulk_mail.use_fallback_locale', true)) {
$fallbackLocale = timebank_config('bulk_mail.fallback_locale', 'en');
if ($locale !== $fallbackLocale) { // Avoid infinite loop
$translation = $post->translations()->where('locale', $fallbackLocale)->first();
}
}
return $translation;
}
/**
* Get recipients query based on mailing type
*/
public function getRecipientsQuery()
{
$queries = [];
// Initialize all profile type queries
$users = User::whereHas('messageSettings', function ($query) {
$query->where($this->type, true);
})->select('id', 'name', 'email', 'lang_preference');
$organizations = Organization::whereHas('messageSettings', function ($query) {
$query->where($this->type, true);
})->select('id', 'name', 'email', 'lang_preference');
$banks = Bank::whereHas('message_settings', function ($query) {
$query->where($this->type, true);
})->select('id', 'name', 'email', 'lang_preference');
$admins = Admin::whereHas('message_settings', function ($query) {
$query->where($this->type, true);
})->select('id', 'name', 'email', 'lang_preference');
// Apply profile type filtering if enabled
if ($this->filter_by_profile_type && !empty($this->selected_profile_types)) {
$selectedTypes = $this->selected_profile_types;
// Only include selected profile types
if (in_array('User', $selectedTypes)) {
$queries[] = $users;
}
if (in_array('Organization', $selectedTypes)) {
$queries[] = $organizations;
}
if (in_array('Bank', $selectedTypes)) {
$queries[] = $banks;
}
if (in_array('Admin', $selectedTypes)) {
$queries[] = $admins;
}
} else {
// If no profile type filtering, include all types
$queries = [$users, $organizations, $banks, $admins];
}
// Apply location filtering if enabled
if ($this->filter_by_location) {
$queries = array_map(function($query) {
return $this->applyLocationFilter($query);
}, $queries);
}
// Get count before bounce filtering for logging
$beforeFilteringCount = 0;
foreach ($queries as $query) {
$beforeFilteringCount += $query->count();
}
// Apply bounce filtering to all queries
$queries = array_map(function($query) {
return $query->whereNotIn('email', function($subquery) {
$subquery->select('email')
->from('mailing_bounces')
->where('is_suppressed', true);
});
}, $queries);
// Get count after bounce filtering for logging
$afterFilteringCount = 0;
foreach ($queries as $query) {
$afterFilteringCount += $query->count();
}
// Log suppression statistics if any emails were excluded
$suppressedCount = $beforeFilteringCount - $afterFilteringCount;
if ($suppressedCount > 0) {
Log::warning("MAILING RECIPIENTS: Excluded {$suppressedCount} suppressed emails from Mailing ID: {$this->id}");
// Log which specific emails were suppressed
$suppressedEmails = MailingBounce::where('is_suppressed', true)->pluck('email');
foreach ($suppressedEmails as $email) {
Log::warning("MAILING RECIPIENTS: Suppressed email excluded: {$email}");
}
}
// Combine all queries with union
if (empty($queries)) {
// Return empty query if no profile types selected
return User::whereRaw('1 = 0')->select('id', 'name', 'email', 'lang_preference');
}
$finalQuery = array_shift($queries);
foreach ($queries as $query) {
$finalQuery = $finalQuery->union($query);
}
return $finalQuery;
}
/**
* Apply location filtering to a query
*/
private function applyLocationFilter($query)
{
return $query->whereHas('locations', function ($locationQuery) {
if ($this->location_district_id) {
$locationQuery->where('district_id', $this->location_district_id);
} elseif ($this->location_city_id) {
$locationQuery->where('city_id', $this->location_city_id);
} elseif ($this->location_division_id) {
$locationQuery->where('division_id', $this->location_division_id);
} elseif ($this->location_country_id) {
$locationQuery->where('country_id', $this->location_country_id);
}
});
}
/**
* Get recipients grouped by language preference for efficient processing
*/
public function getRecipientsByLanguage()
{
$recipients = $this->getRecipientsQuery()->get();
return $recipients->groupBy(function ($recipient) {
return $recipient->lang_preference ?: timebank_config('base_language', 'en');
});
}
/**
* Status checking methods
*/
public function canBeSent()
{
$hasValidStatus = in_array($this->status, ['draft', 'scheduled']);
$hasPosts = !empty($this->content_blocks) && count(array_filter($this->content_blocks, function($block) {
return !empty($block['post_id']);
})) > 0;
return $hasValidStatus && $hasPosts;
}
public function canBeScheduled()
{
return in_array($this->status, ['draft']);
}
public function canBeCancelled()
{
return in_array($this->status, ['scheduled', 'sending']);
}
/**
* Scopes
*/
public function scopeDraft($query)
{
return $query->where('status', 'draft');
}
public function scopeScheduled($query)
{
return $query->where('status', 'scheduled');
}
public function scopeSent($query)
{
return $query->where('status', 'sent');
}
public function scopeByType($query, $type)
{
return $query->where('type', $type);
}
/**
* Get available locales for recipients of this mailing type
*/
public function getAvailableRecipientLocales()
{
$recipientsQuery = $this->getRecipientsQuery();
return $recipientsQuery
->whereNotNull('lang_preference')
->distinct()
->pluck('lang_preference')
->filter()
->toArray();
}
/**
* Get available locales from selected posts
*/
public function getAvailablePostLocales()
{
if (empty($this->content_blocks)) {
return [];
}
$postIds = collect($this->content_blocks)->pluck('post_id');
$locales = \DB::table('post_translations')
->whereIn('post_id', $postIds)
->where('status', 1) // Only published translations
->distinct()
->pluck('locale')
->toArray();
// Always include base language first
$baseLanguage = timebank_config('base_language', 'en');
$locales = array_unique(array_merge([$baseLanguage], $locales));
return array_values($locales);
}
/**
* Get subject for a specific locale
*/
public function getSubjectForLocale($locale = null)
{
$locale = $locale ?: timebank_config('base_language', 'en');
$baseLanguage = timebank_config('base_language', 'en');
// Handle legacy string subjects
if (is_string($this->subject)) {
return $this->subject;
}
// Handle multilingual JSON subjects
$subjects = $this->subject ?? [];
// Try preferred locale first
if (isset($subjects[$locale])) {
return $subjects[$locale];
}
// Fallback to base language
if (isset($subjects[$baseLanguage])) {
return $subjects[$baseLanguage];
}
// Fallback to first available subject
return !empty($subjects) ? array_values($subjects)[0] : '';
}
/**
* Set subject for a specific locale
*/
public function setSubjectForLocale($locale, $subject)
{
$subjects = $this->subject ?? [];
$subjects[$locale] = $subject;
$this->subject = $subjects;
}
/**
* Get all subjects with their locales
*/
public function getAllSubjects()
{
return $this->subject ?? [];
}
/**
* Check if subject exists for a locale
*/
public function hasSubjectForLocale($locale)
{
$subjects = $this->subject ?? [];
return isset($subjects[$locale]) && !empty($subjects[$locale]);
}
/**
* Get recipients for a specific locale
*/
public function getRecipientsForLocale($locale)
{
return $this->getRecipientsQuery()
->where('lang_preference', $locale);
}
/**
* Get recipients grouped by available locales with fallback handling
*/
public function getRecipientsGroupedByAvailableLocales()
{
$availableLocales = $this->getAvailablePostLocales();
$allRecipients = $this->getRecipientsQuery()->get();
$recipientsByLocale = [];
foreach ($allRecipients as $recipient) {
$preferredLocale = $recipient->lang_preference ?? timebank_config('base_language', 'en');
// Check if preferred locale has content
if (in_array($preferredLocale, $availableLocales)) {
$recipientsByLocale[$preferredLocale][] = $recipient;
} else {
// Handle fallback logic
if (timebank_config('bulk_mail.use_fallback_locale', true)) {
$fallbackLocale = timebank_config('bulk_mail.fallback_locale', 'en');
if (in_array($fallbackLocale, $availableLocales)) {
$recipientsByLocale[$fallbackLocale][] = $recipient;
}
}
// If fallback is disabled or not available, recipient is skipped
}
}
return $recipientsByLocale;
}
/**
* Filter content blocks to only include posts with published translations in the given locale
*/
public function getContentBlocksForLocale($locale)
{
if (empty($this->content_blocks)) {
return [];
}
$now = now();
$filteredBlocks = [];
foreach ($this->content_blocks as $block) {
$post = Post::with('translations')->find($block['post_id']);
if (!$post) {
continue; // Skip if post doesn't exist
}
// Check if post has a published translation in the requested locale
$hasPublishedTranslation = $post->translations->contains(function ($translation) use ($locale, $now) {
return $translation->locale === $locale
&& $translation->status == 1
&& $translation->from <= $now
&& ($translation->till === null || $translation->till >= $now);
});
if ($hasPublishedTranslation) {
$filteredBlocks[] = $block;
}
}
return $filteredBlocks;
}
/**
* Get recipient count grouped by locale with content availability
*/
public function getRecipientCountsByLocale()
{
// Get all recipients using the same logic as getRecipientsQuery()
$recipients = $this->getRecipientsQuery()->get();
// Get base language
$baseLanguage = timebank_config('base_language', 'en');
// Group recipients by language preference (with NULL fallback to base language)
$recipientsByLocale = $recipients->groupBy(function ($recipient) use ($baseLanguage) {
return $recipient->lang_preference ?: $baseLanguage;
});
// Add content block information for each locale
$result = [];
foreach ($recipientsByLocale as $locale => $localeRecipients) {
$contentBlocks = $this->getContentBlocksForLocale($locale);
$result[$locale] = [
'count' => $localeRecipients->count(),
'content_blocks' => count($contentBlocks),
'has_content' => !empty($contentBlocks)
];
}
return $result;
}
/**
* Get formatted recipient counts display
*/
public function getFormattedRecipientCounts()
{
$counts = $this->getRecipientCountsByLocale();
$formatted = [];
foreach ($counts as $locale => $data) {
$language = \App\Models\Language::where('lang_code', $locale)->first();
$flag = $language ? $language->flag : '🏳️';
if ($data['has_content']) {
$formatted[] = "{$flag} {$data['count']}";
} else {
$formatted[] = "{$flag} {$data['count']} (no content)";
}
}
return implode(' ', $formatted);
}
/**
* Get total recipients that will actually receive content
*/
public function getEffectiveRecipientsCount()
{
$counts = $this->getRecipientCountsByLocale();
return collect($counts)
->where('has_content', true)
->sum('count');
}
/**
* Dispatch locale-specific email jobs for this mailing
*/
public function dispatchLocaleSpecificJobs()
{
Log::info("MAILING: Starting dispatch process for Mailing ID: {$this->id}");
// Get batch size from configuration
$batchSize = timebank_config('mailing.batch_size', 10);
// Get recipients grouped by available locales with fallback logic
$recipientsByLocale = $this->getRecipientsGroupedByAvailableLocales();
$totalJobsDispatched = 0;
foreach ($recipientsByLocale as $locale => $recipients) {
$contentBlocks = $this->getContentBlocksForLocale($locale);
if (!empty($contentBlocks) && !empty($recipients)) {
// Split recipients into batches
$recipientBatches = collect($recipients)->chunk($batchSize);
$batchCount = $recipientBatches->count();
Log::info("MAILING: Dispatching {$batchCount} batch job(s) for Mailing ID: {$this->id}, Locale: {$locale}, Total Recipients: " . count($recipients) . ", Batch Size: {$batchSize}");
// Dispatch a separate job for each batch
foreach ($recipientBatches as $batchIndex => $batch) {
$batchNumber = $batchIndex + 1;
Log::info("MAILING: Dispatching batch {$batchNumber}/{$batchCount} for Mailing ID: {$this->id}, Locale: {$locale}, Recipients in batch: " . $batch->count());
// Dispatch the actual job with batch of recipients to dedicated mailing queue
\App\Jobs\SendBulkMailJob::dispatch($this, $locale, [], $batch)
->onQueue('mailing');
$totalJobsDispatched++;
}
}
}
// Calculate actual recipients who will receive emails
$totalRecipients = $this->getRecipientsQuery()->count();
$processedRecipients = collect($recipientsByLocale)->sum(function($recipients) {
return count($recipients);
});
// Update recipients_count to reflect actual sendable recipients
$this->update(['recipients_count' => $processedRecipients]);
// Get suppressed email count for logging
$suppressedCount = MailingBounce::where('is_suppressed', true)->count();
Log::info("MAILING: Completed dispatch for Mailing ID: {$this->id} - Jobs dispatched: {$totalJobsDispatched}, Locales: " . count($recipientsByLocale) . ", Recipients processed: {$processedRecipients}, Suppressed emails: {$suppressedCount}");
// If no jobs were dispatched (no recipients or no content), mark as sent immediately
if (empty($recipientsByLocale) || $processedRecipients === 0) {
Log::warning("MAILING: No jobs dispatched for Mailing ID: {$this->id} - marking as sent immediately");
$this->update([
'status' => 'sent',
'sent_at' => now()
]);
}
}
}

View File

@@ -0,0 +1,254 @@
<?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';
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);
}
/**
* Check if an email address should be suppressed from sending
*/
public static function isSuppressed(string $email): bool
{
return self::where('email', $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' => $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
): self {
// Create the bounce record
$bounce = self::create([
'email' => $email,
'bounce_type' => $bounceType,
'bounce_reason' => $reason,
'mailing_id' => $mailingId,
'bounced_at' => now(),
'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
{
$config = timebank_config('mailing.bounce_thresholds', []);
$countedTypes = $config['counted_bounce_types'] ?? ['hard'];
$patterns = $config['definitive_hard_bounce_patterns'] ?? [];
$cutoffDate = now()->subDays($windowDays);
return self::where('email', $email)
->whereIn('bounce_type', $countedTypes)
->where('bounced_at', '>=', $cutoffDate)
->where(function ($query) use ($patterns) {
foreach ($patterns as $pattern) {
$query->orWhere('bounce_reason', 'like', "%{$pattern}%");
}
})
->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);
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
{
// Update the latest bounce record to mark as suppressed
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
{
$windowDays = timebank_config('bulk_mail.bounce_thresholds.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
app/Models/Meeting.php Normal file
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');
}
}

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
app/Models/News.php Normal file
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;
}

604
app/Models/Organization.php Normal file
View File

@@ -0,0 +1,604 @@
<?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',
'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',
'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 all of the organization's message settings.
*/
public function message_settings()
{
return $this->morphMany(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',
'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();
}
/**
* Get the message settings for this organization
*/
public function messageSettings()
{
return $this->morphMany(MessageSetting::class, 'message_settingable');
}
}

259
app/Models/Post.php Normal file
View File

@@ -0,0 +1,259 @@
<?php
namespace App\Models;
use App\Helpers\StringHelper;
use App\Models\Category;
use App\Models\Image;
use App\Models\Meeting;
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
use Reactable; // cybercog/laravel-love
protected $fillable = ['postable_id', 'postable_type', 'category_id', 'author_id', 'author_model'];
/**
* Get the index name for the model.
*
* @return string
*/
public function searchableAs()
{
return 'posts_index';
}
/**
* 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 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();
}
}

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
app/Models/Profile.php Normal file
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);
}
}

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()]);
}
}

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
app/Models/Social.php Normal file
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
app/Models/Tag.php Normal file
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;
}
}

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);
}
}

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');
}
}

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');
}
}

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 = [];
}

129
app/Models/Transaction.php Normal file
View File

@@ -0,0 +1,129 @@
<?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';
/**
* 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()
{
// Log the start of the method
// info('toSearchableArray method called for Transaction ID: ' . $this->id);
// Eager load relationships
$this->load(['accountFrom.accountable', 'accountTo.accountable']);
// Extract relationship data safely
$accountFrom = $this->accountFrom;
$accountTo = $this->accountTo;
$accountFromName = $accountFrom->name ?? '';
$accountToName = $accountTo->name ?? '';
$relationFromName = $accountFrom && $accountFrom->accountable ? $accountFrom->accountable->first()->name ?? '' : '';
$relationToName = $accountTo && $accountTo->accountable ? $accountTo->accountable->first()->name ?? '' : '';
// Log the loaded relationships for debugging
// info('Account From:', ['accountFrom' => $accountFrom]);
// info('Account To:', ['accountTo' => $accountTo]);
return [
'id' => $this->id,
'created_at' => $this->created_at,
'from_account_id' => $this->accountFrom,
'to_account_id' => $this->accountTo,
'amount' => $this->amount,
'account_from_name' => $accountFromName,
'account_to_name' => $accountToName,
'relation_from' => 'From ' . $relationFromName,
'relation_to' => 'To ' . $relationToName,
'description' => $this->description ?? '',
];
}
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);
}
}

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);
}
}

711
app/Models/User.php Normal file
View File

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