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,10 @@
<?php
use Illuminate\Support\Facades\Auth;
if (!function_exists('get_layout')) {
function get_layout()
{
return Auth::check() ? 'app-layout' : 'guest-layout';
}
}

View File

@@ -0,0 +1,205 @@
<?php
if (!function_exists('platform_trans')) {
/**
* Get platform-specific translation for the current locale
*
* @param string $key Translation key (e.g., 'platform_users', 'platform_name')
* @param string|null $locale Optional locale override
* @param mixed $default Default value if key is not found
* @return string
*/
function platform_trans($key, $locale = null, $default = null)
{
$locale = $locale ?? app()->getLocale();
$baseLanguage = timebank_config('base_language', 'en');
// Try to get translation for current locale
$translation = timebank_config("platform_translations.{$locale}.{$key}");
// Fallback to base language if not found
if ($translation === null && $locale !== $baseLanguage) {
$translation = timebank_config("platform_translations.{$baseLanguage}.{$key}");
}
// Return default if still not found
return $translation ?? $default ?? $key;
}
}
if (!function_exists('platform_name')) {
/**
* Get the platform name for the current locale
*
* @param string|null $locale Optional locale override
* @return string
*/
function platform_name($locale = null)
{
return platform_trans('platform_name', $locale, 'Timebank.cc');
}
}
if (!function_exists('platform_name_short')) {
/**
* Get the short platform name for the current locale
*
* @param string|null $locale Optional locale override
* @return string
*/
function platform_name_short($locale = null)
{
return platform_trans('platform_name_short', $locale, 'Timebank');
}
}
if (!function_exists('platform_name_legal')) {
/**
* Get the legal platform name for the current locale
*
* @param string|null $locale Optional locale override
* @return string
*/
function platform_name_legal($locale = null)
{
return platform_trans('platform_name_legal', $locale, 'association Timebank.cc');
}
}
if (!function_exists('platform_slogan')) {
/**
* Get the platform slogan for the current locale
*
* @param string|null $locale Optional locale override
* @return string
*/
function platform_slogan($locale = null)
{
return platform_trans('platform_slogan', $locale, 'Your time is currency');
}
}
if (!function_exists('platform_user')) {
/**
* Get the singular platform user term for the current locale
*
* @param string|null $locale Optional locale override
* @return string
*/
function platform_user($locale = null)
{
return platform_trans('platform_user', $locale, 'Timebanker');
}
}
if (!function_exists('platform_users')) {
/**
* Get the plural platform users term for the current locale
*
* @param string|null $locale Optional locale override
* @return string
*/
function platform_users($locale = null)
{
return platform_trans('platform_users', $locale, 'Timebankers');
}
}
if (!function_exists('platform_principles')) {
/**
* Get the platform principles term for the current locale
*
* @param string|null $locale Optional locale override
* @return string
*/
function platform_principles($locale = null)
{
return platform_trans('platform_principles', $locale, 'Timebank principles');
}
}
if (!function_exists('platform_currency_name')) {
/**
* Get the singular platform currency name for the current locale
*
* @param string|null $locale Optional locale override
* @return string
*/
function platform_currency_name($locale = null)
{
return platform_trans('platform_currency_name', $locale, 'Hour');
}
}
if (!function_exists('platform_currency_name_plural')) {
/**
* Get the plural platform currency name for the current locale
*
* @param string|null $locale Optional locale override
* @return string
*/
function platform_currency_name_plural($locale = null)
{
return platform_trans('platform_currency_name_plural', $locale, 'Hours');
}
}
if (!function_exists('platform_currency_symbol')) {
/**
* Get the platform currency symbol for the current locale
*
* @param string|null $locale Optional locale override
* @return string
*/
function platform_currency_symbol($locale = null)
{
return platform_trans('platform_currency_symbol', $locale, 'H');
}
}
if (!function_exists('trans_with_platform')) {
/**
* Translate a string and replace platform-specific placeholders
*
* Replaces:
* - @PLATFORM_NAME@ with platform_name()
* - @PLATFORM_NAME_SHORT@ with platform_name_short()
* - @PLATFORM_NAME_LEGAL@ with platform_name_legal()
* - @PLATFORM_SLOGAN@ with platform_slogan()
* - @PLATFORM_USER@ with platform_user()
* - @PLATFORM_USERS@ with platform_users()
* - @PLATFORM_PRINCIPLES@ with platform_principles()
* - @PLATFORM_CURRENCY_NAME@ with platform_currency_name()
* - @PLATFORM_CURRENCY_NAME_PLURAL@ with platform_currency_name_plural()
* - @PLATFORM_CURRENCY_SYMBOL@ with platform_currency_symbol()
*
* @param string $key Translation key
* @param array $replace Additional replacements
* @param string|null $locale Optional locale override
* @return string
*/
function trans_with_platform($key, $replace = [], $locale = null)
{
$translation = __($key, $replace, $locale);
// Replace platform-specific placeholders
$replacements = [
'@PLATFORM_NAME@' => platform_name($locale),
'@PLATFORM_NAME_SHORT@' => platform_name_short($locale),
'@PLATFORM_NAME_LEGAL@' => platform_name_legal($locale),
'@PLATFORM_SLOGAN@' => platform_slogan($locale),
'@PLATFORM_USER@' => platform_user($locale),
'@PLATFORM_USERS@' => platform_users($locale),
'@PLATFORM_PRINCIPLES@' => platform_principles($locale),
'@PLATFORM_CURRENCY_NAME@' => platform_currency_name($locale),
'@PLATFORM_CURRENCY_NAME_PLURAL@' => platform_currency_name_plural($locale),
'@PLATFORM_CURRENCY_SYMBOL@' => platform_currency_symbol($locale),
];
return str_replace(
array_keys($replacements),
array_values($replacements),
$translation
);
}
}

View File

@@ -0,0 +1,286 @@
<?php
namespace App\Helpers;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
/**
* Profile Authorization Helper
*
* Provides centralized authorization validation for profile operations.
* Prevents IDOR (Insecure Direct Object Reference) vulnerabilities by
* validating that authenticated users have permission to act on profiles.
*/
class ProfileAuthorizationHelper
{
/**
* Get authenticated profile from any guard (multi-guard support).
* Returns the authenticated model (User, Organization, Bank, or Admin).
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
private static function getAuthenticatedProfile()
{
// Check all guards and return the authenticated model
return Auth::guard('admin')->user()
?: Auth::guard('bank')->user()
?: Auth::guard('organization')->user()
?: Auth::guard('web')->user();
}
/**
* Validate that the authenticated user has ownership/access to a profile.
*
* This function prevents IDOR attacks by ensuring:
* - Users can only access their own User profile
* - Users can only access Organizations they're linked to
* - Users can only access Banks they're linked to
* - Users can only access Admin profiles they're linked to
*
* @param mixed $profile The profile to validate (User, Organization, Bank, or Admin)
* @param bool $throwException Whether to throw 403 exception (default: true)
* @return bool True if authorized, false if not (when $throwException = false)
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public static function validateProfileOwnership($profile, bool $throwException = true): bool
{
$authenticatedProfile = self::getAuthenticatedProfile();
// Must be authenticated
if (!$authenticatedProfile) {
Log::warning('ProfileAuthorizationHelper: Attempted profile access without authentication', [
'profile_id' => $profile?->id,
'profile_type' => $profile ? get_class($profile) : null,
]);
if ($throwException) {
abort(401, 'Authentication required');
}
return false;
}
// IMPORTANT: Verify guard matches profile type to prevent cross-guard attacks
// Even if the user has a relationship with the profile, they must be authenticated on the correct guard
$expectedGuardForProfile = null;
if ($profile instanceof \App\Models\Bank) {
$expectedGuardForProfile = 'bank';
} elseif ($profile instanceof \App\Models\Organization) {
$expectedGuardForProfile = 'organization';
} elseif ($profile instanceof \App\Models\Admin) {
$expectedGuardForProfile = 'admin';
} elseif ($profile instanceof \App\Models\User) {
$expectedGuardForProfile = 'web';
}
// Check which guard the current authentication is from
$currentGuard = null;
if (Auth::guard('admin')->check() && Auth::guard('admin')->user() === $authenticatedProfile) {
$currentGuard = 'admin';
} elseif (Auth::guard('bank')->check() && Auth::guard('bank')->user() === $authenticatedProfile) {
$currentGuard = 'bank';
} elseif (Auth::guard('organization')->check() && Auth::guard('organization')->user() === $authenticatedProfile) {
$currentGuard = 'organization';
} elseif (Auth::guard('web')->check() && Auth::guard('web')->user() === $authenticatedProfile) {
$currentGuard = 'web';
}
// Prevent cross-guard access
if ($expectedGuardForProfile && $currentGuard && $expectedGuardForProfile !== $currentGuard) {
Log::warning('ProfileAuthorizationHelper: Cross-guard access attempt blocked', [
'authenticated_guard' => $currentGuard,
'target_profile_type' => get_class($profile),
'expected_guard' => $expectedGuardForProfile,
'profile_id' => $profile->id,
]);
if ($throwException) {
abort(403, 'Unauthorized: Cannot access ' . class_basename($profile) . ' profile from ' . $currentGuard . ' guard');
}
return false;
}
// Check if authenticated profile is same type and ID as target profile (direct match)
if (get_class($authenticatedProfile) === get_class($profile) && $authenticatedProfile->id === $profile->id) {
// User is accessing their own profile of same type
Log::info('ProfileAuthorizationHelper: Direct profile access authorized', [
'profile_type' => get_class($profile),
'profile_id' => $profile->id,
]);
return true;
}
// For cross-profile access, we need to check relationships via User model
// Get the underlying User for relationship checks
$authenticatedUser = null;
if ($authenticatedProfile instanceof \App\Models\User) {
$authenticatedUser = $authenticatedProfile;
} elseif ($authenticatedProfile instanceof \App\Models\Admin) {
// Admin can access if they ARE the target admin (already checked above)
// For other profile types, need to get linked users
if (!($profile instanceof \App\Models\Admin)) {
// Admin trying to access non-admin profile - get one of the linked users
$authenticatedUser = $authenticatedProfile->users()->first();
}
} elseif ($authenticatedProfile instanceof \App\Models\Organization) {
// Organization can access if they ARE the target org (already checked above)
// For other profile types, need to get linked users
if (!($profile instanceof \App\Models\Organization)) {
$authenticatedUser = $authenticatedProfile->users()->first();
}
} elseif ($authenticatedProfile instanceof \App\Models\Bank) {
// Bank can access if they ARE the target bank (already checked above)
// For other profile types, need to get linked users
if (!($profile instanceof \App\Models\Bank)) {
$authenticatedUser = $authenticatedProfile->users()->first();
}
}
// If we couldn't get a User for relationship checking and it's not a direct match, deny
if (!$authenticatedUser) {
if ($throwException) {
abort(403, 'Unauthorized: Cannot validate cross-profile access');
}
return false;
}
// Validate based on target profile type
if ($profile instanceof \App\Models\User) {
// User can only access their own user profile
if ($profile->id !== $authenticatedUser->id) {
Log::warning('ProfileAuthorizationHelper: Unauthorized User profile access attempt', [
'authenticated_user_id' => $authenticatedUser->id,
'target_user_id' => $profile->id,
]);
if ($throwException) {
abort(403, 'Unauthorized: You cannot access another user\'s profile');
}
return false;
}
} elseif ($profile instanceof \App\Models\Organization) {
// User must be linked to this organization
if (!$authenticatedUser->organizations()->where('organization_user.organization_id', $profile->id)->exists()) {
Log::warning('ProfileAuthorizationHelper: Unauthorized Organization access attempt', [
'authenticated_user_id' => $authenticatedUser->id,
'target_organization_id' => $profile->id,
'user_organizations' => $authenticatedUser->organizations()->pluck('organizations.id')->toArray(),
]);
if ($throwException) {
abort(403, 'Unauthorized: You are not linked to this organization');
}
return false;
}
} elseif ($profile instanceof \App\Models\Bank) {
// User must be linked to this bank
if (!$authenticatedUser->banksManaged()->where('bank_user.bank_id', $profile->id)->exists()) {
Log::warning('ProfileAuthorizationHelper: Unauthorized Bank access attempt', [
'authenticated_user_id' => $authenticatedUser->id,
'target_bank_id' => $profile->id,
'user_banks' => $authenticatedUser->banksManaged()->pluck('banks.id')->toArray(),
]);
if ($throwException) {
abort(403, 'Unauthorized: You are not linked to this bank');
}
return false;
}
} elseif ($profile instanceof \App\Models\Admin) {
// User must be linked to this admin profile
if (!$authenticatedUser->admins()->where('admin_user.admin_id', $profile->id)->exists()) {
Log::warning('ProfileAuthorizationHelper: Unauthorized Admin access attempt', [
'authenticated_user_id' => $authenticatedUser->id,
'target_admin_id' => $profile->id,
'user_admins' => $authenticatedUser->admins()->pluck('admins.id')->toArray(),
]);
if ($throwException) {
abort(403, 'Unauthorized: You are not linked to this admin profile');
}
return false;
}
} else {
// Unknown profile type
Log::error('ProfileAuthorizationHelper: Unknown profile type', [
'profile_type' => get_class($profile),
'profile_id' => $profile?->id,
]);
if ($throwException) {
abort(500, 'Unknown profile type');
}
return false;
}
// Authorization successful
Log::info('ProfileAuthorizationHelper: Profile access authorized', [
'authenticated_user_id' => $authenticatedUser->id,
'profile_type' => get_class($profile),
'profile_id' => $profile->id,
]);
return true;
}
/**
* Validate profile ownership and throw exception if unauthorized.
*
* Convenience method for the most common use case.
*
* @param mixed $profile The profile to validate
* @return void
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public static function authorize($profile): void
{
self::validateProfileOwnership($profile, true);
}
/**
* Check if user has access to profile without throwing exception.
*
* @param mixed $profile The profile to check
* @return bool True if authorized, false otherwise
*/
public static function can($profile): bool
{
return self::validateProfileOwnership($profile, false);
}
/**
* Check if the web-authenticated user owns a profile (for profile switching).
*
* This method is specifically for profile switching and does NOT enforce guard matching
* since during a switch, the user is authenticated on 'web' guard but wants to access
* an elevated profile (Admin, Bank, Organization).
*
* @param mixed $profile The profile to check ownership of
* @return bool True if the web-authenticated user owns this profile
*/
public static function userOwnsProfile($profile): bool
{
$user = Auth::guard('web')->user();
if (!$user || !$profile) {
return false;
}
// Check based on profile type
if ($profile instanceof \App\Models\User) {
return $profile->id === $user->id;
} elseif ($profile instanceof \App\Models\Organization) {
return $user->organizations()->where('organization_user.organization_id', $profile->id)->exists();
} elseif ($profile instanceof \App\Models\Bank) {
return $user->banksManaged()->where('bank_user.bank_id', $profile->id)->exists();
} elseif ($profile instanceof \App\Models\Admin) {
return $user->admins()->where('admin_user.admin_id', $profile->id)->exists();
}
return false;
}
}

View File

@@ -0,0 +1,101 @@
<?php
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
/**
* Retrieve the active profile based on the session data.
*
* This function checks if the session contains 'activeProfileType' and
* 'activeProfileId'. If both are present, it attempts to find and return
* the profile using the specified type and ID. If either is missing,
* it returns null.
*
* @return mixed|null The active profile object if found, otherwise null.
*/
if (!function_exists('getActiveProfile')) {
function getActiveProfile()
{
$profileType = Session::get('activeProfileType');
$profileId = Session::get('activeProfileId');
if ($profileType && $profileId) {
return $profileType::find($profileId);
}
return null;
}
}
if (!function_exists('getActiveProfileType')) {
function getActiveProfileType()
{
$profileType = Session::get('activeProfileType');
$profileTypeName = class_basename($profileType);
if ($profileType && $profileTypeName) {
return $profileTypeName;
}
return null;
}
}
/**
* Check if the currently authenticated web user can create payments as the active profile.
*
* Users with the coordinator role (organization-coordinator / bank-coordinator) have
* full profile access except payment execution. Only manager roles may pay.
* User profiles are always allowed to pay.
*
* @return bool
*/
if (!function_exists('canActiveProfileCreatePayments')) {
function canActiveProfileCreatePayments(): bool
{
$activeType = Session::get('activeProfileType');
$activeId = Session::get('activeProfileId');
if (!$activeType || !$activeId) {
return false;
}
// User profiles can always pay
if ($activeType === 'App\Models\User') {
return true;
}
$user = Auth::guard('web')->user();
if (!$user) {
return false;
}
$managerRoles = [
'App\Models\Organization' => "Organization\\{$activeId}\\organization-manager",
'App\Models\Bank' => "Bank\\{$activeId}\\bank-manager",
];
if (!isset($managerRoles[$activeType])) {
return false;
}
return $user->hasRole($managerRoles[$activeType]);
}
}
/**
* Check if the system is in maintenance mode.
*
* @return bool
*/
if (!function_exists('isMaintenanceMode')) {
function isMaintenanceMode()
{
return \Illuminate\Support\Facades\Cache::remember('system_setting_maintenance_mode', 300, function () {
$setting = \Illuminate\Support\Facades\DB::table('system_settings')
->where('key', 'maintenance_mode')
->first();
return $setting ? $setting->value === 'true' : false;
});
}
}

View File

@@ -0,0 +1,466 @@
<?php
namespace App\Helpers;
use App\Models\Category;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class SearchOptimizationHelper
{
private const CACHE_PREFIX = 'search_optimization_';
private const DEFAULT_TTL = 3600; // 1 hour
/**
* Build optimized category hierarchy with caching
*/
public static function getCategoryHierarchy(string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
$cacheKey = self::CACHE_PREFIX . "category_hierarchy_{$locale}";
return Cache::remember($cacheKey, self::DEFAULT_TTL, function () use ($locale) {
$categories = Category::where('type', 'App\Models\Tag')
->with(['translations' => function ($query) use ($locale) {
$query->where('locale', $locale);
}])
->get();
return self::buildHierarchyRecursive($categories);
});
}
/**
* Get expanded category IDs with caching
*/
public static function getExpandedCategoryIds(array $categoryIds): array
{
$cacheKey = self::CACHE_PREFIX . 'expanded_' . md5(serialize($categoryIds));
return Cache::remember($cacheKey, self::DEFAULT_TTL, function () use ($categoryIds) {
$allIds = [];
foreach ($categoryIds as $categoryId) {
if (is_numeric($categoryId) && $categoryId > 0) {
$descendants = self::getCategoryDescendants((int)$categoryId);
$allIds = array_merge($allIds, $descendants);
}
}
return array_values(array_unique($allIds));
});
}
/**
* Get category names by IDs with caching
*/
public static function getCategoryNamesByIds(array $categoryIds, string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
$cacheKey = self::CACHE_PREFIX . "names_{$locale}_" . md5(serialize($categoryIds));
return Cache::remember($cacheKey, 300, function () use ($categoryIds, $locale) {
$categories = Category::whereIn('id', $categoryIds)
->with(['translations' => function ($query) use ($locale) {
$query->where('locale', $locale);
}])
->get();
return $categories->map(function ($category) {
return $category->translation ? $category->translation->name : null;
})->filter()->values()->toArray();
});
}
/**
* Clear all search-related caches using app locales
*/
public static function clearSearchCaches(): void
{
// Use your supported locales
$locales = ['en', 'nl', 'fr', 'es', 'de'];
foreach ($locales as $locale) {
Cache::forget(self::CACHE_PREFIX . "category_hierarchy_{$locale}");
}
// Clear pattern-based cache keys
if (Cache::getStore() instanceof \Illuminate\Cache\RedisStore) {
$cacheKeys = Cache::getRedis()->keys(self::CACHE_PREFIX . '*');
if (!empty($cacheKeys)) {
Cache::getRedis()->del($cacheKeys);
}
}
Log::info('Search caches cleared');
}
/**
* Build search query suggestions based on category names
*/
public static function buildSearchSuggestions(array $categoryNames, string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
$suggestions = [];
foreach ($categoryNames as $categoryName) {
// Add exact match
$suggestions[] = [
'text' => $categoryName,
'weight' => 10,
'input' => [$categoryName],
];
// Add partial matches
$words = explode(' ', $categoryName);
if (count($words) > 1) {
foreach ($words as $word) {
if (strlen($word) > 2) {
$suggestions[] = [
'text' => $word,
'weight' => 5,
'input' => [$word],
];
}
}
}
}
return array_unique($suggestions, SORT_REGULAR);
}
/**
* Optimize search results by applying relevance scoring including location
*/
public static function optimizeSearchResults(array $results, array $searchTerms = [], array $userLocation = []): array
{
$optimized = [];
foreach ($results as $result) {
$score = $result['score'] ?? 1;
// Boost based on model type
$modelBoost = self::getModelTypeBoost($result['model'] ?? '');
$score *= $modelBoost;
// Boost based on highlight quality
$highlightBoost = self::getHighlightBoost($result['highlight'] ?? []);
$score *= $highlightBoost;
// Boost based on data completeness
$completenessBoost = self::getCompletenessBoost($result);
$score *= $completenessBoost;
// Boost based on recency
$recencyBoost = self::getRecencyBoost($result);
$score *= $recencyBoost;
// Boost based on location proximity
$locationBoost = self::getLocationBoost($result, $userLocation);
$score *= $locationBoost;
$result['optimized_score'] = $score;
$optimized[] = $result;
}
// Sort by optimized score primarily, with location as a tie-breaker
usort($optimized, function ($a, $b) {
// Primary sort: optimized score (higher scores first)
$aScore = $a['optimized_score'] ?? 0;
$bScore = $b['optimized_score'] ?? 0;
$scoreDiff = $bScore <=> $aScore;
if ($scoreDiff !== 0) {
return $scoreDiff;
}
// Secondary sort: location distance (closer locations first)
$aDistance = $a['location_proximity']['distance'] ?? 9;
$bDistance = $b['location_proximity']['distance'] ?? 9;
return $aDistance <=> $bDistance;
});
return $optimized;
}
/**
* Generate search analytics data
*/
public static function trackSearchAnalytics(array $categoryIds, int $totalResults, float $executionTime): void
{
$analyticsData = [
'timestamp' => now(),
'category_ids' => $categoryIds,
'total_results' => $totalResults,
'execution_time' => $executionTime,
'locale' => app()->getLocale(),
'user_id' => auth()->id(),
];
// Store in cache for recent searches
$recentSearchesKey = self::CACHE_PREFIX . 'recent_searches_' . (auth()->id() ?? 'guest');
$recentSearches = Cache::get($recentSearchesKey, []);
array_unshift($recentSearches, $analyticsData);
$recentSearches = array_slice($recentSearches, 0, 10); // Keep last 10 searches
Cache::put($recentSearchesKey, $recentSearches, now()->addHours(24));
// Log for analysis
Log::info('Search performed', $analyticsData);
}
/**
* Get search performance metrics
*/
public static function getSearchMetrics(): array
{
$userId = auth()->id();
$recentSearchesKey = self::CACHE_PREFIX . 'recent_searches_' . ($userId ?? 'guest');
$recentSearches = Cache::get($recentSearchesKey, []);
if (empty($recentSearches)) {
return [
'total_searches' => 0,
'avg_results' => 0,
'avg_execution_time' => 0,
'popular_categories' => [],
];
}
$totalSearches = count($recentSearches);
$totalResults = array_sum(array_column($recentSearches, 'total_results'));
$totalExecutionTime = array_sum(array_column($recentSearches, 'execution_time'));
// Get popular categories
$allCategories = [];
foreach ($recentSearches as $search) {
$allCategories = array_merge($allCategories, $search['category_ids']);
}
$popularCategories = array_count_values($allCategories);
arsort($popularCategories);
return [
'total_searches' => $totalSearches,
'avg_results' => $totalSearches > 0 ? round($totalResults / $totalSearches, 2) : 0,
'avg_execution_time' => $totalSearches > 0 ? round($totalExecutionTime / $totalSearches, 4) : 0,
'popular_categories' => array_slice($popularCategories, 0, 5, true),
];
}
/**
* Build hierarchy recursively
*/
private static function buildHierarchyRecursive($categories, $parentId = null): array
{
$hierarchy = [];
foreach ($categories as $category) {
if ($category->parent_id == $parentId) {
$categoryData = [
'id' => $category->id,
'name' => $category->translation->name ?? $category->name,
'color' => $category->relatedColor,
'parent_id' => $category->parent_id,
'children' => self::buildHierarchyRecursive($categories, $category->id)
];
$hierarchy[] = $categoryData;
}
}
return $hierarchy;
}
/**
* Get category descendants with caching
*/
private static function getCategoryDescendants(int $categoryId): array
{
$cacheKey = self::CACHE_PREFIX . "descendants_{$categoryId}";
return Cache::remember($cacheKey, self::DEFAULT_TTL, function () use ($categoryId) {
$category = Category::find($categoryId);
if (!$category) {
return [$categoryId];
}
try {
$descendants = $category->descendants()->pluck('id')->toArray();
return array_merge([$categoryId], array_map('intval', $descendants));
} catch (\Exception $e) {
Log::warning("Failed to get descendants for category {$categoryId}: " . $e->getMessage());
return [$categoryId];
}
});
}
/**
* Get model type boost factor using timebank-cc configuration
*/
private static function getModelTypeBoost(string $modelClass): float
{
$type = strtolower(class_basename($modelClass));
return timebank_config('main_search_bar.boosted_models.' . $type, 1.0);
}
/**
* Get highlight quality boost
*/
private static function getHighlightBoost(array $highlights): float
{
if (empty($highlights)) {
return 1.0;
}
$totalHighlights = 0;
foreach ($highlights as $fieldHighlights) {
$totalHighlights += count($fieldHighlights);
}
// More highlights = higher relevance
return 1.0 + (min($totalHighlights, 10) * 0.05);
}
/**
* Get data completeness boost
*/
private static function getCompletenessBoost(array $result): float
{
$completenessFactors = 0;
$totalFactors = 0;
// Check various completeness factors
$checks = [
'about' => !empty($result['about'] ?? ''),
'about_short' => !empty($result['about_short'] ?? ''),
'subtitle' => !empty($result['subtitle'] ?? ''),
'photo' => !empty($result['photo'] ?? ''),
'location' => !empty($result['location'] ?? ''),
'category' => !empty($result['category'] ?? ''),
];
foreach ($checks as $factor => $isPresent) {
$totalFactors++;
if ($isPresent) {
$completenessFactors++;
}
}
if ($totalFactors === 0) {
return 1.0;
}
$completenessRatio = $completenessFactors / $totalFactors;
return 1.0 + ($completenessRatio * 0.2); // Up to 20% boost
}
/**
* Get location-based boost based on proximity to user
*/
private static function getLocationBoost(array $result, array $userLocation): float
{
if (empty($userLocation) || !isset($result['location_proximity'])) {
return 1.0;
}
$proximity = $result['location_proximity'];
// Location proximity boost factors
$locationBoosts = [
'same_district' => 1.1,
'same_city' => 1.05,
'same_division' => 1.02,
'same_country' => 1.0,
'different_country' => 1.0,
'no_location' => 1.0,
'unknown' => 1.0,
'error' => 1.0,
];
return $locationBoosts[$proximity['level'] ?? 'unknown'] ?? 1.0;
}
/**
* Get recency boost based on creation/update time
*/
private static function getRecencyBoost(array $result): float
{
// This would need to be implemented based on your specific timestamp fields
// For now, return neutral boost
return 1.0;
}
/**
* Generate location-aware search analytics
*/
public static function trackLocationSearchAnalytics(array $categoryIds, int $totalResults, float $executionTime, array $locationHierarchy = []): void
{
$analyticsData = [
'timestamp' => now(),
'category_ids' => $categoryIds,
'total_results' => $totalResults,
'execution_time' => $executionTime,
'locale' => app()->getLocale(),
'user_id' => auth()->id(),
'location_hierarchy' => [
'country_id' => $locationHierarchy['country']->id ?? null,
'division_id' => $locationHierarchy['division']->id ?? null,
'city_id' => $locationHierarchy['city']->id ?? null,
'district_id' => $locationHierarchy['district']->id ?? null,
],
];
// Store in cache for recent searches
$recentSearchesKey = self::CACHE_PREFIX . 'recent_searches_' . (auth()->id() ?? 'guest');
$recentSearches = Cache::get($recentSearchesKey, []);
array_unshift($recentSearches, $analyticsData);
$recentSearches = array_slice($recentSearches, 0, 10); // Keep last 10 searches
Cache::put($recentSearchesKey, $recentSearches, now()->addHours(24));
// Log for analysis
Log::info('Location-aware search performed', $analyticsData);
}
/**
* Validate search parameters using timebank-cc configuration
*/
public static function validateSearchParams(array $params): array
{
$validated = [];
// Validate category IDs
if (isset($params['category_ids']) && is_array($params['category_ids'])) {
$validated['category_ids'] = array_filter($params['category_ids'], function ($id) {
return is_numeric($id) && $id > 0;
});
}
// Validate locale using your base language and supported locales
if (isset($params['locale'])) {
// Use your supported locales (en, nl, fr, es, de from your config)
$availableLocales = ['en', 'nl', 'fr', 'es', 'de'];
$validated['locale'] = in_array($params['locale'], $availableLocales)
? $params['locale']
: timebank_config('base_language', 'en');
}
// Validate limit using your max_results
if (isset($params['limit'])) {
$maxResults = timebank_config('main_search_bar.search.max_results', 50);
$validated['limit'] = max(1, min((int)$params['limit'], $maxResults));
}
return $validated;
}
/**
* Build cache key for search results compatible with timebank-cc naming
*/
public static function buildSearchCacheKey(array $params): string
{
ksort($params); // Ensure consistent ordering
return 'category_search_results_' . md5(serialize($params));
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Helpers;
class StringHelper
{
/**
* Transform the string: capitalize the first letter, lowercase the rest and ensure it ends with a full stop.
*
* @param string $value
* @return string
*/
public static function SentenceCase(string $value): string
{
$value = strtolower($value);
$value = ucfirst($value);
if (substr($value, -1) !== '.') {
$value .= '.';
}
return $value;
}
/**
* Transform the string: capitalize the first letter, lowercase the rest and ensure it ends without a full stop.
*
* @param string $value
* @return string
*/
public static function DutchTitleCase(string $value): string
{
return ucfirst(strtolower($value));
}
/**
* Get the translated page title for the current route.
* Maps route names to page title translation keys.
*
* @param string|null $routeName
* @return string
*/
public static function getPageTitle(?string $routeName = null): string
{
$routeName = $routeName ?: \Route::currentRouteName();
// Map route names to page title translation keys
$routeMap = [
'welcome' => 'page_title.welcome',
'dashboard' => 'page_title.dashboard',
'search' => 'page_title.search',
'search.results' => 'page_title.search',
'transactions.index' => 'page_title.transactions',
'transactions.show' => 'page_title.transactions',
'profile.show' => 'page_title.profile',
'profile.edit' => 'page_title.profile',
'profile.settings' => 'page_title.settings',
'user-profile-information.update' => 'page_title.settings',
'login' => 'page_title.login',
'register' => 'page_title.register',
'post.index' => 'page_title.posts',
'post.show' => 'page_title.posts',
'admin.index' => 'page_title.admin',
'contacts' => 'Contacts',
'static-faq' => 'FAQ',
'static-getting-started' => 'Getting started',
'static-privacy' => 'Privacy',
'static-organizations' => 'Organizations',
'static-principles' => 'Principles',
'static-report-issue' => 'Report an issue',
'static-events' => 'Events',
'static-messenger' => 'Messages',
'static-report-error' => 'Report an error',
];
// Get the translation key for this route, or default to welcome
$translationKey = $routeMap[$routeName] ?? 'page_title.welcome';
return __($translationKey);
}
/**
* Sanitize HTML content to prevent XSS attacks while preserving rich text formatting.
* Allows safe HTML tags like paragraphs, headings, links, images, lists, etc.
*
* IMPORTANT: This method uses Laravel's cache directory for HTMLPurifier cache,
* avoiding permission issues with the vendor directory on production servers.
*
* @param string|null $html
* @return string
*/
public static function sanitizeHtml(?string $html): string
{
if (empty($html)) {
return '';
}
// Create HTMLPurifier configuration
$config = \HTMLPurifier_Config::createDefault();
// Use Laravel's cache directory instead of vendor directory
// This avoids "Directory not writable" errors on production servers
$cacheDir = storage_path('framework/cache/htmlpurifier');
// Create cache directory if it doesn't exist
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
$config->set('Cache.SerializerPath', $cacheDir);
// Allow target="_blank" for links - must be set before getHTMLDefinition()
$config->set('Attr.AllowedFrameTargets', ['_blank']);
// Enable HTML5 mode and allow data-* attributes
$config->set('HTML.DefinitionID', 'html5-definitions');
$config->set('HTML.DefinitionRev', 1);
// Allow rich text formatting elements including data-list attribute
$config->set('HTML.Allowed',
'p,br,strong,b,em,i,u,strike,del,ins,' .
'h1,h2,h3,h4,h5,h6,' .
'ul,ol,li[data-list],' .
'a[href|target|title|rel],' .
'img[src|alt|width|height|title],' .
'blockquote,pre,code,' .
'table,thead,tbody,tr,th,td,' .
'span[class|contenteditable],div[class]'
);
// Get HTML definition and add custom data-list attribute
// Use maybeGetRawHTMLDefinition to avoid caching warnings
if ($def = $config->maybeGetRawHTMLDefinition()) {
// Add data-list as an enumerated attribute with specific allowed values
$def->addAttribute('li', 'data-list', new \HTMLPurifier_AttrDef_Enum(
array('bullet', 'ordered')
));
}
// Create purifier and clean the HTML
$purifier = new \HTMLPurifier($config);
return $purifier->purify($html);
}
}
/**
* Get the translated page title for the current route.
*
* @param string|null $routeName
* @return string
*/
if (!function_exists('page_title')) {
function page_title(?string $routeName = null): string
{
return \App\Helpers\StringHelper::getPageTitle($routeName);
}
}

View File

@@ -0,0 +1,99 @@
<?php
function tailwindColorToHex($tailwindColor)
{
$conversionMap = [
'red-200' => '#fecaca',
'orange-200' => '#fed7aa',
'amber-200' => '#fde68a',
'yellow-200' => '#fef08a',
'lime-200' => '#d9f99d',
'green-200' => '#bbf7d0',
'emerald-200' => '#a7f3d0',
'teal-200' => '#99f6e4',
'cyan-200' => '#a5f3fc',
'sky-200' => '#bae6fd',
'blue-200' => '#dbeafe',
'gray-200' => '#e5e7eb',
'indigo-200' => '#c7d2fe',
'violet-200' => '#ddd6fe',
'purple-200' => '#e9d5ff',
'fuchsia-200' => '#f5d0fe',
'pink-200' => '#fce7f3',
'red-300' => '#fca5a5',
'orange-300' => '#fdba74',
'amber-300' => '#fcd34d',
'yellow-300' => '#fde047',
'lime-300' => '#bef264',
'green-300' => '#86efac',
'emerald-300' => '#6ee7b7',
'teal-300' => '#5eead4',
'cyan-300' => '#67e8f9',
'sky-300' => '#7dd3fc',
'blue-300' => '#93c5fd',
'gray-300' => '#d1d5db',
'indigo-300' => '#a5b4fc',
'violet-300' => '#c4b5fd',
'purple-300' => '#d8b4fe',
'fuchsia-300' => '#f0abfc',
'pink-300' => '#f9a8d4',
'red-400' => '#f87171',
'orange-400' => '#fb923c',
'amber-400' => '#fbbf24',
'yellow-400' => '#facc15',
'lime-400' => '#a3e635',
'green-400' => '#4ade80',
'emerald-400' => '#34d399',
'teal-400' => '#2dd4bf',
'cyan-400' => '#22d3ee',
'sky-400' => '#60a5fa',
'blue-400' => '#60a5fa',
'gray-400' => '#9ca3af',
'violet-400' => '#a78bfa',
'indigo-400' => '#818cf8',
'purple-400' => '#c084fc',
'fuchsia-400' => '#e879f9',
'pink-400' => '#f472b6',
'red-600' => '#dc2626',
'orange-600' => '#ea580c',
'amber-600' => '#d97706',
'yellow-600' => '#ca8a04',
'lime-600' => '#65a30d',
'green-600' => '#16a34a',
'emerald-600' => '#059669',
'teal-600' => '#0d9488',
'cyan-600' => '#0891b2',
'sky-600' => '#0284c7',
'blue-600' => '#2563eb',
'gray-600' => '#4b5563',
'indigo-600' => '#4f46e5',
'violet-600' => '#7c3aed',
'purple-600' => '#9333ea',
'fuchsia-600' => '#c026d3',
'pink-600' => '#db2777',
'red-800' => '#991b1b',
'orange-800' => '#9a3412',
'amber-800' => '#92400e',
'yellow-800' => '#854d0e',
'lime-800' => '#3f6212',
'green-800' => '#166534',
'emerald-800' => '#065f46',
'teal-800' => '#115e59',
'cyan-800' => '#155e63',
'sky-800' => '#075985',
'blue-800' => '#1e40af',
'gray-800' => '#1f2937',
'indigo-800' => '#3730a3',
'violet-800' => '#5b21b6',
'purple-800' => '#6b21a8',
'fuchsia-800' => '#86198f',
'pink-800' => '#9d174d',
];
// Return the HEX value
return $conversionMap[$tailwindColor] ?? null;
}

179
app/Helpers/ThemeHelper.php Normal file
View File

@@ -0,0 +1,179 @@
<?php
if (!function_exists('theme')) {
/**
* Get theme configuration or specific theme property
*
* @param string|null $key Optional key to get specific theme property
* @param mixed $default Default value if key is not found
* @return mixed
*/
function theme($key = null, $default = null)
{
$activeTheme = config('themes.active', 'timebank_cc');
$themes = config('themes.themes', []);
if (!isset($themes[$activeTheme])) {
$activeTheme = 'timebank_cc'; // fallback to default
}
$themeConfig = $themes[$activeTheme] ?? [];
if ($key === null) {
return array_merge(['id' => $activeTheme], $themeConfig);
}
return data_get($themeConfig, $key, $default);
}
}
if (!function_exists('theme_name')) {
/**
* Get the current theme name
*
* @return string
*/
function theme_name()
{
return theme('name', 'Timebank.cc');
}
}
if (!function_exists('theme_id')) {
/**
* Get the current theme ID/key
*
* @return string
*/
function theme_id()
{
return config('themes.active', 'timebank_cc');
}
}
if (!function_exists('theme_color')) {
/**
* Get a theme color value
*
* @param string $colorKey Color key (e.g., 'primary.500', 'accent', 'text.primary')
* @param string|null $default Default color value
* @return string
*/
function theme_color($colorKey, $default = null)
{
return theme("colors.{$colorKey}", $default);
}
}
if (!function_exists('theme_font')) {
/**
* Get a theme typography value
*
* @param string $fontKey Font key (e.g., 'font_family_body', 'font_size_base')
* @param string|null $default Default font value
* @return string
*/
function theme_font($fontKey, $default = null)
{
return theme("typography.{$fontKey}", $default);
}
}
if (!function_exists('is_theme')) {
/**
* Check if the current theme matches the given theme ID
*
* @param string $themeId Theme ID to check against
* @return bool
*/
function is_theme($themeId)
{
return theme_id() === $themeId;
}
}
if (!function_exists('theme_logo')) {
/**
* Get a theme logo path or view name
*
* @param string $type Logo type: 'svg_inline' or 'email_logo'
* @param string|null $default Default value if logo not configured
* @return string
*/
function theme_logo($type = 'svg_inline', $default = null)
{
return theme("logos.{$type}", $default);
}
}
if (!function_exists('theme_css_vars')) {
/**
* Generate CSS custom properties for the current theme
*
* @return string CSS custom properties as inline styles
*/
function theme_css_vars()
{
$themeConfig = theme();
$colors = $themeConfig['colors'] ?? [];
$typography = $themeConfig['typography'] ?? [];
$cssVars = [];
// Process color variables
foreach ($colors as $colorGroup => $colorValue) {
if (is_array($colorValue)) {
foreach ($colorValue as $shade => $hex) {
$rgbValue = hexToRgb($hex);
$cssVars["--color-{$colorGroup}-{$shade}"] = $rgbValue;
}
} else {
$rgbValue = hexToRgb($colorValue);
$cssVars["--color-{$colorGroup}"] = $rgbValue;
}
}
// Process typography variables
foreach ($typography as $typographyKey => $typographyValue) {
if (is_array($typographyValue)) {
// Handle nested arrays (heading_sizes, font_sizes, font_weights)
foreach ($typographyValue as $subKey => $subValue) {
$cssVars["--{$typographyKey}-{$subKey}"] = $subValue;
}
} else {
$cssVars["--{$typographyKey}"] = $typographyValue;
}
}
// Convert to CSS string
$cssString = '';
foreach ($cssVars as $property => $value) {
$cssString .= "{$property}: {$value}; ";
}
return $cssString;
}
}
if (!function_exists('hexToRgb')) {
/**
* Convert hex color to RGB values (space-separated for CSS custom properties)
*
* @param string $hex Hex color value
* @return string RGB values separated by spaces
*/
function hexToRgb($hex)
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
return "{$r} {$g} {$b}";
}
}

View File

@@ -0,0 +1,98 @@
<?php
/**
* Format the given number of minutes into a time format string.
* Minutes are used in the database to record currency.
*
* @param int $minutes The number of minutes to format.
* @return string The formatted time string.
*/
function tbFormat($minutes)
{
$isNegative = $minutes < 0;
$minutes = abs($minutes);
$wholeHours = intdiv($minutes, 60);
$restMinutes = sprintf("%02d", $minutes % 60);
$currencySymbol = platform_currency_symbol();
$timeValue = ($isNegative ? '-' : '') . $wholeHours . ':' . $restMinutes;
// Check if currency symbol should be at the end (default is start)
$positionEnd = platform_trans('platform_currency_position_end', null, false);
if ($positionEnd) {
$formattedTime = $timeValue . ' ' . $currencySymbol;
} else {
$formattedTime = $currencySymbol . ' ' . $timeValue;
}
return $formattedTime;
}
/**
* Converts a time string in the format "HHH:MM" to minutes.
*
* @param string $hhh_mm The time string to convert.
* @return int The time in minutes.
*/
function dbFormat($hhh_mm)
{
list($wholeHours, $restMinutes) = explode(':', $hhh_mm);
// Check if the wholeHours part is negative
$isNegative = $wholeHours < 0;
// Convert the values to absolute for calculation
$wholeHours = abs($wholeHours);
$restMinutes = abs($restMinutes);
// Calculate the total minutes
$minutes = ($wholeHours * 60) + $restMinutes;
// Adjust the sign if the original value was negative
return $isNegative ? -$minutes : $minutes;
}
function hoursAndMinutes($time, $format = '%02d:%02d')
// Usage: echo hoursAndMinutes('188', '%02d Hours, %02d Minutes');
// this will output 3 Hours, 8 Minutes
// hoursAndMinutes('188', '%02dH,%02dM');
// will output 3H,8M
{
if ($time < 1) {
return;
}
$hours = floor($time / 60);
$minutes = ($time % 60);
return sprintf($format, $hours, $minutes);
}
/**
* Convert days to human-readable format
* Returns format like "2 weeks", "3 months", "1 year"
* Uses 30 days = 1 month, 7 days = 1 week
*
* @param int $days The number of days to convert
* @return string The human-readable format
*/
function daysToHumanReadable($days)
{
if ($days < 7) {
return $days . ' ' . trans_choice('day|days', $days);
} elseif ($days < 30) {
$weeks = round($days / 7);
return $weeks . ' ' . trans_choice('week|weeks', $weeks);
} elseif ($days < 365) {
$months = round($days / 30);
return $months . ' ' . trans_choice('month|months', $months);
} else {
$years = round($days / 365);
return $years . ' ' . trans_choice('year|years', $years);
}
}

View File

@@ -0,0 +1,35 @@
<?php
if (!function_exists('timebank_config')) {
/**
* Get platform-specific configuration value
*
* This function dynamically loads configuration from the file specified
* in the TIMEBANK_CONFIG environment variable (defaults to 'timebank-default').
*
* @param string $key Configuration key in dot notation (e.g., 'profiles.user.limit_max')
* @param mixed $default Default value if key is not found
* @return mixed
*
* @example
* // Get user profile limit_max from config/{TIMEBANK_CONFIG}.php
* $limitMax = timebank_config('profiles.user.limit_max');
*
* // With a default value
* $limitMax = timebank_config('profiles.user.limit_max', 6000);
*/
function timebank_config($key, $default = null)
{
static $configName = null;
// Cache the config name to avoid repeated env() calls
if ($configName === null) {
$configName = env('TIMEBANK_CONFIG', 'timebank-default');
}
// Build the full config key
$fullKey = $configName . '.' . $key;
return config($fullKey, $default);
}
}