Initial commit
This commit is contained in:
243
app/Traits/AccountInfoTrait.php
Normal file
243
app/Traits/AccountInfoTrait.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
trait AccountInfoTrait
|
||||
{
|
||||
use ProfilePermissionTrait;
|
||||
/**
|
||||
* Get the balance of an account.
|
||||
*
|
||||
* @param int $accountId The ID of the account.
|
||||
* @return float The balance of the account in minutes.
|
||||
*/
|
||||
public function getBalance($accountId)
|
||||
{
|
||||
// TODO: Store balance in extra column of transactions table with model events for create/update/delete
|
||||
// a new getBalanceFast() method could read this column for fast access of balance.
|
||||
// This new method would then be used for non-critical balance info.
|
||||
$cacheKey = "account_balance_{$accountId}";
|
||||
return Cache::remember($cacheKey, 60, function () use ($accountId) {
|
||||
$balance = Transaction::where('from_account_id', $accountId)
|
||||
->orWhere('to_account_id', $accountId)
|
||||
// selectRaw query, secured with input sanitization ans parameter binding [$accountId}]
|
||||
->selectRaw('SUM(CASE WHEN to_account_id = ? THEN amount ELSE -amount END) as balance', [$accountId])
|
||||
->value('balance');
|
||||
|
||||
return $balance ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get accounts associated with a profile.
|
||||
* If no profileType and profileId is specified, the active profile is used.
|
||||
* Returns an array with account id, name, and balance (in minutes).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getAccountsInfo($profileType = null, $profileId = null)
|
||||
{
|
||||
if ($profileType === null) {
|
||||
$profileType = session('activeProfileType');
|
||||
}
|
||||
if ($profileId === null) {
|
||||
$profileId = session('activeProfileId');
|
||||
}
|
||||
|
||||
$cacheKey = "accounts_info_{$profileType}_{$profileId}";
|
||||
return Cache::remember($cacheKey, 60, function () use ($profileType, $profileId) {
|
||||
// Get the profile
|
||||
$profile = $profileType::find($profileId);
|
||||
|
||||
if (!$profile) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Check if profile has accounts relation (e.g., Admin profiles don't)
|
||||
if (!method_exists($profile, 'accounts')) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Get the profile and its accounts in a single query
|
||||
$profile = $profileType::with(['accounts'])->find($profileId);
|
||||
|
||||
if (!$profile) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Return early if no accounts exist
|
||||
if ($profile->accounts->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Calculate the total balance of all accounts of the profile in a single query
|
||||
$accountIds = $profile->accounts->pluck('id')->toArray();
|
||||
// Convert array to comma-separated string for selectRaw sanitization and parameter binding
|
||||
$accountIdsString = implode(',', $accountIds);
|
||||
$sumAccounts = DB::table('transactions')
|
||||
->whereIn('from_account_id', $accountIds)
|
||||
->orWhereIn('to_account_id', $accountIds)
|
||||
->selectRaw("SUM(CASE WHEN to_account_id IN ($accountIdsString) THEN amount ELSE -amount END) as balance")
|
||||
->value('balance');
|
||||
|
||||
$limitReceivable = $profile->limit_max - ($sumAccounts ?? 0) - $profile->limit_min;
|
||||
// If the limitReceivable is negative, set it to 0
|
||||
if ($limitReceivable < 0) {
|
||||
$limitReceivable = 0;
|
||||
}
|
||||
|
||||
// Map the collection to include the total balance
|
||||
$accounts = $profile->accounts->map(function ($account) use ($profileType, $limitReceivable) {
|
||||
return [
|
||||
'id' => $account->id,
|
||||
'name' => __('messages.' . $account->name . '_account'),
|
||||
'type' => strtolower(class_basename($profileType)),
|
||||
'balance' => $this->getBalance($account->id), // Use getBalance function
|
||||
'balanceTbFormat' => tbFormat($this->getBalance($account->id)),
|
||||
'limitMin' => $account->limit_min,
|
||||
'limitMax' => $account->limit_max,
|
||||
'limitReceivable' => $limitReceivable,
|
||||
'inactive' => $account->inactive_at ? \Illuminate\Support\Carbon::parse($account->inactive_at)->isPast() : false,
|
||||
'inactiveAt' => $account->inactive_at,
|
||||
'removed' => $account->deleted_at ? \Illuminate\Support\Carbon::parse($account->deleted_at)->isPast() : false,
|
||||
'deletedAt' => $account->deleted_at,
|
||||
];
|
||||
});
|
||||
|
||||
return $accounts;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves the account totals of a profile.
|
||||
* If no profileType and profileId is specified, the active profile is used.
|
||||
*
|
||||
* @param string|null $profileType The profile type. If null, the active profile type from the session will be used.
|
||||
* @param int|null $profileId The profile ID. If null, the active profile ID from the session will be used.
|
||||
* @param int|null $sinceDaysAgo The number of days to filter the counted transfers. If null, all transfers will be counted.
|
||||
* @return array Sum of all balances (in minutes), count of transfers, count of transfers received, count of transfers sent.
|
||||
*/
|
||||
public function getAccountsTotals($profileType = null, $profileId = null, $sinceDaysAgo = null)
|
||||
{
|
||||
if ($profileType === null) {
|
||||
$profileType = session('activeProfileType');
|
||||
}
|
||||
if ($profileId === null) {
|
||||
$profileId = session('activeProfileId');
|
||||
}
|
||||
|
||||
// Get the profile instance
|
||||
$profile = $profileType::find($profileId);
|
||||
$type = strtolower(class_basename($profile));
|
||||
|
||||
// Robust check: profile exists and has accounts relation/method
|
||||
if (
|
||||
!$profile ||
|
||||
!(method_exists($profile, 'accounts') || property_exists($profile, 'accounts'))
|
||||
) {
|
||||
return [
|
||||
'sumBalances' => 0,
|
||||
'countTransfersSince' => $sinceDaysAgo !== null ? now()->subDays($sinceDaysAgo) : null,
|
||||
'transfers' => 0,
|
||||
'transfersReceived' => 0,
|
||||
'transfersGiven' => 0,
|
||||
'lastTransferDate' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// Get all accounts for the profile
|
||||
$accounts = $profile->accounts ?? collect();
|
||||
if ($accounts->isEmpty()) {
|
||||
return [
|
||||
'sumBalances' => 0,
|
||||
'countTransfersSince' => $sinceDaysAgo !== null ? now()->subDays($sinceDaysAgo) : null,
|
||||
'transfers' => 0,
|
||||
'transfersReceived' => 0,
|
||||
'transfersGiven' => 0,
|
||||
'lastTransferDate' => null,
|
||||
];
|
||||
}
|
||||
$accountIds = $accounts->pluck('id')->toArray();
|
||||
|
||||
// 1. Calculate sum of balances by iterating through the profile's accounts
|
||||
$sumBalances = 0;
|
||||
foreach ($accounts as $account) {
|
||||
$sumBalances += $this->getBalance($account->id);
|
||||
}
|
||||
|
||||
// 2. Get all relevant transactions in a single query
|
||||
$transfersQuery = Transaction::where(function ($query) use ($accountIds) {
|
||||
$query->whereIn('from_account_id', $accountIds)
|
||||
->orWhereIn('to_account_id', $accountIds);
|
||||
});
|
||||
|
||||
if ($sinceDaysAgo !== null) {
|
||||
$transfersQuery->whereDate('created_at', '>=', now()->subDays($sinceDaysAgo));
|
||||
}
|
||||
|
||||
// Get the date of the most recent transfer before fetching the full collection
|
||||
$lastTransferDate = (clone $transfersQuery)->latest('created_at')->value('created_at');
|
||||
|
||||
$transfers = $transfersQuery->get();
|
||||
|
||||
// 3. Initialize counters
|
||||
$countTransfers = 0;
|
||||
$countTransfersReceived = 0;
|
||||
$countTransfersGiven = 0;
|
||||
|
||||
// 4. Process all transactions in a single loop
|
||||
foreach ($transfers as $transfer) {
|
||||
// Check if the transaction is internal (both from and to accounts belong to the profile)
|
||||
$isInternal = in_array($transfer->from_account_id, $accountIds) && in_array($transfer->to_account_id, $accountIds);
|
||||
|
||||
// Only count non-internal transfers
|
||||
if (!$isInternal) {
|
||||
$countTransfers++;
|
||||
if (in_array($transfer->to_account_id, $accountIds)) {
|
||||
$countTransfersReceived++;
|
||||
}
|
||||
if (in_array($transfer->from_account_id, $accountIds)) {
|
||||
$countTransfersGiven++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Assemble the result with privacy settings from config
|
||||
// Check if viewing own profile
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
$isViewingOwnProfile = ($activeProfileType === $profileType && $activeProfileId === $profileId);
|
||||
|
||||
if ($this->getCanManageAccounts() || $isViewingOwnProfile) {
|
||||
// Show all account info if user can manage accounts OR is viewing their own profile
|
||||
$totals = [
|
||||
'sumBalances' => $sumBalances,
|
||||
'countTransfersSince' => $sinceDaysAgo !== null ? now()->subDays($sinceDaysAgo) : null,
|
||||
'transfers' => $countTransfers,
|
||||
'transfersReceived' => $countTransfersReceived,
|
||||
'transfersGiven' => $countTransfersGiven,
|
||||
'transfersReceivedOrGiven' => $countTransfersReceived + $countTransfersGiven,
|
||||
'lastTransferDate' => $lastTransferDate,
|
||||
];
|
||||
} else {
|
||||
// Apply privacy settings from config for other viewers
|
||||
$totals = [
|
||||
'sumBalances' => timebank_config('account_info.'.$type.'.sumBalances_public', false) === true ? $sumBalances : null,
|
||||
'countTransfersSince' => $sinceDaysAgo !== null ? now()->subDays($sinceDaysAgo) : null,
|
||||
'transfers' => timebank_config('account_info.' . $type . '.countTransfers_public', false) === true ? $countTransfers : null,
|
||||
'transfersReceived' => timebank_config('account_info.' . $type . '.countTransfersReceived_public', false) === true ? $countTransfersReceived : null,
|
||||
'transfersGiven' => timebank_config('account_info.' . $type . '.countTransfersGiven_public', false) === true ? $countTransfersGiven : null,
|
||||
'transfersReceivedOrGiven' => timebank_config('account_info.' . $type . '.countTransfersReceivedOrGiven_public', false) === true ? $countTransfersReceived + $countTransfersGiven : null,
|
||||
'lastTransferDate' => timebank_config('account_info.' . $type . '.lastTransferDate_public', false) === true ? $lastTransferDate : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
}
|
||||
103
app/Traits/ActiveStatesTrait.php
Normal file
103
app/Traits/ActiveStatesTrait.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait ActiveStatesTrait
|
||||
{
|
||||
/**
|
||||
* Scope a query to only include active (inactive_at collumn) models.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->whereNull('inactive_at')
|
||||
->orWhere('inactive_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include inactive (inactive_at) models.
|
||||
*/
|
||||
public function scopeNotActive($query)
|
||||
{
|
||||
return $query->whereNotNull('inactive_at')
|
||||
->where('inactive_at', '<=', now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate the model (set inactive_at to now).
|
||||
*/
|
||||
public function deactivate()
|
||||
{
|
||||
$this->inactive_at = now();
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate the model (set inactive_at to null).
|
||||
*/
|
||||
public function activate()
|
||||
{
|
||||
$this->inactive_at = null;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Scope a query to not include removed (deleted_at) models.
|
||||
*/
|
||||
public function scopeNotRemoved($query)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->whereNull('deleted_at')
|
||||
->orWhere('deleted_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Scope a query to only include removed (deleted_at) models.
|
||||
*/
|
||||
public function scopeRemoved($query)
|
||||
{
|
||||
return $query->whereNotNull('deleted_at')
|
||||
->where('deleted_at', '<=', now());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Scope a query to only include models with a verified email.
|
||||
*/
|
||||
public function scopeEmailVerified($query)
|
||||
{
|
||||
return $query->whereNotNull('email_verified_at')
|
||||
->where('email_verified_at', '<=', now());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the model has a verified email in the past.
|
||||
*/
|
||||
public function isEmailVerified()
|
||||
{
|
||||
return !is_null($this->email_verified_at) && $this->email_verified_at <= now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model is currently active (inactive_at).
|
||||
*/
|
||||
public function isActive()
|
||||
{
|
||||
return is_null($this->inactive_at) || $this->inactive_at > now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model is currently removed (deleted_at).
|
||||
*/
|
||||
public function isRemoved()
|
||||
{
|
||||
return !is_null($this->deleted_at) && $this->deleted_at <= now();
|
||||
}
|
||||
}
|
||||
21
app/Traits/DateTimeTrait.php
Normal file
21
app/Traits/DateTimeTrait.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
trait DateTimeTrait
|
||||
{
|
||||
/**
|
||||
* Return “yes” / “planned” / “no” based on $field’s timestamp.
|
||||
*/
|
||||
protected function dateStatus(?string $ts): string
|
||||
{
|
||||
if (is_null($ts)) {
|
||||
return __('no');
|
||||
}
|
||||
$dt = \Illuminate\Support\Carbon::parse($ts);
|
||||
return $dt->isFuture() ? __('planned') : __('yes');
|
||||
}
|
||||
}
|
||||
50
app/Traits/FormHelpersTrait.php
Normal file
50
app/Traits/FormHelpersTrait.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
trait FormHelpersTrait
|
||||
{
|
||||
/**
|
||||
* Get character counter text if above 75% of limit
|
||||
*
|
||||
* @param string|null $text
|
||||
* @param int $maxLength
|
||||
* @return string
|
||||
*/
|
||||
public function characterLeftCounter(?string $text, int $maxLength, ?int $warnPercentage = null): string
|
||||
{
|
||||
$warnFactor = $warnPercentage ? $warnPercentage / 100 : 0.75;
|
||||
$currentLength = strlen($text ?? '');
|
||||
$threshold = (int) ($maxLength * $warnFactor);
|
||||
|
||||
if ($currentLength >= $threshold) {
|
||||
$remaining = $maxLength - $currentLength;
|
||||
return $remaining . ' ' . __('characters remaining');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get character counter text if above 75% of limit (strips HTML tags)
|
||||
*
|
||||
* @param string|null $text
|
||||
* @param int $maxLength
|
||||
* @param int|null $warnPercentage
|
||||
* @return string
|
||||
*/
|
||||
public function characterLeftCounterWithoutHtml(?string $text, int $maxLength, ?int $warnPercentage = null): string
|
||||
{
|
||||
$warnFactor = $warnPercentage ? $warnPercentage / 100 : 0.75;
|
||||
$plainText = strip_tags($text ?? '');
|
||||
$currentLength = strlen($plainText);
|
||||
$threshold = (int) ($maxLength * $warnFactor);
|
||||
|
||||
if ($currentLength >= $threshold) {
|
||||
$remaining = $maxLength - $currentLength;
|
||||
return $remaining . ' ' . __('characters remaining');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
32
app/Traits/HasPresence.php
Normal file
32
app/Traits/HasPresence.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
// 4. Trait for User Models (add to all your auth models)
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Services\PresenceService;
|
||||
|
||||
trait HasPresence
|
||||
{
|
||||
public function updatePresence($guard = null)
|
||||
{
|
||||
app(PresenceService::class)->updatePresence($this, $guard);
|
||||
}
|
||||
|
||||
public function isOnline($guard = 'web', $minutes = 5)
|
||||
{
|
||||
return app(PresenceService::class)->isUserOnline($this, $guard, $minutes);
|
||||
}
|
||||
|
||||
public function getLastSeenAttribute($guard = 'web')
|
||||
{
|
||||
return app(PresenceService::class)->getUserLastSeen($this, $guard);
|
||||
}
|
||||
|
||||
public function scopeOnline($query, $guard = 'web', $minutes = 5)
|
||||
{
|
||||
$onlineUsers = app(PresenceService::class)->getOnlineUsers($guard, $minutes);
|
||||
$onlineIds = $onlineUsers->where('user_type', static::class)->pluck('id');
|
||||
|
||||
return $query->whereIn('id', $onlineIds);
|
||||
}
|
||||
}
|
||||
331
app/Traits/LocationTrait.php
Normal file
331
app/Traits/LocationTrait.php
Normal file
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
trait LocationTrait
|
||||
{
|
||||
/**
|
||||
* Get the profile's location and optionally generate a link to OpenStreetMap.
|
||||
* Attention: do not extensively use the $lookUpOsmLocation as too many request will be rate-limited!
|
||||
*
|
||||
* @param bool $lookUpOsmLocation Whether to fetch OSM coordinates, URL and polygon data
|
||||
* @return array
|
||||
*/
|
||||
public function getLocationFirst($lookUpOsmLocation = false)
|
||||
{
|
||||
// Initialize variables
|
||||
$location = '';
|
||||
$country = '';
|
||||
$division = '';
|
||||
$city = '';
|
||||
$district = '';
|
||||
$locationData = [];
|
||||
|
||||
if ($this->locations && $this->locations->isNotEmpty()) {
|
||||
// Use the first location if available
|
||||
$firstLocation = $this->locations->first();
|
||||
} else {
|
||||
// Fallback to the single location property
|
||||
$firstLocation = $this->location;
|
||||
}
|
||||
|
||||
if ($firstLocation) {
|
||||
if (isset($firstLocation->city)) {
|
||||
$cityTranslation = $firstLocation->city->translations->first();
|
||||
$city = $cityTranslation ? $cityTranslation->name : '';
|
||||
$location = $city;
|
||||
}
|
||||
if (isset($firstLocation->district)) {
|
||||
$districtTranslation = $firstLocation->district->translations->first();
|
||||
$district = $districtTranslation ? $districtTranslation->name : '';
|
||||
$location = $city ? $city . ' ' . $district : $district;
|
||||
}
|
||||
if (isset($firstLocation->division)) {
|
||||
$divisionTranslation = $firstLocation->division->translations->first();
|
||||
$division = $divisionTranslation ? $divisionTranslation->name : '';
|
||||
$location = $city || $district ? $location . ', ' . $division : $division;
|
||||
}
|
||||
if (isset($firstLocation->country)) {
|
||||
if ($firstLocation->country->code === 'XX') {
|
||||
$country = __('Location not specified');
|
||||
$countryName = $country;
|
||||
} else {
|
||||
$country = $firstLocation->country->code;
|
||||
$countryName = $firstLocation->country->translations->first()->name;
|
||||
}
|
||||
$location = $city || $district || $division ? $location . ', ' . $country : $country;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing comma and space
|
||||
$locationName = rtrim($location, ', ');
|
||||
|
||||
// Remove any space before a comma
|
||||
$locationName = preg_replace('/\s+,/', ',', $locationName);
|
||||
|
||||
$locationData['name'] = $locationName;
|
||||
|
||||
// Construct name_short based on available properties
|
||||
$nameShortParts = [];
|
||||
|
||||
|
||||
if ($city) {
|
||||
$nameShortParts[] = $city;
|
||||
}
|
||||
if ($district && count($nameShortParts) < 2) {
|
||||
$nameShortParts[] = $district;
|
||||
}
|
||||
if ($division && count($nameShortParts) < 2) {
|
||||
$nameShortParts[] = $division;
|
||||
}
|
||||
if (!$city && $country && count($nameShortParts) < 2) {
|
||||
$nameShortParts[] = $countryName;
|
||||
}
|
||||
|
||||
|
||||
// Join the parts with a space
|
||||
$locationData['name_short'] = implode(' ', $nameShortParts);
|
||||
|
||||
// Remove any space before a comma
|
||||
$locationData['name_short'] = preg_replace('/\s+,/', ',', $locationData['name_short']);
|
||||
|
||||
if ($lookUpOsmLocation == true) {
|
||||
// Construct the URL for Nominatim search with polygon data
|
||||
$searchUrl = 'https://nominatim.openstreetmap.org/search?format=json&polygon_geojson=1&q=' . urlencode($locationName);
|
||||
|
||||
// Define your User-Agent
|
||||
$userAgent = config('app.name') . ' (' . timebank_config('mail.system_admin.email') . ')';
|
||||
|
||||
// Send the HTTP request to the Nominatim API
|
||||
$response = Http::withHeaders([
|
||||
'User-Agent' => $userAgent,
|
||||
])->get($searchUrl);
|
||||
|
||||
// Parse the JSON response
|
||||
$data = $response->json();
|
||||
|
||||
// Extract the first result's coordinates (if available)
|
||||
if (!empty($data[0])) {
|
||||
$latitude = $data[0]['lat'];
|
||||
$longitude = $data[0]['lon'];
|
||||
|
||||
// Add coordinate data
|
||||
$locationData['latitude'] = $latitude;
|
||||
$locationData['longitude'] = $longitude;
|
||||
|
||||
// Add polygon data if available
|
||||
if (isset($data[0]['geojson'])) {
|
||||
$locationData['polygon'] = $data[0]['geojson'];
|
||||
$locationData['polygon_type'] = $data[0]['geojson']['type'] ?? null;
|
||||
|
||||
// Create OpenStreetMap URL using polygon data (relation/way)
|
||||
if (isset($data[0]['osm_type']) && isset($data[0]['osm_id'])) {
|
||||
$osmType = $data[0]['osm_type'];
|
||||
$osmId = $data[0]['osm_id'];
|
||||
|
||||
// Use relation or way URL for default polygon display with sidebar
|
||||
if ($osmType === 'relation') {
|
||||
$locationData['url'] = "https://www.openstreetmap.org/relation/{$osmId}";
|
||||
} elseif ($osmType === 'way') {
|
||||
$locationData['url'] = "https://www.openstreetmap.org/way/{$osmId}";
|
||||
} else {
|
||||
// Fallback to coordinate-based URL with center marker
|
||||
$locationData['url'] = "https://www.openstreetmap.org/?mlat={$latitude}&mlon={$longitude}#map=12/{$latitude}/{$longitude}&layers=V";
|
||||
}
|
||||
} else {
|
||||
// Fallback to coordinate-based URL with center marker
|
||||
$locationData['url'] = "https://www.openstreetmap.org/?mlat={$latitude}&mlon={$longitude}#map=12/{$latitude}/{$longitude}&layers=V";
|
||||
}
|
||||
} else {
|
||||
// No polygon data, use coordinate-based URL with center marker
|
||||
$locationData['url'] = "https://www.openstreetmap.org/?mlat={$latitude}&mlon={$longitude}#map=12/{$latitude}/{$longitude}&layers=V";
|
||||
}
|
||||
|
||||
// Add place information
|
||||
$locationData['place_id'] = $data[0]['place_id'] ?? null;
|
||||
$locationData['osm_type'] = $data[0]['osm_type'] ?? null;
|
||||
$locationData['osm_id'] = $data[0]['osm_id'] ?? null;
|
||||
$locationData['display_name'] = $data[0]['display_name'] ?? null;
|
||||
} else {
|
||||
$locationData['url'] = null; // No location found
|
||||
}
|
||||
}
|
||||
|
||||
return $locationData;
|
||||
}
|
||||
|
||||
|
||||
private function getOsmUrl($locationName)
|
||||
{
|
||||
$searchUrl = 'https://nominatim.openstreetmap.org/search?format=json&q=' . urlencode($locationName);
|
||||
|
||||
// Define your User-Agent
|
||||
$userAgent = config('app.name') . ' (' . timebank_config('mail.system_admin.email') . ')';
|
||||
|
||||
// Send the HTTP request to the Nominatim API
|
||||
$response = Http::withHeaders([
|
||||
'User-Agent' => $userAgent,
|
||||
])->get($searchUrl);
|
||||
|
||||
// Parse the JSON response
|
||||
$data = $response->json();
|
||||
|
||||
// Extract the first result's coordinates (if available)
|
||||
if (!empty($data[0])) {
|
||||
$latitude = $data[0]['lat'];
|
||||
$longitude = $data[0]['lon'];
|
||||
|
||||
// Create the OpenStreetMap URL with the coordinates
|
||||
$locationData['url'] = "https://www.openstreetmap.org/?mlat={$latitude}&mlon={$longitude}#map=12/{$latitude}/{$longitude}";
|
||||
} else {
|
||||
$locationData['url'] = null; // No location found
|
||||
}
|
||||
|
||||
|
||||
return $locationData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get polygon boundary for a specific location
|
||||
*
|
||||
* @param string $locationName The location to search for
|
||||
* @param array $options Additional search options (type, limit, etc.)
|
||||
* @return array|null Returns polygon data or null if not found
|
||||
*/
|
||||
public function getLocationPolygon($locationName, $options = [])
|
||||
{
|
||||
// Default options
|
||||
$defaultOptions = [
|
||||
'type' => null, // e.g., 'city', 'administrative', 'boundary'
|
||||
'limit' => 1,
|
||||
'admin_level' => null, // e.g., 8 for cities, 4 for states
|
||||
];
|
||||
|
||||
$options = array_merge($defaultOptions, $options);
|
||||
|
||||
// Construct the URL for Nominatim search
|
||||
$searchUrl = 'https://nominatim.openstreetmap.org/search?format=json&polygon_geojson=1&q=' . urlencode($locationName);
|
||||
|
||||
// Add optional filters
|
||||
if ($options['limit']) {
|
||||
$searchUrl .= '&limit=' . $options['limit'];
|
||||
}
|
||||
|
||||
// Define your User-Agent
|
||||
$userAgent = config('app.name') . ' (' . timebank_config('mail.system_admin.email') . ')';
|
||||
|
||||
try {
|
||||
// Send the HTTP request to the Nominatim API
|
||||
$response = Http::timeout(10)->withHeaders([
|
||||
'User-Agent' => $userAgent,
|
||||
])->get($searchUrl);
|
||||
|
||||
// Parse the JSON response
|
||||
$data = $response->json();
|
||||
|
||||
if (empty($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter results by type if specified
|
||||
if ($options['type']) {
|
||||
$data = array_filter($data, function($item) use ($options) {
|
||||
return isset($item['type']) && $item['type'] === $options['type'];
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by admin level if specified
|
||||
if ($options['admin_level']) {
|
||||
$data = array_filter($data, function($item) use ($options) {
|
||||
return isset($item['admin_level']) && $item['admin_level'] == $options['admin_level'];
|
||||
});
|
||||
}
|
||||
|
||||
// Get the first (best) result
|
||||
$result = reset($data);
|
||||
|
||||
if (!$result || !isset($result['geojson'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'polygon' => $result['geojson'],
|
||||
'polygon_type' => $result['geojson']['type'] ?? null,
|
||||
'coordinates_count' => $this->countPolygonCoordinates($result['geojson']),
|
||||
'latitude' => $result['lat'] ?? null,
|
||||
'longitude' => $result['lon'] ?? null,
|
||||
'display_name' => $result['display_name'] ?? null,
|
||||
'place_id' => $result['place_id'] ?? null,
|
||||
'osm_type' => $result['osm_type'] ?? null,
|
||||
'osm_id' => $result['osm_id'] ?? null,
|
||||
'boundingbox' => $result['boundingbox'] ?? null,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Log error if needed
|
||||
\Log::warning('Nominatim polygon request failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of coordinates in a GeoJSON polygon
|
||||
* Useful for determining polygon complexity
|
||||
*
|
||||
* @param array $geojson
|
||||
* @return int
|
||||
*/
|
||||
private function countPolygonCoordinates($geojson)
|
||||
{
|
||||
if (!isset($geojson['coordinates'])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
if ($geojson['type'] === 'Polygon') {
|
||||
foreach ($geojson['coordinates'] as $ring) {
|
||||
$count += count($ring);
|
||||
}
|
||||
} elseif ($geojson['type'] === 'MultiPolygon') {
|
||||
foreach ($geojson['coordinates'] as $polygon) {
|
||||
foreach ($polygon as $ring) {
|
||||
$count += count($ring);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get simplified polygon for display purposes
|
||||
* Nominatim returns detailed polygons that might be too complex for some uses
|
||||
*
|
||||
* @param string $locationName
|
||||
* @return array|null
|
||||
*/
|
||||
public function getSimplifiedLocationPolygon($locationName)
|
||||
{
|
||||
$polygon = $this->getLocationPolygon($locationName);
|
||||
|
||||
if (!$polygon || !$polygon['polygon']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If polygon has too many coordinates, you might want to simplify it
|
||||
// This is a basic example - you might want to use a proper simplification algorithm
|
||||
if ($polygon['coordinates_count'] > 1000) {
|
||||
// For very complex polygons, you might want to:
|
||||
// 1. Use a different endpoint
|
||||
// 2. Implement coordinate simplification
|
||||
// 3. Use bounding box instead
|
||||
|
||||
$polygon['simplified'] = true;
|
||||
$polygon['original_coordinates_count'] = $polygon['coordinates_count'];
|
||||
}
|
||||
|
||||
return $polygon;
|
||||
}
|
||||
}
|
||||
154
app/Traits/ProfilePermissionTrait.php
Normal file
154
app/Traits/ProfilePermissionTrait.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
trait ProfilePermissionTrait
|
||||
{
|
||||
/**
|
||||
* Determines if the currently authenticated user has permission to manage profiles.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function getCanManageProfiles()
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
$activeType = session('activeProfileType');
|
||||
$activeId = session('activeProfileId');
|
||||
|
||||
if (!$user || !$activeType || !$activeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$typeMap = [
|
||||
'App\Models\Admin' => ['prefix' => 'Admin', 'suffix' => 'admin'],
|
||||
'App\Models\Bank' => ['prefix' => 'Bank', 'suffix' => 'bank-manager'],
|
||||
'App\Models\Organization' => ['prefix' => 'Organization', 'suffix' => 'organization-manager'],
|
||||
];
|
||||
|
||||
if (!isset($typeMap[$activeType])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roleName = "{$typeMap[$activeType]['prefix']}\\{$activeId}\\{$typeMap[$activeType]['suffix']}";
|
||||
|
||||
if (!$user->hasRole($roleName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = Role::where('name', $roleName)->first();
|
||||
if (!$role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $role->permissions->where('name', 'manage profiles')->count() > 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Determines if the active profile can view incomplete profiles.
|
||||
*
|
||||
* Only Admin and Bank profiles can view incomplete profiles.
|
||||
* This is a simple profile type check, independent of permission system.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function canViewIncompleteProfiles()
|
||||
{
|
||||
if (!function_exists('getActiveProfile')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
if (!$activeProfile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$activeProfileClass = get_class($activeProfile);
|
||||
|
||||
// Only Admin and Bank profiles can view incomplete profiles
|
||||
return in_array($activeProfileClass, [
|
||||
'App\Models\Admin',
|
||||
'App\Models\Bank',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Determines if the currently authenticated user can create payments as the active profile.
|
||||
*
|
||||
* Users with the coordinator role (organization-coordinator / bank-coordinator) have
|
||||
* full access to the profile EXCEPT payment execution. Only manager roles can pay.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function getCanCreatePayments()
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
$activeType = session('activeProfileType');
|
||||
$activeId = session('activeProfileId');
|
||||
|
||||
// User profiles can always pay (no elevated profile restriction)
|
||||
if ($activeType === 'App\Models\User') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$user || !$activeType || !$activeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$managerRoleMap = [
|
||||
'App\Models\Organization' => "Organization\\{$activeId}\\organization-manager",
|
||||
'App\Models\Bank' => "Bank\\{$activeId}\\bank-manager",
|
||||
];
|
||||
|
||||
if (!isset($managerRoleMap[$activeType])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasRole($managerRoleMap[$activeType]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the currently authenticated user has permission to manage accounts.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function getCanManageAccounts()
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
$activeType = session('activeProfileType');
|
||||
$activeId = session('activeProfileId');
|
||||
|
||||
if (!$user || !$activeType || !$activeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$typeMap = [
|
||||
'App\Models\Admin' => ['prefix' => 'Admin', 'suffix' => 'admin'],
|
||||
'App\Models\Bank' => ['prefix' => 'Bank', 'suffix' => 'bank-manager'],
|
||||
'App\Models\Organization' => ['prefix' => 'Organization', 'suffix' => 'organization-manager'],
|
||||
];
|
||||
|
||||
if (!isset($typeMap[$activeType])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roleName = "{$typeMap[$activeType]['prefix']}\\{$activeId}\\{$typeMap[$activeType]['suffix']}";
|
||||
|
||||
if (!$user->hasRole($roleName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = Role::where('name', $roleName)->first();
|
||||
if (!$role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $role->permissions->where('name', 'manage accounts')->count() > 0;
|
||||
}
|
||||
}
|
||||
330
app/Traits/ProfileTrait.php
Normal file
330
app/Traits/ProfileTrait.php
Normal file
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
trait ProfileTrait
|
||||
{
|
||||
/***
|
||||
* Retrieve the online status of the user and sets the properties isOnline and isAway accordingly.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getOnlineStatus()
|
||||
{
|
||||
|
||||
// TODO: Replace commented rtippin messenger logic with wirechat logic
|
||||
// Check online status of the user
|
||||
// $messenger = app(Messenger::class);
|
||||
// $status = $messenger->getProviderOnlineStatus($this->profile);
|
||||
|
||||
// if ($status === 1) {
|
||||
// $this->isOnline = true;
|
||||
// $this->isAway = false;
|
||||
// } elseif ($status === 2) {
|
||||
// $this->isOnline = false;
|
||||
// $this->isAway = true;
|
||||
// } else {
|
||||
// $this->isOnline = false;
|
||||
// $this->isAway = false;
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve the phone number of the profile.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getPhone($profile)
|
||||
{
|
||||
if ($profile->phone_public) {
|
||||
return $profile->phone;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the profile's location and generate a link to OpenStreetMap.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getLocation($profile)
|
||||
{
|
||||
return$profile->getLocationFirst(true);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve the user's languages and their language competence.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getLanguages($profile)
|
||||
{
|
||||
// If languages is null, return an empty collection
|
||||
if (empty($profile->languages)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $profile->languages->map(function ($language) {
|
||||
$language->competence_name = DB::table('language_competences')->find($language->pivot->competence)->name;
|
||||
return $language;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public function getLangPreference($profile)
|
||||
{
|
||||
// Get the user's language preference (if it exists)
|
||||
$lang_preference = DB::table('languages')->where('lang_code',$profile->lang_preference)->first();
|
||||
if ($lang_preference) {
|
||||
return $lang_preference->name;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves the skills of this user.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
//TODO! This will set the skill Cache, and not get the skills from cache! FIX THIS!
|
||||
public function getSkills($profile)
|
||||
{
|
||||
$skillsCache = Cache::remember('skills-user-' .$profile->id . '-lang-' . app()->getLocale(), now()->addDays(7), function () use ($profile) { // remember cache
|
||||
$tagIds = $profile->tags->pluck('tag_id');
|
||||
$translatedTags = collect((new Tag())->translateTagIdsWithContexts($tagIds, App::getLocale(), App::getFallbackLocale())); // Translate to app locale, if not available to fallback locale, if not available do not translate
|
||||
|
||||
$skills = $translatedTags->map(function ($item) {
|
||||
return [
|
||||
'tag_id' => $item['tag_id'],
|
||||
'name' => $item['tag'],
|
||||
'foreign' => (isset($item['locale']['locale']) && $item['locale']['locale'] == App::getLocale()) ? false : true,
|
||||
'example' => (isset($item['locale']) && isset($item['locale']['example'])) ? $item['locale']['example'] : null,
|
||||
'category' => $item['category'],
|
||||
'category_path' => $item['category_path'],
|
||||
'category_color' => $item['category_color'],
|
||||
'title' => $item['category_path']
|
||||
];
|
||||
});
|
||||
|
||||
$skills = collect($skills);
|
||||
|
||||
return $skills;
|
||||
});
|
||||
|
||||
$skills = $skillsCache;
|
||||
|
||||
if ($skills) {
|
||||
return $skills;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function getAge($profile)
|
||||
{
|
||||
// Check if the table has the date_of_birth column
|
||||
if (Schema::hasColumn($profile->getTable(), 'date_of_birth') && $profile->date_of_birth) {
|
||||
return Carbon::parse($profile->date_of_birth)->age;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the last login time for the profile.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getLastLogin($profile)
|
||||
{
|
||||
$activityLog =
|
||||
Activity::where('subject_id', $profile->id)
|
||||
->where('subject_type', get_class($profile))
|
||||
->whereNotNull('properties->old->last_login_at')
|
||||
->get('properties')->last();
|
||||
if (isset($activityLog)) {
|
||||
$lastLoginAt = json_decode($activityLog, true)['properties']['old']['last_login_at'];
|
||||
return Carbon::createFromTimeStamp(strtotime($lastLoginAt))->diffForHumans();
|
||||
} elseif ($profile->last_login_at) {
|
||||
$lastLoginAt = $profile->last_login_at;
|
||||
return Carbon::createFromTimeStamp(strtotime($lastLoginAt))->diffForHumans();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves the totals of the user's accounts.
|
||||
* This method calls the `getAccountsTotals` method of the `Account` model to calculate the totals of the user's accounts.
|
||||
*
|
||||
*/
|
||||
public function getAccountsTotals($profile)
|
||||
{
|
||||
$type = strtolower(class_basename($profile));
|
||||
$totals = (new Account())->getAccountsTotals(get_class($profile), $profile->id, timebank_config('account_info.'.$type.'.countTransfersSince'));
|
||||
|
||||
if ($totals) {
|
||||
return $totals;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the last exchange date for the profile.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getLastExchangeAt($accountsTotals = null, $profile = null)
|
||||
{
|
||||
if (!$accountsTotals && $profile) {
|
||||
$accountsTotals = (new Account())->getAccountsTotals($profile->id, timebank_config('account_info.account_totals.countTransfersSince'));
|
||||
}
|
||||
|
||||
// Normalize lastTransferDate to always be an array
|
||||
if (!isset($accountsTotals['lastTransferDate'])) {
|
||||
$accountsTotals['lastTransferDate'] = [];
|
||||
} elseif (!is_array($accountsTotals['lastTransferDate'])) {
|
||||
$accountsTotals['lastTransferDate'] = [$accountsTotals['lastTransferDate']];
|
||||
}
|
||||
|
||||
if (!empty($accountsTotals['lastTransferDate'][0])) {
|
||||
$date = $accountsTotals['lastTransferDate'][0];
|
||||
if ($date instanceof \Illuminate\Support\Carbon) {
|
||||
return $date->diffForHumans();
|
||||
} else {
|
||||
return Carbon::parse($date)->diffForHumans();
|
||||
}
|
||||
} else {
|
||||
return null; // no transfers
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Calculates and sets the registered since date for the user.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getRegisteredSince($profile)
|
||||
{
|
||||
$createdAt = Carbon::parse($profile->created_at);
|
||||
$registeredSince = $createdAt->diffForHumans();
|
||||
|
||||
if ($registeredSince) {
|
||||
return $registeredSince;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves the social media accounts associated with this user.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getSocials($profile)
|
||||
{
|
||||
$socials = $profile->socials()
|
||||
->withPivot(['user_on_social', 'server_of_social', 'created_at', 'updated_at'])
|
||||
->orderByPivot('updated_at', 'desc')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return $socials->isNotEmpty() ? $socials : collect();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if a model has an incomplete profile based on configured fields and relations.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Model $model
|
||||
* @return bool
|
||||
*/
|
||||
public function hasIncompleteProfile($model)
|
||||
{
|
||||
$config = timebank_config('profile_incomplete');
|
||||
|
||||
if (!$config) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$checkFields = $config['check_fields'] ?? [];
|
||||
$checkRelations = $config['check_relations'] ?? [];
|
||||
$minTotalLength = $config['check_fields_min_total_length'] ?? 0;
|
||||
|
||||
// Check if at least one field has data and calculate total length
|
||||
$hasFieldData = false;
|
||||
$totalFieldLength = 0;
|
||||
foreach ($checkFields as $field) {
|
||||
if (!empty($model->{$field})) {
|
||||
$hasFieldData = true;
|
||||
$totalFieldLength += strlen(trim($model->{$field}));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if minimum total length requirement is met
|
||||
$meetsMinLength = $totalFieldLength >= $minTotalLength;
|
||||
|
||||
// Check if at least one relation has data
|
||||
$hasRelationData = false;
|
||||
foreach ($checkRelations as $relation) {
|
||||
if (!$model->relationLoaded($relation)) {
|
||||
$model->load($relation);
|
||||
}
|
||||
|
||||
if (!$model->{$relation}->isEmpty()) {
|
||||
$hasRelationData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Profile is complete (return false) when ALL three conditions are met:
|
||||
// 1. At least one field has data
|
||||
// 2. Total length meets minimum requirement
|
||||
// 3. At least one relation has data
|
||||
$isComplete = $hasFieldData && $meetsMinLength && $hasRelationData;
|
||||
|
||||
// Return true if incomplete, false if complete
|
||||
return !$isComplete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the profile has never received a transaction on any of its active accounts.
|
||||
*/
|
||||
public function hasNeverReceivedTransaction($model): bool
|
||||
{
|
||||
if (!method_exists($model, 'accounts')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$accountIds = $model->accounts()->pluck('id');
|
||||
|
||||
if ($accountIds->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !\App\Models\Transaction::whereIn('to_account_id', $accountIds)->exists();
|
||||
}
|
||||
|
||||
}
|
||||
62
app/Traits/SwitchGuardTrait.php
Normal file
62
app/Traits/SwitchGuardTrait.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
trait SwitchGuardTrait
|
||||
{
|
||||
/**
|
||||
* Switches the authentication guard to the specified guard and logs in the given profile.
|
||||
*
|
||||
* This method logs out the user from all other guards except the specified new guard,
|
||||
* then logs in the provided profile using the new guard and updates the session to
|
||||
* reflect the active guard.
|
||||
*
|
||||
* @param string $newGuard The name of the guard to switch to (e.g., 'web', 'admin', 'bank', 'organization').
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable $profile The user profile to log in with the new guard.
|
||||
* @return void
|
||||
*/
|
||||
function switchGuard($newGuard, $profile) {
|
||||
foreach (['admin', 'bank', 'organization'] as $guard) {
|
||||
if ($guard !== $newGuard) {
|
||||
Auth::guard($guard)->logout();
|
||||
}
|
||||
}
|
||||
Auth::guard($newGuard)->login($profile);
|
||||
session(['active_guard' => $newGuard]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Logs out users from all non-web authentication guards ('admin', 'bank', 'organization')
|
||||
* and sets the session 'active_guard' to 'web'.
|
||||
*
|
||||
* This method is useful for ensuring that only the 'web' guard remains active,
|
||||
* preventing conflicts when switching between different user roles.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function logoutNonWebGuards()
|
||||
{
|
||||
$presenceService = app(\App\Services\PresenceService::class);
|
||||
|
||||
foreach (['admin', 'bank', 'organization'] as $guard) {
|
||||
// Set the user offline before logging out
|
||||
if (Auth::guard($guard)->check()) {
|
||||
$user = Auth::guard($guard)->user();
|
||||
$presenceService->setUserOffline($user, $guard);
|
||||
|
||||
// Explicitly clear all caches for this user/guard combination
|
||||
\Cache::forget("presence_{$guard}_{$user->id}");
|
||||
\Cache::forget("presence_last_update_{$guard}_{$user->id}");
|
||||
}
|
||||
|
||||
// Clear the guard's online users cache
|
||||
\Cache::forget("online_users_{$guard}_" . \App\Services\PresenceService::ONLINE_THRESHOLD_MINUTES);
|
||||
|
||||
Auth::guard($guard)->logout();
|
||||
}
|
||||
|
||||
session(['active_guard' => 'web']);
|
||||
}
|
||||
}
|
||||
986
app/Traits/TaggableWithLocale.php
Normal file
986
app/Traits/TaggableWithLocale.php
Normal file
@@ -0,0 +1,986 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Helpers\StringHelper;
|
||||
use App\Models\Category;
|
||||
use App\Models\CategoryTranslation;
|
||||
use App\Models\TaggableLocale;
|
||||
use Cviebrock\EloquentTaggable\Events\ModelTagged;
|
||||
use Cviebrock\EloquentTaggable\Events\ModelUntagged;
|
||||
use Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException;
|
||||
use Cviebrock\EloquentTaggable\Models\Tag;
|
||||
use Cviebrock\EloquentTaggable\Services\TagService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Class TaggableWithLocale
|
||||
*
|
||||
* Is a customized copy of:
|
||||
* @package Cviebrock\EloquentTaggable 9.0
|
||||
*
|
||||
* This trait includes modified Taggable methods to use also the extra table tag_contexts,
|
||||
* which is not included in the original Taggable package.
|
||||
*/
|
||||
|
||||
trait TaggableWithLocale
|
||||
{
|
||||
public function translateTagName($tagName, $fromLocale, $toLocale)
|
||||
{
|
||||
$tagName = mb_strtolower($tagName);
|
||||
$result = Tag::where('name', $tagName)
|
||||
->whereHas('locale', function ($query) use ($fromLocale) {
|
||||
$query->where('locale', $fromLocale);
|
||||
})
|
||||
->with(
|
||||
'contexts.tags',
|
||||
function ($q) use ($toLocale) {
|
||||
$q->whereHas('locale', function ($q) use ($toLocale) {
|
||||
$q->where('locale', $toLocale);
|
||||
})->select('normalized');
|
||||
}
|
||||
)
|
||||
->get();
|
||||
|
||||
if ($result->count() != 0) {
|
||||
|
||||
$result = $result->first()
|
||||
->contexts;
|
||||
|
||||
if ($result->first()) {
|
||||
$result = $result
|
||||
->first()
|
||||
->tags
|
||||
->pluck('normalized')
|
||||
->unique()
|
||||
->values()
|
||||
->reject($tagName)
|
||||
->flatten()
|
||||
;
|
||||
} else {
|
||||
$result = [];
|
||||
}
|
||||
} else {
|
||||
$result = [];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
//! TODO conditionally select fallback or source locale, similar method as in translateTagIdsWithContexts()
|
||||
public function translateTagNameWithContext($name = null, $toLocale = null)
|
||||
{
|
||||
if (!$toLocale) {
|
||||
$toFallbackLocale = app()->getFallBackLocale();
|
||||
}
|
||||
|
||||
if (!$toFallbackLocale) {
|
||||
$toFallbackLocale = app()->getFallBackLocale();
|
||||
}
|
||||
|
||||
$result = Tag::where('name', $name)
|
||||
->with([
|
||||
'contexts.tags' => function ($query) use ($toLocale) {
|
||||
$query->with(['locale' => function ($query) use ($toLocale) {
|
||||
$query->where('locale', $toLocale)->select('taggable_tag_id', 'locale');
|
||||
}])
|
||||
->whereHas('locale', function ($query) use ($toLocale) {
|
||||
$query->where('locale', $toLocale);
|
||||
})
|
||||
->select('taggable_tags.tag_id', 'normalized');
|
||||
},
|
||||
'contexts.category' => function ($query) use ($toLocale) {
|
||||
$query->with(['translations' => function ($query) use ($toLocale) {
|
||||
$query->where('locale', $toLocale)->select('category_id', 'name');
|
||||
}])
|
||||
->select('id');
|
||||
},
|
||||
])
|
||||
->get();
|
||||
|
||||
|
||||
if ($result->count() != 0) {
|
||||
|
||||
$result = $result->first()
|
||||
->contexts;
|
||||
|
||||
if ($result->first()) {
|
||||
|
||||
$tag = $result
|
||||
->first()
|
||||
->tags
|
||||
->unique()
|
||||
->values()
|
||||
->flatten();
|
||||
$category = $result
|
||||
->first()
|
||||
->category
|
||||
->translations
|
||||
->first();
|
||||
$categoryPath = $result
|
||||
->first()
|
||||
->category
|
||||
->ancestorsAndSelf
|
||||
->sortBy('id')
|
||||
->pluck('id');
|
||||
|
||||
$result = $tag->map(function ($item) use ($category, $categoryPath, $toLocale) {
|
||||
$localeItem = $item->locale ? $item->locale : null;
|
||||
$mapped = [
|
||||
'tag_id' => $item->tag_id,
|
||||
'tag' => StringHelper::dutchTitleCase($item->normalized),
|
||||
'category_id' => $category->category_id,
|
||||
'category' => StringHelper::dutchTitleCase($item->normalized),
|
||||
'category_path' => implode(
|
||||
' > ',
|
||||
CategoryTranslation::whereIn('category_id', $categoryPath)->where('locale', $toLocale)->pluck('name')->toArray()
|
||||
) . ' > ' . StringHelper::dutchTitleCase($item->normalized),
|
||||
'locale' => $localeItem->find($item->tag_id)
|
||||
];
|
||||
return $mapped;
|
||||
});
|
||||
|
||||
} else {
|
||||
$result = [];
|
||||
}
|
||||
} else {
|
||||
$result = [];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
//! TODO conditionally select fallback or source locale, similar method as in translateTagIdsWithContexts()
|
||||
public function translateTagNamesWithContexts($array, $toLocale = null, $toFallbackLocale = null)
|
||||
{
|
||||
|
||||
if (!$toLocale) {
|
||||
$toFallbackLocale = app()->getFallBackLocale();
|
||||
}
|
||||
|
||||
if (!$toFallbackLocale) {
|
||||
$toFallbackLocale = app()->getFallBackLocale();
|
||||
}
|
||||
|
||||
$collection = collect($array);
|
||||
$translated = $collection->map(function ($item, $key) use ($toLocale, $toFallbackLocale) {
|
||||
|
||||
$source = $item;
|
||||
|
||||
$transLocale = $this->translateTagNameWithContext($source, $toLocale);
|
||||
$transFallbackLocale = $this->translateTagNameWithContext($source, $toFallbackLocale);
|
||||
|
||||
if ($transLocale === $source) {
|
||||
return $source;
|
||||
} elseif (count($transLocale) > 0) {
|
||||
return $transLocale;
|
||||
} elseif (count($transFallbackLocale) > 0) {
|
||||
return $transFallbackLocale;
|
||||
} else {
|
||||
return $source;
|
||||
}
|
||||
})
|
||||
->flatMap(function ($innerCollection) {
|
||||
return $innerCollection;
|
||||
});
|
||||
|
||||
return $translated;
|
||||
}
|
||||
|
||||
|
||||
public function translateTagId($tagId, $toLocale)
|
||||
{
|
||||
$result = Tag::where('tag_id', $tagId)
|
||||
->with(
|
||||
'contexts.tags',
|
||||
function ($q) use ($toLocale) {
|
||||
$q->whereHas('locale', function ($q) use ($toLocale) {
|
||||
$q->where('locale', $toLocale);
|
||||
})->select('normalized');
|
||||
}
|
||||
)
|
||||
->get();
|
||||
|
||||
if ($result->count() != 0) {
|
||||
|
||||
$result = $result->first()
|
||||
->contexts;
|
||||
|
||||
if ($result->first()) {
|
||||
$result = $result
|
||||
->first()
|
||||
->tags
|
||||
->pluck('pivot')
|
||||
->pluck('tag_id')
|
||||
->unique()
|
||||
->values()
|
||||
->flatten()
|
||||
;
|
||||
} else {
|
||||
$result = [];
|
||||
}
|
||||
} else {
|
||||
$result = [];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
public function translateTagIdWithContext($tagId)
|
||||
{
|
||||
if ($tagId) {
|
||||
$sourceLocale = TaggableLocale::where('taggable_tag_id', $tagId)->value('locale');
|
||||
$tag = Tag::find($tagId);
|
||||
$translatedTag = $tag->translation;
|
||||
$category = Category::find($tag->contexts->pluck('category_id')->first());
|
||||
$translatedCategory = $category->translation ?? '';
|
||||
if ($translatedCategory) {
|
||||
$categoryPathIds = $category->ancestorsAndSelf->sortBy('id')->pluck('id');
|
||||
$categoryPath = implode(
|
||||
' > ',
|
||||
CategoryTranslation::whereIn('category_id', $categoryPathIds)
|
||||
->where('locale', App::getLocale())
|
||||
->pluck('name')
|
||||
->toArray()
|
||||
);
|
||||
$categoryColor = $category->rootAncestor ? $category->rootAncestor->color : $category->color;
|
||||
}
|
||||
// Map and return the finalized result
|
||||
return [
|
||||
'original_tag_id' => $tagId,
|
||||
'tag_id' => $translatedTag->tag_id,
|
||||
'tag' => $translatedTag->name,
|
||||
'comment' => $translatedTag->comment,
|
||||
'locale' => $translatedTag->locale,
|
||||
'category_id' => $category->id ?? null,
|
||||
'category' => $translatedCategory->name ?? '',
|
||||
'category_path' => $categoryPath ?? '',
|
||||
'category_color' => $categoryColor ?? 'gray',
|
||||
];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function translateTagIds($array, $toLocale = null, $toFallbackLocale = null)
|
||||
{
|
||||
if (!$toLocale) {
|
||||
$toFallbackLocale = app()->getFallBackLocale();
|
||||
}
|
||||
|
||||
if (!$toFallbackLocale) {
|
||||
$toFallbackLocale = app()->getFallBackLocale();
|
||||
}
|
||||
|
||||
$collection = collect($array);
|
||||
$translated = $collection->map(function ($item, $key) use ($toLocale, $toFallbackLocale) {
|
||||
|
||||
$source = $item;
|
||||
|
||||
$transLocale = $this->translateTagId($source, $toLocale);
|
||||
$transFallbackLocale = $this->translateTagId($source, $toFallbackLocale);
|
||||
|
||||
if ($transLocale === $source) {
|
||||
return $source;
|
||||
} elseif (count($transLocale) > 0) {
|
||||
return $transLocale;
|
||||
} elseif (count($transFallbackLocale) > 0) {
|
||||
return $transFallbackLocale;
|
||||
} else {
|
||||
return $source;
|
||||
}
|
||||
})->flatten()->toArray();
|
||||
|
||||
return $translated;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function translateTagIdsWithContexts($tagIds)
|
||||
{
|
||||
$collectionIds = collect($tagIds);
|
||||
$translated = $collectionIds->map(function ($item) {
|
||||
$item = $this->translateTagIdWithContext($item);
|
||||
return $item;
|
||||
});
|
||||
|
||||
return $translated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of normalized tags for a given locale.
|
||||
*
|
||||
* @param string $locale The locale to get tags for.
|
||||
*
|
||||
* @return array An array of normalized tags for the given locale.
|
||||
*/
|
||||
public function localTagArray($locale)
|
||||
{
|
||||
$array = Tag::whereHas('locale', function ($query) use ($locale) {
|
||||
$query->where('locale', $locale);
|
||||
})->pluck('name')->toArray();
|
||||
return $array;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clean up duplicate taggables with different locales
|
||||
* and clean up any orphaned taggables
|
||||
* Example Usage:
|
||||
* $user = User::find(161)->cleantaggables();
|
||||
*
|
||||
* @return mixed The result of the untagging operation.
|
||||
*/
|
||||
public function cleanTaggables()
|
||||
{
|
||||
$this->cleanForeignTaggables();
|
||||
$this->cleanOrhphanedTaggables();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public function cleanForeignTaggables()
|
||||
{
|
||||
// Get the tags with their contexts
|
||||
$tagsWithContexts = $this->tags()->with('localeContext')->get();
|
||||
$tagIds = $tagsWithContexts->pluck('tag_id');
|
||||
$contextIds = $tagsWithContexts->pluck('localeContext.context_id')->flatten();
|
||||
|
||||
// Find the context IDs that appear more than once
|
||||
$duplicateContextIds = $contextIds->duplicates()->flatten();
|
||||
|
||||
$duplicateTagIds = DB::table('taggable_locale_context')
|
||||
->whereIn('context_id', $duplicateContextIds)
|
||||
->whereIn('tag_id', $tagIds)
|
||||
->pluck('tag_id')
|
||||
->flatten();
|
||||
|
||||
$duplicateTagsIdsForeign = DB::table('taggable_locales')
|
||||
->where('locale', '!=', App::getLocale())
|
||||
->whereIn('taggable_tag_id', $duplicateTagIds)
|
||||
->pluck('taggable_tag_id');
|
||||
|
||||
$orhpanedTagIds = DB::table('taggable_taggables')
|
||||
->leftJoin('taggable_tags', 'taggable_taggables.tag_id', '=', 'taggable_tags.tag_id')
|
||||
->whereNull('taggable_tags.tag_id')
|
||||
->select('taggable_taggables.tag_id')
|
||||
->distinct()
|
||||
->pluck('tag_id');
|
||||
|
||||
return $this->untagById($duplicateTagsIdsForeign);
|
||||
}
|
||||
|
||||
|
||||
Public function cleanOrhphanedTaggables()
|
||||
{
|
||||
$orhpanedTagIds = DB::table('taggable_taggables')
|
||||
->leftJoin('taggable_tags', 'taggable_taggables.tag_id', '=', 'taggable_tags.tag_id')
|
||||
->whereNull('taggable_tags.tag_id')
|
||||
->select('taggable_taggables.tag_id')
|
||||
->distinct()
|
||||
->pluck('tag_id');
|
||||
|
||||
return $this->tags()->detach($orhpanedTagIds);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a string of normalized tags for the given locale.
|
||||
*
|
||||
* @param string $locale The locale to filter tags by.
|
||||
* @return string The string of normalized tags.
|
||||
*/
|
||||
public function localTagList($locale)
|
||||
{
|
||||
$array = Tag::whereHas('locale', function ($query) use ($locale) {
|
||||
$query->where('locale', $locale);
|
||||
})->pluck('normalized')->toArray();
|
||||
return implode(config('taggable.glue'), $array);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Find the tag with the given name.
|
||||
*
|
||||
* @param string $value
|
||||
*
|
||||
* @return static|null
|
||||
*/
|
||||
public static function findByName(string $value)
|
||||
{
|
||||
return app(TagService::class)->find($value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Property to control sequence on alias
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $taggableAliasSequence = 0;
|
||||
|
||||
|
||||
/**
|
||||
* Boot the trait.
|
||||
*
|
||||
* Listen for the deleting event of a model, then remove the relation between it and tags
|
||||
*/
|
||||
protected static function bootTaggable(): void
|
||||
{
|
||||
static::deleting(function ($model) {
|
||||
if (!method_exists($model, 'runSoftDelete') || $model->isForceDeleting()) {
|
||||
$model->detag();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get a collection of all tags the model has.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphToMany
|
||||
*/
|
||||
public function tags(): MorphToMany
|
||||
{
|
||||
$model = config('taggable.model');
|
||||
$table = config('taggable.tables.taggable_taggables', 'taggable_taggables');
|
||||
return $this->morphToMany($model, 'taggable', $table, 'taggable_id', 'tag_id')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Attach one or multiple tags to the model.
|
||||
*
|
||||
* @param string|array $tags
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function tag($tags): self
|
||||
{
|
||||
$tags = app(TagService::class)->buildTagArray($tags);
|
||||
|
||||
foreach ($tags as $tagName) {
|
||||
$this->addOneTag($tagName);
|
||||
$this->load('tags');
|
||||
}
|
||||
|
||||
event(new ModelTagged($this, $tags));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach one or more existing tags to a model,
|
||||
* identified by the tag's IDs.
|
||||
*
|
||||
* @param int|int[] $ids
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function tagById($ids): self
|
||||
{
|
||||
$tags = app(TagService::class)->findByIds($ids);
|
||||
$names = $tags->pluck('name')->all();
|
||||
|
||||
return $this->tag($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach one or multiple tags from the model.
|
||||
*
|
||||
* @param string|array $tags
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function untag($tags): self
|
||||
{
|
||||
$tags = app(TagService::class)->buildTagArray($tags);
|
||||
|
||||
foreach ($tags as $tagName) {
|
||||
$this->removeOneTag($tagName);
|
||||
}
|
||||
|
||||
event(new ModelUntagged($this, $tags));
|
||||
|
||||
return $this->load('tags');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach one or more existing tags to a model,
|
||||
* identified by the tag's IDs.
|
||||
*
|
||||
* @param int|int[] $ids
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function untagById($ids): self
|
||||
{
|
||||
$tags = app(TagService::class)->findByIds($ids);
|
||||
$names = $tags->pluck('name')->all();
|
||||
|
||||
return $this->untag($names);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove all tags from the model and assign the given ones.
|
||||
*
|
||||
* @param string|array $tags
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function retag($tags): self
|
||||
{
|
||||
return $this->detag()->tag($tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all tags from the model and assign the given ones by ID.
|
||||
*
|
||||
* @param int|int[] $ids
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function retagById($ids): self
|
||||
{
|
||||
return $this->detag()->tagById($ids);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove all tags from the model.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function detag(): self
|
||||
{
|
||||
$this->tags()->sync([]);
|
||||
|
||||
return $this->load('tags');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add one tag to the model.
|
||||
*
|
||||
* @param string $tagName
|
||||
*/
|
||||
protected function addOneTag(string $tagName): void
|
||||
{
|
||||
/** @var Tag $tag */
|
||||
$tag = app(TagService::class)->findOrCreate($tagName);
|
||||
$tagKey = $tag->getKey();
|
||||
if (!$this->getAttribute('tags')->contains($tagKey)) {
|
||||
$this->tags()->attach($tagKey);
|
||||
}
|
||||
|
||||
$locale = ['locale' => App::getLocale()]; // Customization: include App Locale when adding a tag
|
||||
TaggableLocale::updateOrCreate(['taggable_tag_id' => $tag->getKey()], $locale); // Customization: include App Locale when adding a tag
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one tag from the model
|
||||
*
|
||||
* @param string $tagName
|
||||
*/
|
||||
protected function removeOneTag(string $tagName): void
|
||||
{
|
||||
$tag = app(TagService::class)->find($tagName);
|
||||
|
||||
if ($tag) {
|
||||
$this->tags()->detach($tag);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the tags of the model as a delimited string.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getTagListAttribute(): string
|
||||
{
|
||||
return app(TagService::class)->makeTagList($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all normalized tags of a model as a delimited string.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getTagListNormalizedAttribute(): string
|
||||
{
|
||||
return app(TagService::class)->makeTagList($this, 'normalized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags of a model as an array.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getTagArrayAttribute(): array
|
||||
{
|
||||
return app(TagService::class)->makeTagArray($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all normalized tags of a model as an array.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getTagArrayNormalizedAttribute(): array
|
||||
{
|
||||
return app(TagService::class)->makeTagArray($this, 'normalized');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Determine if a given tag is attached to the model.
|
||||
*
|
||||
* @param Tag|string $tag
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasTag($tag): bool
|
||||
{
|
||||
if ($tag instanceof Tag) {
|
||||
$normalized = $tag->getAttribute('normalized');
|
||||
} else {
|
||||
$normalized = app(TagService::class)->normalize($tag);
|
||||
}
|
||||
|
||||
return in_array($normalized, $this->getTagArrayNormalizedAttribute(), true);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Query scope for models that have all of the given tags.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param array|string $tags
|
||||
*
|
||||
* @return Builder
|
||||
* @throws \Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public function scopeWithAllTags(Builder $query, $tags): Builder
|
||||
{
|
||||
/** @var TagService $service */
|
||||
$service = app(TagService::class);
|
||||
$normalized = $service->buildTagArrayNormalized($tags);
|
||||
|
||||
// If there are no tags specified, then there
|
||||
// can't be any results so short-circuit
|
||||
if (count($normalized) === 0) {
|
||||
if (config('taggable.throwEmptyExceptions')) {
|
||||
throw new NoTagsSpecifiedException('Empty tag data passed to withAllTags scope.');
|
||||
}
|
||||
|
||||
return $query->where(\DB::raw(1), 0);
|
||||
}
|
||||
|
||||
$tagKeys = $service->getTagModelKeys($normalized);
|
||||
|
||||
// If some of the tags specified don't exist, then there can't
|
||||
// be any models with all the tags, so so short-circuit
|
||||
if (count($tagKeys) !== count($normalized)) {
|
||||
return $query->where(\DB::raw(1), 0);
|
||||
}
|
||||
|
||||
$alias = $this->taggableCreateNewAlias(__FUNCTION__);
|
||||
$morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
|
||||
|
||||
return $this->prepareTableJoin($query, 'inner', $alias)
|
||||
->whereIn($morphTagKeyName, $tagKeys)
|
||||
->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) = ?", [count($tagKeys)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query scope for models that have any of the given tags.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param array|string $tags
|
||||
*
|
||||
* @return Builder
|
||||
* @throws \Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public function scopeWithAnyTags(Builder $query, $tags): Builder
|
||||
{
|
||||
/** @var TagService $service */
|
||||
$service = app(TagService::class);
|
||||
$normalized = $service->buildTagArrayNormalized($tags);
|
||||
|
||||
// If there are no tags specified, then there is
|
||||
// no filtering to be done so short-circuit
|
||||
if (count($normalized) === 0) {
|
||||
if (config('taggable.throwEmptyExceptions')) {
|
||||
throw new NoTagsSpecifiedException('Empty tag data passed to withAnyTags scope.');
|
||||
}
|
||||
|
||||
return $query->where(\DB::raw(1), 0);
|
||||
}
|
||||
|
||||
$tagKeys = $service->getTagModelKeys($normalized);
|
||||
|
||||
$alias = $this->taggableCreateNewAlias(__FUNCTION__);
|
||||
$morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
|
||||
|
||||
return $this->prepareTableJoin($query, 'inner', $alias)
|
||||
->whereIn($morphTagKeyName, $tagKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query scope for models that have any tag.
|
||||
*
|
||||
* @param Builder $query
|
||||
*
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeIsTagged(Builder $query): Builder
|
||||
{
|
||||
$alias = $this->taggableCreateNewAlias(__FUNCTION__);
|
||||
return $this->prepareTableJoin($query, 'inner', $alias);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query scope for models that do not have all of the given tags.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param string|array $tags
|
||||
* @param bool $includeUntagged
|
||||
*
|
||||
* @return Builder
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public function scopeWithoutAllTags(Builder $query, $tags, bool $includeUntagged = false): Builder
|
||||
{
|
||||
/** @var TagService $service */
|
||||
$service = app(TagService::class);
|
||||
$normalized = $service->buildTagArrayNormalized($tags);
|
||||
$tagKeys = $service->getTagModelKeys($normalized);
|
||||
$tagKeyList = implode(',', $tagKeys);
|
||||
|
||||
$alias = $this->taggableCreateNewAlias(__FUNCTION__);
|
||||
$morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
|
||||
|
||||
$query = $this->prepareTableJoin($query, 'left', $alias)
|
||||
->havingRaw(
|
||||
"COUNT(DISTINCT CASE WHEN ({$morphTagKeyName} IN ({$tagKeyList})) THEN {$morphTagKeyName} ELSE NULL END) < ?",
|
||||
[count($tagKeys)]
|
||||
);
|
||||
|
||||
if (!$includeUntagged) {
|
||||
$query->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) > 0");
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query scope for models that do not have any of the given tags.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param string|array $tags
|
||||
* @param bool $includeUntagged
|
||||
*
|
||||
* @return Builder
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public function scopeWithoutAnyTags(Builder $query, $tags, bool $includeUntagged = false): Builder
|
||||
{
|
||||
/** @var TagService $service */
|
||||
$service = app(TagService::class);
|
||||
$normalized = $service->buildTagArrayNormalized($tags);
|
||||
$tagKeys = $service->getTagModelKeys($normalized);
|
||||
$tagKeyList = implode(',', $tagKeys);
|
||||
|
||||
$alias = $this->taggableCreateNewAlias(__FUNCTION__);
|
||||
$morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
|
||||
|
||||
$query = $this->prepareTableJoin($query, 'left', $alias)
|
||||
->havingRaw("COUNT(DISTINCT CASE WHEN ({$morphTagKeyName} IN ({$tagKeyList})) THEN {$morphTagKeyName} ELSE NULL END) = 0");
|
||||
|
||||
if (!$includeUntagged) {
|
||||
$query->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) > 0");
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query scope for models that does not have have any tags.
|
||||
*
|
||||
* @param Builder $query
|
||||
*
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeIsNotTagged(Builder $query): Builder
|
||||
{
|
||||
$alias = $this->taggableCreateNewAlias(__FUNCTION__);
|
||||
$morphForeignKeyName = $this->getQualifiedForeignPivotKeyNameWithAlias($alias);
|
||||
|
||||
return $this->prepareTableJoin($query, 'left', $alias)
|
||||
->havingRaw("COUNT(DISTINCT {$morphForeignKeyName}) = 0");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder $query
|
||||
* @param string $joinType
|
||||
*
|
||||
* @return Builder
|
||||
*/
|
||||
private function prepareTableJoin(Builder $query, string $joinType, string $alias): Builder
|
||||
{
|
||||
$morphTable = $this->tags()->getTable();
|
||||
$morphTableAlias = $morphTable.'_'.$alias;
|
||||
|
||||
$modelKeyName = $this->getQualifiedKeyName();
|
||||
$morphForeignKeyName = $this->getQualifiedForeignPivotKeyNameWithAlias($alias);
|
||||
|
||||
$morphTypeName = $morphTableAlias.'.'. $this->tags()->getMorphType();
|
||||
$morphClass = $this->tags()->getMorphClass();
|
||||
|
||||
$closure = function (JoinClause $join) use ($modelKeyName, $morphForeignKeyName, $morphTypeName, $morphClass) {
|
||||
$join->on($modelKeyName, $morphForeignKeyName)
|
||||
->where($morphTypeName, $morphClass);
|
||||
};
|
||||
|
||||
return $query
|
||||
->select($this->getTable() . '.*')
|
||||
->join($morphTable.' as '.$morphTableAlias, $closure, null, null, $joinType)
|
||||
->groupBy($modelKeyName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a collection of all the tag models used for the called class.
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public static function allTagModels(): Collection
|
||||
{
|
||||
return app(TagService::class)->getAllTags(static::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of all tags used for the called class.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function allTags(): array
|
||||
{
|
||||
/** @var \Illuminate\Database\Eloquent\Collection $tags */
|
||||
$tags = static::allTagModels();
|
||||
|
||||
return $tags->pluck('name')->sort()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the tags used for the called class as a delimited string.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function allTagsList(): string
|
||||
{
|
||||
return app(TagService::class)->joinList(static::allTags());
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename one the tags for the called class.
|
||||
*
|
||||
* @param string $oldTag
|
||||
* @param string $newTag
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function renameTag(string $oldTag, string $newTag): int
|
||||
{
|
||||
return app(TagService::class)->renameTags($oldTag, $newTag, static::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most popular tags for the called class.
|
||||
*
|
||||
* @param int $limit
|
||||
* @param int $minCount
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function popularTags(int $limit = null, int $minCount = 1): array
|
||||
{
|
||||
/** @var Collection $tags */
|
||||
$tags = app(TagService::class)->getPopularTags($limit, static::class, $minCount);
|
||||
|
||||
return $tags->pluck('taggable_count', 'name')->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most popular tags for the called class.
|
||||
*
|
||||
* @param int $limit
|
||||
* @param int $minCount
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function popularTagsNormalized(int $limit = null, int $minCount = 1): array
|
||||
{
|
||||
/** @var Collection $tags */
|
||||
$tags = app(TagService::class)->getPopularTags($limit, static::class, $minCount);
|
||||
|
||||
return $tags->pluck('taggable_count', 'normalized')->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Related Pivot Key Name with the table alias.
|
||||
*
|
||||
* @param string $alias
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getQualifiedRelatedPivotKeyNameWithAlias(string $alias): string
|
||||
{
|
||||
$morph = $this->tags();
|
||||
|
||||
return $morph->getTable() . '_' . $alias .
|
||||
'.' . $morph->getRelatedPivotKeyName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Foreign Pivot Key Name with the table alias.
|
||||
*
|
||||
* @param string $alias
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getQualifiedForeignPivotKeyNameWithAlias(string $alias): string
|
||||
{
|
||||
$morph = $this->tags();
|
||||
|
||||
return $morph->getTable() . '_' . $alias .
|
||||
'.' . $morph->getForeignPivotKeyName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new alias to use on scopes to be able to combine many scopes
|
||||
*
|
||||
* @param string $scope
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function taggableCreateNewAlias(string $scope): string
|
||||
{
|
||||
$this->taggableAliasSequence++;
|
||||
$alias = strtolower($scope) . '_' . $this->taggableAliasSequence;
|
||||
|
||||
return $alias;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user