Initial commit

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

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

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

View 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 $fields timestamp.
*/
protected function dateStatus(?string $ts): string
{
if (is_null($ts)) {
return __('no');
}
$dt = \Illuminate\Support\Carbon::parse($ts);
return $dt->isFuture() ? __('planned') : __('yes');
}
}

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

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

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

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

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

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