Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\User;
|
||||
use App\Notifications\NonUserPasswordResetNotification;
|
||||
use App\Notifications\VerifyProfileEmail;
|
||||
use App\Traits\ActiveStatesTrait;
|
||||
use App\Traits\HasPresence;
|
||||
use App\Traits\LocationTrait;
|
||||
use App\Traits\TaggableWithLocale;
|
||||
use Illuminate\Auth\MustVerifyEmail as AuthMustVerifyEmail;
|
||||
use Illuminate\Auth\Passwords\CanResetPassword;
|
||||
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Namu\WireChat\Traits\Chatable;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class Admin extends Authenticatable implements MustVerifyEmail, CanResetPasswordContract
|
||||
{
|
||||
use HasFactory;
|
||||
use AuthMustVerifyEmail;
|
||||
use Notifiable;
|
||||
use HasRoles;
|
||||
use LogsActivity; // Spatie Activity Log
|
||||
use HasProfilePhoto;
|
||||
use TaggableWithLocale;
|
||||
use LocationTrait;
|
||||
use CanResetPassword; // Trait
|
||||
use Chatable;
|
||||
use ActiveStatesTrait;
|
||||
use HasPresence;
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
* BEWARE: API's CAN POTENTIALLY EXPOSE ALL VISIBLE FIELDS
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $hidden = [
|
||||
'email',
|
||||
'email_verified_at',
|
||||
'full_name',
|
||||
'password',
|
||||
'remember_token',
|
||||
'phone',
|
||||
'cyclos_id',
|
||||
'cyclos_salt',
|
||||
'two_factor_confirmed_at',
|
||||
'two_factor_recovery_codes',
|
||||
'two_factor_secret',
|
||||
'limit_min',
|
||||
'limit_max',
|
||||
'lang_preference',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'last_login_at',
|
||||
'inactive_at',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'full_name',
|
||||
'email',
|
||||
'profile_photo_path',
|
||||
'about',
|
||||
'about_short',
|
||||
'motivation',
|
||||
'website',
|
||||
'phone',
|
||||
'phone_public',
|
||||
'password',
|
||||
'limit_min',
|
||||
'limit_max',
|
||||
'lang_preference',
|
||||
'last_login_at',
|
||||
'last_login_ip'
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'phone_public' => 'boolean',
|
||||
'email_verified_at' => 'datetime',
|
||||
'inactive_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Get the admin's user(s) that can manage admin profiles.
|
||||
* Many-to-many.
|
||||
*/
|
||||
public function users()
|
||||
{
|
||||
return $this->belongsToMany(User::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all related languages of the admin.
|
||||
* Many-to-many polymorphic.
|
||||
*/
|
||||
public function languages()
|
||||
{
|
||||
return $this->morphToMany(Language::class, 'languagable')->withPivot('competence');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all related the locations of the admin.
|
||||
* One-to-many polymorph.
|
||||
*/
|
||||
public function locations()
|
||||
{
|
||||
return $this->morphMany(Location::class, 'locatable');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all related accounts of the user.
|
||||
* One-to-many polymorphic.
|
||||
* NOTE: At the moment admins do not use accounts, but to keep methods consistent
|
||||
* with the other profile models, we keep this method.
|
||||
* Removing this method will break generic methods that will are used on all profile models!
|
||||
*/
|
||||
public function accounts()
|
||||
{
|
||||
return $this->morphMany(Account::class, 'accountable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the admin's message settings.
|
||||
*/
|
||||
public function message_settings()
|
||||
{
|
||||
return $this->morphOne(MessageSetting::class, 'message_settingable');
|
||||
}
|
||||
|
||||
|
||||
public function sendEmailVerificationNotification()
|
||||
{
|
||||
\Mail::to($this->email)->send(new \App\Mail\VerifyProfileEmailMailable($this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the password reset notification.
|
||||
*
|
||||
* @param string $token
|
||||
* @return void
|
||||
*/
|
||||
public function sendPasswordResetNotification($token)
|
||||
{
|
||||
// Construct the full reset URL using the named route
|
||||
// The route 'non-user.password.reset' expects 'profileType' and 'token' parameters.
|
||||
// It's also good practice to include the email in the query string for the reset form.
|
||||
$resetUrl = route('non-user.password.reset', [
|
||||
'profileType' => 'admin',
|
||||
'token' => $token,
|
||||
'email' => $this->getEmailForPasswordReset(),
|
||||
]);
|
||||
|
||||
// Pass the fully constructed URL to your notification
|
||||
$this->notify(new NonUserPasswordResetNotification($resetUrl, $this));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Configure the activity log options for the User model.
|
||||
*
|
||||
* This method sets up the logging to:
|
||||
* - Only log changes to the 'name', 'password', and 'last_login_ip' attributes.
|
||||
* - Log only when these attributes have been modified (dirty).
|
||||
* - Prevent submission of empty logs.
|
||||
* - Use 'user' as the log name.
|
||||
*
|
||||
* @return \Spatie\Activitylog\LogOptions
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly([
|
||||
'name',
|
||||
'last_login_ip',
|
||||
'inactive_at',
|
||||
'deleted_at',
|
||||
])
|
||||
->logOnlyDirty() // Only log attributes that have been changed
|
||||
->dontSubmitEmptyLogs()
|
||||
->useLogName('Admin');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Returns the URL for the user's cover image (avatar).
|
||||
* Adjust the 'avatar_url' field to your database setup.
|
||||
*/
|
||||
public function getCoverUrlAttribute(): ?string
|
||||
{
|
||||
return Storage::url($this->profile_photo_path) ?? null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat:Returns the URL for the user's profile page.
|
||||
* Adjust the 'profile' route as needed for your setup.
|
||||
*/
|
||||
public function getProfileUrlAttribute(): ?string
|
||||
{
|
||||
return route('profile.show_by_type_and_id', ['type' => __('admin'), 'id' => $this->id]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Returns the display name for the user.
|
||||
* Modify this to use your preferred name field.
|
||||
*/
|
||||
public function getDisplayNameAttribute(): ?string
|
||||
{
|
||||
if ($this->full_name !== $this->name) {
|
||||
return $this->full_name . ' (' . $this->name . ')';
|
||||
} elseif ($this->name) {
|
||||
return $this->name;
|
||||
} else {
|
||||
return __('Admin');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Search for users when creating a new chat or adding members to a group.
|
||||
* Customize the search logic to limit results, such as restricting to friends or eligible users only.
|
||||
*/
|
||||
public function searchChatables(string $query): ?\Illuminate\Support\Collection
|
||||
{
|
||||
$searchableFields = ['name', 'full_name'];
|
||||
|
||||
// Search across all profile types
|
||||
$users = \App\Models\User::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
$organizations = \App\Models\Organization::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
$banks = \App\Models\Bank::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
$admins = Admin::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
// Combine all results into a base Collection to avoid serialization issues
|
||||
$results = collect()
|
||||
->merge($users->all())
|
||||
->merge($organizations->all())
|
||||
->merge($banks->all())
|
||||
->merge($admins->all());
|
||||
|
||||
// Filter out profiles based on configuration
|
||||
return $results->filter(function ($profile) {
|
||||
// Check inactive profiles
|
||||
if (timebank_config('profile_inactive.messenger_hidden') && !$profile->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check email unverified profiles
|
||||
if (timebank_config('profile_email_unverified.messenger_hidden') && !$profile->isEmailVerified()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check incomplete profiles
|
||||
if (
|
||||
timebank_config('profile_incomplete.messenger_hidden')
|
||||
&& method_exists($profile, 'hasIncompleteProfile')
|
||||
&& $profile->hasIncompleteProfile($profile)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})->take(6)->values();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Determine if the user can create new groups.
|
||||
*/
|
||||
public function canCreateGroups(): bool
|
||||
{
|
||||
return timebank_config('profiles.bank.messenger_can_create_groups');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class AdminRole extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'label', 'permissions'];
|
||||
|
||||
protected $casts = [
|
||||
'permissions' => 'array',
|
||||
'is_system' => 'boolean',
|
||||
];
|
||||
|
||||
public function getPermissionsListAttribute(): array
|
||||
{
|
||||
return $this->permissions ?? [];
|
||||
}
|
||||
|
||||
public function scopeNotSystem($query)
|
||||
{
|
||||
return $query->where('is_system', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The role a user linked to a new admin profile gets: the platform setting
|
||||
* admin_settings.default_admin_role, 'administrator' when it is not set.
|
||||
*/
|
||||
public static function defaultName(): string
|
||||
{
|
||||
return (string) (timebank_config('admin_settings.default_admin_role') ?: 'administrator');
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a user this role on one admin profile: the scoped Spatie role Admin\{adminId}\{name}, created
|
||||
* when missing and synced with this definition's permissions.
|
||||
*/
|
||||
public function assignScopedTo(User $user, int $adminId): Role
|
||||
{
|
||||
$role = Role::findOrCreate("Admin\\{$adminId}\\{$this->name}", 'web');
|
||||
$role->syncPermissions($this->permissions ?? []);
|
||||
|
||||
if (!$user->hasRole($role)) {
|
||||
$user->assignRole($role);
|
||||
}
|
||||
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Helpers\StringHelper;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\User;
|
||||
use App\Notifications\NonUserPasswordResetNotification;
|
||||
use App\Notifications\VerifyProfileEmail;
|
||||
use App\Traits\ActiveStatesTrait;
|
||||
use App\Traits\HasPresence;
|
||||
use App\Traits\LocationTrait;
|
||||
use App\Traits\ProfileTrait;
|
||||
use App\Traits\TaggableWithLocale;
|
||||
use Cog\Contracts\Love\Reactable\Models\Reactable as ReactableInterface;
|
||||
use Cog\Contracts\Love\Reacterable\Models\Reacterable as ReacterableInterface;
|
||||
use Cog\Laravel\Love\Reactable\Models\Traits\Reactable;
|
||||
use Cog\Laravel\Love\Reacterable\Models\Traits\Reacterable;
|
||||
use Illuminate\Auth\MustVerifyEmail as AuthMustVerifyEmail;
|
||||
use Illuminate\Auth\Passwords\CanResetPassword;
|
||||
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Namu\WireChat\Traits\Chatable;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class Bank extends Authenticatable implements ReacterableInterface, ReactableInterface, MustVerifyEmail, CanResetPasswordContract
|
||||
{
|
||||
use HasFactory;
|
||||
use HasProfilePhoto;
|
||||
use AuthMustVerifyEmail;
|
||||
use Notifiable;
|
||||
use HasRoles;
|
||||
use LogsActivity; // Spatie Activity Log
|
||||
use TaggableWithLocale;
|
||||
use Reacterable; // cybercog/laravel-love
|
||||
use Reactable; // cybercog/laravel-love
|
||||
use Searchable; // laravel/scout with ElasticSearch
|
||||
use LocationTrait;
|
||||
use CanResetPassword; // Trait
|
||||
use Chatable;
|
||||
use ActiveStatesTrait;
|
||||
use HasPresence;
|
||||
use ProfileTrait;
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
* BEWARE: API'S CAN POTENTIALLY EXPOSE ALL VISIBLE FIELDS
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $hidden = [
|
||||
'email',
|
||||
'email_verified_at',
|
||||
'full_name',
|
||||
'password',
|
||||
'remember_token',
|
||||
'phone',
|
||||
'cyclos_id',
|
||||
'cyclos_salt',
|
||||
'two_factor_confirmed_at',
|
||||
'two_factor_recovery_codes',
|
||||
'two_factor_secret',
|
||||
'limit_min',
|
||||
'limit_max',
|
||||
'lang_preference',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'last_login_at',
|
||||
'inactive_at',
|
||||
'incomplete_at',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'full_name',
|
||||
'email',
|
||||
'profile_photo_path',
|
||||
'about',
|
||||
'about_short',
|
||||
'motivation',
|
||||
'website',
|
||||
'phone',
|
||||
'phone_public',
|
||||
'password',
|
||||
'level',
|
||||
'limit_min',
|
||||
'limit_max',
|
||||
'lang_preference',
|
||||
'last_login_at',
|
||||
'last_login_ip'
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'phone_public' => 'boolean',
|
||||
'date_of_birth' => 'date',
|
||||
'email_verified_at' => 'datetime',
|
||||
'inactive_at' => 'datetime',
|
||||
'incomplete_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $guard_name = 'web';
|
||||
|
||||
/**
|
||||
* Get the index name for the model.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function searchableAs()
|
||||
{
|
||||
return 'banks_index';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convert this model to a searchable array.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function toSearchableArray()
|
||||
{
|
||||
// Prepare eager loaded relationships
|
||||
$this->load(
|
||||
'languages',
|
||||
'locations.district.translations',
|
||||
'locations.city.translations',
|
||||
'locations.division.translations',
|
||||
'locations.country.translations',
|
||||
'tags.contexts.tags',
|
||||
'tags.contexts.tags.locale',
|
||||
'tags.contexts.category.ancestorsAndSelf',
|
||||
);
|
||||
|
||||
return [
|
||||
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
|
||||
//TODO: Update to multilang database structure in future
|
||||
'about_nl' => $this->about,
|
||||
'about_en' => $this->about,
|
||||
'about_fr' => $this->about,
|
||||
'about_de' => $this->about,
|
||||
'about_es' => $this->about,
|
||||
'about_short_nl' => $this->about_short,
|
||||
'about_short_en' => $this->about_short,
|
||||
'about_short_fr' => $this->about_short,
|
||||
'about_short_de' => $this->about_short,
|
||||
'about_short_es' => $this->about_short,
|
||||
'motivation_nl' => $this->motivation,
|
||||
'motivation_en' => $this->motivation,
|
||||
'motivation_fr' => $this->motivation,
|
||||
'motivation_de' => $this->motivation,
|
||||
'motivation_es' => $this->motivation,
|
||||
'website' => $this->website,
|
||||
|
||||
'last_login_at' => $this->last_login_at,
|
||||
'lang_preference' => $this->lang_preference,
|
||||
|
||||
'languages' => $this->languages->map(function ($language) { // map() as languages is a collection
|
||||
return [
|
||||
'id' => $language->id,
|
||||
'name' => $language->name,
|
||||
'lang_code' => $language->lang_code,
|
||||
];
|
||||
}),
|
||||
|
||||
'locations' => $this->locations->map(function ($location) { // map() as locations is a collection
|
||||
return [
|
||||
'id' => $location->id,
|
||||
'district' => $location->district ? $location->district->translations->map(function ($translation) { // map() as translations is a collection
|
||||
return $translation->name;
|
||||
})->toArray() : [],
|
||||
'city' => $location->city ? $location->city->translations->map(function ($translation) { // map() as translations is a collection
|
||||
return $translation->name;
|
||||
})->toArray() : [],
|
||||
'division' => $location->division ? $location->division->translations->map(function ($translation) { // map() as translations is a collection
|
||||
return $translation->name;
|
||||
})->toArray() : [],
|
||||
'country' => $location->country ? $location->country->translations->map(function ($translation) { // map() as translations is a collection
|
||||
return $translation->name;
|
||||
})->toArray() : [],
|
||||
];
|
||||
}),
|
||||
|
||||
'tags' => $this->tags->map(function ($tag) {
|
||||
return [
|
||||
'contexts' => $tag->contexts
|
||||
->map(function ($context) {
|
||||
return [
|
||||
'tags' => $context->tags->map(function ($tag) {
|
||||
// Include the locale in the field name for tags
|
||||
return [
|
||||
'name_' . $tag->locale->locale => StringHelper::DutchTitleCase($tag->normalized),
|
||||
];
|
||||
}),
|
||||
'categories' => Category::with(['translations' => function ($query) { $query->select('category_id', 'locale', 'name');}])
|
||||
->find([ $context->category->ancestorsAndSelf()->get()->flatMap(function ($related) {
|
||||
$categoryPath = explode('.', $related->path);
|
||||
return $categoryPath;
|
||||
})
|
||||
->unique()->values()->toArray()
|
||||
])->map(function ($category) {
|
||||
// Include the locale in the field name for categories
|
||||
return $category->translations->mapWithKeys(function ($translation) {
|
||||
return ['name_' . $translation->locale => StringHelper::DutchTitleCase($translation->name)];
|
||||
});
|
||||
}),
|
||||
];
|
||||
}),
|
||||
];
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bank's related admin's.
|
||||
* Many-to-many.
|
||||
*/
|
||||
public function admins()
|
||||
{
|
||||
return $this->belongsToMany(Admin::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all of the bank's accounts.
|
||||
* One-to-many polymorphic.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function accounts()
|
||||
{
|
||||
return $this->morphMany(Account::class, 'accountable');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the bank's manager(s) that can manage bank profiles.
|
||||
* Many-to-many.
|
||||
*/
|
||||
public function managers()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'bank_user', 'bank_id', 'user_id');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve all related bank clients.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function clients()
|
||||
{
|
||||
return $this->userClients->merge($this->organizationClients);
|
||||
}
|
||||
|
||||
|
||||
public function userClients()
|
||||
{
|
||||
return $this->morphedByMany(User::class, 'client', 'bank_clients')
|
||||
->withPivot('relationship_type');
|
||||
}
|
||||
|
||||
|
||||
public function organizationClients()
|
||||
{
|
||||
return $this->morphedByMany(Organization::class, 'client', 'bank_clients')
|
||||
->withPivot('relationship_type');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all related the locations of the bank.
|
||||
* One-to-many polymorph.
|
||||
*/
|
||||
public function locations()
|
||||
{
|
||||
return $this->morphMany(Location::class, 'locatable');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all of the languages for the organization.
|
||||
* Many-to-many polymorphic.
|
||||
*/
|
||||
public function languages()
|
||||
{
|
||||
return $this->morphToMany(Language::class, 'languagable')->withPivot('competence');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all of the social for the organization.
|
||||
* Many-to-many polymorphic.
|
||||
*/
|
||||
public function socials()
|
||||
{
|
||||
return $this->morphToMany(Social::class, 'sociable')->withPivot('id', 'user_on_social', 'server_of_social');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the bank's message settings.
|
||||
*/
|
||||
public function message_settings()
|
||||
{
|
||||
return $this->morphOne(MessageSetting::class, 'message_settingable');
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function sendEmailVerificationNotification()
|
||||
{
|
||||
\Mail::to($this->email)->send(new \App\Mail\VerifyProfileEmailMailable($this));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send the password reset notification.
|
||||
*
|
||||
* @param string $token
|
||||
* @return void
|
||||
*/
|
||||
public function sendPasswordResetNotification($token)
|
||||
{
|
||||
// Construct the full reset URL using the named route
|
||||
// The route 'non-user.password.reset' expects 'profileType' and 'token' parameters.
|
||||
$resetUrl = route('non-user.password.reset', [
|
||||
'profileType' => 'bank', // Hardcode 'admin' for the Admin model
|
||||
'token' => $token,
|
||||
'email' => $this->getEmailForPasswordReset(), // Method from CanResetPassword trait
|
||||
]);
|
||||
|
||||
// Pass the fully constructed URL to your notification
|
||||
$this->notify(new NonUserPasswordResetNotification($resetUrl, $this));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Configure the activity log options for the User model.
|
||||
*
|
||||
* This method sets up the logging to:
|
||||
* - Only log changes to the 'name', 'password', and 'last_login_ip' attributes.
|
||||
* - Log only when these attributes have been modified (dirty).
|
||||
* - Prevent submission of empty logs.
|
||||
* - Use 'user' as the log name.
|
||||
*
|
||||
* @return \Spatie\Activitylog\LogOptions
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly([
|
||||
'name',
|
||||
'last_login_ip',
|
||||
'inactive_at',
|
||||
'incomplete_at',
|
||||
'deleted_at',
|
||||
])
|
||||
->logOnlyDirty() // Only log attributes that have been changed
|
||||
->dontSubmitEmptyLogs()
|
||||
->useLogName('Bank');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wirechat: Returns the URL for the user's cover image (avatar).
|
||||
* Adjust the 'avatar_url' field to your database setup.
|
||||
*/
|
||||
public function getCoverUrlAttribute(): ?string
|
||||
{
|
||||
return Storage::url($this->profile_photo_path) ?? null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat:Returns the URL for the user's profile page.
|
||||
* Adjust the 'profile' route as needed for your setup.
|
||||
*/
|
||||
public function getProfileUrlAttribute(): ?string
|
||||
{
|
||||
return route('profile.show_by_type_and_id', ['type' => __('bank'), 'id' => $this->id]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Returns the display name for the user.
|
||||
* Modify this to use your preferred name field.
|
||||
*/
|
||||
public function getDisplayNameAttribute(): ?string
|
||||
{
|
||||
if ($this->full_name !== $this->name) {
|
||||
return $this->full_name . ' (' . $this->name . ')';
|
||||
} elseif ($this->name) {
|
||||
return $this->name;
|
||||
} else {
|
||||
return __('Bank');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Search for users when creating a new chat or adding members to a group.
|
||||
* Customize the search logic to limit results, such as restricting to friends or eligible users only.
|
||||
*/
|
||||
public function searchChatables(string $query): ?\Illuminate\Support\Collection
|
||||
{
|
||||
$searchableFields = ['name', 'full_name'];
|
||||
|
||||
// Search across all profile types
|
||||
$users = \App\Models\User::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
$organizations = \App\Models\Organization::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
$banks = Bank::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
$admins = \App\Models\Admin::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
// Combine all results into a base Collection to avoid serialization issues
|
||||
$results = collect()
|
||||
->merge($users->all())
|
||||
->merge($organizations->all())
|
||||
->merge($banks->all())
|
||||
->merge($admins->all());
|
||||
|
||||
// Filter out profiles based on configuration
|
||||
return $results->filter(function ($profile) {
|
||||
// Check inactive profiles
|
||||
if (timebank_config('profile_inactive.messenger_hidden') && !$profile->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check email unverified profiles
|
||||
if (timebank_config('profile_email_unverified.messenger_hidden') && !$profile->isEmailVerified()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check incomplete profiles
|
||||
if (
|
||||
timebank_config('profile_incomplete.messenger_hidden')
|
||||
&& method_exists($profile, 'hasIncompleteProfile')
|
||||
&& $profile->hasIncompleteProfile($profile)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide banks by level for the current viewer type
|
||||
if ($profile instanceof Bank && !canViewerSeeBankLevel($profile->level, 'messenger_visible')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})->take(6)->values();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Determine if the user can create new groups.
|
||||
*/
|
||||
public function canCreateGroups(): bool
|
||||
{
|
||||
return timebank_config('profiles.bank.messenger_can_create_groups');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the Bank has any transactions with another model (User, Organization, Bank).
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Model $model
|
||||
* @return bool
|
||||
*/
|
||||
public function hasTransactionsWith($model): bool
|
||||
{
|
||||
$modelClass = get_class($model);
|
||||
$modelId = $model->id;
|
||||
|
||||
// Check if this Bank is involved in any transaction where the other model is either the sender or receiver
|
||||
return Transaction::where(function ($query) use ($modelClass, $modelId) {
|
||||
$query->whereHas('accountFrom.accountable', function ($q) use ($modelClass, $modelId) {
|
||||
$q->where('accountable_type', $modelClass)
|
||||
->where('accountable_id', $modelId);
|
||||
})
|
||||
->orWhereHas('accountTo.accountable', function ($q) use ($modelClass, $modelId) {
|
||||
$q->where('accountable_type', $modelClass)
|
||||
->where('accountable_id', $modelId);
|
||||
});
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereHas('accountFrom.accountable', function ($q) {
|
||||
$q->where('accountable_type', Bank::class)
|
||||
->where('accountable_id', $this->id);
|
||||
})
|
||||
->orWhereHas('accountTo.accountable', function ($q) {
|
||||
$q->where('accountable_type', Bank::class)
|
||||
->where('accountable_id', $this->id);
|
||||
});
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Locations;
|
||||
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\District;
|
||||
use App\Models\Locations\Division;
|
||||
use App\Models\Meeting;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Location extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'country_id', 'division_id', 'city_id', 'district_id', 'locatable_id', 'locatable_type'];
|
||||
|
||||
/**
|
||||
* Return related country.
|
||||
* one-to-many
|
||||
* @return void
|
||||
*/
|
||||
public function country()
|
||||
{
|
||||
return $this->belongsTo(Country::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most appropriate country for this location.
|
||||
* Tries multiple sources in order of preference.
|
||||
* If resolved it syncs the location's missing country_id.
|
||||
*
|
||||
* @return Country|null
|
||||
*/
|
||||
public function getCountry()
|
||||
{
|
||||
// 1. Direct country relationship
|
||||
if ($this->country_id && $this->country) {
|
||||
return $this->country;
|
||||
}
|
||||
|
||||
// 2. Country from division
|
||||
if ($this->division_id && $this->division && $this->division->country_id) {
|
||||
$this->syncCountryFromDivision();
|
||||
return $this->fresh()->country;
|
||||
}
|
||||
|
||||
// 3. Country from city
|
||||
if ($this->city_id && $this->city && $this->city->country_id) {
|
||||
$this->syncCountryFromCity();
|
||||
return $this->fresh()->country;
|
||||
}
|
||||
|
||||
// 4. Country from district's city (if district exists)
|
||||
if ($this->district_id && $this->district && $this->district->city_id) {
|
||||
// First ensure we have city synced
|
||||
if (!$this->city_id) {
|
||||
$this->syncCityFromDistrict();
|
||||
}
|
||||
|
||||
// Now try to get country from city
|
||||
if ($this->city && $this->city->country_id) {
|
||||
$this->syncCountryFromCity();
|
||||
return $this->fresh()->country;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the location's country_id based on the division's country.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function syncCountryFromDivision()
|
||||
{
|
||||
if ($this->division && $this->division->country_id) {
|
||||
$this->country_id = $this->division->country_id;
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the location's country_id based on the city's country.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function syncCountryFromCity()
|
||||
{
|
||||
if ($this->city && $this->city->country_id) {
|
||||
$this->country_id = $this->city->country_id;
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return all related divisions.
|
||||
* One-to-many
|
||||
* @return void
|
||||
*/
|
||||
public function division()
|
||||
{
|
||||
return $this->belongsTo(Division::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most appropriate division for this location.
|
||||
* Tries multiple sources in order of preference.
|
||||
* If resolved it syncs the location's missing division_id.
|
||||
*
|
||||
* @return Division|null
|
||||
*/
|
||||
public function getDivision()
|
||||
{
|
||||
// 1. Direct division relationship
|
||||
if ($this->division_id && $this->division) {
|
||||
return $this->division;
|
||||
}
|
||||
|
||||
// 2. Division from city
|
||||
if ($this->city_id && $this->city && $this->city->division_id) {
|
||||
$this->syncDivisionFromCity();
|
||||
return $this->fresh()->division; // Fresh instance to get updated division_id
|
||||
}
|
||||
|
||||
// 3. Division from district's city (if district exists)
|
||||
if ($this->district_id && $this->district && $this->district->city_id) {
|
||||
// First sync city from district if city_id is missing
|
||||
if (!$this->city_id) {
|
||||
$this->syncCityFromDistrict();
|
||||
}
|
||||
|
||||
// Now try to get division from city
|
||||
if ($this->city && $this->city->division_id) {
|
||||
$this->syncDivisionFromCity();
|
||||
return $this->fresh()->division;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the location's country_id based on the division's country.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function syncCountryFromDistrict()
|
||||
{
|
||||
if ($this->division && $this->division->country_id) {
|
||||
$this->country_id = $this->division->country_id;
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the location's division_id based on the city's division.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function syncDivisionFromCity()
|
||||
{
|
||||
if ($this->city && $this->city->division_id) {
|
||||
$this->division_id = $this->city->division_id;
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the location's division_id based on the city's division.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function syncCityFromDistrict()
|
||||
{
|
||||
if ($this->district && $this->district->city_id) {
|
||||
$this->city_id = $this->district->city_id;
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return related city.
|
||||
* One-to-many
|
||||
* @return void
|
||||
*/
|
||||
public function city()
|
||||
{
|
||||
return $this->belongsTo(City::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return related district.
|
||||
* One-to-many
|
||||
* @return void
|
||||
*/
|
||||
public function district()
|
||||
{
|
||||
return $this->belongsTo(District::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sync all missing location hierarchy data.
|
||||
* This method will populate all missing IDs based on available relationships.
|
||||
*
|
||||
* @return array Returns what was synced
|
||||
*/
|
||||
public function syncAllLocationData()
|
||||
{
|
||||
$synced = [];
|
||||
|
||||
// Step 1: Sync city from district if missing
|
||||
if (!$this->city_id && $this->district_id) {
|
||||
if ($this->syncCityFromDistrict()) {
|
||||
$synced[] = 'city_from_district';
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Sync division from city if missing
|
||||
if (!$this->division_id && $this->city_id) {
|
||||
if ($this->syncDivisionFromCity()) {
|
||||
$synced[] = 'division_from_city';
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Sync country from division if missing
|
||||
if (!$this->country_id && $this->division_id) {
|
||||
if ($this->syncCountryFromDivision()) {
|
||||
$synced[] = 'country_from_division';
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Fallback - sync country from city if still missing
|
||||
if (!$this->country_id && $this->city_id) {
|
||||
if ($this->syncCountryFromCity()) {
|
||||
$synced[] = 'country_from_city';
|
||||
}
|
||||
}
|
||||
|
||||
return $synced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a complete location hierarchy.
|
||||
* This method ensures all data is synced and returns the complete hierarchy.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getCompleteHierarchy()
|
||||
{
|
||||
// Sync all data first
|
||||
$this->syncAllLocationData();
|
||||
|
||||
// Refresh the model to get updated relationships
|
||||
$location = $this->fresh();
|
||||
|
||||
return [
|
||||
'country' => $location->country,
|
||||
'division' => $location->getDivision(),
|
||||
'city' => $location->city,
|
||||
'district' => $location->district,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return related locatable (i.e. user or organization).
|
||||
* One-to-many polymorph
|
||||
* @return void
|
||||
*/
|
||||
public function locatable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return related meeting.
|
||||
* one-to-many
|
||||
* @return void
|
||||
*/
|
||||
public function meeting()
|
||||
{
|
||||
return $this->hasOne(Meeting::class);
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MailingBounce extends Model
|
||||
{
|
||||
protected $table = 'mailing_bounces';
|
||||
|
||||
/**
|
||||
* Bounce reasons that say the address itself does not exist. These count
|
||||
* even with a 5.7.x code (Tutanota: "550 5.7.1 ... Recipient not found").
|
||||
* Used when the platform config has no 'address_bounce_patterns'.
|
||||
*/
|
||||
public const ADDRESS_BOUNCE_PATTERNS = [
|
||||
'user unknown',
|
||||
'unknown user',
|
||||
'no such user',
|
||||
'recipient not found',
|
||||
'recipient unknown',
|
||||
'mailbox not found',
|
||||
'does not exist',
|
||||
'5.1.1',
|
||||
'5.1.2',
|
||||
'5.1.3',
|
||||
];
|
||||
|
||||
/**
|
||||
* Bounce reasons that reject our server rather than the address.
|
||||
* Used when the platform config has no 'not_definitive_bounce_patterns'.
|
||||
*/
|
||||
public const NOT_DEFINITIVE_BOUNCE_PATTERNS = [
|
||||
'5.7.',
|
||||
'block list',
|
||||
'blocklist',
|
||||
'blacklist',
|
||||
'spamhaus',
|
||||
'spam',
|
||||
'policy',
|
||||
'reputation',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'email',
|
||||
'bounce_type',
|
||||
'bounce_reason',
|
||||
'mailing_id',
|
||||
'bounced_at',
|
||||
'is_suppressed',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'bounced_at' => 'datetime',
|
||||
'is_suppressed' => 'boolean',
|
||||
];
|
||||
|
||||
public function mailing(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Mailing::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise an email address: lowercase + trim whitespace.
|
||||
* Applied consistently before every DB read or write.
|
||||
*/
|
||||
private static function normalise(string $email): string
|
||||
{
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an email address should be suppressed from sending
|
||||
*/
|
||||
public static function isSuppressed(string $email): bool
|
||||
{
|
||||
return self::where('email', self::normalise($email))
|
||||
->where('is_suppressed', true)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress an email address from future mailings
|
||||
*/
|
||||
public static function suppressEmail(string $email, string $reason = null): void
|
||||
{
|
||||
self::updateOrCreate(
|
||||
['email' => self::normalise($email)],
|
||||
[
|
||||
'bounce_reason' => $reason ?? 'Email suppressed',
|
||||
'bounced_at' => now(),
|
||||
'is_suppressed' => true,
|
||||
'bounce_type' => 'suppressed',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a bounce for an email with threshold-based actions
|
||||
*/
|
||||
public static function recordBounce(
|
||||
string $email,
|
||||
string $bounceType = 'hard',
|
||||
string $reason = null,
|
||||
int $mailingId = null,
|
||||
?\DateTimeInterface $bouncedAt = null
|
||||
): self {
|
||||
$email = self::normalise($email);
|
||||
|
||||
// When the bounce happened, not when it was processed: a backlog of
|
||||
// old bounces must not all fall inside the counting window at once.
|
||||
$values = [
|
||||
'bounce_type' => $bounceType,
|
||||
'bounce_reason' => $reason,
|
||||
'bounced_at' => $bouncedAt ?? now(),
|
||||
];
|
||||
|
||||
// One row per address and mailing (unique index): a second report for
|
||||
// the same mailing - a retry, or a soft bounce turning hard - updates
|
||||
// that row. Inserting it failed and stopped every bounce run for weeks.
|
||||
$bounce = $mailingId !== null
|
||||
? self::updateOrCreate(['email' => $email, 'mailing_id' => $mailingId], $values)
|
||||
: self::create($values + [
|
||||
'email' => $email,
|
||||
'mailing_id' => null,
|
||||
'is_suppressed' => false, // Don't auto-suppress, use threshold-based logic
|
||||
]);
|
||||
|
||||
// Check if this bounce should trigger threshold-based actions
|
||||
if (self::isDefinitiveHardBounce($bounceType, $reason)) {
|
||||
self::checkAndApplyThresholds($email, $reason);
|
||||
}
|
||||
|
||||
return $bounce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check bounce counts and apply threshold-based actions
|
||||
*/
|
||||
protected static function checkAndApplyThresholds(string $email, string $reason): void
|
||||
{
|
||||
$config = timebank_config('mailing.bounce_thresholds', []);
|
||||
$suppressionThreshold = $config['suppression_threshold'] ?? 3;
|
||||
$verificationResetThreshold = $config['verification_reset_threshold'] ?? 2;
|
||||
$windowDays = $config['counting_window_days'] ?? 30;
|
||||
|
||||
// Count recent definitive hard bounces for this email
|
||||
$recentBounceCount = self::countRecentDefinitiveBounces($email, $windowDays);
|
||||
|
||||
Log::info("Email {$email} has {$recentBounceCount} recent hard bounces (suppression: {$suppressionThreshold}, verification reset: {$verificationResetThreshold})");
|
||||
|
||||
// Check verification reset threshold first (lower threshold)
|
||||
if ($recentBounceCount >= $verificationResetThreshold && !self::hasVerificationBeenReset($email)) {
|
||||
self::resetEmailVerificationForAddress($email, $reason);
|
||||
self::markVerificationReset($email);
|
||||
}
|
||||
|
||||
// Check suppression threshold
|
||||
if ($recentBounceCount >= $suppressionThreshold) {
|
||||
self::suppressEmailByThreshold($email, $reason, $recentBounceCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count recent definitive hard bounces for an email address
|
||||
*/
|
||||
protected static function countRecentDefinitiveBounces(string $email, int $windowDays): int
|
||||
{
|
||||
$email = self::normalise($email);
|
||||
$config = timebank_config('mailing.bounce_thresholds', []);
|
||||
$countedTypes = $config['counted_bounce_types'] ?? ['hard'];
|
||||
|
||||
$cutoffDate = now()->subDays($windowDays);
|
||||
|
||||
// Matched in PHP so the same rules apply as in isDefinitiveHardBounce(),
|
||||
// including the exclusion of blocks against our own server.
|
||||
return self::where('email', $email)
|
||||
->whereIn('bounce_type', $countedTypes)
|
||||
->where('bounced_at', '>=', $cutoffDate)
|
||||
->get(['bounce_type', 'bounce_reason'])
|
||||
->filter(fn ($bounce) => self::isDefinitiveHardBounce($bounce->bounce_type, $bounce->bounce_reason))
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if bounce qualifies as a definitive hard bounce
|
||||
*/
|
||||
protected static function isDefinitiveHardBounce(string $bounceType, ?string $reason): bool
|
||||
{
|
||||
$config = timebank_config('mailing.bounce_thresholds', []);
|
||||
$countedTypes = $config['counted_bounce_types'] ?? ['hard'];
|
||||
$patterns = $config['definitive_hard_bounce_patterns'] ?? [];
|
||||
|
||||
if (!in_array($bounceType, $countedTypes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($reason) || empty($patterns)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reasonLower = strtolower($reason);
|
||||
|
||||
$addressGone = $config['address_bounce_patterns'] ?? self::ADDRESS_BOUNCE_PATTERNS;
|
||||
foreach ($addressGone as $pattern) {
|
||||
if (strpos($reasonLower, strtolower($pattern)) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// A block against our sending server (IP on a block list, spam or
|
||||
// policy rejection, 5.7.x) says nothing about the recipient's address,
|
||||
// even though it comes with a 550 code.
|
||||
$notDefinitive = $config['not_definitive_bounce_patterns'] ?? self::NOT_DEFINITIVE_BOUNCE_PATTERNS;
|
||||
foreach ($notDefinitive as $pattern) {
|
||||
if (strpos($reasonLower, strtolower($pattern)) !== false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (strpos($reasonLower, strtolower($pattern)) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress email due to threshold being reached
|
||||
*/
|
||||
protected static function suppressEmailByThreshold(string $email, string $reason, int $bounceCount): void
|
||||
{
|
||||
$email = self::normalise($email);
|
||||
self::where('email', $email)
|
||||
->where('is_suppressed', false)
|
||||
->update(['is_suppressed' => true]);
|
||||
|
||||
Log::warning("Email {$email} suppressed due to {$bounceCount} hard bounces. Latest reason: {$reason}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset email verification for all profile types using this email
|
||||
*/
|
||||
protected static function resetEmailVerificationForAddress(string $email, string $reason): void
|
||||
{
|
||||
$updatedProfiles = [];
|
||||
|
||||
// Reset verification for all profile types
|
||||
$userUpdated = User::where('email', $email)->whereNotNull('email_verified_at')->update(['email_verified_at' => null]);
|
||||
if ($userUpdated) $updatedProfiles[] = "Users: {$userUpdated}";
|
||||
|
||||
$orgUpdated = Organization::where('email', $email)->whereNotNull('email_verified_at')->update(['email_verified_at' => null]);
|
||||
if ($orgUpdated) $updatedProfiles[] = "Organizations: {$orgUpdated}";
|
||||
|
||||
$bankUpdated = Bank::where('email', $email)->whereNotNull('email_verified_at')->update(['email_verified_at' => null]);
|
||||
if ($bankUpdated) $updatedProfiles[] = "Banks: {$bankUpdated}";
|
||||
|
||||
$adminUpdated = Admin::where('email', $email)->whereNotNull('email_verified_at')->update(['email_verified_at' => null]);
|
||||
if ($adminUpdated) $updatedProfiles[] = "Admins: {$adminUpdated}";
|
||||
|
||||
if (!empty($updatedProfiles)) {
|
||||
Log::warning("Reset email verification for {$email} due to hard bounce threshold. Affected: " . implode(', ', $updatedProfiles) . ". Reason: {$reason}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that verification has been reset for this email (to prevent multiple resets)
|
||||
*/
|
||||
protected static function markVerificationReset(string $email): void
|
||||
{
|
||||
// We could add a verification_reset_at column to track this, but for now
|
||||
// we'll rely on the email_verified_at being null to prevent duplicate resets
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if verification has already been reset for this email
|
||||
*/
|
||||
protected static function hasVerificationBeenReset(string $email): bool
|
||||
{
|
||||
// Check if any profile with this email has null email_verified_at
|
||||
return User::where('email', $email)->whereNull('email_verified_at')->exists()
|
||||
|| Organization::where('email', $email)->whereNull('email_verified_at')->exists()
|
||||
|| Bank::where('email', $email)->whereNull('email_verified_at')->exists()
|
||||
|| Admin::where('email', $email)->whereNull('email_verified_at')->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method for testing pattern matching
|
||||
*/
|
||||
public static function testPatternMatching(string $bounceType, ?string $reason): bool
|
||||
{
|
||||
return self::isDefinitiveHardBounce($bounceType, $reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounce statistics for an email address
|
||||
*/
|
||||
public static function getBounceStats(string $email): array
|
||||
{
|
||||
$email = self::normalise($email);
|
||||
$config = timebank_config('mailing.bounce_thresholds', []);
|
||||
$windowDays = $config['counting_window_days'] ?? 30;
|
||||
$cutoffDate = now()->subDays($windowDays);
|
||||
|
||||
$totalBounces = self::where('email', $email)->count();
|
||||
$recentBounces = self::where('email', $email)->where('bounced_at', '>=', $cutoffDate)->count();
|
||||
$recentHardBounces = self::countRecentDefinitiveBounces($email, $windowDays);
|
||||
$isSuppressed = self::isSuppressed($email);
|
||||
|
||||
return [
|
||||
'email' => $email,
|
||||
'total_bounces' => $totalBounces,
|
||||
'recent_bounces' => $recentBounces,
|
||||
'recent_hard_bounces' => $recentHardBounces,
|
||||
'is_suppressed' => $isSuppressed,
|
||||
'window_days' => $windowDays,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Helpers\StringHelper;
|
||||
use App\Models\Language;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Notifications\VerifyProfileEmail;
|
||||
use App\Traits\ActiveStatesTrait;
|
||||
use App\Traits\HasPresence;
|
||||
use App\Traits\LocationTrait;
|
||||
use App\Traits\ProfileTrait;
|
||||
use App\Traits\TaggableWithLocale;
|
||||
use Cog\Contracts\Love\Reactable\Models\Reactable as ReactableInterface;
|
||||
use Cog\Contracts\Love\Reacterable\Models\Reacterable as ReacterableInterface;
|
||||
use Cog\Laravel\Love\Reactable\Models\Traits\Reactable;
|
||||
use Cog\Laravel\Love\Reacterable\Models\Traits\Reacterable;
|
||||
use Illuminate\Auth\MustVerifyEmail as AuthMustVerifyEmail;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Namu\WireChat\Traits\Chatable;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class Organization extends Authenticatable implements ReacterableInterface, MustVerifyEmail, ReactableInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use AuthMustVerifyEmail;
|
||||
use Notifiable;
|
||||
use HasProfilePhoto;
|
||||
use HasRoles;
|
||||
use TaggableWithLocale;
|
||||
use Reacterable; // cybercog/laravel-love
|
||||
use Reactable; // cybercog/laravel-love
|
||||
use Searchable; // laravel/scout with ElasticSearch
|
||||
use LocationTrait;
|
||||
use Chatable;
|
||||
use ActiveStatesTrait;
|
||||
use LogsActivity; // Spatie Activity Log
|
||||
use HasPresence;
|
||||
use ProfileTrait;
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* The attributes that should be hidden for serialization.
|
||||
* WARNING: API'S CAN POTENTIALLY EXPOSE ALL VISIBLE FIELDS
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $hidden = [
|
||||
'email',
|
||||
'email_verified_at',
|
||||
'full_name',
|
||||
'password',
|
||||
'remember_token',
|
||||
'phone',
|
||||
'cyclos_id',
|
||||
'cyclos_salt',
|
||||
'cyclos_skills',
|
||||
'two_factor_confirmed_at',
|
||||
'two_factor_recovery_codes',
|
||||
'two_factor_secret',
|
||||
'limit_min',
|
||||
'limit_max',
|
||||
'lang_preference',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'last_login_at',
|
||||
'inactive_at',
|
||||
'incomplete_at',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'full_name',
|
||||
'email',
|
||||
'profile_photo_path',
|
||||
'about',
|
||||
'about_short',
|
||||
'motivation',
|
||||
'website',
|
||||
'phone',
|
||||
'phone_public',
|
||||
'password',
|
||||
'limit_min',
|
||||
'limit_max',
|
||||
'lang_preference',
|
||||
'last_login_at',
|
||||
'last_login_ip'
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'phone_public' => 'boolean',
|
||||
'date_of_birth' => 'date',
|
||||
'email_verified_at' => 'datetime',
|
||||
'inactive_at' => 'datetime',
|
||||
'incomplete_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
|
||||
// protected $guard_name = 'web';
|
||||
|
||||
/**
|
||||
* Get the index name for the model.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function searchableAs()
|
||||
{
|
||||
return 'organizations_index';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convert this model to a searchable array.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function toSearchableArray()
|
||||
{
|
||||
// Prepare eager loaded relationships
|
||||
$this->load(
|
||||
'languages',
|
||||
'locations.district.translations',
|
||||
'locations.city.translations',
|
||||
'locations.division.translations',
|
||||
'locations.country.translations',
|
||||
'tags.contexts.tags',
|
||||
'tags.contexts.tags.locale',
|
||||
'tags.contexts.category.ancestorsAndSelf',
|
||||
);
|
||||
|
||||
return [
|
||||
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
|
||||
//TODO: Update to multilang database structure in future
|
||||
'about_nl' => $this->about,
|
||||
'about_en' => $this->about,
|
||||
'about_fr' => $this->about,
|
||||
'about_de' => $this->about,
|
||||
'about_es' => $this->about,
|
||||
'about_short_nl' => $this->about_short,
|
||||
'about_short_en' => $this->about_short,
|
||||
'about_short_fr' => $this->about_short,
|
||||
'about_short_de' => $this->about_short,
|
||||
'about_short_es' => $this->about_short,
|
||||
'motivation_nl' => $this->motivation,
|
||||
'motivation_en' => $this->motivation,
|
||||
'motivation_fr' => $this->motivation,
|
||||
'motivation_de' => $this->motivation,
|
||||
'motivation_es' => $this->motivation,
|
||||
'cyclos_skills' => $this->cyclos_skills, // Legacy column, will not be used in the future
|
||||
'website' => $this->website,
|
||||
|
||||
'last_login_at' => $this->last_login_at,
|
||||
'lang_preference' => $this->lang_preference,
|
||||
|
||||
'languages' => $this->languages->map(function ($language) { // map() as languages is a collection
|
||||
return [
|
||||
'id' => $language->id,
|
||||
'name' => $language->name,
|
||||
'lang_code' => $language->lang_code,
|
||||
];
|
||||
}),
|
||||
|
||||
'locations' => $this->locations->map(function ($location) { // map() as locations is a collection
|
||||
return [
|
||||
'id' => $location->id,
|
||||
'district' => $location->district ? $location->district->translations->map(function ($translation) { // map() as translations is a collection
|
||||
return $translation->name;
|
||||
})->toArray() : [],
|
||||
'city' => $location->city ? $location->city->translations->map(function ($translation) { // map() as translations is a collection
|
||||
return $translation->name;
|
||||
})->toArray() : [],
|
||||
'division' => $location->division ? $location->division->translations->map(function ($translation) { // map() as translations is a collection
|
||||
return $translation->name;
|
||||
})->toArray() : [],
|
||||
'country' => $location->country ? $location->country->translations->map(function ($translation) { // map() as translations is a collection
|
||||
return $translation->name;
|
||||
})->toArray() : [],
|
||||
];
|
||||
}),
|
||||
|
||||
'tags' => $this->tags->map(function ($tag) {
|
||||
return [
|
||||
'contexts' => $tag->contexts
|
||||
->map(function ($context) {
|
||||
return [
|
||||
'tags' => $context->tags->map(function ($tag) {
|
||||
// Include the locale in the field name for tags
|
||||
return [
|
||||
'name_' . $tag->locale->locale => StringHelper::DutchTitleCase($tag->normalized),
|
||||
];
|
||||
}),
|
||||
'categories' => Category::with(['translations' => function ($query) { $query->select('category_id', 'locale', 'name');}])
|
||||
->find([ $context->category->ancestorsAndSelf()->get()->flatMap(function ($related) {
|
||||
$categoryPath = explode('.', $related->path);
|
||||
return $categoryPath;
|
||||
})
|
||||
->unique()->values()->toArray()
|
||||
])->map(function ($category) {
|
||||
// Include the locale in the field name for categories
|
||||
return $category->translations->mapWithKeys(function ($translation) {
|
||||
return ['name_' . $translation->locale => StringHelper::DutchTitleCase($translation->name)];
|
||||
});
|
||||
}),
|
||||
];
|
||||
}),
|
||||
];
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the organization's profile.
|
||||
* One-to-one
|
||||
*/
|
||||
public function profile()
|
||||
{
|
||||
return $this->hasOne(Profile::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all of the organization's accounts.
|
||||
* One-to-many polymorphic.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function accounts()
|
||||
{
|
||||
return $this->morphMany(Account::class, 'accountable');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the organization has any associated accounts.
|
||||
*
|
||||
* @return bool Returns true if the user has accounts, false otherwise.
|
||||
*/
|
||||
public function hasAccounts()
|
||||
{
|
||||
$accountsExists = DB::table('accounts')
|
||||
->where('accountable_id', $this->id)
|
||||
->where('accountable_type', 'App\Models\User')
|
||||
->exists();
|
||||
return $accountsExists;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the profile's bank(s) that it can manage.
|
||||
* Many-to-many.
|
||||
*/
|
||||
public function banksManaged()
|
||||
{
|
||||
return $this->belongsToMany(Bank::class);
|
||||
}
|
||||
|
||||
|
||||
public function banksClient()
|
||||
{
|
||||
return $this->morphToMany(Bank::class, 'client', 'bank_clients')
|
||||
->wherePivot('relationship_type', 'local');
|
||||
}
|
||||
|
||||
|
||||
public function attachBankClient($bank, $relationshipType = null)
|
||||
{
|
||||
// Set default relationship type if not provided
|
||||
$relationshipType = $relationshipType ?? 'local';
|
||||
|
||||
// Accept either ID or Bank model
|
||||
$bankId = $bank instanceof Bank ? $bank->id : $bank;
|
||||
|
||||
// Verify existence if numeric ID was provided
|
||||
if (is_numeric($bankId)) {
|
||||
if (!Bank::where('id', $bankId)->exists()) {
|
||||
throw new \Exception("Bank with ID {$bankId} does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
$this->banksClient()->sync([$bankId => [
|
||||
'relationship_type' => $relationshipType,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now()
|
||||
]]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
public function detachBankClient($bank, $relationshipType = null)
|
||||
{
|
||||
// Set default relationship type if not provided
|
||||
$relationshipType = $relationshipType ?? 'local';
|
||||
|
||||
// Accept either ID or Bank model
|
||||
$bankId = $bank instanceof Bank ? $bank->id : $bank;
|
||||
|
||||
// Verify existence if numeric ID was provided
|
||||
if (is_numeric($bankId)) {
|
||||
if (!Bank::where('id', $bankId)->exists()) {
|
||||
throw new \Exception("Bank with ID {$bankId} does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
// Detach with additional pivot conditions
|
||||
$this->banksClient()->wherePivot('relationship_type', $relationshipType)
|
||||
->detach($bankId);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the organization's user(s).
|
||||
* Many-to-many.
|
||||
*/
|
||||
// todo: replace instances to managers() as these are actually profile managers and no organization users. See $this-> manangers()
|
||||
public function users()
|
||||
{
|
||||
return $this->belongsToMany(User::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the organization's profile manager(s).
|
||||
* Many-to-many.
|
||||
*/
|
||||
public function managers()
|
||||
{
|
||||
return $this->belongsToMany(User::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all of the languages for the organization.
|
||||
* Many-to-many polymorphic.
|
||||
*/
|
||||
public function languages()
|
||||
{
|
||||
return $this->morphToMany(Language::class, 'languagable')->withPivot('competence');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all of the social for the organization.
|
||||
* Many-to-many polymorphic.
|
||||
*/
|
||||
public function socials()
|
||||
{
|
||||
return $this->morphToMany(Social::class, 'sociable')->withPivot('id', 'user_on_social', 'server_of_social');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all related the locations of the organization.
|
||||
* One-to-many polymorph.
|
||||
*/
|
||||
public function locations()
|
||||
{
|
||||
return $this->morphMany(Location::class, 'locatable');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the organization's message settings.
|
||||
*/
|
||||
public function message_settings()
|
||||
{
|
||||
return $this->morphOne(MessageSetting::class, 'message_settingable');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all of the Organization's posts.
|
||||
*/
|
||||
public function posts()
|
||||
{
|
||||
return $this->morphMany(Post::class, 'postable');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all of the Organization's posts.
|
||||
*/
|
||||
public function categories()
|
||||
{
|
||||
return $this->morphMany(Category::class, 'categoryable');
|
||||
}
|
||||
|
||||
|
||||
public function sendEmailVerificationNotification()
|
||||
{
|
||||
\Mail::to($this->email)->send(new \App\Mail\VerifyProfileEmailMailable($this));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Configure the activity log options for the User model.
|
||||
*
|
||||
* This method sets up the logging to:
|
||||
* - Only log changes to the 'name', 'password', and 'last_login_ip' attributes.
|
||||
* - Log only when these attributes have been modified (dirty).
|
||||
* - Prevent submission of empty logs.
|
||||
* - Use 'user' as the log name.
|
||||
*
|
||||
* @return \Spatie\Activitylog\LogOptions
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly([
|
||||
'name',
|
||||
'last_login_ip',
|
||||
'inactive_at',
|
||||
'incomplete_at',
|
||||
'deleted_at',
|
||||
])
|
||||
->logOnlyDirty() // Only log attributes that have been changed
|
||||
->dontSubmitEmptyLogs()
|
||||
->useLogName('Organization');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Returns the URL for the user's cover image (avatar).
|
||||
* Adjust the 'avatar_url' field to your database setup.
|
||||
*/
|
||||
public function getCoverUrlAttribute(): ?string
|
||||
{
|
||||
return Storage::url($this->profile_photo_path) ?? null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat:Returns the URL for the user's profile page.
|
||||
* Adjust the 'profile' route as needed for your setup.
|
||||
*/
|
||||
public function getProfileUrlAttribute(): ?string
|
||||
{
|
||||
return route('profile.show_by_type_and_id', ['type' => __('organization'), 'id' => $this->id]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Returns the display name for the user.
|
||||
* Modify this to use your preferred name field.
|
||||
*/
|
||||
public function getDisplayNameAttribute(): ?string
|
||||
{
|
||||
if ($this->full_name !== $this->name) {
|
||||
return $this->full_name . ' (' . $this->name . ')';
|
||||
} elseif ($this->name) {
|
||||
return $this->name;
|
||||
} else {
|
||||
return __('Organization');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Search for users when creating a new chat or adding members to a group.
|
||||
* Customize the search logic to limit results, such as restricting to friends or eligible users only.
|
||||
*/
|
||||
public function searchChatables(string $query): ?\Illuminate\Support\Collection
|
||||
{
|
||||
$searchableFields = ['name', 'full_name'];
|
||||
|
||||
// Search across all profile types
|
||||
$users = \App\Models\User::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
$organizations = Organization::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
$banks = \App\Models\Bank::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
$admins = \App\Models\Admin::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
// Combine all results into a base Collection to avoid serialization issues
|
||||
$results = collect()
|
||||
->merge($users->all())
|
||||
->merge($organizations->all())
|
||||
->merge($banks->all())
|
||||
->merge($admins->all());
|
||||
|
||||
// Filter out profiles based on configuration
|
||||
return $results->filter(function ($profile) {
|
||||
// Check inactive profiles
|
||||
if (timebank_config('profile_inactive.messenger_hidden') && !$profile->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check email unverified profiles
|
||||
if (timebank_config('profile_email_unverified.messenger_hidden') && !$profile->isEmailVerified()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check incomplete profiles
|
||||
if (
|
||||
timebank_config('profile_incomplete.messenger_hidden')
|
||||
&& method_exists($profile, 'hasIncompleteProfile')
|
||||
&& $profile->hasIncompleteProfile($profile)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})->take(6)->values();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Determine if the user can create new groups.
|
||||
*/
|
||||
public function canCreateGroups(): bool
|
||||
{
|
||||
return timebank_config('profiles.organization.messenger_can_create_groups');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the Organization has any transactions with another model (User, Organization, Bank).
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Model $model
|
||||
* @return bool
|
||||
*/
|
||||
public function hasTransactionsWith($model): bool
|
||||
{
|
||||
$modelClass = get_class($model);
|
||||
$modelId = $model->id;
|
||||
|
||||
// Check if this Organization is involved in any transaction where the other model is either the sender or receiver
|
||||
return Transaction::where(function ($query) use ($modelClass, $modelId) {
|
||||
$query->whereHas('accountFrom.accountable', function ($q) use ($modelClass, $modelId) {
|
||||
$q->where('accountable_type', $modelClass)
|
||||
->where('accountable_id', $modelId);
|
||||
})
|
||||
->orWhereHas('accountTo.accountable', function ($q) use ($modelClass, $modelId) {
|
||||
$q->where('accountable_type', $modelClass)
|
||||
->where('accountable_id', $modelId);
|
||||
});
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereHas('accountFrom.accountable', function ($q) {
|
||||
$q->where('accountable_type', Organization::class)
|
||||
->where('accountable_id', $this->id);
|
||||
})
|
||||
->orWhereHas('accountTo.accountable', function ($q) {
|
||||
$q->where('accountable_type', Organization::class)
|
||||
->where('accountable_id', $this->id);
|
||||
});
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class OrganizationRole extends Model
|
||||
{
|
||||
protected $table = 'organization_roles';
|
||||
|
||||
protected $fillable = ['name', 'label', 'permissions'];
|
||||
|
||||
protected $casts = [
|
||||
'permissions' => 'array',
|
||||
'is_system' => 'boolean',
|
||||
];
|
||||
|
||||
public function scopeNotSystem($query)
|
||||
{
|
||||
return $query->where('is_system', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Services\Stats\LocationFilterResolver;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Poll extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_OPEN = 'open';
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
|
||||
public const VOTE_SCOPE_PER_PROFILE = 'per_profile';
|
||||
public const VOTE_SCOPE_PER_USER = 'per_user';
|
||||
|
||||
public const MEMBER_SCOPE_ALL_USERS = 'all_users';
|
||||
public const MEMBER_SCOPE_MANAGERS_COORDINATORS = 'managers_coordinators';
|
||||
|
||||
public const RESULTS_NEVER = 'never';
|
||||
public const RESULTS_AFTER_VOTE = 'after_vote';
|
||||
public const RESULTS_AFTER_CLOSE = 'after_close';
|
||||
|
||||
public const VERIFICATION_OFF = 'off';
|
||||
public const VERIFICATION_RECEIPTS = 'receipts';
|
||||
public const VERIFICATION_PUBLIC = 'public';
|
||||
|
||||
/** Profile types a poll can target, and their manager/coordinator role names. */
|
||||
public const PROFILE_TYPES = [User::class, Organization::class, Bank::class, Admin::class];
|
||||
public const MANAGER_COORDINATOR_ROLES = [
|
||||
Organization::class => ['organization-manager', 'organization-coordinator'],
|
||||
Bank::class => ['bank-manager', 'bank-coordinator'],
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'post_id', 'pollable_id', 'pollable_type',
|
||||
'status', 'opens_at', 'closes_at',
|
||||
'vote_scope', 'member_scope', 'allow_vote_change', 'anonymous', 'results_visibility',
|
||||
'verification', 'sealed_at', 'seal_hash', 'archived_at',
|
||||
'filter_by_location', 'location_country_ids', 'location_division_ids',
|
||||
'location_city_ids', 'location_district_ids',
|
||||
'target_profile_types',
|
||||
'last_login_from', 'last_login_until', 'registered_from', 'registered_until',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'archived_at' => 'datetime',
|
||||
'opens_at' => 'datetime',
|
||||
'closes_at' => 'datetime',
|
||||
'sealed_at' => 'datetime',
|
||||
'allow_vote_change' => 'boolean',
|
||||
'anonymous' => 'boolean',
|
||||
'filter_by_location' => 'boolean',
|
||||
'location_country_ids' => 'array',
|
||||
'location_division_ids' => 'array',
|
||||
'location_city_ids' => 'array',
|
||||
'location_district_ids' => 'array',
|
||||
'target_profile_types' => 'array',
|
||||
'last_login_from' => 'date',
|
||||
'last_login_until' => 'date',
|
||||
'registered_from' => 'date',
|
||||
'registered_until' => 'date',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Get related post for this poll
|
||||
* One-to-one relationship
|
||||
*/
|
||||
public function post()
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the owner of the poll (i.e. user, organization, bank or admin).
|
||||
*/
|
||||
public function pollable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function questions()
|
||||
{
|
||||
return $this->hasMany(PollQuestion::class)->orderBy('position');
|
||||
}
|
||||
|
||||
public function responses()
|
||||
{
|
||||
return $this->hasMany(PollResponse::class);
|
||||
}
|
||||
|
||||
public function ledger()
|
||||
{
|
||||
return $this->hasMany(PollLedgerEntry::class)->orderBy('sequence');
|
||||
}
|
||||
|
||||
/**
|
||||
* Once someone voted, content stays locked and the poll cannot go back to draft
|
||||
* (see isContentLocked), so stored answers never point at changed options.
|
||||
*/
|
||||
public function hasResponses(): bool
|
||||
{
|
||||
return $this->responses()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Post and questions are read-only once the poll left draft, and always once someone voted:
|
||||
* voters must all see the same poll.
|
||||
*/
|
||||
public function isContentLocked(): bool
|
||||
{
|
||||
return $this->status !== self::STATUS_DRAFT || $this->hasResponses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open status and inside the opens_at / closes_at window.
|
||||
*/
|
||||
public function isOpen(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_OPEN) {
|
||||
return false;
|
||||
}
|
||||
if ($this->opens_at && $this->opens_at->isFuture()) {
|
||||
return false;
|
||||
}
|
||||
if ($this->closes_at && $this->closes_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed by status, or past closes_at.
|
||||
*/
|
||||
public function isClosed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CLOSED || ($this->closes_at && $this->closes_at->isPast());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a voter may see the results (owner and admins always can, on the admin page).
|
||||
*/
|
||||
public function resultsVisibleToVoter(bool $hasVoted): bool
|
||||
{
|
||||
return match ($this->results_visibility) {
|
||||
self::RESULTS_AFTER_VOTE => $hasVoted || $this->isClosed(),
|
||||
self::RESULTS_AFTER_CLOSE => $this->isClosed(),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Results per question, from the question types' aggregate().
|
||||
*
|
||||
* @return array<int, array> keyed by question id
|
||||
*/
|
||||
public function questionSummaries(): array
|
||||
{
|
||||
$questions = $this->questions()->get();
|
||||
$answers = PollAnswer::whereIn('poll_question_id', $questions->pluck('id'))->get()->groupBy('poll_question_id');
|
||||
|
||||
$summaries = [];
|
||||
foreach ($questions as $question) {
|
||||
$summaries[$question->id] = $question->questionType()->aggregate(
|
||||
$answers->get($question->id, collect()),
|
||||
$question->settings ?? []
|
||||
);
|
||||
}
|
||||
|
||||
return $summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key used by the unique index on poll_responses:
|
||||
* one vote per profile, or one vote per user behind the profile.
|
||||
*/
|
||||
public function dedupeKey(Model $profile, int $userId): string
|
||||
{
|
||||
$key = get_class($profile) . ':' . $profile->getKey();
|
||||
|
||||
// A User profile always votes for itself, so vote_scope only matters for the other profile types
|
||||
if ($this->vote_scope === self::VOTE_SCOPE_PER_USER && !$profile instanceof User) {
|
||||
$key .= ':' . $userId;
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls that are open right now (status and opens_at / closes_at window).
|
||||
*/
|
||||
public function scopeOpenNow(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_OPEN)
|
||||
->where(fn ($q) => $q->whereNull('opens_at')->orWhere('opens_at', '<=', now()))
|
||||
->where(fn ($q) => $q->whereNull('closes_at')->orWhere('closes_at', '>', now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the active profile, voting as $user, may answer this poll right now.
|
||||
* For Organization / Bank / Admin profiles $user is the logged-in web user behind it.
|
||||
*/
|
||||
public function isTargetedAt(Model $profile, ?User $user = null): bool
|
||||
{
|
||||
return $this->isOpen()
|
||||
&& $this->matchesProfile($profile)
|
||||
&& $this->memberMayVote($profile, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the active profile administers polls: an Admin (or central bank) profile with
|
||||
* 'access polls'. Same rule as the Polls page and the results export (security audit R4).
|
||||
*/
|
||||
public static function viewerIsPollAdmin(): bool
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
return ($profile instanceof Admin || ($profile instanceof Bank && $profile->level === 0))
|
||||
&& \Illuminate\Support\Facades\Gate::allows('access polls');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the poll is only for part of the members (any targeting, or only managers
|
||||
* and coordinators of organizations and banks). Used to warn in the mailing editor.
|
||||
*/
|
||||
public function hasLimitedAudience(): bool
|
||||
{
|
||||
$types = array_values($this->target_profile_types ?? []);
|
||||
|
||||
return ($types && count(array_intersect(self::PROFILE_TYPES, $types)) < count(self::PROFILE_TYPES))
|
||||
|| $this->filter_by_location
|
||||
|| $this->last_login_from || $this->last_login_until
|
||||
|| $this->registered_from || $this->registered_until
|
||||
|| $this->member_scope === self::MEMBER_SCOPE_MANAGERS_COORDINATORS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile type, location, last login and registration targeting.
|
||||
*/
|
||||
public function matchesProfile(Model $profile): bool
|
||||
{
|
||||
if (!$this->targetsProfileType(get_class($profile))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->applyProfileFilters($profile->newQuery()->whereKey($profile->getKey()))->exists();
|
||||
}
|
||||
|
||||
public function targetsProfileType(string $profileClass): bool
|
||||
{
|
||||
return empty($this->target_profile_types) || in_array($profileClass, $this->target_profile_types, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrain a query on one profile table to the profiles this poll targets
|
||||
* (location, last login, registration). Profile type is checked by targetsProfileType().
|
||||
*/
|
||||
public function applyProfileFilters(Builder $query): Builder
|
||||
{
|
||||
if ($this->filter_by_location) {
|
||||
LocationFilterResolver::apply(
|
||||
$query,
|
||||
$this->location_country_ids ?? [],
|
||||
$this->location_division_ids ?? [],
|
||||
$this->location_city_ids ?? [],
|
||||
$this->location_district_ids ?? []
|
||||
);
|
||||
}
|
||||
|
||||
$table = $query->getModel()->getTable();
|
||||
if ($this->last_login_from) {
|
||||
$query->whereDate("$table.last_login_at", '>=', $this->last_login_from->toDateString());
|
||||
}
|
||||
if ($this->last_login_until) {
|
||||
$query->whereDate("$table.last_login_at", '<=', $this->last_login_until->toDateString());
|
||||
}
|
||||
if ($this->registered_from) {
|
||||
$query->whereDate("$table.created_at", '>=', $this->registered_from->toDateString());
|
||||
}
|
||||
if ($this->registered_until) {
|
||||
$query->whereDate("$table.created_at", '<=', $this->registered_until->toDateString());
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* member_scope: for Organization / Bank profiles optionally only managers and coordinators
|
||||
* may vote. A User profile votes for itself; all users linked to an Admin profile may vote.
|
||||
*/
|
||||
public function memberMayVote(Model $profile, ?User $user): bool
|
||||
{
|
||||
if ($profile instanceof User) {
|
||||
return $user === null || $user->is($profile);
|
||||
}
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
if ($this->member_scope !== self::MEMBER_SCOPE_MANAGERS_COORDINATORS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$roles = self::MANAGER_COORDINATOR_ROLES[get_class($profile)] ?? null;
|
||||
if ($roles === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$prefix = class_basename($profile) . '\\' . $profile->getKey() . '\\';
|
||||
|
||||
return $user->hasRole(array_map(fn ($role) => $prefix . $role, $roles));
|
||||
}
|
||||
|
||||
/**
|
||||
* The existing vote of this profile (or of this user behind it, for per_user polls).
|
||||
*/
|
||||
public function responseFor(Model $profile, int $userId): ?PollResponse
|
||||
{
|
||||
return $this->responses()->where('dedupe_key', $this->dedupeKey($profile, $userId))->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the voter may (still) submit: no vote yet, or changing votes is allowed.
|
||||
*/
|
||||
public function canSubmit(Model $profile, int $userId): bool
|
||||
{
|
||||
return $this->allow_vote_change || $this->responseFor($profile, $userId) === null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PollAnswer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['poll_response_id', 'poll_question_id', 'value', 'text'];
|
||||
|
||||
protected $casts = [
|
||||
'value' => 'array',
|
||||
];
|
||||
|
||||
|
||||
public function response()
|
||||
{
|
||||
return $this->belongsTo(PollResponse::class, 'poll_response_id');
|
||||
}
|
||||
|
||||
public function question()
|
||||
{
|
||||
return $this->belongsTo(PollQuestion::class, 'poll_question_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* One entry in a poll's append-only ballot ledger. Holds no voter identity.
|
||||
* Entries are only ever inserted, through App\Polls\Ledger.
|
||||
*/
|
||||
class PollLedgerEntry extends Model
|
||||
{
|
||||
public const CAST = 'cast';
|
||||
public const CHANGE = 'change';
|
||||
public const WITHDRAW = 'withdraw';
|
||||
public const SEAL = 'seal';
|
||||
|
||||
protected $table = 'poll_ledger';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'poll_id', 'sequence', 'format_version', 'entry_type', 'fingerprint', 'refers_to_sequence',
|
||||
'payload', 'recorded_at', 'previous_hash', 'hash',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sequence' => 'integer',
|
||||
'format_version' => 'integer',
|
||||
'refers_to_sequence' => 'integer',
|
||||
'payload' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(fn () => throw new LogicException('Poll ledger entries cannot be changed.'));
|
||||
static::deleting(fn () => throw new LogicException('Poll ledger entries cannot be deleted.'));
|
||||
}
|
||||
|
||||
public function poll()
|
||||
{
|
||||
return $this->belongsTo(Poll::class);
|
||||
}
|
||||
|
||||
public function isBallot(): bool
|
||||
{
|
||||
return in_array($this->entry_type, [self::CAST, self::CHANGE], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Polls\Contracts\QuestionType;
|
||||
use App\Polls\Localized;
|
||||
use App\Polls\QuestionTypeRegistry;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PollQuestion extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['poll_id', 'position', 'type', 'label', 'settings', 'required'];
|
||||
|
||||
protected $casts = [
|
||||
'label' => 'array',
|
||||
'settings' => 'array',
|
||||
'required' => 'boolean',
|
||||
];
|
||||
|
||||
|
||||
public function poll()
|
||||
{
|
||||
return $this->belongsTo(Poll::class);
|
||||
}
|
||||
|
||||
public function answers()
|
||||
{
|
||||
return $this->hasMany(PollAnswer::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label in the given (or current) locale, falling back to the app fallback locale
|
||||
* and then to the first label that is filled in.
|
||||
*/
|
||||
public function labelFor(?string $locale = null): string
|
||||
{
|
||||
return Localized::pick($this->label, $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* The question type handling this question.
|
||||
*/
|
||||
public function questionType(): QuestionType
|
||||
{
|
||||
return app(QuestionTypeRegistry::class)->get($this->type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PollResponse extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['poll_id', 'voter_id', 'voter_type', 'user_id', 'dedupe_key', 'ledger_sequence', 'submitted_at'];
|
||||
|
||||
protected $casts = [
|
||||
'submitted_at' => 'datetime',
|
||||
'ledger_sequence' => 'integer',
|
||||
];
|
||||
|
||||
|
||||
public function poll()
|
||||
{
|
||||
return $this->belongsTo(Poll::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active profile that voted (user, organization, bank or admin).
|
||||
*/
|
||||
public function voter()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* The user behind the voting profile.
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function answers()
|
||||
{
|
||||
return $this->hasMany(PollAnswer::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Helpers\StringHelper;
|
||||
use App\Models\Category;
|
||||
use App\Models\Image;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PostTranslation;
|
||||
use Cog\Contracts\Love\Reactable\Models\Reactable as ReactableInterface;
|
||||
use Cog\Laravel\Love\Reactable\Models\Traits\Reactable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Znck\Eloquent\Traits\BelongsToThrough;
|
||||
|
||||
|
||||
class Post extends Model implements HasMedia, ReactableInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use BelongsToThrough;
|
||||
use InteractsWithMedia;
|
||||
use SoftDeletes;
|
||||
use Searchable { // laravel/scout with ElasticSearch
|
||||
searchableUsing as scoutSearchableUsing;
|
||||
}
|
||||
use Reactable; // cybercog/laravel-love
|
||||
|
||||
|
||||
|
||||
protected $fillable = ['postable_id', 'postable_type', 'category_id', 'author_id', 'author_model', 'is_locked'];
|
||||
|
||||
protected $casts = [
|
||||
'is_locked' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the index name for the model.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function searchableAs()
|
||||
{
|
||||
return 'posts_index';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Polls are for logged-in profiles only: never in the search index, whatever the
|
||||
* search category settings. The daily scout:import also skips them (PullFromSource).
|
||||
*/
|
||||
public function shouldBeSearchable(): bool
|
||||
{
|
||||
return !$this->isPoll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way into the index ends in searchableUsing()->update(), but Scout only checks
|
||||
* shouldBeSearchable() in its own model events and imports. The wrapper makes that check
|
||||
* for every update, so a poll is never indexed (and is removed should it be there).
|
||||
*/
|
||||
public function searchableUsing()
|
||||
{
|
||||
return new \App\Polls\SearchEngineWithoutPolls($this->scoutSearchableUsing());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this post is a poll (poll category or a poll attached).
|
||||
*/
|
||||
public function isPoll(): bool
|
||||
{
|
||||
return $this->category?->type === Poll::class || $this->poll()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the this model to a searchable array.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function toSearchableArray()
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'category_id' => $this->category_id,
|
||||
|
||||
'postable' => [
|
||||
'id' => $this->postable ? $this->postable->id : '',
|
||||
'name' => $this->postable ? $this->postable->name : '',
|
||||
],
|
||||
|
||||
'post_translations' => $this->translations->mapWithKeys(function ($translation) {
|
||||
// mapWithKeys() as translations is a collection
|
||||
return [
|
||||
'title_' . $translation->locale => $translation->title,
|
||||
'excerpt_' . $translation->locale => $translation->excerpt,
|
||||
'content_' . $translation->locale => $translation->content,
|
||||
// Add publication fields
|
||||
'from_' . $translation->locale => $translation->from,
|
||||
'till_' . $translation->locale => $translation->till,
|
||||
'deleted_at_' . $translation->locale => $translation->deleted_at,
|
||||
'status_' . $translation->locale => $translation->status,
|
||||
];
|
||||
})->toArray(),
|
||||
|
||||
'post_category' => $this->category ? [
|
||||
'id' => $this->category->id,
|
||||
'names' => $this->category->translations->mapWithKeys(function ($translation) {
|
||||
// Include the locale in the field name for categories
|
||||
return ['name_' . $translation->locale => StringHelper::DutchTitleCase($translation->name)];
|
||||
})->toArray(),
|
||||
] : [],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the creator of the post (i.e. user or organization).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function postable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the author of the post (polymorphic: User, Organization, or Bank).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function author()
|
||||
{
|
||||
return $this->morphTo('author', 'author_model', 'author_id');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the related translations of the post
|
||||
* One-to-many relationship
|
||||
* @return void
|
||||
*/
|
||||
public function translations()
|
||||
{
|
||||
return $this->hasMany(PostTranslation::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the related category of the post
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the related images of the posts
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function images()
|
||||
{
|
||||
return $this->morphToMany(Image::class, 'imageable');
|
||||
}
|
||||
|
||||
|
||||
public static function last()
|
||||
{
|
||||
return static::all()->last();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Spatie Media Library media conversions
|
||||
* When updated you can use:
|
||||
* php artisan media-library:regenerate
|
||||
* to regenerate media disk and database of all media stored (make sure you restart the queue worker first)
|
||||
*
|
||||
* @param mixed $media
|
||||
* @return void
|
||||
*/
|
||||
public function registerMediaConversions(Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('favicon') //1:1
|
||||
->focalCrop(32, 32, 50, 50)
|
||||
->optimize()
|
||||
->format('gif')
|
||||
->performOnCollections('posts');
|
||||
|
||||
$this->addMediaConversion('logo') //1:1
|
||||
->focalCrop(160, 160, 50, 50)
|
||||
->optimize()
|
||||
->format('webp')
|
||||
->performOnCollections('posts');
|
||||
|
||||
$this->addMediaConversion('thumbnail') // 1:1
|
||||
->focalCrop(150, 150, 50, 50)
|
||||
->optimize()
|
||||
->format('webp')
|
||||
->performOnCollections('posts');
|
||||
|
||||
$this->addMediaConversion('blog') //3:2
|
||||
->focalCrop(1200, 630, 50, 50)
|
||||
->withResponsiveImages()
|
||||
->optimize()
|
||||
->format('webp')
|
||||
->performOnCollections('posts');
|
||||
|
||||
$this->addMediaConversion('hero') //16:9
|
||||
->focalCrop(3840, 2160, 50, 50)
|
||||
->withResponsiveImages()
|
||||
->optimize()
|
||||
->format('webp')
|
||||
->performOnCollections('posts');
|
||||
|
||||
$this->addMediaConversion('half_hero') //16:4.5 panoramic
|
||||
->focalCrop(3840, 1080, 50, 50)
|
||||
->format('webp')
|
||||
->performOnCollections('posts');
|
||||
|
||||
$this->addMediaConversion('4_3') //4:3
|
||||
->focalCrop(3072, 2304, 50, 50)
|
||||
->withResponsiveImages()
|
||||
->optimize()
|
||||
->format('webp')
|
||||
->performOnCollections('posts');
|
||||
|
||||
$this->addMediaConversion('email') // For email newsletters - resizes without cropping
|
||||
->width(800)
|
||||
->optimize()
|
||||
->format('jpg')
|
||||
->quality(85)
|
||||
->performOnCollections('posts');
|
||||
|
||||
}
|
||||
|
||||
public function registerMediaCollection(): void
|
||||
{
|
||||
$this->addMediaCollection('posts')
|
||||
->useDisk('media')
|
||||
->singleFile();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the related meeting of the post
|
||||
* One-to-one relation
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function meeting()
|
||||
{
|
||||
return $this->hasOne(Meeting::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the related poll of the post
|
||||
* One-to-one relation
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function poll()
|
||||
{
|
||||
return $this->hasOne(Poll::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of 'like' reactions for this post
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getLikeCountAttribute()
|
||||
{
|
||||
// Check if post is registered as reactant before getting reactions
|
||||
if (!$this->isRegisteredAsLoveReactant()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get the reactant facade
|
||||
$reactantFacade = $this->viaLoveReactant();
|
||||
|
||||
// Get the reaction counter for 'Like' type
|
||||
$reactionCounter = $reactantFacade->getReactionCounterOfType('Like');
|
||||
|
||||
if (!$reactionCounter) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Return the total count from all users
|
||||
return $reactionCounter->getCount();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SearchMetric extends Model
|
||||
{
|
||||
protected $table = 'search_metrics';
|
||||
|
||||
protected $fillable = [
|
||||
'searched_at',
|
||||
'search_string',
|
||||
'category_ids',
|
||||
'total_results',
|
||||
'execution_time',
|
||||
'locale',
|
||||
'user_type',
|
||||
'country_id',
|
||||
'division_id',
|
||||
'city_id',
|
||||
'district_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'searched_at' => 'datetime',
|
||||
'category_ids' => 'array',
|
||||
'total_results' => 'integer',
|
||||
'execution_time' => 'float',
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
class Transaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use Searchable; // laravel/scout with ElasticSearch
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'from_account_id',
|
||||
'to_account_id',
|
||||
'creator_user_id',
|
||||
'from_authorised_by_user_id',
|
||||
'from_authorisation_time',
|
||||
'to_authorised_by_user_id',
|
||||
'to_authorisation_time',
|
||||
'amount',
|
||||
'programmed_time',
|
||||
'description',
|
||||
'from_reference',
|
||||
'to_reference',
|
||||
'transaction_type_id',
|
||||
'transaction_status_id',
|
||||
'cancelled_by_user_id',
|
||||
'cancelled_time',
|
||||
'advertisement_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'transactions';
|
||||
|
||||
|
||||
/**
|
||||
* Refuse invalid transactions whichever code path creates them. Transactions are
|
||||
* immutable once stored, and the table has no foreign keys or check constraints.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Transaction $transaction) {
|
||||
if (!is_numeric($transaction->amount) || (int) $transaction->amount != $transaction->amount || $transaction->amount <= 0) {
|
||||
throw new \InvalidArgumentException('Transaction amount must be a positive whole number of minutes.');
|
||||
}
|
||||
if ((int) $transaction->from_account_id === (int) $transaction->to_account_id) {
|
||||
throw new \InvalidArgumentException('Transaction from and to account must differ.');
|
||||
}
|
||||
if (Account::whereKey([$transaction->from_account_id, $transaction->to_account_id])->count() !== 2) {
|
||||
throw new \InvalidArgumentException('Transaction from and to account must exist.');
|
||||
}
|
||||
if (!TransactionType::whereKey($transaction->transaction_type_id)->exists()) {
|
||||
throw new \InvalidArgumentException('Transaction type must exist.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the index name for the model.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function searchableAs()
|
||||
{
|
||||
return 'transactions_index';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convert the transaction model to a searchable array.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function toSearchableArray()
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'from_account_id' => $this->from_account_id,
|
||||
'to_account_id' => $this->to_account_id,
|
||||
'amount' => $this->amount,
|
||||
'description' => $this->description ?? '',
|
||||
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function accountFrom()
|
||||
{
|
||||
return $this->belongsTo(Account::class, 'from_account_id');
|
||||
}
|
||||
|
||||
|
||||
public function accountTo()
|
||||
{
|
||||
return $this->belongsTo(Account::class, 'to_account_id');
|
||||
}
|
||||
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'creator_user_id');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the transaction type that owns the transaction.
|
||||
*/
|
||||
public function transactionType()
|
||||
{
|
||||
return $this->belongsTo(TransactionType::class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Helpers\StringHelper;
|
||||
use App\Models\Account;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Language;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Post;
|
||||
use App\Notifications\VerifyProfileEmail;
|
||||
use App\Traits\ActiveStatesTrait;
|
||||
use App\Traits\HasPresence;
|
||||
use App\Traits\LocationTrait;
|
||||
use App\Traits\ProfileTrait;
|
||||
use App\Traits\TaggableWithLocale;
|
||||
use Cog\Contracts\Love\Reactable\Models\Reactable as ReactableInterface;
|
||||
use Cog\Contracts\Love\Reacterable\Models\Reacterable as ReacterableInterface;
|
||||
use Cog\Laravel\Love\Reactable\Models\Traits\Reactable;
|
||||
use Cog\Laravel\Love\Reacterable\Models\Traits\Reacterable;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Namu\WireChat\Traits\Chatable;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable implements MustVerifyEmail, ReacterableInterface, ReactableInterface
|
||||
{
|
||||
use HasApiTokens;
|
||||
use HasFactory;
|
||||
use Notifiable;
|
||||
use TwoFactorAuthenticatable;
|
||||
use HasProfilePhoto;
|
||||
use HasApiTokens; // Laravel Sanctum API support
|
||||
use HasRoles; // Spatie Permissions
|
||||
use LogsActivity; // Spatie Activity Log
|
||||
use TaggableWithLocale;
|
||||
use Reacterable; // cybercog/laravel-love
|
||||
use Reactable; // cybercog/laravel-love
|
||||
use Searchable; // laravel/scout with ElasticSearch
|
||||
use LocationTrait;
|
||||
use Chatable;
|
||||
use ActiveStatesTrait;
|
||||
use HasPresence;
|
||||
use ProfileTrait;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'full_name',
|
||||
'email',
|
||||
'profile_photo_path',
|
||||
'about',
|
||||
'about_short',
|
||||
'motivation',
|
||||
'website',
|
||||
'phone',
|
||||
'phone_public',
|
||||
'password',
|
||||
'limit_min',
|
||||
'limit_max',
|
||||
'lang_preference',
|
||||
'last_login_at',
|
||||
'last_login_ip',
|
||||
'principles_terms_accepted',
|
||||
'fcm_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
* BEWARE: API'S CAN POTENTIALLY EXPOSE ALL VISIBLE FIELDS
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $hidden = [
|
||||
'email',
|
||||
'email_verified_at',
|
||||
'full_name',
|
||||
'password',
|
||||
'remember_token',
|
||||
'phone',
|
||||
'cyclos_id',
|
||||
'cyclos_salt',
|
||||
'cyclos_skills',
|
||||
'two_factor_confirmed_at',
|
||||
'two_factor_recovery_codes',
|
||||
'two_factor_secret',
|
||||
'limit_min',
|
||||
'limit_max',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'last_login_at',
|
||||
'last_login_ip',
|
||||
'principles_terms_accepted',
|
||||
'inactive_at',
|
||||
'incomplete_at',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'phone_public' => 'boolean',
|
||||
'date_of_birth' => 'date',
|
||||
'email_verified_at' => 'datetime',
|
||||
'principles_terms_accepted' => 'array',
|
||||
'inactive_at' => 'datetime',
|
||||
'incomplete_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* The name of the guard used by this model.
|
||||
* This is used to determine the default guard for authentication.
|
||||
*/
|
||||
protected string $guard_name = 'web';
|
||||
protected function getDefaultGuardName(): string
|
||||
{
|
||||
return $this->guard_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the index name for the model.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function searchableAs()
|
||||
{
|
||||
return 'users_index';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this model to a searchable array.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function toSearchableArray()
|
||||
{
|
||||
// Prepare eager loaded relationships
|
||||
$this->load(
|
||||
'languages',
|
||||
'locations.district.translations',
|
||||
'locations.city.translations',
|
||||
'locations.division.translations',
|
||||
'locations.country.translations',
|
||||
'tags.contexts.tags',
|
||||
'tags.contexts.tags.locale',
|
||||
'tags.contexts.category.ancestorsAndSelf',
|
||||
);
|
||||
|
||||
return [
|
||||
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
|
||||
//TODO: Update to multilang database structure in future, this ElasticSearch index is already prepared for it
|
||||
'about_nl' => $this->about,
|
||||
'about_en' => $this->about,
|
||||
'about_fr' => $this->about,
|
||||
'about_de' => $this->about,
|
||||
'about_es' => $this->about,
|
||||
'about_short_nl' => $this->about_short,
|
||||
'about_short_en' => $this->about_short,
|
||||
'about_short_fr' => $this->about_short,
|
||||
'about_short_de' => $this->about_short,
|
||||
'about_short_es' => $this->about_short,
|
||||
'motivation_nl' => $this->motivation,
|
||||
'motivation_en' => $this->motivation,
|
||||
'motivation_fr' => $this->motivation,
|
||||
'motivation_de' => $this->motivation,
|
||||
'motivation_es' => $this->motivation,
|
||||
'cyclos_skills' => $this->cyclos_skills, // Legacy column, will not be used in the future
|
||||
'website' => $this->website,
|
||||
|
||||
|
||||
'last_login_at' => $this->last_login_at,
|
||||
'lang_preference' => $this->lang_preference,
|
||||
|
||||
'languages' => $this->languages->map(function ($language) { // map() as languages is a collection
|
||||
return [
|
||||
'id' => $language->id,
|
||||
'name' => $language->name,
|
||||
'lang_code' => $language->lang_code,
|
||||
];
|
||||
}),
|
||||
|
||||
'locations' => $this->locations->map(function ($location) { // map() as locations is a collection
|
||||
return [
|
||||
'id' => $location->id,
|
||||
'district' => $location->district ? $location->district->translations->map(function ($translation) { // map() as translations is a collection
|
||||
return $translation->name;
|
||||
})->toArray() : [],
|
||||
'city' => $location->city ? $location->city->translations->map(function ($translation) { // map() as translations is a collection
|
||||
return $translation->name;
|
||||
})->toArray() : [],
|
||||
'division' => $location->division ? $location->division->translations->map(function ($translation) { // map() as translations is a collection
|
||||
return $translation->name;
|
||||
})->toArray() : [],
|
||||
'country' => $location->country ? $location->country->translations->map(function ($translation) { // map() as translations is a collection
|
||||
return $translation->name;
|
||||
})->toArray() : [],
|
||||
];
|
||||
}),
|
||||
|
||||
'tags' => $this->tags->map(function ($tag) {
|
||||
return [
|
||||
'contexts' => $tag->contexts
|
||||
->map(function ($context) {
|
||||
return [
|
||||
'tags' => $context->tags->map(function ($tag) {
|
||||
// Include the locale in the field name for tags
|
||||
return [
|
||||
'name_' . $tag->locale->locale => StringHelper::DutchTitleCase($tag->normalized),
|
||||
];
|
||||
}),
|
||||
'categories' => $context->category ? Category::with(['translations' => function ($query) { $query->select('category_id', 'locale', 'name');}])
|
||||
->find([ $context->category->ancestorsAndSelf()->get()->flatMap(function ($related) {
|
||||
$categoryPath = explode('.', $related->path);
|
||||
return $categoryPath;
|
||||
})
|
||||
->unique()->values()->toArray()
|
||||
])->map(function ($category) {
|
||||
// Include the locale in the field name for categories
|
||||
return $category->translations->mapWithKeys(function ($translation) {
|
||||
return ['name_' . $translation->locale => StringHelper::DutchTitleCase($translation->name)];
|
||||
});
|
||||
}) : collect(),
|
||||
];
|
||||
}),
|
||||
];
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the user's profile.
|
||||
* One-to-one
|
||||
*/
|
||||
public function profile()
|
||||
{
|
||||
return $this->hasOne(Profile::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the user's organization(s).
|
||||
* Many-to-many.
|
||||
*/
|
||||
public function organizations()
|
||||
{
|
||||
return $this->belongsToMany(Organization::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the user's bank(s) that it can manage.
|
||||
* Many-to-many.
|
||||
*/
|
||||
public function banksManaged()
|
||||
{
|
||||
return $this->belongsToMany(Bank::class, 'bank_user', 'user_id', 'bank_id');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Determine if the user has any non-personal profiles.
|
||||
*
|
||||
* Checks whether the user is associated with any organizations, manages any banks,
|
||||
* or holds any administrative assignments. Returns true if at least one of these
|
||||
* related profiles exists; otherwise returns false.
|
||||
*
|
||||
* @return bool True if the user has organization, bank-managed, or admin profiles; false otherwise.
|
||||
*/
|
||||
public function hasOtherProfiles()
|
||||
{
|
||||
return $this->organizations()->exists()
|
||||
|| $this->banksManaged()->exists()
|
||||
|| $this->admins()->exists();
|
||||
}
|
||||
|
||||
|
||||
public function banksClient()
|
||||
{
|
||||
return $this->morphToMany(Bank::class, 'client', 'bank_clients')
|
||||
->wherePivot('relationship_type', 'local');
|
||||
}
|
||||
|
||||
|
||||
public function attachBankClient($bank, $relationshipType = null)
|
||||
{
|
||||
// Set default relationship type if not provided
|
||||
$relationshipType = $relationshipType ?? 'local';
|
||||
|
||||
// Accept either ID or Bank model
|
||||
$bankId = $bank instanceof Bank ? $bank->id : $bank;
|
||||
|
||||
// Verify existence if numeric ID was provided
|
||||
if (is_numeric($bankId)) {
|
||||
if (!Bank::where('id', $bankId)->exists()) {
|
||||
throw new \Exception("Bank with ID {$bankId} does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
$this->banksClient()->sync([$bankId => [
|
||||
'relationship_type' => $relationshipType,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now()
|
||||
]]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
public function detachBankClient($bank, $relationshipType = null)
|
||||
{
|
||||
// Set default relationship type if not provided
|
||||
$relationshipType = $relationshipType ?? 'local';
|
||||
|
||||
// Accept either ID or Bank model
|
||||
$bankId = $bank instanceof Bank ? $bank->id : $bank;
|
||||
|
||||
// Verify existence if numeric ID was provided
|
||||
if (is_numeric($bankId)) {
|
||||
if (!Bank::where('id', $bankId)->exists()) {
|
||||
throw new \Exception("Bank with ID {$bankId} does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
// Detach with additional pivot conditions
|
||||
$this->banksClient()->wherePivot('relationship_type', $relationshipType)
|
||||
->detach($bankId);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the user's admin(s) that it can manage.
|
||||
* Many-to-many.
|
||||
*/
|
||||
public function admins()
|
||||
{
|
||||
return $this->belongsToMany(Admin::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all related accounts of the user.
|
||||
* One-to-many polymorphic.
|
||||
*/
|
||||
public function accounts()
|
||||
{
|
||||
return $this->morphMany(Account::class, 'accountable');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Check if the user has any associated accounts.
|
||||
*
|
||||
* @return bool Returns true if the user has accounts, false otherwise.
|
||||
*/
|
||||
public function hasAccounts()
|
||||
{
|
||||
$accountsExists = DB::table('accounts')
|
||||
->where('accountable_id', $this->id)
|
||||
->where('accountable_type', 'App\Models\User')
|
||||
->exists();
|
||||
return $accountsExists;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get all related languages of the user.
|
||||
* Many-to-many polymorphic.
|
||||
*/
|
||||
public function languages()
|
||||
{
|
||||
return $this->morphToMany(Language::class, 'languagable')->withPivot('competence');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related socials of the user.
|
||||
* Many-to-many polymorphic.
|
||||
*/
|
||||
public function socials()
|
||||
{
|
||||
return $this->morphToMany(Social::class, 'sociable')->withPivot('id', 'user_on_social', 'server_of_social');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all related the locations of the user.
|
||||
* One-to-many polymorph.
|
||||
*/
|
||||
public function locations()
|
||||
{
|
||||
return $this->morphMany(Location::class, 'locatable');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all of the user's message settings.
|
||||
*/
|
||||
public function message_settings()
|
||||
{
|
||||
return $this->morphOne(MessageSetting::class, 'message_settingable');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Configure the activity log options for the User model.
|
||||
*
|
||||
* This method sets up the logging to:
|
||||
* - Only log changes to the 'name', 'password', and 'last_login_ip' attributes.
|
||||
* - Log only when these attributes have been modified (dirty).
|
||||
* - Prevent submission of empty logs.
|
||||
* - Use 'user' as the log name.
|
||||
*
|
||||
* @return \Spatie\Activitylog\LogOptions
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly([
|
||||
'name',
|
||||
'last_login_ip',
|
||||
'inactive_at',
|
||||
'incomplete_at',
|
||||
'deleted_at',
|
||||
])
|
||||
->logOnlyDirty() // Only log attributes that have been changed
|
||||
->dontSubmitEmptyLogs()
|
||||
->useLogName('User');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all of the User's posts.
|
||||
*/
|
||||
public function posts()
|
||||
{
|
||||
return $this->morphMany(Post::class, 'postable');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all post translations updated by the user.
|
||||
*/
|
||||
public function post_translations_updated()
|
||||
{
|
||||
return $this->hasMany(PostTranslation::class, 'updated_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all of the User's categories.
|
||||
*/
|
||||
public function categories()
|
||||
{
|
||||
return $this->morphMany(Category::class, 'categoryable');
|
||||
}
|
||||
|
||||
|
||||
public function sendEmailVerificationNotification()
|
||||
{
|
||||
\Mail::to($this->email)->send(new \App\Mail\VerifyProfileEmailMailable($this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the password reset notification.
|
||||
*
|
||||
* @param string $token
|
||||
* @return void
|
||||
*/
|
||||
public function sendPasswordResetNotification($token)
|
||||
{
|
||||
$locale = $this->lang_preference ?? app()->getLocale() ?? config('app.fallback_locale');
|
||||
$this->notify(new \App\Notifications\UserPasswordResetNotification($token, $locale));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wirechat: Returns the URL for the user's cover image (avatar).
|
||||
* Adjust the 'avatar_url' field to your database setup.
|
||||
*/
|
||||
public function getCoverUrlAttribute(): ?string
|
||||
{
|
||||
return Storage::url($this->profile_photo_path) ?? null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat:Returns the URL for the user's profile page.
|
||||
* Adjust the 'profile' route as needed for your setup.
|
||||
*/
|
||||
public function getProfileUrlAttribute(): ?string
|
||||
{
|
||||
return route('profile.show_by_type_and_id', ['type' => __('user'), 'id' => $this->id]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Returns the display name for the user.
|
||||
* Modify this to use your preferred name field.
|
||||
*/
|
||||
public function getDisplayNameAttribute(): ?string
|
||||
{
|
||||
if ($this->full_name !== $this->name) {
|
||||
return $this->full_name . ' (' . $this->name . ')';
|
||||
} elseif ($this->name) {
|
||||
return $this->name;
|
||||
} else {
|
||||
return __('User');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Search for users when creating a new chat or adding members to a group.
|
||||
* Customize the search logic to limit results, such as restricting to friends or eligible users only.
|
||||
*/
|
||||
public function searchChatables(string $query): ?\Illuminate\Support\Collection
|
||||
{
|
||||
$searchableFields = ['name', 'full_name'];
|
||||
|
||||
// Search across all profile types
|
||||
$users = User::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
$organizations = \App\Models\Organization::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
$banks = \App\Models\Bank::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
$admins = \App\Models\Admin::where(function ($queryBuilder) use ($searchableFields, $query) {
|
||||
foreach ($searchableFields as $field) {
|
||||
$queryBuilder->orWhere($field, 'LIKE', '%'.$query.'%');
|
||||
}
|
||||
})->limit(6)->get();
|
||||
|
||||
// Combine all results into a base Collection to avoid serialization issues
|
||||
$results = collect()
|
||||
->merge($users->all())
|
||||
->merge($organizations->all())
|
||||
->merge($banks->all())
|
||||
->merge($admins->all());
|
||||
|
||||
// Filter out profiles based on configuration
|
||||
return $results->filter(function ($profile) {
|
||||
// Check inactive profiles
|
||||
if (timebank_config('profile_inactive.messenger_hidden') && !$profile->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check email unverified profiles
|
||||
if (timebank_config('profile_email_unverified.messenger_hidden') && !$profile->isEmailVerified()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check incomplete profiles
|
||||
if (
|
||||
timebank_config('profile_incomplete.messenger_hidden')
|
||||
&& method_exists($profile, 'hasIncompleteProfile')
|
||||
&& $profile->hasIncompleteProfile($profile)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})->take(6)->values();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wirechat: Determine if the user can create new groups.
|
||||
*/
|
||||
public function canCreateGroups(): bool
|
||||
{
|
||||
return timebank_config('profiles.user.messenger_can_create_groups');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has any transactions with another model (User, Organization, Bank).
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Model $model
|
||||
* @return bool
|
||||
*/
|
||||
public function hasTransactionsWith($model): bool
|
||||
{
|
||||
$modelClass = get_class($model);
|
||||
$modelId = $model->id;
|
||||
|
||||
// Check if this user is involved in any transaction where the other model is either the sender or receiver
|
||||
return Transaction::where(function ($query) use ($modelClass, $modelId) {
|
||||
$query->whereHas('accountFrom.accountable', function ($q) use ($modelClass, $modelId) {
|
||||
$q->where('accountable_type', $modelClass)
|
||||
->where('accountable_id', $modelId);
|
||||
})
|
||||
->orWhereHas('accountTo.accountable', function ($q) use ($modelClass, $modelId) {
|
||||
$q->where('accountable_type', $modelClass)
|
||||
->where('accountable_id', $modelId);
|
||||
});
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereHas('accountFrom.accountable', function ($q) {
|
||||
$q->where('accountable_type', User::class)
|
||||
->where('accountable_id', $this->id);
|
||||
})
|
||||
->orWhereHas('accountTo.accountable', function ($q) {
|
||||
$q->where('accountable_type', User::class)
|
||||
->where('accountable_id', $this->id);
|
||||
});
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the user has accepted the principles.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasAcceptedPrinciples(): bool
|
||||
{
|
||||
return !is_null($this->principles_terms_accepted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user needs to re-accept principles due to a newer version.
|
||||
* Compares the stored version with the current active principles post.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function needsToReacceptPrinciples(): bool
|
||||
{
|
||||
// Find the currently active Principles post (any locale) — must come BEFORE
|
||||
// checking acceptance to avoid redirect loops when no post exists.
|
||||
$activeTranslationQuery = function ($query) {
|
||||
$query->where('from', '<=', now())
|
||||
->where(function ($q) {
|
||||
$q->where('till', '>', now())->orWhereNull('till');
|
||||
});
|
||||
};
|
||||
|
||||
$currentPost = Post::whereHas('category', function ($query) {
|
||||
$query->where('type', 'SiteContents\Static\Principles');
|
||||
})
|
||||
->whereHas('translations', $activeTranslationQuery)
|
||||
->first();
|
||||
|
||||
// Failsafe: no active principles post means no acceptance needed
|
||||
if (!$currentPost) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user hasn't accepted at all, they need to accept
|
||||
if (!$this->hasAcceptedPrinciples()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$acceptedData = $this->principles_terms_accepted;
|
||||
|
||||
// Compare by post_id — re-acceptance is only triggered by a new Principles post
|
||||
if (isset($acceptedData['post_id'])) {
|
||||
return (int) $acceptedData['post_id'] !== $currentPost->id;
|
||||
}
|
||||
|
||||
return true; // No post_id stored — require re-acceptance
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Namu\WireChat\Models\Attachment;
|
||||
|
||||
/**
|
||||
* Extends the WireChat Attachment model to override URL generation.
|
||||
*
|
||||
* The vendor model calls Storage::disk('local')->url() which throws because
|
||||
* the local disk has no web-accessible URL. We store files on the local disk
|
||||
* intentionally — access is gated through WireChatAttachmentController which
|
||||
* verifies the requester is a conversation participant before streaming the file.
|
||||
*/
|
||||
class WireChatAttachment extends Attachment
|
||||
{
|
||||
protected function generateUrl(?string $path): ?string
|
||||
{
|
||||
if (! $path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return the authenticated secure route instead of a direct storage URL.
|
||||
// The controller enforces participant-only access.
|
||||
$attachment = static::where('file_path', $path)->first();
|
||||
|
||||
if (! $attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('wirechat.attachment.serve', ['attachment' => $attachment->id]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user