Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class AccountController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class AdminController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function settings()
{
Gate::authorize('admin settings');
return view('profile-admin.settings');
}
}
@@ -0,0 +1,239 @@
<?php
namespace App\Http\Controllers;
use App\Events\ProfileSwitchEvent;
use App\Models\Admin;
use App\Models\User;
use App\Traits\SwitchGuardTrait;
use Hash;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AdminLoginController extends Controller
{
use SwitchGuardTrait;
/**
* Direct link to admin login - can be used in emails
* Handles the layered authentication flow:
* 1. If user not authenticated -> redirect to user login first
* 2. If user authenticated -> set intent and redirect to admin login
* 3. After admin login -> redirect to intended URL or main page
*/
public function directLogin(Request $request, $adminId)
{
// Validate admin exists and load users
$admin = Admin::with('users')->find($adminId);
if (!$admin) {
abort(404, __('Admin not found'));
}
// Get optional intended destination after successful admin login.
// Validate it is a local URL to prevent open redirect attacks.
$intendedParam = $request->query('intended');
$intendedUrl = $this->validateLocalUrl($intendedParam, $request);
// Store intended URL in session if provided
if ($intendedUrl) {
session(['admin_login_intended_url' => $intendedUrl]);
}
// Check if user is authenticated on web guard
if (!Auth::guard('web')->check()) {
// User not logged in - redirect to user login with return URL
$returnUrl = route('admin.direct-login', ['adminId' => $adminId]);
if ($intendedUrl) {
$returnUrl .= '?intended=' . urlencode($intendedUrl);
}
// Store in session for Laravel to redirect after login
session()->put('url.intended', $returnUrl);
// Get the first user associated with this admin to pre-populate the login form
$firstUser = $admin->users()->first();
if ($firstUser) {
// Build the login URL with the name parameter and localization
$loginRoute = route('login') . '?name=' . urlencode($firstUser->name);
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
return redirect($loginUrl);
}
return redirect()->route('login');
}
// User is authenticated - verify they have this admin profile
$user = Auth::guard('web')->user();
$userWithRelations = User::with('admins')->find($user->id);
if (!$userWithRelations || !$userWithRelations->admins->contains('id', $adminId)) {
// Wrong user is logged in — log out all guards and redirect to login
foreach (['web', 'organization', 'bank', 'admin'] as $guard) {
Auth::guard($guard)->logout();
}
$request->session()->invalidate();
$request->session()->regenerateToken();
$returnUrl = route('admin.direct-login', ['adminId' => $adminId]);
if ($intendedUrl) {
$returnUrl .= '?intended=' . urlencode($intendedUrl);
}
session()->put('url.intended', $returnUrl);
$firstUser = $admin->users()->first();
if ($firstUser) {
$loginRoute = route('login') . '?name=' . urlencode($firstUser->name);
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
return redirect($loginUrl);
}
return redirect()->route('login');
}
// Set the profile switch intent in session (kept for showLoginForm)
session([
'intended_profile_switch_type' => 'Admin',
'intended_profile_switch_id' => $adminId,
]);
// Redirect to admin login form — pass adminId in URL so login() doesn't
// depend solely on session values that may be lost by Livewire writes.
return redirect()->route('admin.login', ['admin_id' => $adminId]);
}
public function showLoginForm(Request $request)
{
$user = Auth::guard('web')->user();
$id = $request->query('admin_id') ?? session('intended_profile_switch_id');
$type = $id ? 'admin' : session('intended_profile_switch_type');
$profile = $this->getTargetProfileByTypeAndId($user, $type, $id);
return view('profile-admin.login', [
'profile' => $profile,
'adminId' => $id,
'canResetPassword' => $this->profileCanResetPassword($user, $id),
]);
}
public function login(Request $request)
{
$request->validate([
'password' => 'required',
]);
$user = Auth::guard('web')->user();
$id = $request->input('admin_id') ?? session('intended_profile_switch_id');
$type = $id ? 'admin' : session('intended_profile_switch_type');
$admin = $this->getTargetProfileByTypeAndId($user, $type, $id);
if (!$admin) {
session()->put('admin_login_error', __('Admin not found'));
return redirect()->route('admin.login');
}
// Legacy Cyclos password support
if (!empty($admin->cyclos_salt)) {
info('Auth attempt using original Cyclos password');
$concatenated = $admin->cyclos_salt . $request->password;
$hashedInputPassword = hash("sha256", $concatenated);
if (strtolower($hashedInputPassword) === strtolower($admin->password)) {
info('Auth success: Password is verified');
// Rehash to Laravel hash and remove salt
$admin->password = \Hash::make($request->password);
$admin->cyclos_salt = null;
$admin->save();
info('Auth success: Cyclos password has been rehashed for next login');
}
}
// Check if the provided password matches the hashed password in the database
if (\Hash::check($request->password, $admin->password)) {
$this->switchGuard('admin', $admin); // log in as admin
// Remove intended switch from session
session()->forget(['intended_profile_switch_type', 'intended_profile_switch_id']);
// Set active profile session as before
session([
'activeProfileType' => get_class($admin),
'activeProfileId' => $admin->id,
'activeProfileName' => $admin->name,
'activeProfilePhoto' => $admin->profile_photo_path,
'activeProfileRoleLabel' => getProfileRoleLabel($admin, $user),
'last_activity' => now(),
'profile-switched-notification' => true,
]);
// Re-activate profile if inactive
if (timebank_config('profile_inactive.re-activate_at_login')) {
if (!$admin->isActive()) {
$admin->inactive_at = null;
$admin->save();
info('Admin re-activated: ' . $admin->name);
}
}
event(new \App\Events\ProfileSwitchEvent($admin));
// Check for intended URL from direct login flow
$intendedUrl = session('admin_login_intended_url');
if ($intendedUrl) {
session()->forget('admin_login_intended_url');
return redirect($intendedUrl);
}
return redirect()->route('main'); // Or your special admin main page
}
info('Auth failed: Input password does not match stored password');
session()->put('admin_login_error', __('Invalid admin password'));
return redirect()->route('admin.login');
}
//TODO: Move to Trait as suggested below.
/**
* Helper method to get the target profile model based on the index.
* Note: This duplicates logic from ProfileSelect::mount. Consider refactoring
* this logic into a service or trait if used in multiple places.
*/
private function getTargetProfileByTypeAndId($user, $type, $id)
{
if (!$type || !$id || !$user) {
return null;
}
$userWithRelations = User::with(['organizations', 'banksManaged', 'admins'])->find($user->id);
if (!$userWithRelations) return null;
if (strtolower($type) === 'organization') {
return $userWithRelations->organizations->firstWhere('id', $id);
} elseif (strtolower($type) === 'bank') {
return $userWithRelations->banksManaged->firstWhere('id', $id);
} elseif (strtolower($type) === 'admin') {
return $userWithRelations->admins->firstWhere('id', $id);
}
return null;
}
private function profileCanResetPassword($user, $adminId): bool
{
if (!$user || !$adminId) {
return false;
}
$prefix = "Admin\\{$adminId}\\";
$scopedRoles = $user->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
foreach ($scopedRoles as $spatieRole) {
$roleName = str_replace($prefix, '', $spatieRole->name);
$adminRole = \App\Models\AdminRole::where('name', $roleName)->first();
if ($adminRole && in_array('admin change-password', $adminRole->permissions ?? [])) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
class AppVersionController extends Controller
{
/**
* Return the latest Android app version info.
*
* To publish a new version:
* 1. Build a signed APK in Android Studio.
* 2. Rename it to the android_app.apk_file name in the platform config, e.g. timebank-cc-1.0.1.apk.
* 3. Upload it to storage/app/public/apk/.
* 4. Update $versionCode and $versionName below.
* 5. Run: php artisan storage:link (once, if not already done)
*/
public function show(): JsonResponse
{
abort_unless(timebank_config('android_app.enabled', true), 404);
return response()->json(self::latest());
}
/**
* The latest version and its download URL; also used by the static Android app page.
* scripts/deploy-apk.sh rewrites the two version lines below.
*/
public static function latest(): array
{
$versionCode = 6;
$versionName = '1.0.4';
$apkFile = str_replace('{version}', $versionName, timebank_config('android_app.apk_file', 'timebank-cc-{version}.apk'));
return [
'versionCode' => $versionCode,
'versionName' => $versionName,
'apkUrl' => url("storage/apk/{$apkFile}"),
];
}
}
@@ -0,0 +1,158 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\ProfileAuthorizationHelper;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use ZipArchive;
class BackupChunkUploadController extends Controller
{
/**
* Receive a single chunk of the backup file.
* POST /posts/backup-upload/chunk
*/
public function uploadChunk(Request $request): JsonResponse
{
$this->authorizeAdminAccess();
$request->validate([
'uploadId' => 'required|string|uuid',
'chunkIndex' => 'required|integer|min:0|max:999',
'totalChunks' => 'required|integer|min:1|max:1000',
'chunk' => 'required|file|max:3072', // 3MB max per chunk
]);
$uploadId = $request->input('uploadId');
$chunkIndex = (int) $request->input('chunkIndex');
$totalChunks = (int) $request->input('totalChunks');
if ($chunkIndex >= $totalChunks) {
return response()->json(['error' => 'Invalid chunk index'], 422);
}
$chunkDir = storage_path("app/temp/chunks/{$uploadId}");
if (!File::isDirectory($chunkDir)) {
File::makeDirectory($chunkDir, 0755, true);
}
$request->file('chunk')->move($chunkDir, "chunk_{$chunkIndex}");
return response()->json([
'success' => true,
'chunkIndex' => $chunkIndex,
'received' => count(File::files($chunkDir)),
'total' => $totalChunks,
]);
}
/**
* Finalize: reassemble chunks into the complete file.
* POST /posts/backup-upload/finalize
*/
public function finalize(Request $request): JsonResponse
{
$this->authorizeAdminAccess();
$request->validate([
'uploadId' => 'required|string|uuid',
'totalChunks' => 'required|integer|min:1|max:1000',
'fileName' => 'required|string|max:255',
]);
$uploadId = $request->input('uploadId');
$totalChunks = (int) $request->input('totalChunks');
$fileName = basename($request->input('fileName'));
$chunkDir = storage_path("app/temp/chunks/{$uploadId}");
$outputDir = storage_path('app/temp');
if (!File::isDirectory($outputDir)) {
File::makeDirectory($outputDir, 0755, true);
}
// Verify all chunks exist
for ($i = 0; $i < $totalChunks; $i++) {
if (!File::exists("{$chunkDir}/chunk_{$i}")) {
return response()->json(['error' => "Missing chunk {$i}"], 422);
}
}
// Reassemble
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
if (!in_array($ext, ['zip', 'json'])) {
File::deleteDirectory($chunkDir);
return response()->json(['error' => 'Invalid file type'], 422);
}
$assembledName = 'restore_' . uniqid() . '.' . $ext;
$assembledPath = "{$outputDir}/{$assembledName}";
$output = fopen($assembledPath, 'wb');
for ($i = 0; $i < $totalChunks; $i++) {
$chunkPath = "{$chunkDir}/chunk_{$i}";
$input = fopen($chunkPath, 'rb');
stream_copy_to_stream($input, $output);
fclose($input);
}
fclose($output);
// Clean up chunks
File::deleteDirectory($chunkDir);
// Validate ZIP if applicable
if ($ext === 'zip') {
$zip = new ZipArchive();
if ($zip->open($assembledPath) !== true) {
@unlink($assembledPath);
return response()->json(['error' => 'Invalid ZIP file'], 422);
}
if ($zip->getFromName('backup.json') === false) {
$zip->close();
@unlink($assembledPath);
return response()->json(['error' => 'ZIP does not contain backup.json'], 422);
}
$zip->close();
}
// Store path in session for Livewire to retrieve
session(["backup_restore_file_{$uploadId}" => $assembledPath]);
return response()->json([
'success' => true,
'uploadId' => $uploadId,
]);
}
/**
* Authorization check matching RequiresAdminAuthorization trait logic.
*/
private function authorizeAdminAccess(): void
{
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
if (!$activeProfileType || !$activeProfileId) {
abort(403, 'No active profile selected');
}
$profile = $activeProfileType::find($activeProfileId);
if (!$profile) {
abort(403, 'Active profile not found');
}
ProfileAuthorizationHelper::authorize($profile);
if ($profile instanceof \App\Models\Admin) {
return;
}
if ($profile instanceof \App\Models\Bank && $profile->level === 0) {
return;
}
abort(403, 'Admin or central bank access required');
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\ProfileController;
class BankController extends Controller
{
public function show($id)
{
return app(ProfileController::class)->showBank($id);
}
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit()
{
$bank = getActiveProfile();
// Verify this is a bank profile
if (!($bank instanceof \App\Models\Bank)) {
abort(403, 'Not a valid bank profile');
}
// Verify the user can manage this bank
$user = Auth::guard('web')->user();
if (!$user->banksManaged->contains($bank->id)) {
abort(403, 'You do not have permission to edit this bank');
}
return view('profile-bank.edit', [
'bank' => $bank
]);
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function settings()
{
return view('profile-bank.settings');
}
}
@@ -0,0 +1,240 @@
<?php
namespace App\Http\Controllers;
use App\Events\ProfileSwitchEvent;
use App\Models\Bank;
use App\Models\User;
use App\Traits\SwitchGuardTrait;
use Hash;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class BankLoginController extends Controller
{
use SwitchGuardTrait;
/**
* Direct link to bank login - can be used in emails
* Handles the layered authentication flow:
* 1. If user not authenticated -> redirect to user login first
* 2. If user authenticated -> set intent and redirect to bank login
* 3. After bank login -> redirect to intended URL or main page
*/
public function directLogin(Request $request, $bankId)
{
// Validate bank exists and load managers
$bank = Bank::with('managers')->find($bankId);
if (!$bank) {
abort(404, __('Bank not found'));
}
// Get optional intended destination after successful bank login.
// Validate it is a local URL to prevent open redirect attacks.
$intendedParam = $request->query('intended');
$intendedUrl = $this->validateLocalUrl($intendedParam, $request);
// Store intended URL in session if provided
if ($intendedUrl) {
session(['bank_login_intended_url' => $intendedUrl]);
}
// Check if user is authenticated on web guard
if (!Auth::guard('web')->check()) {
// User not logged in - redirect to user login with return URL
$returnUrl = route('bank.direct-login', ['bankId' => $bankId]);
if ($intendedUrl) {
$returnUrl .= '?intended=' . urlencode($intendedUrl);
}
// Store in session for Laravel to redirect after login
session()->put('url.intended', $returnUrl);
// Get the first manager's name to pre-populate the login form
$firstManager = $bank->managers()->first();
if ($firstManager) {
// Build the login URL with the name parameter and localization
$loginRoute = route('login') . '?name=' . urlencode($firstManager->name);
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
return redirect($loginUrl);
}
return redirect()->route('login');
}
// User is authenticated - verify they manage this bank
$user = Auth::guard('web')->user();
$userWithRelations = User::with('banksManaged')->find($user->id);
if (!$userWithRelations || !$userWithRelations->banksManaged->contains('id', $bankId)) {
// Wrong user is logged in — log out all guards and redirect to login
foreach (['web', 'organization', 'bank', 'admin'] as $guard) {
Auth::guard($guard)->logout();
}
$request->session()->invalidate();
$request->session()->regenerateToken();
$returnUrl = route('bank.direct-login', ['bankId' => $bankId]);
if ($intendedUrl) {
$returnUrl .= '?intended=' . urlencode($intendedUrl);
}
session()->put('url.intended', $returnUrl);
$firstManager = $bank->managers()->first();
if ($firstManager) {
$loginRoute = route('login') . '?name=' . urlencode($firstManager->name);
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
return redirect($loginUrl);
}
return redirect()->route('login');
}
// Set the profile switch intent in session (kept for showLoginForm)
session([
'intended_profile_switch_type' => 'Bank',
'intended_profile_switch_id' => $bankId,
]);
// Redirect to bank login form — pass bankId in URL so login() doesn't
// depend solely on session values that may be lost by Livewire writes.
return redirect()->route('bank.login', ['bank_id' => $bankId]);
}
public function showLoginForm(Request $request)
{
$user = Auth::guard('web')->user();
// Prefer URL param (reliable), fall back to session (may be lost)
$id = $request->query('bank_id') ?? session('intended_profile_switch_id');
$type = $id ? 'bank' : session('intended_profile_switch_type');
$profile = $this->getTargetProfileByTypeAndId($user, $type, $id);
return view('profile-bank.login', [
'profile' => $profile,
'bankId' => $id,
'canResetPassword' => $this->profileCanResetPassword($user, 'Bank', $id),
]);
}
public function login(Request $request)
{
$request->validate([
'password' => 'required',
]);
$user = Auth::guard('web')->user();
// Prefer POST field (reliable), fall back to session (may be lost to Livewire writes)
$id = $request->input('bank_id') ?? session('intended_profile_switch_id');
$type = $id ? 'bank' : session('intended_profile_switch_type');
$bank = $this->getTargetProfileByTypeAndId($user, $type, $id);
if (!$bank) {
return back()->withErrors(['index' => __('Bank not found')]);
}
// Legacy Cyclos password support
if (!empty($bank->cyclos_salt)) {
info('Auth attempt using original Cyclos password');
$concatenated = $bank->cyclos_salt . $request->password;
$hashedInputPassword = hash("sha256", $concatenated);
if (strtolower($hashedInputPassword) === strtolower($bank->password)) {
info('Auth success: Password is verified');
// Rehash to Laravel hash and remove salt
$bank->password = \Hash::make($request->password);
$bank->cyclos_salt = null;
$bank->save();
info('Auth success: Cyclos password has been rehashed for next login');
}
}
// Check if the provided password matches the hashed password in the database
if (\Hash::check($request->password, $bank->password)) {
$this->switchGuard('bank', $bank); // Log in as bank
// Remove intended switch from session
session()->forget(['intended_profile_switch_type', 'intended_profile_switch_id']);
// Set active profile session as before
session([
'activeProfileType' => get_class($bank),
'activeProfileId' => $bank->id,
'activeProfileName' => $bank->name,
'activeProfilePhoto' => $bank->profile_photo_path,
'activeProfileRoleLabel' => getProfileRoleLabel($bank, $user),
'last_activity' => now(),
'profile-switched-notification' => true,
]);
// Re-activate profile if inactive
if (timebank_config('profile_inactive.re-activate_at_login')) {
if (!$bank->isActive()) {
$bank->inactive_at = null;
$bank->save();
info('Bank re-activated: ' . $bank->name);
}
}
event(new \App\Events\ProfileSwitchEvent($bank));
// Check for intended URL from direct login flow
$intendedUrl = session('bank_login_intended_url');
if ($intendedUrl) {
session()->forget('bank_login_intended_url');
return redirect($intendedUrl);
}
return redirect()->route('main'); // TODO: Or special bank main page
}
info('Auth failed: Input password does not match stored password');
return back()->withErrors(['password' => __('Invalid bank password')]);
}
//TODO: Move to Trait as suggested below.
/**
* Helper method to get the target profile model based on the index.
* Note: This duplicates logic from ProfileSelect::mount. Consider refactoring
* this logic into a service or trait if used in multiple places.
*/
private function getTargetProfileByTypeAndId($user, $type, $id)
{
if (!$type || !$id) {
return null;
}
$userWithRelations = User::with(['organizations', 'banksManaged', 'admins'])->find($user->id);
if (!$userWithRelations) return null;
if (strtolower($type) === 'organization') {
return $userWithRelations->organizations->firstWhere('id', $id);
} elseif (strtolower($type) === 'bank') {
return $userWithRelations->banksManaged->firstWhere('id', $id);
} elseif (strtolower($type) === 'admin') {
return $userWithRelations->admins->firstWhere('id', $id);
}
return null;
}
private function profileCanResetPassword($user, string $profileType, $profileId): bool
{
if (!$user || !$profileId) {
return false;
}
$prefix = "{$profileType}\\{$profileId}\\";
$scopedRoles = $user->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
foreach ($scopedRoles as $spatieRole) {
$roleName = str_replace($prefix, '', $spatieRole->name);
$orgRole = \App\Models\OrganizationRole::where('name', $roleName)->first();
if ($orgRole && in_array('org change-password', $orgRole->permissions ?? [])) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Broadcasting\BroadcastController as BaseBroadcastController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Broadcast;
class BroadcastController extends BaseBroadcastController
{
/**
* Authenticate the request for channel access.
*
* This custom implementation supports multi-guard authentication.
* It uses the active guard from the session to authenticate broadcasting requests.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function authenticate(Request $request)
{
// Get the active guard from session (set by SwitchGuardTrait)
$activeGuard = session('active_guard', 'web');
// Ensure the user is authenticated on the active guard
if (!Auth::guard($activeGuard)->check()) {
abort(403, 'Unauthorized - No active authentication found');
}
// Get the authenticated user from the active guard
$user = Auth::guard($activeGuard)->user();
// Set the default guard to use for this request
Auth::shouldUse($activeGuard);
// Get the channel name from the request
$channelName = $request->input('channel_name');
// Attempt to authorize the channel
try {
$result = Broadcast::auth($request);
return response()->json($result);
} catch (\Exception $e) {
\Log::error('Broadcasting authentication failed', [
'guard' => $activeGuard,
'user_id' => $user->id,
'channel' => $channelName,
'error' => $e->getMessage()
]);
abort(403, 'Unauthorized');
}
}
}
+450
View File
@@ -0,0 +1,450 @@
<?php
namespace App\Http\Controllers;
use App\Models\Call;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
class CallController extends Controller
{
public function showById($id)
{
$call = Call::withTrashed()->with([
'callable',
'translations',
'tag.contexts.category.translations',
'tag.contexts.category.ancestors.translations',
'location.city.translations',
'location.district.translations',
'location.country.translations',
'location.division.translations',
'loveReactant.reactionCounters',
])->findOrFail($id);
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
$isAdmin = $activeProfileType === \App\Models\Admin::class;
$isCallable = $activeProfileType === $call->callable_type
&& (int) $activeProfileId === (int) $call->callable_id;
// Deleted calls are only visible to admins and the callable itself
$isDeleted = $call->trashed();
if ($isDeleted && !$isAdmin && !$isCallable) {
return view('calls.unavailable');
}
// Blocked calls are only visible to admins and the callable itself
if ($call->is_suppressed && !$isAdmin && !$isCallable) {
return view('calls.unavailable');
}
// Paused calls are only visible to admins and the callable itself
if ($call->is_paused && !$isAdmin && !$isCallable) {
return view('calls.unavailable');
}
// Guests cannot view private calls
if (!$call->is_public && !Auth::check()) {
return redirect()->route('login')
->with('status', __('This call is private. Please log in to view it.'));
}
// Expired calls are only visible to the owner and admins
$isExpired = $call->till !== null && $call->till->isPast();
if ($isExpired && !$isCallable && !$isAdmin) {
return view('calls.unavailable');
}
$locale = App::getLocale();
// Build category hierarchy
$tagCategory = $call->tag?->contexts->first()?->category;
$tagColor = $tagCategory?->relatedColor ?? 'gray';
$tagCategories = [];
if ($tagCategory) {
foreach ($tagCategory->ancestorsAndSelf()->get()->reverse() as $cat) {
$name = $cat->translations->firstWhere('locale', $locale)?->name
?? $cat->translations->first()?->name
?? '';
if ($name) {
$tagCategories[] = ['id' => $cat->id, 'name' => $name, 'color' => $cat->relatedColor ?? 'gray'];
}
}
}
// Build callable profile's location short string (city first, fallback to division or country)
$callableLocation = null;
if ($call->callable && method_exists($call->callable, 'locations')) {
$firstLoc = $call->callable->locations()->with(['city.translations', 'division.translations', 'country.translations'])->first();
if ($firstLoc) {
$cCity = optional($firstLoc->city?->translations->first())->name;
$cDivision = optional($firstLoc->division?->translations->first())->name;
$cCountry = optional($firstLoc->country?->translations->first())->name;
$callableLocation = $cCity ?? $cDivision ?? $cCountry ?: null;
}
}
// Build call exchange location: "City District, Division, COUNTRY_CODE"
$callLocation = null;
if ($call->location) {
$loc = $call->location;
$parts = [];
$cityPart = null;
if ($loc->city) {
$cityPart = optional($loc->city->translations->first())->name;
}
if ($loc->district) {
$districtName = optional($loc->district->translations->first())->name;
$cityPart = $cityPart ? $cityPart . ' ' . $districtName : $districtName;
}
if ($cityPart) $parts[] = $cityPart;
if ($loc->division) {
$divisionName = optional($loc->division->translations->first())->name;
if ($divisionName) $parts[] = $divisionName;
}
if ($loc->country && $loc->country->code !== 'XX') {
$parts[] = strtoupper($loc->country->code);
} elseif ($loc->country && $loc->country->code === 'XX') {
$parts[] = __('Location not specified');
}
$callLocation = $parts ? implode(', ', $parts) : null;
}
// Determine whether to show the expiry badge (within configured warning window)
$expiryBadgeText = $call->till
? __('Expires') . ': ' . $call->till->translatedFormat('d F Y')
: null;
$callableType = $call->callable ? strtolower(class_basename($call->callable)) : null;
$callDescription = $call->translations->firstWhere('locale', App::getLocale())?->content
?? $call->translations->firstWhere('locale', config('app.fallback_locale'))?->content
?? $call->translations->first()?->content;
$viewName = Auth::check() ? 'calls.show' : 'calls.show-guest';
return view($viewName, compact('call', 'tagColor', 'tagCategories', 'callableType', 'callableLocation', 'callLocation', 'expiryBadgeText', 'isExpired', 'isDeleted', 'isAdmin', 'callDescription'));
}
public function callPdf($id)
{
$data = $this->buildCallPdfData($id);
$pdf = Pdf::loadView('calls.pdf', $data)
->setPaper('A6', 'portrait')
->setOptions([
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => false,
'defaultFont' => 'DejaVu Sans',
'dpi' => 150,
'chroot' => public_path(),
]);
return $pdf->download('call-' . $data['call']->id . '.pdf');
}
public function callPdfBw($id)
{
$data = $this->buildCallPdfData($id);
$pdf = Pdf::loadView('calls.pdf-bw', $data)
->setPaper('A6', 'portrait')
->setOptions([
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => false,
'defaultFont' => 'DejaVu Sans',
'dpi' => 150,
'chroot' => public_path(),
]);
return $pdf->download('call-' . $data['call']->id . '-bw.pdf');
}
public function callPdfA4($id)
{
$data = $this->buildCallPdfData($id);
$pdf = Pdf::loadView('calls.pdf-a4', $data)
->setPaper('A4', 'portrait')
->setOptions([
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => false,
'defaultFont' => 'DejaVu Sans',
'dpi' => 150,
'chroot' => public_path(),
]);
return $pdf->download('call-' . $data['call']->id . '-a4.pdf');
}
public function callPdfA4Bw($id)
{
$data = $this->buildCallPdfData($id);
$pdf = Pdf::loadView('calls.pdf-a4-bw', $data)
->setPaper('A4', 'portrait')
->setOptions([
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => false,
'defaultFont' => 'DejaVu Sans',
'dpi' => 150,
'chroot' => public_path(),
]);
return $pdf->download('call-' . $data['call']->id . '-a4-bw.pdf');
}
public function callPdfBulk(\Illuminate\Http\Request $request)
{
$ids = array_filter(array_map('intval', explode(',', $request->input('ids', ''))));
$perPage = in_array((int) $request->input('per_page', 1), [1, 2, 4])
? (int) $request->input('per_page', 1)
: 1;
$color = $request->input('color', 'bw') === 'color' ? 'color' : 'bw';
if (empty($ids)) abort(400);
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
$isAdmin = $activeProfileType === \App\Models\Admin::class
|| ($activeProfileType === \App\Models\Bank::class
&& \App\Models\Bank::find($activeProfileId)?->level === 0);
// Build per-call data, each in the call's own locale
$calls = [];
foreach ($ids as $id) {
$call = Call::withTrashed()->find($id);
if (!$call) continue;
// Authorization: non-admins can only print their own calls
if (!$isAdmin) {
$isOwner = $activeProfileType === $call->callable_type
&& (int) $activeProfileId === (int) $call->callable_id;
if (!$isOwner) continue;
}
// Use the call's translation locale, fall back to callable's lang_preference
$callLocale = $call->translations->first()?->locale
?? $call->callable?->lang_preference
?? App::getLocale();
$calls[] = $this->buildCallPdfData($id, $callLocale);
}
if (empty($calls)) abort(403);
$view = $color === 'color' ? 'calls.pdf-bulk' : 'calls.pdf-bulk-bw';
$orientation = $perPage === 2 ? 'landscape' : 'portrait';
$pdf = Pdf::loadView($view, compact('calls', 'perPage'))
->setPaper('A4', $orientation)
->setOptions([
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => false,
'defaultFont' => 'DejaVu Sans',
'dpi' => 150,
'chroot' => public_path(),
'margin_top' => 0,
'margin_right' => 0,
'margin_bottom' => 0,
'margin_left' => 0,
]);
return $pdf->download('calls-bulk-' . now()->format('Ymd-His') . '.pdf');
}
protected function buildCallPdfData($id, ?string $locale = null): array
{
$previousLocale = App::getLocale();
$locale = $locale ?? $previousLocale;
App::setLocale($locale);
try {
$data = $this->doBuildCallPdfData($id, $locale);
} finally {
App::setLocale($previousLocale);
}
return $data;
}
private function doBuildCallPdfData($id, string $locale): array
{
$call = Call::withTrashed()->with([
'callable',
'translations',
'tag.contexts.category.translations',
'tag.contexts.category.ancestors.translations',
'location.city.translations',
'location.district.translations',
'location.country.translations',
'location.division.translations',
])->findOrFail($id);
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
$isAdmin = $activeProfileType === \App\Models\Admin::class;
$isCallable = $activeProfileType === $call->callable_type
&& (int) $activeProfileId === (int) $call->callable_id;
$isDeleted = $call->trashed();
if ($isDeleted && !$isAdmin && !$isCallable) abort(403);
if ($call->is_suppressed && !$isAdmin && !$isCallable) abort(403);
if ($call->is_paused && !$isAdmin && !$isCallable) abort(403);
if (!$call->is_public && !Auth::check()) abort(403);
$isExpired = $call->till !== null && $call->till->isPast();
if ($isExpired && !$isCallable && !$isAdmin) abort(403);
$tagCategory = $call->tag?->contexts->first()?->category;
$tagColor = $tagCategory?->relatedColor ?? 'gray';
$tagColorHex = $this->tailwindColorToHex($tagColor);
$bgHex = $tagColorHex;
$bgDarkHex = $this->darkenHex($tagColorHex, 0.50);
$tagCategories = [];
if ($tagCategory) {
foreach ($tagCategory->ancestorsAndSelf()->get()->reverse() as $cat) {
$name = $cat->translations->firstWhere('locale', $locale)?->name
?? $cat->translations->first()?->name
?? '';
if ($name) {
$tagCategories[] = [
'id' => $cat->id,
'name' => $name,
'colorHex' => $this->tailwindColorToHex($cat->relatedColor ?? 'gray'),
];
}
}
}
$callableLocation = null;
if ($call->callable && method_exists($call->callable, 'locations')) {
$firstLoc = $call->callable->locations()->with(['city.translations', 'division.translations', 'country.translations'])->first();
if ($firstLoc) {
$cCity = optional($firstLoc->city?->translations->first())->name;
$cDivision = optional($firstLoc->division?->translations->first())->name;
$cCountry = optional($firstLoc->country?->translations->first())->name;
$callableLocation = $cCity ?? $cDivision ?? $cCountry ?: null;
}
}
$callLocation = null;
if ($call->location) {
$loc = $call->location;
$parts = [];
$cityPart = null;
if ($loc->city) $cityPart = optional($loc->city->translations->first())->name;
if ($loc->district) {
$districtName = optional($loc->district->translations->first())->name;
$cityPart = $cityPart ? $cityPart . ' ' . $districtName : $districtName;
}
if ($cityPart) $parts[] = $cityPart;
if ($loc->division) {
$divisionName = optional($loc->division->translations->first())->name;
if ($divisionName) $parts[] = $divisionName;
}
if ($loc->country && $loc->country->code !== 'XX') {
$parts[] = strtoupper($loc->country->code);
} elseif ($loc->country && $loc->country->code === 'XX') {
$parts[] = __('Location not specified');
}
$callLocation = $parts ? implode(', ', $parts) : null;
}
$expiryBadgeText = $call->till
? __('Expires') . ': ' . $call->till->translatedFormat('d F Y')
: null;
// Pre-translate status labels in the call's locale (used by PDF templates)
$statusLabelDeleted = __('DELETED');
$statusLabelBlocked = __('BLOCKED');
$statusLabelPaused = __('PAUSED');
$statusLabelExpired = __('EXPIRED');
$rawContent = $call->translations->firstWhere('locale', $locale)?->content
?? $call->translations->firstWhere('locale', config('app.fallback_locale'))?->content
?? $call->translations->first()?->content
?? '';
$callContent = mb_strlen($rawContent) > 250 ? mb_substr($rawContent, 0, 247) . '…' : $rawContent;
$avatarBase64 = null;
if ($call->callable?->profile_photo_path) {
$avatarPath = storage_path('app/public/' . $call->callable->profile_photo_path);
$ext = strtolower(pathinfo($avatarPath, PATHINFO_EXTENSION));
if (file_exists($avatarPath)) {
if ($ext === 'svg') {
// SVG avatars cannot be rendered by GD — skip silently.
} else {
$mime = mime_content_type($avatarPath) ?: 'image/jpeg';
$avatarBase64 = 'data:' . $mime . ';base64,' . base64_encode(file_get_contents($avatarPath));
}
}
}
$logoBase64 = null;
$logoPath = theme_email_logo_file();
if (file_exists($logoPath)) {
$logoBase64 = 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath));
}
// QR codes as inline SVG (no ImageMagick needed)
$callUrl = route('call.show', ['id' => $call->id]);
$qrSvg = 'data:image/svg+xml;base64,' . base64_encode(
\SimpleSoftwareIO\QrCode\Facades\QrCode::format('svg')
->size(300)->margin(2)->errorCorrection('M')->color(0, 0, 0)->backgroundColor(255, 255, 255)->generate($callUrl)
);
$qrSvgBw = 'data:image/svg+xml;base64,' . base64_encode(
\SimpleSoftwareIO\QrCode\Facades\QrCode::format('svg')
->size(300)->margin(2)->errorCorrection('M')->color(0, 0, 0)->generate($callUrl)
);
return compact(
'call', 'tagColor', 'tagColorHex', 'bgHex', 'bgDarkHex', 'tagCategories',
'callableLocation', 'callLocation', 'expiryBadgeText',
'isExpired', 'isDeleted', 'avatarBase64', 'logoBase64', 'callContent', 'qrSvg', 'qrSvgBw',
'statusLabelDeleted', 'statusLabelBlocked', 'statusLabelPaused', 'statusLabelExpired'
);
}
/**
* Darken a 6-char hex color by the given factor (0–1).
* Used to simulate the dark overlay on the call card background.
*/
private function darkenHex(string $hex, float $factor): string
{
$r = (int) hexdec(substr($hex, 0, 2));
$g = (int) hexdec(substr($hex, 2, 2));
$b = (int) hexdec(substr($hex, 4, 2));
$r = (int) round($r * (1 - $factor));
$g = (int) round($g * (1 - $factor));
$b = (int) round($b * (1 - $factor));
return sprintf('%02x%02x%02x', $r, $g, $b);
}
/**
* Map a Tailwind color name to its -400 hex value for use in DomPDF.
*/
private function tailwindColorToHex(string $color): string
{
return match ($color) {
'red' => 'f87171',
'orange' => 'fb923c',
'amber' => 'fbbf24',
'yellow' => 'facc15',
'lime' => 'a3e635',
'green' => '4ade80',
'emerald' => '34d399',
'teal' => '2dd4bf',
'cyan' => '22d3ee',
'sky' => '38bdf8',
'blue' => '60a5fa',
'indigo' => '818cf8',
'violet' => 'a78bfa',
'purple' => 'c084fc',
'fuchsia' => 'e879f9',
'pink' => 'f472b6',
'rose' => 'fb7185',
default => '9ca3af', // gray-400
};
}
public function manage()
{
return view('calls.manage');
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class CategoryController extends Controller
{
public function manage()
{
return view('categories.manage');
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Organization;
use App\Models\Bank;
use App\Models\Admin;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ChatController extends Controller
{
/**
* Create a new conversation with a specific profile and redirect to the chat.
*
* @param string $profileType The type of profile (user, organization, bank, admin)
* @param int $id The ID of the profile
* @return \Illuminate\Http\RedirectResponse
*/
public function startConversationWith(string $profileType, int $id)
{
// Ensure user is authenticated
if (!Auth::guard('web')->check() &&
!Auth::guard('organization')->check() &&
!Auth::guard('bank')->check() &&
!Auth::guard('admin')->check()) {
abort(403, 'Unauthorized');
}
// Map profile type to model class
$modelClass = match(strtolower($profileType)) {
'user' => User::class,
'organization' => Organization::class,
'bank' => Bank::class,
'admin' => Admin::class,
default => abort(404, 'Invalid profile type'),
};
// Find the recipient profile
$recipient = $modelClass::find($id);
if (!$recipient) {
abort(404, 'Profile not found');
}
// Check if recipient is removed
if (method_exists($recipient, 'isRemoved') && $recipient->isRemoved()) {
abort(404, 'Profile no longer available');
}
// Get the active profile using the helper function
$activeProfile = getActiveProfile();
// Prevent creating conversation with yourself
if ($activeProfile->id === $recipient->id && get_class($activeProfile) === get_class($recipient)) {
return redirect()->route('chats')->with('error', __('You cannot start a conversation with yourself.'));
}
// Create or get existing conversation
$conversation = $activeProfile->createConversationWith($recipient);
// Redirect to the chat page
return redirect()->route('chat', ['conversation' => $conversation->id]);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Pipeline\Pipeline;
use Laravel\Fortify\Contracts\LoginResponse;
use Laravel\Fortify\Http\Requests\LoginRequest;
class CustomAuthenticatedSessionController
{
public function store(LoginRequest $request): LoginResponse
{
return $this->loginPipeline($request)->then(function ($request) {
// Skip session regeneration in Docker environment to avoid session persistence issues
if (!env('IS_DOCKER', false)) {
$request->session()->regenerate();
}
return app(LoginResponse::class);
});
}
protected function loginPipeline(LoginRequest $request)
{
if (method_exists($this, 'fortify')) {
return $this->fortify()->loginPipeline($request);
}
return (new Pipeline(app()))->send($request)->through(array_filter([
config('fortify.limiters.login') ? null : \Laravel\Fortify\Actions\EnsureLoginIsNotThrottled::class,
config('fortify.lowercase_usernames') ? \Laravel\Fortify\Actions\CanonicalizeUsername::class : null,
\Laravel\Fortify\Actions\AttemptToAuthenticate::class,
// Skip PrepareAuthenticatedSession in Docker as it calls session()->migrate() which causes issues
env('IS_DOCKER', false) ? null : \Laravel\Fortify\Actions\PrepareAuthenticatedSession::class,
]));
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Services\PresenceService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Session;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
class CustomLogoutController extends Controller
{
public function destroy(Request $request)
{
$presenceService = app(PresenceService::class);
// Log out from all guards and set them offline
foreach (['web', 'admin', 'bank', 'organization'] as $guard) {
if (Auth::guard($guard)->check()) {
$user = Auth::guard($guard)->user();
// Set user offline before logging out
$presenceService->setUserOffline($user, $guard);
// Clear presence caches
Cache::forget("presence_{$guard}_{$user->id}");
Cache::forget("presence_last_update_{$guard}_{$user->id}");
// Logout
Auth::guard($guard)->logout();
}
}
$request->session()->invalidate();
$request->session()->regenerateToken();
// For AJAX/Livewire requests, force a hard redirect
if ($request->expectsJson() || $request->hasHeader('X-Livewire')) {
$redirectUrl = LaravelLocalization::localizeUrl('/');
return response('<script>window.location.href = "' . $redirectUrl . '";</script>', 200)
->header('Content-Type', 'text/html');
}
// Force a full page redirect with no-cache headers
$response = redirect(LaravelLocalization::localizeUrl('/'));
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0');
return $response;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Exports\TransactionsExport;
use App\Exports\UsersExport;
use Illuminate\Support\Facades\Session;
use Maatwebsite\Excel\Facades\Excel;
class ExportController extends Controller
{
public function allUsersExport($year = null)
{
return Excel::download(new UsersExport(), 'users.xlsx');
}
public function transactionsExport($type)
{
$dataArray = Session::get('export_data', []);
Session::forget('export_data'); // Optionally clear the session data after use
$data = collect($dataArray); // Convert array to collection
// Process the data and generate the export
return Excel::download(new TransactionsExport($data), "export.{$type}");
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\File;
class LangJsController extends Controller
{
/**
* Returns a JavaScript file containing all the translations for the supported locales in Laravel.
*
* @return \Illuminate\Http\Response
*/
public function js()
{
$locales = config('app.locales');
$translations = [];
foreach ($locales as $locale) {
$strings = File::get(resource_path("lang/{$locale}.json"));
// ds($strings)->label('LanJsController');
$translations[$locale] = json_decode($strings, true);
}
$js = "window.i18n = " . json_encode($translations) . ";";
$response = response($js, 200);
$response->header('Content-Type', 'application/javascript');
return $response;
}
}
+276
View File
@@ -0,0 +1,276 @@
<?php
namespace App\Http\Controllers;
use App\Models\Mailing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
class MailingsController extends Controller
{
/**
* Display the mailings management page
*/
public function index()
{
// Check authorization - admins need at least view or manage mailings; banks always allowed
if (Auth::guard('admin')->check()) {
$webUser = Auth::guard('web')->user();
if (!$webUser || !Gate::forUser($webUser)->allows('access mailings')) {
abort(403, 'Unauthorized to access mailings management.');
}
} elseif (!Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to access mailings management.');
}
return view('mailings.manage');
}
/**
* Store a new mailing
*/
public function store(Request $request)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to create mailings.');
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'type' => ['required', Rule::in(['local_newsletter', 'general_newsletter', 'system_message'])],
'subject' => 'required|string|max:255',
'content_blocks' => 'nullable|array',
'content_blocks.*.post_id' => 'required|integer|exists:posts,id',
'content_blocks.*.order' => 'required|integer|min:1',
'scheduled_at' => 'nullable|date|after:now',
]);
// Determine creator
$creator = Auth::guard('admin')->user() ?: Auth::guard('bank')->user();
$mailing = Mailing::create([
'title' => $validated['title'],
'type' => $validated['type'],
'subject' => $validated['subject'],
'content_blocks' => $validated['content_blocks'] ?? [],
'scheduled_at' => $validated['scheduled_at'] ?? null,
'status' => $validated['scheduled_at'] ? 'scheduled' : 'draft',
'created_by_id' => $creator->id,
'created_by_type' => get_class($creator),
]);
// Update recipient count
$mailing->recipients_count = $mailing->getRecipientsCount();
$mailing->save();
return response()->json([
'success' => true,
'message' => 'Mailing created successfully.',
'mailing' => $mailing->load('createdBy')
]);
}
/**
* Update an existing mailing
*/
public function update(Request $request, Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to update mailings.');
}
// Can only edit drafts
if (!$mailing->canBeScheduled()) {
return response()->json(['error' => 'Only draft mailings can be edited.'], 422);
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'type' => ['required', Rule::in(['local_newsletter', 'general_newsletter', 'system_message'])],
'subject' => 'required|string|max:255',
'content_blocks' => 'nullable|array',
'content_blocks.*.post_id' => 'required|integer|exists:posts,id',
'content_blocks.*.order' => 'required|integer|min:1',
'scheduled_at' => 'nullable|date|after:now',
]);
$mailing->update([
'title' => $validated['title'],
'type' => $validated['type'],
'subject' => $validated['subject'],
'content_blocks' => $validated['content_blocks'] ?? [],
'scheduled_at' => $validated['scheduled_at'] ?? null,
'status' => $validated['scheduled_at'] ? 'scheduled' : 'draft',
]);
// Update recipient count
$mailing->recipients_count = $mailing->getRecipientsCount();
$mailing->save();
return response()->json([
'success' => true,
'message' => 'Mailing updated successfully.',
'mailing' => $mailing->load('createdBy')
]);
}
/**
* Delete a mailing (soft delete)
*/
public function destroy(Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to delete mailings.');
}
// Can only delete drafts and scheduled mailings
if (!in_array($mailing->status, ['draft', 'scheduled'])) {
return response()->json(['error' => 'Cannot delete sent or sending mailings.'], 422);
}
$mailing->delete();
return response()->json([
'success' => true,
'message' => 'Mailing deleted successfully.'
]);
}
/**
* Send a mailing immediately
*/
public function send(Request $request, Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to send mailings.');
}
if (!Gate::allows('send mailings')) {
abort(403, 'Unauthorized to send mailings.');
}
if (!$mailing->canBeSent()) {
return response()->json(['error' => 'Mailing cannot be sent in its current status.'], 422);
}
// Update status to sending
$mailing->update(['status' => 'sending']);
// Dispatch bulk email job (to be implemented in Phase 6)
// SendBulkMailJob::dispatch($mailing);
return response()->json([
'success' => true,
'message' => 'Mailing is being sent. This process may take several minutes.',
'mailing' => $mailing
]);
}
/**
* Schedule a mailing for future sending
*/
public function schedule(Request $request, Mailing $mailing)
{
// Authorization check — scheduling is deferred sending, requires send permission
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to schedule mailings.');
}
if (!Gate::allows('send mailings')) {
abort(403, 'Unauthorized to schedule mailings.');
}
if (!$mailing->canBeScheduled()) {
return response()->json(['error' => 'Mailing cannot be scheduled in its current status.'], 422);
}
$validated = $request->validate([
'scheduled_at' => 'required|date|after:now',
]);
$mailing->update([
'scheduled_at' => $validated['scheduled_at'],
'status' => 'scheduled'
]);
return response()->json([
'success' => true,
'message' => 'Mailing scheduled successfully.',
'mailing' => $mailing
]);
}
/**
* Cancel a scheduled mailing
*/
public function cancel(Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to cancel mailings.');
}
if (!Gate::allows('send mailings')) {
abort(403, 'Unauthorized to cancel mailings.');
}
if (!$mailing->canBeCancelled()) {
return response()->json(['error' => 'Mailing cannot be cancelled in its current status.'], 422);
}
$mailing->update([
'scheduled_at' => null,
'status' => 'draft'
]);
return response()->json([
'success' => true,
'message' => 'Scheduled mailing cancelled and reverted to draft.',
'mailing' => $mailing
]);
}
/**
* Preview a mailing
*/
public function preview(Request $request, Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to preview mailings.');
}
// Get current user as preview recipient
$recipient = Auth::guard('admin')->user() ?: Auth::guard('bank')->user();
// Generate preview using NewsletterMail
$newsletterMail = new \App\Mail\NewsletterMail($mailing, $recipient);
return response()->json([
'success' => true,
'preview_url' => route('mailings.preview_render', $mailing->id)
]);
}
/**
* Render mailing preview as HTML
*/
public function previewRender(Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to preview mailings.');
}
// Resolve a locale that actually has content for this mailing
$previewLocale = $mailing->resolvePreviewLocale();
// Generate and render preview for that locale
$newsletterMail = new \App\Mail\NewsletterMail($mailing, $previewLocale, true);
return $newsletterMail->render();
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\MessageSetting;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
class NewsletterUnsubscribeController extends Controller
{
/**
* Handle newsletter unsubscribe requests
*/
public function unsubscribe(Request $request)
{
$email = $request->get('email');
$type = $request->get('type');
$signature = $request->get('signature');
// Verify the signature to prevent unauthorized unsubscribes
$expectedSignature = hash_hmac('sha256', $email . $type, config('app.key'));
if (!hash_equals($expectedSignature, $signature)) {
return view('newsletter.unsubscribe-error', [
'message' => 'Invalid unsubscribe link. Please contact support if you need help unsubscribing.'
]);
}
// Validate newsletter type
if (!in_array($type, ['local_newsletter', 'general_newsletter', 'system_message'])) {
return view('newsletter.unsubscribe-error', [
'message' => 'Invalid newsletter type.'
]);
}
// Find the user or organization by email
$recipient = $this->findRecipientByEmail($email);
if (!$recipient) {
return view('newsletter.unsubscribe-error', [
'message' => 'Email address not found in our system.'
]);
}
// Get or create message settings
$messageSettings = $recipient->message_settings()->first();
if (!$messageSettings) {
$messageSettings = new MessageSetting();
$messageSettings->message_settingable_id = $recipient->id;
$messageSettings->message_settingable_type = get_class($recipient);
// Set all newsletter types to true by default (assuming they were subscribed)
$messageSettings->local_newsletter = true;
$messageSettings->general_newsletter = true;
$messageSettings->system_message = true;
}
// Unsubscribe from the specific newsletter type
$messageSettings->{$type} = false;
$messageSettings->save();
return view('newsletter.unsubscribe-success', [
'email' => $email,
'type' => $type,
'typeName' => $this->getNewsletterTypeName($type),
'recipient' => $recipient
]);
}
/**
* Find recipient (User or Organization) by email
*/
protected function findRecipientByEmail(string $email)
{
// Try to find a User first
$user = User::where('email', $email)->first();
if ($user) {
return $user;
}
// Try to find an Organization
$organization = Organization::where('email', $email)->first();
if ($organization) {
return $organization;
}
// Try to find a Bank
$bank = Bank::where('email', $email)->first();
if ($bank) {
return $bank;
}
// Try to find an Admin
$admin = Admin::where('email', $email)->first();
if ($admin) {
return $admin;
}
return null;
}
/**
* Get human-readable newsletter type name
*/
protected function getNewsletterTypeName(string $type): string
{
return match ($type) {
'local_newsletter' => 'Local Newsletter',
'general_newsletter' => 'General Newsletter',
'system_message' => 'System Messages',
default => 'Newsletter'
};
}
}
@@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Organization;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class OrganizationController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
//
}
public function show($orgId)
{
$org = Organization::select([
'id',
'name',
'profile_photo_path',
'about',
'motivation',
'website',
// 'phone_public',
'created_at',
// 'last_login_at',
'love_reactant_id',
'inactive_at',
])
->with([
'users:id,name,profile_photo_path',
'accounts:id,name,accountable_type,accountable_id',
'languages:id,name,lang_code,flag',
'socials:id,name,icon,urL_structure',
'locations.district.translations:district_id,name',
'locations.city.translations:city_id,name',
'locations.division.translations:division_id,name',
'locations.country.translations:country_id,name',
'loveReactant.reactions.reacter.reacterable',
// 'loveReactant.reactions.type',
'loveReactant.reactionCounters',
// 'loveReactant.reactionTotal',
])
->find($orgId);
if ($org->count() >= 1) {
$org->reactionCounter = $org->loveReactant->reactionCounters->first() ? (int)$org->loveReactant->reactionCounters->first()->weight : null;
$registerDate = Carbon::createFromTimeStamp(strtotime($org->created_at), config('app.timezone'))->isoFormat('LL');
$lastLoginDate = Carbon::createFromTimeStamp(strtotime($org->last_login_at), config('app.timezone'))->isoFormat('LL');
} else {
return view('profile-organization.not_found');
}
//TODO: add permission check
//TODO: if 403, but has permission, redirect with message to switch profile
//TODO: replace 403 with custom redirect page incl explanation
return ($org != null ? view('profile-organization.show', compact(['org'])) : abort(403));
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit()
{
return view('profile-organization.edit');
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(Request $request, $id)
{
//
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy($id)
{
//
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function settings()
{
return view('profile-organization.settings');
}
}
@@ -0,0 +1,266 @@
<?php
namespace App\Http\Controllers;
use App\Events\ProfileSwitchEvent;
use App\Models\Organization;
use App\Models\User;
use App\Traits\SwitchGuardTrait;
use Hash;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class OrganizationLoginController extends Controller
{
use SwitchGuardTrait;
/**
* Direct link to organization profile - can be used in emails
* Organizations don't require password re-authentication
* Handles the authentication flow:
* 1. If user not authenticated -> redirect to user login first
* 2. If user authenticated -> verify access and switch to organization
* 3. Redirect to intended URL or main page
*/
public function directLogin(Request $request, $organizationId)
{
// Validate organization exists and load managers
$organization = Organization::with('managers')->find($organizationId);
if (!$organization) {
abort(404, __('Organization not found'));
}
// Get optional intended destination after successful org switch.
// Validate it is a local URL to prevent open redirect attacks.
$intendedParam = $request->query('intended');
$intendedUrl = $this->validateLocalUrl($intendedParam, $request);
// Check if user is authenticated on web guard
if (!Auth::guard('web')->check()) {
// User not logged in - redirect to user login with return URL
$returnUrl = route('organization.direct-login', ['organizationId' => $organizationId]);
if ($intendedUrl) {
$returnUrl .= '?intended=' . urlencode($intendedUrl);
}
// Store in session for Laravel to redirect after login
session()->put('url.intended', $returnUrl);
// Get the first manager's name to pre-populate the login form
$firstManager = $organization->managers()->first();
\Log::info('OrganizationLoginController: Redirecting unauthenticated user', [
'organization_id' => $organizationId,
'manager_found' => $firstManager ? 'yes' : 'no',
'manager_name' => $firstManager ? $firstManager->name : null,
]);
if ($firstManager) {
// Build the login URL with the name parameter and localization
$loginRoute = route('login') . '?name=' . urlencode($firstManager->name);
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
\Log::info('OrganizationLoginController: Redirect URL', [
'url' => $loginUrl,
]);
return redirect($loginUrl);
}
return redirect()->route('login');
}
// User is authenticated - verify they own/manage this organization
$user = Auth::guard('web')->user();
$userWithRelations = User::with('organizations')->find($user->id);
if (!$userWithRelations || !$userWithRelations->organizations->contains('id', $organizationId)) {
// Wrong user is logged in — log out all guards and redirect to login
foreach (['web', 'organization', 'bank', 'admin'] as $guard) {
Auth::guard($guard)->logout();
}
$request->session()->invalidate();
$request->session()->regenerateToken();
$returnUrl = route('organization.direct-login', ['organizationId' => $organizationId]);
if ($intendedUrl) {
$returnUrl .= '?intended=' . urlencode($intendedUrl);
}
session()->put('url.intended', $returnUrl);
$firstManager = $organization->managers()->first();
if ($firstManager) {
$loginRoute = route('login') . '?name=' . urlencode($firstManager->name);
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
return redirect($loginUrl);
}
return redirect()->route('login');
}
// Switch to organization guard directly (no password required for organizations)
$this->switchGuard('organization', $organization);
// Set active profile session
session([
'activeProfileType' => get_class($organization),
'activeProfileId' => $organization->id,
'activeProfileName' => $organization->name,
'activeProfilePhoto' => $organization->profile_photo_path,
'activeProfileRoleLabel' => getProfileRoleLabel($organization, $user),
'last_activity' => now(),
'profile-switched-notification' => true,
]);
// Re-activate profile if inactive
if (timebank_config('profile_inactive.re-activate_at_login')) {
if (!$organization->isActive()) {
$organization->inactive_at = null;
$organization->save();
info('Organization re-activated: ' . $organization->name);
}
}
// Broadcast profile switch event
event(new \App\Events\ProfileSwitchEvent($organization));
// Redirect to intended URL or main page
if ($intendedUrl) {
return redirect($intendedUrl);
}
return redirect()->route('main');
}
public function showLoginForm()
{
$user = Auth::guard('web')->user();
$type = session('intended_profile_switch_type');
$id = session('intended_profile_switch_id');
$profile = $this->getTargetProfileByTypeAndId($user, $type, $id);
return view('profile-organization.login', [
'profile' => $profile,
'canResetPassword' => $this->profileCanResetPassword($user, 'Organization', $id),
]);
}
public function login(Request $request)
{
$request->validate([
'password' => 'required',
]);
$user = Auth::guard('web')->user();
$type = session('intended_profile_switch_type');
$id = session('intended_profile_switch_id');
$organization = $this->getTargetProfileByTypeAndId($user, $type, $id);
if (!$organization) {
return back()->withErrors(['index' => __('Organization not found')]);
}
// Legacy Cyclos password support
if (!empty($organization->cyclos_salt)) {
info('Auth attempt using original Cyclos password');
$concatenated = $organization->cyclos_salt . $request->password;
$hashedInputPassword = hash("sha256", $concatenated);
if (strtolower($hashedInputPassword) === strtolower($organization->password)) {
info('Auth success: Password is verified');
// Rehash to Laravel hash and remove salt
$organization->password = \Hash::make($request->password);
$organization->cyclos_salt = null;
$organization->save();
info('Auth success: Cyclos password has been rehashed for next login');
}
}
// Check if the provided password matches the hashed password in the database
if (\Hash::check($request->password, $organization->password)) {
$this->switchGuard('organization', $organization); // log in as organization
// Remove intended switch from session
session()->forget(['intended_profile_switch_type', 'intended_profile_switch_id']);
// Set active profile session as before
session([
'activeProfileType' => get_class($organization),
'activeProfileId' => $organization->id,
'activeProfileName' => $organization->name,
'activeProfilePhoto' => $organization->profile_photo_path,
'activeProfileRoleLabel' => getProfileRoleLabel($organization, $user),
'last_activity' => now(),
'profile-switched-notification' => true,
]);
// Re-activate profile if inactive
if (timebank_config('profile_inactive.re-activate_at_login')) {
if (!$organization->isActive()) {
$organization->inactive_at = null;
$organization->save();
info('Organization re-activated: ' . $organization->name);
}
}
event(new \App\Events\ProfileSwitchEvent($organization));
// Check for intended URL from direct login flow
$intendedUrl = session('organization_login_intended_url');
if ($intendedUrl) {
session()->forget('organization_login_intended_url');
return redirect($intendedUrl);
}
return redirect()->route('main'); // Or your special organization main page
}
info('Auth failed: Input password does not match stored password');
return back()->withErrors(['password' => __('Invalid organization password')]);
}
//TODO: Move to Trait as suggested below.
/**
* Helper method to get the target profile model based on the index.
* Note: This duplicates logic from ProfileSelect::mount. Consider refactoring
* this logic into a service or trait if used in multiple places.
*/
private function getTargetProfileByTypeAndId($user, $type, $id)
{
if (!$type || !$id) {
return null;
}
$userWithRelations = User::with(['organizations', 'banksManaged', 'admins'])->find($user->id);
if (!$userWithRelations) return null;
if (strtolower($type) === 'organization') {
return $userWithRelations->organizations->firstWhere('id', $id);
} elseif (strtolower($type) === 'bank') {
return $userWithRelations->banksManaged->firstWhere('id', $id);
} elseif (strtolower($type) === 'admin') {
return $userWithRelations->admins->firstWhere('id', $id);
}
return null;
}
private function profileCanResetPassword($user, string $profileType, $profileId): bool
{
if (!$user || !$profileId) {
return false;
}
$prefix = "{$profileType}\\{$profileId}\\";
$scopedRoles = $user->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
foreach ($scopedRoles as $spatieRole) {
$roleName = str_replace($prefix, '', $spatieRole->name);
$orgRole = \App\Models\OrganizationRole::where('name', $roleName)->first();
if ($orgRole && in_array('org change-password', $orgRole->permissions ?? [])) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class PermissionController extends Controller
{
public function manage()
{
return view('permissions.manage');
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\Poll;
use App\Models\Post;
use App\Models\PostTranslation;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
/**
* Dedicated page of a poll: its post (title, image, intro, text) with the poll form below.
*/
class PollController extends Controller
{
public function show(string $slug)
{
// Polls are for logged-in profiles only. The route requires a login already; this is a
// safety net should the route ever be moved out of its protected group.
if (!Auth::guard('web')->check()) {
session(['url.intended' => request()->url()]);
return redirect(LaravelLocalization::localizeUrl('/login'));
}
$postId = PostTranslation::where('slug', $slug)->value('post_id');
$post = $postId ? Post::with(['poll', 'category.translations'])->find($postId) : null;
if (!$post || !$post->poll) {
return view('posts.not_found');
}
// Poll admins may preview unpublished polls and drafts; for everyone else a draft does
// not exist yet, even when its post already has a publication date (security audit R3)
$preview = Poll::viewerIsPollAdmin();
if ($post->poll->status === Poll::STATUS_DRAFT && !$preview) {
return view('posts.not_found');
}
$published = function ($query) use ($preview) {
if (!$preview) {
$query->whereDate('from', '<=', now())
->where(fn ($q) => $q->whereDate('till', '>', now())->orWhereNull('till'));
}
};
// The current language, otherwise any published language
$translation = $post->translations()->where('locale', App::getLocale())->where($published)->first()
?? $post->translations()->where($published)->first();
if (!$translation) {
return view('posts.not_found');
}
// Another of the user's profiles may take part while the active one may not: right after
// logging in to this poll (a one-time session marker set by LoginResponse) with only one
// such profile, switch to it straight away (its own login flow, password for banks and
// admins); otherwise offer them on the page.
$justLoggedIn = session()->pull('poll_switch_after_login') === '/' . ltrim(request()->path(), '/');
$switchProfiles = $this->switchProfiles($post->poll, request()->url());
if ($justLoggedIn && count($switchProfiles) === 1) {
return redirect($switchProfiles[0]['url']);
}
$media = $post->getFirstMedia('posts');
$category = $post->category?->translations->firstWhere('locale', App::getLocale())?->name
?? $post->category?->translations->first()?->name;
$update = Carbon::parse($translation->updated_at)->isoFormat('LL');
return view('polls.show', compact('post', 'translation', 'media', 'category', 'update', 'switchProfiles'));
}
/**
* The logged-in user's other profiles that may take part when the active profile may not,
* each with the direct login link of that profile that returns to this poll.
*
* @return array<int, array{name: string, type: string, url: string}>
*/
private function switchProfiles(Poll $poll, string $pollUrl): array
{
$user = Auth::guard('web')->user();
$active = getActiveProfile();
if (!$user instanceof User || !$active || !$poll->isOpen() || $poll->isTargetedAt($active, $user)) {
return [];
}
$user->loadMissing(['organizations', 'banksManaged', 'admins']);
$routes = [
User::class => ['user.direct-login', 'userId'],
Organization::class => ['organization.direct-login', 'organizationId'],
Bank::class => ['bank.direct-login', 'bankId'],
Admin::class => ['admin.direct-login', 'adminId'],
];
$profiles = collect([$user])->concat($user->organizations)->concat($user->banksManaged)->concat($user->admins)
->reject(fn (Model $profile) => $profile->is($active))
->filter(fn (Model $profile) => $poll->isTargetedAt($profile, $user));
return $profiles->map(function (Model $profile) use ($routes, $pollUrl) {
[$route, $parameter] = $routes[get_class($profile)];
return [
'name' => $profile->name,
'type' => class_basename($profile),
'url' => route($route, [$parameter => $profile->getKey()]) . '?intended=' . urlencode($pollUrl),
];
})->values()->all();
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\ProfileAuthorizationHelper;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Poll;
use App\Polls\ResultsCsv;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
/**
* Poll results as a CSV download. A plain HTTP route (not a Livewire action) so the file is
* streamed instead of buffered in memory.
*/
class PollResultsExportController extends Controller
{
public function __invoke(Poll $poll)
{
// Admin (or central bank) profile allowed to see who voted
$profile = getActiveProfile();
if (!$profile || !($profile instanceof Admin || ($profile instanceof Bank && $profile->level === 0))) {
abort(403);
}
ProfileAuthorizationHelper::authorize($profile);
if (!Gate::allows('export polls')) {
abort(403);
}
Log::info('Poll results exported', ['poll_id' => $poll->id, 'user_id' => auth('web')->id()]);
$csv = new ResultsCsv($poll, App::getLocale());
$filename = 'poll-' . $poll->id . '-results-' . now()->format('Y-m-d') . '.csv';
return response()->streamDownload(function () use ($csv) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOM, so Excel shows accents correctly
fputcsv($out, $csv->header());
foreach ($csv->rows() as $row) {
fputcsv($out, $row);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}
@@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Poll;
use App\Models\PollLedgerEntry;
use App\Models\User;
use App\Polls\Ledger;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
/**
* Verification page of a poll and its ledger as JSON.
*
* The page is for the profiles the poll is meant for (and poll admins), once its post is
* published: chain state, seal and the receipt check. Level "public" adds the anonymous
* ballot list, the recount and the ledger download, but only for viewers who may see the
* results under the poll's results setting: the ballots are the results.
*/
class PollVerificationController extends Controller
{
public function show(Poll $poll, Ledger $ledger)
{
if (!$this->mayView($poll)) {
return view('posts.not_found');
}
$public = $this->maySeeBallots($poll);
$entries = PollLedgerEntry::where('poll_id', $poll->id)->get(['sequence', 'entry_type', 'refers_to_sequence']);
$counts = [
'cast' => $entries->where('entry_type', PollLedgerEntry::CAST)->count(),
'change' => $entries->where('entry_type', PollLedgerEntry::CHANGE)->count(),
'withdraw' => $entries->where('entry_type', PollLedgerEntry::WITHDRAW)->count(),
'active' => $ledger->activeBallots($poll)->count(),
];
return view('polls.verify', [
'poll' => $poll,
'title' => $this->title($poll),
'slug' => $this->slug($poll),
'chain' => $ledger->verifyChain($poll),
'counts' => $counts,
'public' => $public,
'ballotsHidden' => !$public && $poll->verification === Poll::VERIFICATION_PUBLIC,
'recount' => $public ? $this->recount($poll, $ledger) : null,
'ballots' => $public ? PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->paginate(50) : null,
'replacedBy' => $entries->whereNotNull('refers_to_sequence')->pluck('entry_type', 'refers_to_sequence'),
'questions' => $poll->questions()->get()->keyBy('id'),
]);
}
/** The public ledger, for verification in the browser or with any other tool. */
public function ledger(Poll $poll, Ledger $ledger)
{
if (!$this->mayView($poll) || !$this->maySeeBallots($poll)) {
abort(404);
}
$entries = PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->get()
->map(fn (PollLedgerEntry $entry) => $ledger->publicEntry($entry));
return response()->json([
'poll_id' => $poll->id,
'format' => 'hash = SHA-256 of canonical JSON of {v, poll_id, sequence, entry_type, fingerprint, refers_to_sequence, payload, recorded_at, previous_hash}; keys sorted by UTF-8 bytes; first previous_hash is 64 zeros. Verifier: resources/js/poll-ledger-verifier.js',
'sealed_at' => $poll->sealed_at?->toIso8601String(),
'seal_hash' => $poll->seal_hash,
'published_tally' => $ledger->publishedTally($poll),
'entries' => $entries,
]);
}
/**
* The verification page: verification on, not a draft, and the viewer is a poll admin or
* a profile the poll is meant for, with the poll's post published. Anyone else gets the
* same "not found" as for a poll that does not exist.
*/
private function mayView(Poll $poll): bool
{
if ($poll->verification === Poll::VERIFICATION_OFF || $poll->status === Poll::STATUS_DRAFT) {
return false;
}
if ($this->isPollAdmin()) {
return true;
}
$published = $poll->post?->translations()
->whereDate('from', '<=', now())
->where(fn ($q) => $q->whereDate('till', '>', now())->orWhereNull('till'))
->exists();
[$profile, $user] = $this->viewer();
return $published && $profile && $user
&& $poll->matchesProfile($profile) && $poll->memberMayVote($profile, $user);
}
/**
* The ballot list, recount and ledger JSON reveal the results, so they follow the poll's
* results setting (after voting / after closing / never), except for poll admins.
*/
private function maySeeBallots(Poll $poll): bool
{
if ($poll->verification !== Poll::VERIFICATION_PUBLIC) {
return false;
}
if ($this->isPollAdmin()) {
return true;
}
[$profile, $user] = $this->viewer();
return $profile && $user && $poll->resultsVisibleToVoter($poll->responseFor($profile, $user->id) !== null);
}
/** Same access as the Polls page and the results export. */
private function isPollAdmin(): bool
{
return Poll::viewerIsPollAdmin();
}
/** @return array{0: ?\Illuminate\Database\Eloquent\Model, 1: ?User} */
private function viewer(): array
{
$user = Auth::guard('web')->user();
return [getActiveProfile(), $user instanceof User ? $user : null];
}
/**
* Per question: does the recount of the ledger match the published result?
*
* @return array{matches: bool, questions: array<int, bool>}
*/
private function recount(Poll $poll, Ledger $ledger): array
{
$ledgerTally = collect($ledger->ledgerTally($poll))->groupBy('question_id');
$publishedTally = collect($ledger->publishedTally($poll))->groupBy('question_id');
$questions = [];
foreach ($poll->questions()->pluck('id') as $questionId) {
$questions[$questionId] = $this->rows($ledgerTally->get($questionId)) === $this->rows($publishedTally->get($questionId));
}
return ['matches' => !in_array(false, $questions, true) && $ledgerTally->keys()->diff($publishedTally->keys())->isEmpty(), 'questions' => $questions];
}
private function rows(?Collection $rows): array
{
return $rows ? $rows->values()->all() : [];
}
private function title(Poll $poll): string
{
$translations = $poll->post?->translations ?? collect();
return ($translations->firstWhere('locale', App::getLocale()) ?? $translations->first())?->title ?? '';
}
private function slug(Poll $poll): ?string
{
$translations = $poll->post?->translations ?? collect();
return ($translations->firstWhere('locale', App::getLocale()) ?? $translations->first())?->slug;
}
}
+431
View File
@@ -0,0 +1,431 @@
<?php
namespace App\Http\Controllers;
use App\Models\Language;
use App\Models\Post;
use App\Models\PostTranslation;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
class PostController extends Controller
{
public function showById($id)
{
$post =
Post::with([
'postable' => function ($query) {
$query->select(['id', 'name']);
},
'category' => function ($query) {
$query->with('translations');
},
'translations' => function ($query) {
$query
->where('locale', App::getLocale());
// ->whereDate('from', '<=', now()) //TODO: Exclude date queries ONLY for post Admins!
// ->where( function($query) {
// $query->whereDate('till', '>', now())->orWhereNull('till');
// })
},
])
->where('id', $id)
->firstOrFail();
// Poll posts have their own page
if (\App\Models\Poll::where('post_id', $post->id)->exists()) {
$slug = $post->translations->first()?->slug ?? PostTranslation::where('post_id', $post->id)->value('slug');
if ($slug) {
return redirect()->route('poll.show', ['slug' => $slug]);
}
}
if ($post->media) {
$media = Post::find($id)->getFirstMedia('posts');
}
if ($post->translations->count() >= 1) {
if ($post->category->translations) {
$category = $post->category->translations->where('locale', App::getLocale())->first()->name;
}
$update = Carbon::createFromTimeStamp(strtotime($post->translations->first()->updated_at), config('app.timezone'))->isoFormat('LL');
} else {
// No translation in current locale - check for fallback translations
return $this->handleNoTranslation($post, $id);
}
// Use guest layout for non-authenticated users, app layout for authenticated users
$viewName = Auth::check() ? 'posts.show' : 'posts.show-guest';
return ($post != null ? view($viewName, compact(['post','media','category','update'])) : abort(403));
}
/**
* Handle case when no translation exists for current locale.
* Check for available translations in other locales and offer fallback.
*/
private function handleNoTranslation($post, $postId)
{
// Get all available translations for this post (active ones)
$availableTranslation = PostTranslation::where('post_id', $postId)
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
})
->first();
$viewData = [
'fallbackLocale' => null,
'fallbackLanguageName' => null,
'fallbackUrl' => null,
'post' => $post,
];
if ($availableTranslation) {
// Found an available translation in another locale
$fallbackLocale = $availableTranslation->locale;
$fallbackLanguageName = Language::where('lang_code', $fallbackLocale)->first()->name ?? $fallbackLocale;
// Generate URL to the same post in the fallback locale
$fallbackUrl = \LaravelLocalization::getLocalizedURL($fallbackLocale, route('post.show', ['id' => $postId]));
$viewData['fallbackLocale'] = $fallbackLocale;
$viewData['fallbackLanguageName'] = __($fallbackLanguageName);
$viewData['fallbackUrl'] = $fallbackUrl;
}
// Use guest layout for non-authenticated users, app layout for authenticated users
$viewName = Auth::check() ? 'posts.no_translation' : 'posts.no_translation-guest';
return view($viewName, $viewData);
}
/**
* Show view by .../posts/{slug}.
* Post will be shown in user's App:locale() language if available, even is the slug is in another language.
*
* @param mixed $slug
* @return void
*/
public function showBySlug($slug)
{
$postTranslation = PostTranslation::where('slug', $slug)->get()->first();
if (!$postTranslation) {
return view('posts.not_found');
}
$id = $postTranslation->post_id;
$locale = $postTranslation->locale;
// Poll posts have their own page
if (\App\Models\Poll::where('post_id', $id)->exists()) {
return redirect()->route('poll.show', ['slug' => $slug]);
}
$post = Post::with([
'postable' => function ($query) {
$query->select(['id', 'name']);
},
'category' => function ($query) {
$query->with('translations');
},
'meeting',
'translations' => function ($query) {
//TODO!: Currently only user 1 (Super-admin) can view unpublished posts, change to permission/role based!
$query->where('locale', App::getLocale());
// Only show published posts for guests and non-admin users
if (!Auth::guard('web')->check() || Auth::guard('web')->user()->id != 1) {
$query->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
});
}
}
])
->where('id', $id)
->first();
if ($post->media) {
$media = Post::find($id)->getFirstMedia('posts');
}
if ($post->translations->count() >= 1) {
if ($post->category->translations) {
$category = $post->category->translations->where('locale', App::getLocale())->first()->name;
}
$update = Carbon::createFromTimeStamp(strtotime($post->translations->first()->updated_at), config('app.timezone'))->isoFormat('LL');
} else {
// No translation in current locale - check for fallback translations
return $this->handleNoTranslation($post, $id);
}
// Use guest layout for non-authenticated users, app layout for authenticated users
$viewName = Auth::check() ? 'posts.show' : 'posts.show-guest';
return ($post != null ? view($viewName, compact(['post','media','category','update'])) : abort(403));
}
public function manage()
{
if (getActiveProfileType() !== 'Admin') {
abort(403, __('Admin profile required'));
}
return view('posts.manage');
}
public function postPdf($id)
{
$data = $this->buildPostPdfData($id);
$pdf = Pdf::loadView('posts.pdf', $data)
->setPaper('A4', 'portrait')
->setOptions([
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => false,
'defaultFont' => 'DejaVu Sans',
'dpi' => 150,
'chroot' => public_path(),
]);
return $pdf->download('post-' . $data['post']->id . '.pdf');
}
public function postPdfBulk(\Illuminate\Http\Request $request)
{
$ids = array_filter(array_map('intval', explode(',', $request->input('ids', ''))));
$perPage = in_array((int) $request->input('per_page', 1), [1, 2, 4])
? (int) $request->input('per_page', 1)
: 1;
if (empty($ids)) abort(400);
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
$isAdmin = $activeProfileType === \App\Models\Admin::class
|| ($activeProfileType === \App\Models\Bank::class
&& \App\Models\Bank::find($activeProfileId)?->level === 0);
if (!$isAdmin) abort(403);
$posts = [];
foreach ($ids as $translationId) {
$translation = PostTranslation::withTrashed()->find($translationId);
if (!$translation) continue;
$locale = $translation->locale ?? App::getLocale();
$posts[] = $this->buildPostPdfDataForLocale($translation->post_id, $locale);
}
if (empty($posts)) abort(403);
$view = 'posts.pdf-bulk';
$orientation = $perPage === 2 ? 'landscape' : 'portrait';
$pdf = Pdf::loadView($view, compact('posts', 'perPage'))
->setPaper('A4', $orientation)
->setOptions([
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => false,
'defaultFont' => 'DejaVu Sans',
'dpi' => 150,
'chroot' => public_path(),
'margin_top' => 0,
'margin_right' => 0,
'margin_bottom' => 0,
'margin_left' => 0,
]);
return $pdf->download('posts-bulk-' . now()->format('Ymd-His') . '.pdf');
}
private function buildPostPdfDataForLocale(int $postId, string $locale): array
{
$previous = App::getLocale();
App::setLocale($locale);
try {
return $this->buildPostPdfData($postId);
} finally {
App::setLocale($previous);
}
}
private function buildPostPdfData($id): array
{
$locale = App::getLocale();
$post = Post::with([
'author',
'category.translations',
'translations' => function ($query) use ($locale) {
$query->where('locale', $locale);
},
'meeting.meetingable',
'meeting.location.city.translations',
])->findOrFail($id);
// Polls are for logged-in profiles only
abort_if($post->isPoll() && !Auth::guard('web')->check(), 404);
$translation = $post->translations->first();
$categoryName = $post->category?->translations->firstWhere('locale', $locale)?->name
?? $post->category?->translations->first()?->name;
$categoryType = $post->category?->type ?? '';
$isEvent = $categoryType === 'App\Models\Meeting';
$isArticle = $categoryType === 'App\Models\Article';
$isNews = $categoryType === 'App\Models\News';
$isImagePost = str_starts_with($categoryType, 'App\Models\ImagePost');
$update = $translation
? Carbon::parse($translation->updated_at)->isoFormat('LL')
: null;
// Hero image as base64 for DomPDF (GD for webp, direct read for others)
$heroBase64 = null;
$heroMedia = $post->getFirstMedia('posts');
if ($heroMedia) {
$heroPath = $heroMedia->getPath();
if (file_exists($heroPath)) {
$raw = @file_get_contents($heroPath);
if ($raw) {
$src = @imagecreatefromstring($raw);
if ($src) {
// Crop to A4 portrait aspect ratio (cover behaviour)
$srcW = imagesx($src);
$srcH = imagesy($src);
$dstW = 1240; // 210mm @ 150dpi
$dstH = 1754; // 297mm @ 150dpi
$scale = max($dstW / $srcW, $dstH / $srcH);
$scaledW = (int) round($srcW * $scale);
$scaledH = (int) round($srcH * $scale);
$cropX = (int) round(($scaledW - $dstW) / 2);
$cropY = (int) round(($scaledH - $dstH) / 2);
$dst = imagecreatetruecolor($dstW, $dstH);
imagecopyresampled($dst, $src, 0, 0, (int) round($cropX / $scale), (int) round($cropY / $scale), $dstW, $dstH, (int) round($dstW / $scale), (int) round($dstH / $scale));
// Boost saturation +50%: blend color image with its grayscale at factor > 1
// output = gray + (color - gray) * 1.5 → clamp to 0-255
$gray = imagecreatetruecolor($dstW, $dstH);
imagecopy($gray, $dst, 0, 0, 0, 0, $dstW, $dstH);
imagefilter($gray, IMG_FILTER_GRAYSCALE);
for ($px = 0; $px < $dstW; $px++) {
for ($py = 0; $py < $dstH; $py++) {
$c = imagecolorat($dst, $px, $py);
$g = imagecolorat($gray, $px, $py);
$gr = ($g >> 16) & 0xFF;
$nr = max(0, min(255, (int) round($gr + ((($c >> 16) & 0xFF) - $gr) * 1.75)));
$ng = max(0, min(255, (int) round($gr + ((($c >> 8) & 0xFF) - $gr) * 1.75)));
$nb = max(0, min(255, (int) round($gr + (($c & 0xFF) - $gr) * 1.75)));
imagesetpixel($dst, $px, $py, ($nr << 16) | ($ng << 8) | $nb);
}
}
imagedestroy($gray);
ob_start();
imagejpeg($dst, null, 85);
$heroBase64 = 'data:image/jpeg;base64,' . base64_encode(ob_get_clean());
imagedestroy($src);
imagedestroy($dst);
}
}
}
}
// Author avatar as base64 (SVG skipped — GD cannot render SVG)
$avatarBase64 = null;
$author = $post->author;
if ($author && $author->profile_photo_path) {
$avatarPath = storage_path('app/public/' . $author->profile_photo_path);
$ext = strtolower(pathinfo($avatarPath, PATHINFO_EXTENSION));
if (file_exists($avatarPath) && $ext !== 'svg') {
$mime = mime_content_type($avatarPath) ?: 'image/jpeg';
$avatarBase64 = 'data:' . $mime . ';base64,' . base64_encode(file_get_contents($avatarPath));
}
}
$brandColor = theme_color('brand') ?: '#000000';
// Platform logo
$logoBase64 = null;
$logoPath = theme_email_logo_file();
if (file_exists($logoPath)) {
$logoBase64 = 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath));
}
// Hero logo: invert RGB to white while preserving alpha transparency
$logoWhiteBase64 = null;
if ($logoPath && file_exists($logoPath)) {
$src = @imagecreatefrompng($logoPath);
if ($src) {
$w = imagesx($src);
$h = imagesy($src);
$dst = imagecreatetruecolor($w, $h);
imagealphablending($dst, false);
imagesavealpha($dst, true);
// Fill with transparent background
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
imagefill($dst, 0, 0, $transparent);
// Copy pixel by pixel, inverting RGB but keeping alpha
for ($x = 0; $x < $w; $x++) {
for ($y = 0; $y < $h; $y++) {
$rgba = imagecolorat($src, $x, $y);
$alpha = ($rgba >> 24) & 0x7F;
$r = 255 - (($rgba >> 16) & 0xFF);
$g = 255 - (($rgba >> 8) & 0xFF);
$b = 255 - ($rgba & 0xFF);
imagesetpixel($dst, $x, $y, imagecolorallocatealpha($dst, $r, $g, $b, $alpha));
}
}
ob_start();
imagepng($dst);
$logoWhiteBase64 = 'data:image/png;base64,' . base64_encode(ob_get_clean());
imagedestroy($src);
imagedestroy($dst);
}
}
// QR code as inline SVG (no ImageMagick needed)
$postUrl = route('post.show', ['id' => $post->id]);
$qrSvg = \SimpleSoftwareIO\QrCode\Facades\QrCode::format('svg')
->size(300)->margin(2)->errorCorrection('M')->color(0, 0, 0)->generate($postUrl);
$qrBase64 = 'data:image/svg+xml;base64,' . base64_encode($qrSvg);
// Sanitize and truncate content for PDF, preserving HTML formatting.
// Truncation is based on plain-text length; the HTML is kept intact up to that point.
$rawContent = $translation?->content ?? '';
$plainContent = strip_tags($rawContent);
$contentMax = 800;
$isTruncated = mb_strlen($plainContent) > $contentMax;
$pdfContent = \App\Helpers\StringHelper::sanitizeHtml($rawContent);
// Pass the full excerpt — CSS overflow:hidden on .hero-excerpt clips it within the hero.
$rawExcerpt = $translation?->excerpt ?? '';
$pdfExcerpt = $rawExcerpt;
$meetingCityName = null;
if ($isEvent && $post->meeting?->location?->city) {
$city = $post->meeting->location->city;
$meetingCityName = $city->translations->firstWhere('locale', $locale)?->name
?? $city->translations->first()?->name;
}
return compact(
'post', 'translation', 'categoryName', 'update',
'categoryType', 'isEvent', 'isArticle', 'isNews', 'isImagePost',
'heroBase64', 'avatarBase64', 'logoBase64', 'logoWhiteBase64', 'qrBase64',
'pdfContent', 'isTruncated', 'pdfExcerpt', 'author', 'brandColor',
'meetingCityName'
);
}
public function notFound()
{
return view('posts.not_found');
}
}
@@ -0,0 +1,66 @@
<?php
// 2. Presence Controller for AJAX calls
namespace App\Http\Controllers;
use App\Services\PresenceService;
use Illuminate\Http\Request;
class PresenceController extends Controller
{
protected $presenceService;
public function __construct(PresenceService $presenceService)
{
$this->presenceService = $presenceService;
}
public function heartbeat(Request $request)
{
// Use the session active guard as the authoritative source
$guard = $request->session()->get('active_guard', $request->get('guard', 'web'));
if (auth($guard)->check()) {
$this->presenceService->updatePresence(auth($guard)->user(), $guard);
return response()->json([
'status' => 'online',
'timestamp' => now(),
'user_id' => auth($guard)->id()
]);
}
return response()->json(['status' => 'unauthorized'], 401);
}
public function setOffline(Request $request)
{
// Use the session active guard as the authoritative source
$guard = $request->session()->get('active_guard', $request->get('guard', 'web'));
if (auth($guard)->check()) {
$this->presenceService->setUserOffline(auth($guard)->user(), $guard);
return response()->json([
'status' => 'offline',
'timestamp' => now(),
'user_id' => auth($guard)->id()
]);
}
return response()->json(['status' => 'unauthorized'], 401);
}
public function getOnlineUsers(Request $request)
{
$guard = $request->get('guard', 'web');
$users = $this->presenceService->getOnlineUsers($guard);
return response()->json([
'users' => $users,
'count' => $users->count(),
'guard' => $guard,
'updated_at' => now()
]);
}
}
+542
View File
@@ -0,0 +1,542 @@
<?php
namespace App\Http\Controllers;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use App\Traits\ActiveStatesTrait;
use App\Traits\LocationTrait;
use App\Traits\ProfilePermissionTrait;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
class ProfileController extends Controller
{
use ActiveStatesTrait;
use LocationTrait;
use ProfilePermissionTrait;
public function show($type, $id)
{
if (strtolower($type) === strtolower(__('user'))) {
return $this->showUser($id);
}
if (strtolower($type) === strtolower(__('organization'))) {
return $this->showOrganization($id);
}
if (strtolower($type) === strtolower(__('bank'))) {
return $this->showBank($id);
}
return view('profile.not_found');
}
public function showActive()
{
$profile = getActiveProfile();
return $this->show(__(class_basename($profile)), $profile->id);
}
public function showUser($id)
{
$user = User::select([
'id',
'name',
'full_name',
'profile_photo_path',
'about',
'about_short',
'motivation',
'date_of_birth',
'website',
'phone_public',
'phone',
'cyclos_skills',
'lang_preference',
'email_verified_at',
'created_at',
'last_login_at',
'love_reactant_id',
'inactive_at',
'deleted_at'
])
->with([
'organizations:id,name,profile_photo_path',
'accounts:id,name,accountable_type,accountable_id',
'languages:id,name,lang_code,flag',
'tags:tag_id',
'socials:id,name,icon,urL_structure',
'locations.district.translations:district_id,name',
'locations.city.translations:city_id,name',
'locations.division.translations:division_id,name',
'locations.country.translations:country_id,name',
'loveReactant.reactions.reacter.reacterable',
'loveReactant.reactionCounters',
])
->find($id);
if (!$user) {
return view('profile.not_found');
}
$states = $this->getActiveStates($user);
$canManageProfiles = $this->getCanManageProfiles();
$canViewIncompleteProfiles = $this->canViewIncompleteProfiles();
$isViewingOwnProfile = getActiveProfile() &&
get_class(getActiveProfile()) === User::class &&
getActiveProfile()->id === $user->id;
// For admins/banks: Always show incomplete label if profile is incomplete (ignore config)
if ($canManageProfiles) {
$states['hidden'] = false;
$states['inactiveLabel'] = !$user->isActive();
$states['inactiveSince'] = $user->inactive_at
? \Illuminate\Support\Carbon::parse($user->inactive_at)->diffForHumans()
: '';
$states['emailUnverifiedLabel'] = !$user->isEmailVerified();
$states['incompleteLabel'] = $user->hasIncompleteProfile($user);
}
// When viewing own profile, never hide but show all labels
if ($isViewingOwnProfile) {
$states['hidden'] = false;
$states['inactiveLabel'] = !$user->isActive();
$states['inactiveSince'] = $user->inactive_at
? \Illuminate\Support\Carbon::parse($user->inactive_at)->diffForHumans()
: '';
$states['emailUnverifiedLabel'] = !$user->isEmailVerified();
$states['incompleteLabel'] = $user->hasIncompleteProfile($user);
// Show incomplete warning modal when viewing own profile
$states['showIncompleteWarning'] = timebank_config('profile_incomplete.show_warning_modal') &&
$user->hasIncompleteProfile($user);
}
// Check if profile should be hidden
if ($states['hidden'] && !$canManageProfiles && !$isViewingOwnProfile) {
// Only allow Admin and Bank profiles to view incomplete-only profiles
// Inactive profiles should be hidden from everyone except admins/banks
$isIncompleteOnly = ($states['isIncomplete'] ?? false) &&
!($states['inactive'] ?? false) &&
!($states['emailUnverifiedLabel'] ?? false);
// Show profile only if it's incomplete-only AND viewer is admin/bank
if (!($isIncompleteOnly && $canViewIncompleteProfiles)) {
return view('profile.not_found');
}
}
$user->reactionCounter = $user->loveReactant->reactionCounters->first() ? (int)$user->loveReactant->reactionCounters->first()->weight : null;
$profile = $user;
$header = __('Personal profile');
return $profile != null
? view('profile.show', array_merge(compact('profile', 'header'), $states))
: abort(403);
}
public function showOrganization($id)
{
$organization = Organization::select([
'id',
'name',
'profile_photo_path',
'about',
'motivation',
'website',
'phone_public',
'phone',
'cyclos_skills',
'lang_preference',
'email_verified_at',
'created_at',
'last_login_at',
'love_reactant_id',
'inactive_at',
'deleted_at'
])
->with([
'managers:id,name,profile_photo_path',
'accounts:id,name,accountable_type,accountable_id',
'languages:id,name,lang_code,flag',
'tags:tag_id',
'socials:id,name,icon,urL_structure',
'locations.district.translations:district_id,name',
'locations.city.translations:city_id,name',
'locations.division.translations:division_id,name',
'locations.country.translations:country_id,name',
'loveReactant.reactions.reacter.reacterable',
// 'loveReactant.reactions.type',
'loveReactant.reactionCounters',
// 'loveReactant.reactionTotal',
])
->find($id);
if (!$organization) {
return view('profile.not_found');
}
$states = $this->getActiveStates($organization);
$canManageProfiles = $this->getCanManageProfiles();
$canViewIncompleteProfiles = $this->canViewIncompleteProfiles();
$isViewingOwnProfile = getActiveProfile() &&
get_class(getActiveProfile()) === Organization::class &&
getActiveProfile()->id === $organization->id;
// For admins/banks: Always show incomplete label if profile is incomplete (ignore config)
if ($canManageProfiles) {
$states['hidden'] = false;
$states['inactiveLabel'] = !$organization->isActive();
$states['inactiveSince'] = $organization->inactive_at
? \Illuminate\Support\Carbon::parse($organization->inactive_at)->diffForHumans()
: '';
$states['emailUnverifiedLabel'] = !$organization->isEmailVerified();
$states['incompleteLabel'] = $organization->hasIncompleteProfile($organization);
}
// When viewing own profile, never hide but show all labels
if ($isViewingOwnProfile) {
$states['hidden'] = false;
$states['inactiveLabel'] = !$organization->isActive();
$states['inactiveSince'] = $organization->inactive_at
? \Illuminate\Support\Carbon::parse($organization->inactive_at)->diffForHumans()
: '';
$states['emailUnverifiedLabel'] = !$organization->isEmailVerified();
$states['incompleteLabel'] = $organization->hasIncompleteProfile($organization);
// Show incomplete warning modal when viewing own profile
$states['showIncompleteWarning'] = timebank_config('profile_incomplete.show_warning_modal') &&
$organization->hasIncompleteProfile($organization);
}
// Check if profile should be hidden
if ($states['hidden'] && !$canManageProfiles && !$isViewingOwnProfile) {
// Only allow Admin and Bank profiles to view incomplete-only profiles
// Inactive profiles should be hidden from everyone except admins/banks
$isIncompleteOnly = ($states['isIncomplete'] ?? false) &&
!($states['inactive'] ?? false) &&
!($states['emailUnverifiedLabel'] ?? false);
// Show profile only if it's incomplete-only AND viewer is admin/bank
if (!($isIncompleteOnly && $canViewIncompleteProfiles)) {
return view('profile.not_found');
}
}
$organization->reactionCounter = $organization->loveReactant->reactionCounters->first() ? (int)$organization->loveReactant->reactionCounters->first()->weight : null;
$profile = $organization;
$header = __('Organization profile');
return $profile != null
? view('profile.show', array_merge(compact('profile', 'header'), $states))
: abort(403);
}
public function showBank($id)
{
$bank = Bank::select([
'id',
'name',
'profile_photo_path',
'about',
'motivation',
'website',
'phone_public',
'phone',
'cyclos_skills',
'lang_preference',
'email_verified_at',
'created_at',
'last_login_at',
'love_reactant_id',
'inactive_at',
'deleted_at',
'level',
])
->with([
'managers:id,name,profile_photo_path',
'accounts:id,name,accountable_type,accountable_id',
'languages:id,name,lang_code,flag',
'tags:tag_id',
'socials:id,name,icon,urL_structure',
'locations.district.translations:district_id,name',
'locations.city.translations:city_id,name',
'locations.division.translations:division_id,name',
'locations.country.translations:country_id,name',
'loveReactant.reactions.reacter.reacterable',
// 'loveReactant.reactions.type',
'loveReactant.reactionCounters',
// 'loveReactant.reactionTotal',
])
->find($id);
if (!$bank) {
return view('profile.not_found');
}
$states = $this->getActiveStates($bank);
$canManageProfiles = $this->getCanManageProfiles();
$canViewIncompleteProfiles = $this->canViewIncompleteProfiles();
$isViewingOwnProfile = getActiveProfile() &&
get_class(getActiveProfile()) === Bank::class &&
getActiveProfile()->id === $bank->id;
// For admins/banks: Always show incomplete label if profile is incomplete (ignore config)
if ($canManageProfiles) {
$states['hidden'] = false;
$states['inactiveLabel'] = !$bank->isActive();
$states['inactiveSince'] = $bank->inactive_at
? \Illuminate\Support\Carbon::parse($bank->inactive_at)->diffForHumans()
: '';
$states['emailUnverifiedLabel'] = !$bank->isEmailVerified();
$states['incompleteLabel'] = $bank->hasIncompleteProfile($bank);
}
// When viewing own profile, never hide but show all labels
if ($isViewingOwnProfile) {
$states['hidden'] = false;
$states['inactiveLabel'] = !$bank->isActive();
$states['inactiveSince'] = $bank->inactive_at
? \Illuminate\Support\Carbon::parse($bank->inactive_at)->diffForHumans()
: '';
$states['emailUnverifiedLabel'] = !$bank->isEmailVerified();
$states['incompleteLabel'] = $bank->hasIncompleteProfile($bank);
// Show incomplete warning modal when viewing own profile
$states['showIncompleteWarning'] = timebank_config('profile_incomplete.show_warning_modal') &&
$bank->hasIncompleteProfile($bank);
}
// Hide bank by level for the current viewer type
if (!$canManageProfiles && !$isViewingOwnProfile && $bank->level !== null && !canViewerSeeBankLevel($bank->level, 'profile_visible')) {
$states['hidden'] = true;
}
// Check if profile should be hidden
if ($states['hidden'] && !$canManageProfiles && !$isViewingOwnProfile) {
// Only allow Admin and Bank profiles to view incomplete-only profiles
// Inactive profiles should be hidden from everyone except admins/banks
$isIncompleteOnly = ($states['isIncomplete'] ?? false) &&
!($states['inactive'] ?? false) &&
!($states['emailUnverifiedLabel'] ?? false);
// Show profile only if it's incomplete-only AND viewer is admin/bank
if (!($isIncompleteOnly && $canViewIncompleteProfiles)) {
return view('profile.not_found');
}
}
$bank->reactionCounter = $bank->loveReactant->reactionCounters->first() ? (int)$bank->loveReactant->reactionCounters->first()->weight : null;
$profile = $bank;
$header = __('Bank profile');
return $profile != null
? view('profile.show', array_merge(compact('profile', 'header'), $states))
: abort(403);
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit()
{
$type = strtolower(class_basename(getActiveProfile()));
$profile = getActiveProfile();
if ($profile instanceof Organization || $profile instanceof Bank) {
Gate::authorize('org edit-profile');
}
// Check if profile is incomplete - show warning if config allows
$showIncompleteWarning = false;
if (timebank_config('profile_incomplete.show_warning_modal') &&
method_exists($profile, 'hasIncompleteProfile') &&
$profile->hasIncompleteProfile($profile)) {
$showIncompleteWarning = true;
}
return view('profile-' . $type . '.edit', compact('showIncompleteWarning'));
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(Request $request, $id)
{
//
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy($id)
{
//
}
/**
* Redirects the authenticated user to their profile settings page with locale-specific routing.
*
* This method is used by the route profile.settings.no_locale.
* The email footer template uses this route because when sending the locale of the
* recipient is unknown.
*
* @return \Illuminate\Http\RedirectResponse Redirects to the localized profile settings page.
*/
public function settingsNoLocale()
{
$user = auth()->user();
$locale = $user->lang_preference ?? config('app.fallback_locale');
// Load the route translations for the specific locale
$routeTranslations = include resource_path("lang/{$locale}/routes.php");
$translatedRoute = $routeTranslations['profile.settings'] ?? 'profile/settings';
$localizedUrl = url("/{$locale}/{$translatedRoute}");
return redirect($localizedUrl . '#message_settings');
}
/**
* Show the user settings screen.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\View\View
*/
public function settings(Request $request)
{
$profile = getActiveProfile();
$type = strtolower(class_basename($profile));
if ($profile instanceof Organization || $profile instanceof Bank) {
Gate::authorize('org settings');
} elseif ($profile instanceof \App\Models\Admin) {
Gate::authorize('admin settings');
}
if ($type === 'user') {
return view('profile.settings', [
'request' => $request,
'user' => $request->user(),
]);
} else {
return view('profile-' . $type . '.settings');
}
}
public function manage()
{
return view('profiles.manage');
}
/**
* Determines the inactive status flags for a given profile.
*
* @param mixed $profile The profile object to check for inactivity.
* @return array An associative array containing:
* - 'inactive' (bool): Whether the profile is inactive.
* - 'hidden' (bool): Whether the profile should be hidden.
* - 'inactiveLabel' (bool): Whether the profile should be labeled as inactive.
* - 'inactiveSince' (string): Human-readable duration since the profile became inactive.
*/
private function getActiveStates($profile)
{
$inactive = false;
$hidden = false;
$inactiveLabel = false;
$inactiveSince = '';
$emailUnverifiedLabel = false;
$isIncomplete = false;
$incompleteLabel = false;
$noExchangesYetLabel = false;
$removedSince = '';
if (method_exists($profile, 'isActive') && !$profile->isActive()) {
$inactive = true;
if (timebank_config('profile_inactive.profile_hidden')) {
$hidden = true;
}
if (timebank_config('profile_inactive.profile_labeled')) {
$inactiveLabel = true;
$inactiveSince = $profile->inactive_at
? \Illuminate\Support\Carbon::parse($profile->inactive_at)->diffForHumans()
: '';
}
}
if (method_exists($profile, 'isEmailVerified') && !$profile->isEmailVerified()) {
$emailUnverifiedLabel = false;
if (timebank_config('profile_email_unverified.profile_hidden')) {
$hidden = true;
}
if (timebank_config('profile_email_unverified.profile_labeled')) {
$emailUnverifiedLabel = true;
}
}
if (method_exists($profile, 'hasIncompleteProfile') && $profile->hasIncompleteProfile($profile)) {
$isIncomplete = true;
if (timebank_config('profile_incomplete.profile_hidden')) {
$hidden = true;
}
if (timebank_config('profile_incomplete.profile_labeled')) {
$incompleteLabel = true;
}
}
if (
timebank_config('profile_incomplete.no_exchanges_yet_label') &&
method_exists($profile, 'hasNeverReceivedTransaction') &&
$profile->hasNeverReceivedTransaction($profile)
) {
$noExchangesYetLabel = true;
}
if (
(method_exists($profile, 'isRemoved') && $profile->isRemoved()) ||
(!empty($profile->deleted_at) && \Carbon\Carbon::parse($profile->deleted_at)->isPast())
) {
$hidden = true;
$removedSince = !empty($profile->deleted_at)
? \Carbon\Carbon::parse($profile->deleted_at)->diffForHumans()
: '';
}
return compact('inactive', 'hidden', 'inactiveLabel', 'inactiveSince', 'emailUnverifiedLabel', 'isIncomplete', 'incompleteLabel', 'noExchangesYetLabel', 'removedSince');
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ProfileOrgController extends Controller
{
public function show()
{
return view('profile-organization.show');
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ProfileUserController extends Controller
{
public function show()
{
return view('profile-user.show');
}
}
+331
View File
@@ -0,0 +1,331 @@
<?php
namespace App\Http\Controllers;
use App\Traits\AccountInfoTrait;
use App\Models\Transaction;
use Carbon\Carbon;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Spatie\Browsershot\Browsershot;
class ReportController extends Controller
{
use AccountInfoTrait;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
public function reports()
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
// This prevents unauthorized access to financial reports via session manipulation
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
$profileAccounts = $this->getAccountsInfo();
return view('reports.show', compact('profileAccounts'));
}
public function downloadPdf(Request $request)
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
// This prevents unauthorized PDF report generation via session manipulation
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
$fromDate = $request->get('fromDate');
$toDate = $request->get('toDate');
$decimalFormat = (bool) $request->get('decimal', 0);
if (!$fromDate || !$toDate) {
abort(400, 'Missing date parameters');
}
// Use the same logic from SingleReport component
$singleReport = new \App\Http\Livewire\SingleReport();
$singleReport->fromDate = $fromDate;
$singleReport->toDate = $toDate;
$singleReport->decimalFormat = $decimalFormat;
$accountsData = $singleReport->getAccountsWithPeriodBalances();
$transactionTypesData = $singleReport->calculateTransactionTypeTotals();
$statisticsData = $singleReport->calculatePeriodStatistics();
$returnRatioData = $singleReport->calculateReturnRatioTimeline();
$returnRatioTimelineData = $returnRatioData['timeline'] ?? [];
$returnRatioTrendData = $returnRatioData['trend'] ?? [];
// Check if we have chart images from the frontend
$chartImage = null; // Legacy single chart support
$returnRatioChartImage = null;
$accountBalancesChartImage = null;
$balanceDistributionChartImage = null;
$balanceDistributionOrgChartImage = null;
$networkChartImage = null;
// Read before the session keys are forgotten further down.
$networkSettings = session('report_network_settings');
$networkLegend = session('report_network_legend');
$useChartImage = $request->get('with_chart'); // Legacy single chart
$useChartsImages = $request->get('with_charts'); // New dual charts
Log::info('PDF generation request', [
'useChartImage' => $useChartImage,
'useChartsImages' => $useChartsImages,
'hasSessionChartImageId' => session()->has('chart_image_id'),
'hasSessionChartImageIds' => session()->has('chart_image_ids'),
'sessionChartImageId' => session('chart_image_id'),
'sessionChartImageIds' => session('chart_image_ids')
]);
// Handle new dual charts system
if ($useChartsImages && session('chart_image_ids')) {
$chartImageIds = session('chart_image_ids');
// Each chart: read the temp PNG, inline it, delete the file. DomPDF
// runs with isRemoteEnabled = false, so every image must be inlined.
$charts = [
'return_ratio_chart_id' => 'returnRatioChartImage',
'account_balances_chart_id' => 'accountBalancesChartImage',
'balance_distribution_chart_id' => 'balanceDistributionChartImage',
'balance_distribution_org_chart_id' => 'balanceDistributionOrgChartImage',
'network_chart_id' => 'networkChartImage',
];
foreach ($charts as $sessionKey => $variable) {
if (!isset($chartImageIds[$sessionKey])) {
continue;
}
$chartImageId = $chartImageIds[$sessionKey];
$tempImagePath = storage_path('app/temp/' . $chartImageId . '.png');
if (!file_exists($tempImagePath)) {
Log::warning('Chart image temp file not found', [
'chart' => $sessionKey,
'path' => $tempImagePath,
]);
continue;
}
$imageData = file_get_contents($tempImagePath);
$$variable = 'data:image/png;base64,' . base64_encode($imageData);
Log::info('Chart image found in temp file', [
'chart' => $sessionKey,
'chartImageId' => $chartImageId,
'fileSize' => strlen($imageData),
]);
unlink($tempImagePath);
}
session()->forget('chart_image_ids');
session()->forget('report_network_settings');
session()->forget('report_network_legend');
}
// Handle legacy single chart system (backward compatibility)
else if ($useChartImage && session('chart_image_id')) {
$chartImageId = session('chart_image_id');
$tempImagePath = storage_path('app/temp/' . $chartImageId . '.png');
if (file_exists($tempImagePath)) {
// Read the image file and convert to base64
$imageData = file_get_contents($tempImagePath);
$chartImage = 'data:image/png;base64,' . base64_encode($imageData);
Log::info('Legacy chart image found in temp file', [
'chartImageId' => $chartImageId,
'fileSize' => strlen($imageData),
'base64Length' => strlen($chartImage)
]);
// Clean up temporary file
unlink($tempImagePath);
session()->forget('chart_image_id');
} else {
Log::warning('Legacy chart image temp file not found', ['path' => $tempImagePath]);
}
} else {
Log::info('No chart images available, using fallback chart rendering');
}
// Prepare chart data for PDF rendering (fallback if no image)
$chartData = $this->prepareChartDataForPdf($returnRatioTimelineData, $returnRatioTrendData);
// Clean UTF-8 encoding for all data
$accountsData = $this->cleanUtf8Data($accountsData);
$transactionTypesData = $this->cleanUtf8Data($transactionTypesData);
$statisticsData = $this->cleanUtf8Data($statisticsData);
// Generate title with profile information
$fromDateCarbon = Carbon::parse($fromDate);
$toDateCarbon = Carbon::parse($toDate);
$activeProfile = getActiveProfile();
$profileTitle = '';
if ($activeProfile) {
$fullName = $activeProfile->full_name ?? $activeProfile->name ?? '';
$profileName = $activeProfile->name ?? '';
$profileTitle = ' ' . $fullName . ' (' . $profileName . ')';
}
$title = [
'header' => __('Financial overview') . $profileTitle,
'sub' => $fromDateCarbon->format('d-m-Y') . ' - ' . $toDateCarbon->format('d-m-Y')
];
$pdf = Pdf::loadView('reports.pdf', [
'title' => $title,
'accountsData' => $accountsData,
'transactionTypesData' => $transactionTypesData,
'statisticsData' => $statisticsData,
'returnRatioTimelineData' => $returnRatioTimelineData,
'returnRatioTrendData' => $returnRatioTrendData,
'chartData' => $chartData,
'chartImage' => $chartImage, // Legacy single chart support
'returnRatioChartImage' => $returnRatioChartImage,
'accountBalancesChartImage' => $accountBalancesChartImage,
'balanceDistributionChartImage' => $balanceDistributionChartImage,
'balanceDistributionOrgChartImage' => $balanceDistributionOrgChartImage,
'networkChartImage' => $networkChartImage,
'networkSettings' => $networkSettings,
'networkLegend' => $networkLegend,
// Only the partner count is used, for the caption — the charts
// themselves arrive as rendered images.
'balanceDistribution' => $singleReport->balanceDistribution($activeProfile),
'isOrganization' => $activeProfile instanceof \App\Models\Organization,
'decimalFormat' => $decimalFormat,
])->setPaper('A4', 'portrait')
->setOptions([
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => false,
'defaultFont' => 'DejaVu Sans',
'dpi' => 150,
'defaultPaperSize' => 'A4',
'chroot' => public_path(),
]);
return $pdf->download('financial-report-' . now()->format('Y-m-d') . '.pdf');
}
/**
* Clean UTF-8 encoding for strings
*/
private function cleanUtf8String($string)
{
if (!is_string($string)) {
return $string;
}
// Remove or replace problematic characters
$string = mb_convert_encoding($string, 'UTF-8', 'UTF-8');
$string = preg_replace('/[\x00-\x1F\x7F-\x9F]/u', '', $string);
return $string;
}
/**
* Clean UTF-8 encoding for arrays/collections
*/
private function cleanUtf8Data($data)
{
if (is_string($data)) {
return $this->cleanUtf8String($data);
}
if (is_array($data) || $data instanceof \Illuminate\Support\Collection) {
$cleaned = [];
foreach ($data as $key => $value) {
$cleanedKey = $this->cleanUtf8String($key);
$cleanedValue = $this->cleanUtf8Data($value);
$cleaned[$cleanedKey] = $cleanedValue;
}
return $data instanceof \Illuminate\Support\Collection ? collect($cleaned) : $cleaned;
}
return $data;
}
/**
* Prepare chart data for PDF rendering
*/
private function prepareChartDataForPdf($returnRatioTimelineData, $returnRatioTrendData)
{
if (!$returnRatioTimelineData || count($returnRatioTimelineData) <= 1) {
return null;
}
$values = array_column($returnRatioTimelineData, 'return_ratio');
$labels = array_column($returnRatioTimelineData, 'label');
// Always start from 0% as requested
$minValue = 0;
$maxValue = count($values) > 0 ? max(array_merge($values, [100])) : 100;
// Add padding to top only
$padding = ($maxValue - $minValue) * 0.1;
$maxValue += $padding;
$range = $maxValue - $minValue;
// Ensure we have a valid range
if ($range <= 0) {
$range = 100;
$maxValue = 100;
}
$pointCount = count($returnRatioTimelineData);
$points = [];
$trendPoints = [];
if ($pointCount > 0 && $range > 0) {
// Calculate data points
foreach ($returnRatioTimelineData as $index => $point) {
$x = $pointCount > 1 ? ($index / ($pointCount - 1)) * 100 : 50;
$y = 100 - (($point['return_ratio'] - $minValue) / $range * 100);
$points[] = number_format($x, 2) . ',' . number_format(max(0, min(100, $y)), 2);
}
// Calculate trend points if available
if ($returnRatioTrendData && count($returnRatioTrendData) > 0) {
$trendCount = count($returnRatioTrendData);
foreach ($returnRatioTrendData as $index => $point) {
$x = $trendCount > 1 ? ($index / ($trendCount - 1)) * 100 : 50;
$y = 100 - (($point['trend_value'] - $minValue) / $range * 100);
$trendPoints[] = number_format($x, 2) . ',' . number_format(max(0, min(100, $y)), 2);
}
}
}
return [
'values' => $values,
'labels' => $labels,
'minValue' => $minValue,
'maxValue' => $maxValue,
'range' => $range,
'points' => $points,
'trendPoints' => $trendPoints,
'pointsString' => implode(' ', $points),
'trendPointsString' => implode(' ', $trendPoints),
];
}
}
@@ -0,0 +1,211 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use Illuminate\Support\Facades\Auth;
class ResetNonUserPasswordController extends Controller
{
/**
* Display the form to request a password reset link.
*/
public function showLinkRequestForm(Request $request, $profileType)
{
$profileId = $request->query('profileId');
if ($profileId) {
$profile = $this->resolveProfileById($profileType, $profileId);
if ($profile && !empty($profile->password_change_disabled)) {
return redirect()->back()->withErrors(['email' => __('Password changes are disabled for this account.')]);
}
// Block if the user's role for this profile does not allow password changes
$user = Auth::guard('web')->user();
if ($user && !$this->userRoleAllowsPasswordChange($user, $profileType, $profileId)) {
return redirect()->back()->withErrors(['email' => __('Password changes are disabled for this account.')]);
}
}
return view('auth.forgot-non-user-password', [
'profileType' => $profileType,
'profileId' => $profileId,
]);
}
/**
* Handle sending the password reset link.
*/
public function sendResetLinkEmail(Request $request, $profileType)
{
$validator = Validator::make($request->all(), ['email' => 'required|email']);
if ($validator->fails()) {
return back()->withErrors($validator);
}
// Resolve profile and check password_change_disabled + role permission
$profileId = $request->input('profileId');
$profile = $profileId
? $this->resolveProfileById($profileType, $profileId)
: $this->resolveProfileByEmail($profileType, $request->input('email'));
if ($profile && !empty($profile->password_change_disabled)) {
return back()->withErrors(['email' => __('Password changes are disabled for this account.')]);
}
$user = Auth::guard('web')->user();
$resolvedId = $profileId ?? ($profile ? $profile->id : null);
if ($user && $resolvedId && !$this->userRoleAllowsPasswordChange($user, $profileType, $resolvedId)) {
return back()->withErrors(['email' => __('Password changes are disabled for this account.')]);
}
$broker = $this->getPasswordBroker($profileType);
$status = Password::broker($broker)->sendResetLink(
$request->only('email')
);
return $status === Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withErrors(['email' => __($status)]);
}
/**
* Display the password reset view for the given token.
*
* If no token is present, display the link request form.
*/
public function showResetForm(Request $request, $profileType, $token = null)
{
if (is_null($token)) {
return $this->showLinkRequestForm($profileType);
}
$email = $request->query('email');
return view('auth.reset-non-user-password', [
'token' => $token,
'email' => $email,
'profileType' => $profileType
]);
}
/**
* Reset the given profile's password.
*/
public function reset(Request $request, $profileType)
{
// Dynamically get the password validation rules from the config
$passwordRules = timebank_config('rules.profile_' . strtolower($profileType) . '.password', ['required', 'string', 'min:8', 'confirmed']);
$validator = Validator::make($request->all(), [
'token' => 'required',
'email' => 'required|email',
'password' => $passwordRules,
]);
if ($validator->fails()) {
// Store errors in a persistent (non-flash) session key so Livewire
// background requests don't consume the flash before the browser arrives.
session(['password_reset_errors' => $validator->errors()->toArray()]);
return redirect(url()->previous())->withInput($request->only('email'));
}
$broker = $this->getPasswordBroker($profileType);
$passwordChangeDisabled = false;
$status = Password::broker($broker)->reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($profile, $password) use (&$passwordChangeDisabled) {
if (!empty($profile->password_change_disabled)) {
$passwordChangeDisabled = true;
return;
}
$profile->forceFill([
'password' => Hash::make($password),
])->save();
}
);
if ($passwordChangeDisabled) {
session(['password_reset_errors' => ['password' => [__('Password changes are disabled for this account.')]]]);
return redirect(url()->previous())->withInput($request->only('email'));
}
if ($status === Password::PASSWORD_RESET) {
session()->forget('password_reset_errors');
$loginPath = parse_url(route('login'), PHP_URL_PATH);
return redirect($loginPath)->with('status', __('passwords.reset'));
}
session(['password_reset_errors' => ['email' => [__($status)]]]);
return redirect(url()->previous())->withInput($request->only('email'));
}
/**
* Get the password broker for the given profile type.
*/
private function getPasswordBroker($profileType)
{
// Ensure this maps to the keys in config/auth.php 'passwords'
$brokers = [
'admin' => 'admins',
'bank' => 'banks',
// 'organization' => 'organizations', // etc.
];
// Fallback to 'users' broker if profileType doesn't match,
// or handle as an error if only specific profile types are allowed here.
return $brokers[strtolower($profileType)] ?? 'users';
}
private function resolveProfileById($profileType, $id)
{
$modelMap = [
'admin' => Admin::class,
'bank' => Bank::class,
'organization' => Organization::class,
];
$model = $modelMap[strtolower($profileType)] ?? null;
return $model ? $model::find((int) $id) : null;
}
private function resolveProfileByEmail($profileType, $email)
{
$modelMap = [
'admin' => Admin::class,
'bank' => Bank::class,
'organization' => Organization::class,
];
$model = $modelMap[strtolower($profileType)] ?? null;
return $model ? $model::where('email', $email)->first() : null;
}
private function userRoleAllowsPasswordChange($user, string $profileType, $profileId): bool
{
$typeMap = [
'admin' => ['prefix' => 'Admin', 'model' => \App\Models\AdminRole::class, 'permission' => 'admin change-password'],
'bank' => ['prefix' => 'Bank', 'model' => \App\Models\OrganizationRole::class, 'permission' => 'org change-password'],
'organization' => ['prefix' => 'Organization', 'model' => \App\Models\OrganizationRole::class, 'permission' => 'org change-password'],
];
$config = $typeMap[strtolower($profileType)] ?? null;
if (!$config) {
return true;
}
$prefix = "{$config['prefix']}\\{$profileId}\\";
$scopedRoles = $user->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
foreach ($scopedRoles as $spatieRole) {
$roleName = str_replace($prefix, '', $spatieRole->name);
$role = $config['model']::where('name', $roleName)->first();
if ($role && in_array($config['permission'], $role->permissions ?? [])) {
return true;
}
}
return false;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class RoleController extends Controller
{
public function manage()
{
return view('roles.manage');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class SearchController extends Controller
{
public function show(Request $request)
{
$authId = Auth::guard('web')->id();
$key = 'main_search_bar_results_' . $authId;
// When arriving via a category badge link, clear any stale cache so the
// MainBrowseTagCategories component can run a fresh search on mount.
if ($request->query('category')) {
cache()->forget($key);
}
$data = cache()->get($key, ['results' => [], 'searchTerm' => null, 'total' => 0,]);
$resultRefs = $data['results'];
$searchTerm = $data['searchTerm'];
$total = $data['total'];
// empty cache when retrieved
// cache()->forget($key);
return view('search.show', [
'resultRefs' => $resultRefs,
'searchTerm' => $searchTerm,
'total' => $total,
]);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use Elastic\Elasticsearch\ClientBuilder;
use Illuminate\Support\Facades\Artisan;
class SearchIndexController extends Controller
{
protected $client;
public function __construct()
{
$this->client = ClientBuilder::create()->build();
}
public function indexStopwords($indexName)
{
$stopwords = timebank_config('elasticsearch.stopwords');
$settings = [
'analysis' => [
'analyzer' => [
'my_custom_analyzer' => [
'type' => 'standard',
'stopwords' => $stopwords
]
]
]
];
// Create the index
$params = [
'index' => $indexName,
'body' => [
'settings' => $settings,
// other index settings...
]
];
$response = $this->client->indices()->create($params);
return $response;
}
public function deleteIndex($indexName)
{
$client = ClientBuilder::create()->build();
$params = ['index' => $indexName,
'alias' => $indexName];
$response = $client->indices()->delete($params);
return $response;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Symfony\Component\HttpFoundation\Response;
class StaticController extends Controller
{
/**
* Download the full privacy policy for the current locale
*/
public function downloadPrivacyPolicy(Request $request)
{
$locale = app()->getLocale();
$platform = config('app.timebank_config');
// Get the base path for GDPR references
$basePath = base_path("references/gdpr/{$platform}");
// Find the most recent date folder
$dateFolders = File::isDirectory($basePath) ? File::directories($basePath) : [];
if (empty($dateFolders)) {
abort(404, 'Privacy policy not found');
}
// Sort folders by name (dates in YYYY-MM-DD format) descending
rsort($dateFolders);
$latestFolder = $dateFolders[0];
// Construct the file path
$fileName = "privacy-policy-FULL-{$locale}.md";
$filePath = "{$latestFolder}/{$fileName}";
if (!File::exists($filePath)) {
abort(404, 'Privacy policy not found for this language');
}
// Return the file as a download
return response()->download($filePath, $fileName, [
'Content-Type' => 'text/markdown',
]);
}
}
+289
View File
@@ -0,0 +1,289 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\ProfileAuthorizationHelper;
use App\Services\Stats\StatsFilter;
use App\Services\StatsService;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
class StatsController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index()
{
$this->authorizeStatsAccess();
return view('stats.show');
}
public function downloadPdf(Request $request)
{
$this->authorizeStatsAccess();
$filter = StatsFilter::fromArray([
'periodFrom' => $request->get('fromDate'),
'periodTill' => $request->get('toDate'),
'countryIds' => $this->intArray($request->get('countryIds')),
'divisionIds' => $this->intArray($request->get('divisionIds')),
'cityIds' => $this->intArray($request->get('cityIds')),
'districtIds' => $this->intArray($request->get('districtIds')),
'registeredFrom' => $request->get('registeredFrom'),
'registeredTill' => $request->get('registeredTill'),
'counterparties' => $this->counterparties($request->get('counterparties')),
'allTime' => $request->boolean('allTime'),
]);
if ($filter->isEmpty()) {
abort(400, 'No filters selected');
}
$stats = (new StatsService($filter))->all();
// Read before pullChartImages(), which clears the session keys.
$networkSettings = session('stats_network_settings');
$networkLegend = session('stats_network_legend');
$networkSubtext = session('stats_network_subtext');
$charts = $this->pullChartImages($request->boolean('with_charts'));
$subtitle = $filter->periodFrom && $filter->periodTill
? $filter->periodFrom->format('d-m-Y') . ' - ' . $filter->periodTill->format('d-m-Y')
: __('All time');
// Every applied filter, named, so a printed report is self-describing:
// away from the screen there is nothing else to say what it covers.
$appliedFilters = $this->describeFilters($filter);
$pdf = Pdf::loadView('stats.pdf', [
'title' => [
'header' => __('Platform statistics'),
'sub' => $subtitle,
],
'appliedFilters' => $appliedFilters,
'networkSettings' => $networkSettings,
'networkLegend' => $networkLegend,
'networkSubtext' => $networkSubtext,
// Counterparty-scoped selections describe trading partners, not
// the platform's profiles at large, so the labels change with it.
'partnerScoped' => !empty($filter->counterparties),
'stats' => $stats,
'charts' => $charts,
'decimalFormat' => $request->boolean('decimal'),
'anonymize' => $request->boolean('anonymize'),
'generatedAt' => Carbon::now()->format('d-m-Y H:i'),
])->setPaper('A4', 'portrait')
->setOptions([
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => false,
'defaultFont' => 'DejaVu Sans',
'dpi' => 150,
'defaultPaperSize' => 'A4',
'chroot' => public_path(),
]);
return $pdf->download('platform-stats-' . now()->format('Y-m-d') . '.pdf');
}
/**
* Admin and Bank profiles holding the stats permission.
*/
protected function authorizeStatsAccess(): void
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, __('No active profile'));
}
ProfileAuthorizationHelper::authorize($profile);
if (!($profile instanceof \App\Models\Admin) && !($profile instanceof \App\Models\Bank)) {
abort(403, __('Admin or bank access required'));
}
if (!Gate::allows('access stats') && !Gate::allows('org stats')) {
Log::warning('StatsController: permission denied', [
'profile_type' => get_class($profile),
'profile_id' => $profile->id,
'ip_address' => request()->ip(),
]);
abort(403, __('You do not have permission to view statistics.'));
}
}
/**
* Read chart PNGs written by the Livewire component, inline them, and clean up.
*/
private function pullChartImages(bool $withCharts): array
{
$charts = [];
if (!$withCharts || !session()->has('stats_chart_image_ids')) {
return $charts;
}
foreach (session('stats_chart_image_ids', []) as $name => $id) {
$path = storage_path('app/temp/' . $id . '.png');
if (!file_exists($path)) {
Log::warning('Stats chart image temp file missing', ['path' => $path]);
continue;
}
$charts[$name] = 'data:image/png;base64,' . base64_encode(file_get_contents($path));
unlink($path);
}
session()->forget('stats_chart_image_ids');
session()->forget('stats_network_settings');
session()->forget('stats_network_legend');
session()->forget('stats_network_subtext');
return $charts;
}
/**
* Human-readable list of the filters behind this report.
*
* The on-screen page shows the same summary beside every section; a PDF is
* read away from it, so the header has to carry the whole picture —
* including which profiles were filtered on, by name.
*
* @return array<array{label: string, value: string}>
*/
private function describeFilters(StatsFilter $filter): array
{
$summary = [];
$summary[] = [
'label' => __('Period'),
'value' => $filter->periodFrom && $filter->periodTill
? $filter->periodFrom->format('d-m-Y') . ' – ' . $filter->periodTill->format('d-m-Y')
: __('All time'),
];
if ($names = $this->locationNames($filter)) {
$summary[] = ['label' => __('Location'), 'value' => $names];
}
if ($filter->registeredFrom || $filter->registeredTill) {
$from = $filter->registeredFrom?->format('d-m-Y');
$till = $filter->registeredTill?->format('d-m-Y');
$summary[] = [
'label' => __('Registered'),
'value' => match (true) {
$from && $till => $from . ' – ' . $till,
(bool) $from => __('from :date', ['date' => $from]),
default => __('until :date', ['date' => $till]),
},
];
}
if ($filter->notLoggedInSince) {
$summary[] = [
'label' => __('Not logged in since'),
'value' => $filter->notLoggedInSince->format('d-m-Y'),
];
}
if ($filter->noTransactionSince) {
$summary[] = [
'label' => __('No transaction since'),
'value' => $filter->noTransactionSince->format('d-m-Y'),
];
}
if (!empty($filter->counterparties)) {
$summary[] = [
'label' => __('Transactions with'),
// Named even when anonymising. These profiles are the subject
// of the filter, not data the report is disclosing: without
// them the header cannot say what it covers. Anonymisation
// still applies to the most-active table, where the names
// would be a disclosure rather than a heading.
'value' => $this->counterpartyNames($filter),
];
}
return $summary;
}
/** Deepest selected location level wins, translated into the active locale. */
private function locationNames(StatsFilter $filter): string
{
$levels = [
[\App\Models\Locations\District::class, $filter->districtIds],
[\App\Models\Locations\City::class, $filter->cityIds],
[\App\Models\Locations\Division::class, $filter->divisionIds],
[\App\Models\Locations\Country::class, $filter->countryIds],
];
foreach ($levels as [$model, $ids]) {
if (empty($ids)) {
continue;
}
return $model::whereIn('id', $ids)->with('translations')->get()
->map(fn($row) => $row->translations->firstWhere('locale', app()->getLocale())?->name
?? $row->translations->first()?->name)
->filter()
->implode(', ');
}
return '';
}
private function counterpartyNames(StatsFilter $filter): string
{
return collect($filter->counterparties)
->groupBy('type')
->flatMap(fn($group, $type) => $type::whereIn('id', collect($group)->pluck('id'))->pluck('name'))
->implode(', ');
}
private function intArray($value): array
{
if (empty($value)) {
return [];
}
$value = is_array($value) ? $value : explode(',', $value);
return array_values(array_filter(array_map('intval', $value)));
}
/**
* "Type:id,Type:id" pairs, validated against the known profile classes.
*/
private function counterparties($value): array
{
if (empty($value)) {
return [];
}
$pairs = is_array($value) ? $value : explode(',', $value);
$out = [];
foreach ($pairs as $pair) {
[$type, $id] = array_pad(explode(':', (string) $pair, 2), 2, null);
if (!$id || !in_array($type, StatsFilter::PROFILE_TYPES, true)) {
continue;
}
$out[] = ['type' => $type, 'id' => (int) $id];
}
return $out;
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class TagController extends Controller
{
public function manage()
{
if (getActiveProfileType() !== 'Admin') {
abort(403, __('Admin profile required'));
}
return view('tags.manage');
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Artisan;
use Stevebauman\Location\Facades\Location as IpLocation;
class TestController extends Controller
{
/**
* Test Stevbauman/Location package
*
* @return \Illuminate\Http\Response
*/
public function viewIpLocation(Request $request)
{
if (App::environment(['local', 'staging'])) {
$ip = '103.75.231.255'; // Static IP address Brussels for testing
// $ip = '31.20.250.12'; // Statis IP address The Hague for testing
// $ip = '102.129.156.0'; // Statis IP address Berlin for testing
} else {
$ip = $request->ip(); // Dynamic IP address
}
$IpLocationInfo = IpLocation::get($ip);
// TODO: Test for correct ip address in production mode.
// dd($ipLocatonInfo);
// TODO: Enable alternative IpLocation info providers.
return view('test.ip-location', compact('IpLocationInfo'));
}
public function viewDebug1(Request $request)
{
return view('test.debug-1');
}
public function viewDebug2(Request $request)
{
return view('test.debug-2');
}
public function clearCache()
{
Artisan::call('cache:clear');
return "Cache is cleared";
}
public function optimizeClear()
{
Artisan::call('optimize:clear');
return "Events, views, caches, routes, configs cleared";
}
}
@@ -0,0 +1,456 @@
<?php
namespace App\Http\Controllers;
use App\Mail\TransferReceived;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\Transaction;
use App\Models\TransactionType;
use App\Models\User;
use App\Traits\AccountInfoTrait;
use Cog\Laravel\Love\ReactionType\Models\ReactionType as LoveReactionType;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Http\Request;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
use Namu\WireChat\Enums\MessageType;
use Namu\WireChat\Models\Attachment;
use Namu\WireChat\Models\Message;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
use Illuminate\Support\Facades\Storage;
use Stevebauman\Location\Facades\Location as IpLocation;
class TransactionController extends Controller
{
use AccountInfoTrait;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
public function pay()
{
$toName = null;
return view('pay.show', compact(['toName']));
}
public function payToName($name = null)
{
return view('pay.show', compact(['name']));
}
public function payAmountToName($hours = null, $minutes = null, $name = null)
{
return view('pay.show', compact(['hours', 'minutes', 'name']));
}
public function payAmountToNameWithDescr($hours = null, $minutes = null, $name = null, $description = null)
{
return view('pay.show', compact(['hours', 'minutes', 'name', 'description']));
}
// Legacy Cyclos payment link, as used by Lekkernasuh
public function doCyclosPayment(Request $request, $minutes = null, $toAccoundId = null, $name = null, $description = null, $type = null)
{
$cyclos_id = $request->query('to');
// Amount is in integer minutes
$minutes = (int) $request->query('amount');
$toAccountId = null;
$name = null;
if ($cyclos_id) {
$accounts = Account::accountsCyclosMember($cyclos_id);
if (count($accounts) > 1) {
// More than 1 account found with this cyclos_id — search by name instead
$name = $this->getNameByCyclosId($cyclos_id);
} else {
$toAccountId = $accounts->keys()->first();
}
}
$description = $request->query('description');
$type = $request->query('type');
return view('pay.show', compact(['minutes', 'toAccountId', 'name', 'description', 'type']));
}
public static function getNameByCyclosId($cyclos_id)
{
$user = User::where('cyclos_id', $cyclos_id)->first();
if ($user) {
return $user->name;
}
$organization = Organization::where('cyclos_id', $cyclos_id)->first();
if ($organization) {
return $organization->name;
}
$bank = Bank::where('cyclos_id', $cyclos_id)->first();
if ($bank) {
return $bank->name;
}
return null;
}
public function transactions()
{
$profileAccounts = $this->getAccountsInfo();
return view('transactions.show', compact('profileAccounts'));
}
public function statement($transactionId)
{
$results = Transaction::with('accountTo.accountable', 'accountFrom.accountable')
->where('id', $transactionId)
->whereHas('accountTo', function ($query) {
$query->where('accountable_type', Session('activeProfileType'))
->where('accountable_id', Session('activeProfileId'));
})
->orWhereHas('accountFrom.accountable', function ($query) {
$query->where('accountable_type', Session('activeProfileType'))
->where('accountable_id', Session('activeProfileId'));
})
->find($transactionId);
$qrModalVisible = request()->query('qrModalVisible', false);
//TODO: add permission check
//TODO: if 403, but has permission, redirect with message to switch profile
//TODO: replace 403 with custom redirect page incl explanation
// Check if the transaction exists
if ($transactionId) {
// Pass the transactionId and transaction details to the view
return view('transactions.statement', compact('transactionId', 'qrModalVisible'));
} else {
// Abort with a 403 status code if the transaction does not exist
// TODO to static page explaining that the transactionId is not accessible for current profile.
return abort(403, 'Unauthorized action.');
}
}
/**
* Create a new transaction with all necessary checks.
* This is a non-Livewire version of the transfer logic.
*
* @param int $fromAccountId
* @param int $toAccountId
* @param int $amount
* @param string $description
* @param int $transactionTypeId
* @return \Illuminate\Http\RedirectResponse
*/
public function createTransfer($fromAccountId, $toAccountId, $amount, $description, $transactionTypeId)
{
// 1. SECURITY CHECKS
$fromAccountableType = Account::find($fromAccountId)->accountable_type;
$fromAccountableId = Account::find($fromAccountId)->accountable_id;
$accountsInfo = collect($this->getAccountsInfo($fromAccountableType, $fromAccountableId));
if (!$accountsInfo->contains('id', $fromAccountId)) {
return $this->logAndReport('Unauthorized payment attempt: illegal access of From account', $fromAccountId, $toAccountId, $amount, $description);
}
// Check if From and To Account is different
if ($toAccountId == $fromAccountId) {
return $this->logAndReport('Impossible payment attempt: To and From account are the same', $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the To Account exists and is not removed
$toAccount = Account::notRemoved()->find($toAccountId);
if (!$toAccount) {
return $this->logAndReport('Impossible payment attempt: To account not found', $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the To Accountable exists and is not removed
$toAccountableExists = Account::find($toAccountId)->accountable()->notRemoved()->first();
if (!$toAccountableExists) {
$warningMessage = 'Impossible payment attempt: To account holder not found';
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the From Account exists and is not removed
$fromAccount = Account::notRemoved()->find($fromAccountId);
if (!$fromAccount) {
return $this->logAndReport('Impossible payment attempt: From account not found', $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the From Accountable exists and is not removed
$fromAccountableExists = Account::find($fromAccountId)->accountable()->notRemoved()->first();
if (!$fromAccountableExists) {
$warningMessage = 'Impossible payment attempt: From account holder not found';
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the transactionTypeSelected is allowed, with an exception for internal migration (type 6)
$toHolderType = $toAccount->accountable_type;
$toHolderId = $toAccount->accountable_id;
$isInternalMigration = (
$fromAccountableType === $toHolderType &&
$fromAccountableId == $toHolderId
);
// Only perform the check if it's not the specific internal migration case
if (!$isInternalMigration) {
$canReceive = timebank_config('accounts.' . strtolower(class_basename($toHolderType)) . '.receiving_types');
$canPay = timebank_config('permissions.' . strtolower(class_basename($fromAccountableType)) . '.payment_types');
$allowedTypeIds = array_intersect($canPay, $canReceive);
if (!in_array($transactionTypeId, $allowedTypeIds)) {
$transactionTypeName = TransactionType::find($transactionTypeId)->name ?? 'id: ' . $transactionTypeId;
return $this->logAndReport('Impossible payment attempt: transaction type not allowed', $fromAccountId, $toAccountId, $amount, $description, $transactionTypeName);
}
} else {
$transactionTypeId = 6; // Enforce transactionTypeId 6 (migration) for internal transactions
}
// 2. BALANCE LIMIT CHECKS
$fromAccount = Account::find($fromAccountId);
$limitMinFrom = $fromAccount->limit_min;
$effectiveLimitMaxTo = $toAccount->limit_max - $toAccount->limit_min;
$balanceFrom = $this->getBalance($fromAccountId);
$balanceTo = $this->getBalance($toAccountId);
$transferBudgetFrom = $balanceFrom - $limitMinFrom;
$balanceToPublic = timebank_config('account_info.' . strtolower(class_basename($toHolderType)) . '.balance_public');
$transferBudgetTo = $effectiveLimitMaxTo - $balanceTo;
$limitError = $this->checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic, $toAccount->accountable->name);
if ($limitError) {
return redirect()->back()->with('error', $limitError);
}
// 3. CLEAN UP DESCRIPTION
// Get the validation rule string from the config
$rule = timebank_config('payment.description_rule');
preg_match('/max:(\d+)/', $rule, $matches);
$maxLength = $matches[1] ?? 500;
// Use Str::limit to truncate the description if it's too long, adding ' ...'
$description = \Illuminate\Support\Str::limit($description, $maxLength, ' ...');
// 4. DATABASE TRANSACTION
DB::beginTransaction();
try {
$transfer = new Transaction();
$transfer->from_account_id = $fromAccountId;
$transfer->to_account_id = $toAccountId;
$transfer->amount = $amount;
$transfer->description = $description;
$transfer->transaction_type_id = $transactionTypeId;
$transfer->creator_user_id = null;
$save = $transfer->save();
if ($save) {
DB::commit();
// 4. POST-TRANSACTION ACTIONS
// Send chat message and an email if conditions are met
$recipient = $transfer->accountTo->accountable;
$sender = $transfer->accountFrom->accountable;
$messageLocale = $recipient->lang_preference ?? $sender->lang_preference;
$payChatMessageKey = payChatMessageKey($sender);
if (!Lang::has($payChatMessageKey, $messageLocale)) { // Check if the translation key exists for the selected locale
$messageLocale = config('app.fallback_locale'); // Fallback to the app's default locale
}
// Translate the account name using the RECIPIENT'S language
$toAccountName = __(ucfirst(strtolower($transfer->accountTo->name)), [], $messageLocale);
$chatMessage = __($payChatMessageKey, [
'amount' => tbFormat($amount),
'account_name' => $toAccountName,
], $messageLocale);
$chatTransactionStatement = LaravelLocalization::getURLFromRouteNameTranslated($messageLocale, 'routes.statement', array('transactionId' => $transfer->id));
// Get or create conversation once, reuse for both messages
$conversation = $sender->createConversationWith($recipient);
// Send text notification message
$message = $sender->sendMessageTo($recipient, $chatMessage);
broadcast(new \Namu\WireChat\Events\MessageCreated($message));
\Namu\WireChat\Jobs\NotifyParticipants::dispatch($conversation, $message);
// Send QR code as image attachment (SVG format, renders in <img> tags)
$qrStatementUrl = route('transaction.show', ['transactionId' => $transfer->id]);
$qrSvg = QrCode::size(150)->errorCorrection('L')->color(17, 24, 39)->generate($qrStatementUrl);
$storageDisk = config('wirechat.attachments.storage_disk', 'public');
$storageFolder = config('wirechat.attachments.storage_folder', 'attachments');
$qrFileName = 'qr-transaction-' . $transfer->id . '-' . time() . '.svg';
$qrFilePath = $storageFolder . '/' . $qrFileName;
Storage::disk($storageDisk)->put($qrFilePath, $qrSvg);
$qrMessage = Message::create([
'conversation_id' => $conversation->id,
'sendable_type' => $sender->getMorphClass(),
'sendable_id' => $sender->id,
'body' => $chatTransactionStatement,
'type' => MessageType::ATTACHMENT,
]);
$qrMessage->attachment()->create([
'file_path' => $qrFilePath,
'file_name' => $qrFileName,
'original_name' => $qrFileName,
'mime_type' => 'image/svg+xml',
'url' => Storage::disk($storageDisk)->url($qrFilePath),
]);
$conversation->updated_at = now();
$conversation->save();
// Broadcast to open conversation (live message append) and notify chat list
broadcast(new \Namu\WireChat\Events\MessageCreated($qrMessage));
\Namu\WireChat\Jobs\NotifyParticipants::dispatch($conversation, $qrMessage);
// Check if the recipient has message settings for receiving this email and has also an email address
if (isset($recipient->email)) {
$messageSettings = method_exists($recipient, 'message_settings') ? $recipient->message_settings()->first() : null;
// Always send email unless payment_received is explicitly false
if (!$messageSettings || !($messageSettings->payment_received === false || $messageSettings->payment_received === 0)) {
$now = now();
Mail::to($recipient->email)->later($now->addSeconds(0), new TransferReceived($transfer, $messageLocale));
}
}
// Add Love Reaction with transaction type name on both models
$reactionType = TransactionType::find($transactionTypeId)->name;
try {
$reacterFacadeSender = $sender->viaLoveReacter()->reactTo($recipient, $reactionType);
$reacterFacadeRecipient = $recipient->viaLoveReacter()->reactTo($sender, $reactionType);
} catch (\Exception $e) {
// Ignore if reaction type does not exist
}
$successMessage = tbFormat($amount) . __('was paid to the ') . $toAccountName . __(' of ') . $recipient->name . '.' . '<br /><br />' . '<a href="' . route('transaction.show', ['transactionId' => $transfer->id]) . '">' . __('Show Transaction # ') . $transfer->id . '</a>';
return redirect()->back()->with('success', $successMessage);
} else {
throw new \Exception('Transaction could not be saved');
}
} catch (\Exception $e) {
DB::rollBack();
$this->logAndReport('Transaction failed', $fromAccountId, $toAccountId, $amount, $description, '', $e->getMessage());
return redirect()->back()->with('error', __('Sorry we have an error: this transaction could not be saved!'));
}
}
/**
* Helper to check balance limits for a transfer operation.
* Returns an error string if a limit is exceeded, otherwise null.
*/
private function checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic, $toHolderName)
{
if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom <= $transferBudgetTo) {
return __('messages.pay_limit_error_budget_from', [
'limitMinFrom' => tbFormat($limitMinFrom),
'transferBudgetFrom' => tbFormat($transferBudgetFrom),
]);
}
if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom > $transferBudgetTo) {
return $balanceToPublic
? __('messages.pay_limit_error_budget_from_and_to', [
'limitMinFrom' => tbFormat($limitMinFrom),
'transferBudgetTo' => tbFormat($transferBudgetTo),
])
: __('messages.pay_limit_error_budget_from_and_to_without_budget_to', [
'limitMinFrom' => tbFormat($limitMinFrom),
]);
}
if ($amount > $transferBudgetFrom) {
return __('messages.pay_limit_error_budget_from', [
'limitMinFrom' => tbFormat($limitMinFrom),
'transferBudgetFrom' => tbFormat($transferBudgetFrom),
]);
}
if ($amount > $transferBudgetTo) {
return $balanceToPublic
? __('messages.pay_limit_error_budget_to', [
'transferBudgetTo' => tbFormat($transferBudgetTo),
])
: __('messages.pay_limit_error_budget_to_without_budget_to', [
'transferBudgetTo' => tbFormat($transferBudgetTo),
'toHolderName' => $toHolderName,
]);
}
return null; // No error
}
/**
* Helper to log a warning message and report it via email to the system administrator.
* Returns a redirect response with an error message.
*/
private function logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description, $transactionType = '', $error = '')
{
$ip = request()->ip();
$ipLocationInfo = IpLocation::get($ip);
if (!$ipLocationInfo || App::environment(['local', 'development', 'staging'])) {
$ipLocationInfo = (object) ['cityName' => 'local City', 'regionName' => 'local Region', 'countryName' => 'local Country'];
}
$eventTime = now()->toDateTimeString();
$fromAccountHolder = Account::find($fromAccountId)->accountable()->value('name') ?? 'N/A';
$toAccountHolder = Account::find($toAccountId)->accountable()->value('name') ?? 'N/A';
Log::warning($warningMessage, [
'fromAccountId' => $fromAccountId, 'fromAccountHolder' => $fromAccountHolder,
'toAccountId' => $toAccountId, 'toAccountHolder' => $toAccountHolder,
'amount' => $amount,
'description' => $description,
'userId' => Auth::id(), 'userName' => Auth::user()->name,
'activeProfileId' => session('activeProfileId'), 'activeProfileType' => session('activeProfileType'), 'activeProfileName' => session('activeProfileName'),
'transactionType' => ucfirst($transactionType),
'IP address' => $ip, 'IP location' => "{$ipLocationInfo->cityName}, {$ipLocationInfo->regionName}, {$ipLocationInfo->countryName}",
'Event Time' => $eventTime, 'Message' => $error,
]);
$rawMessage = "{$warningMessage}.\n\n" .
"From Account ID: {$fromAccountId}\nFrom Account Holder: {$fromAccountHolder}\n" .
"To Account ID: {$toAccountId}\nTo Account Holder: {$toAccountHolder}\n" .
"Amount: {$amount}\n" .
"Description: {$description}\n" .
"User ID: " . Auth::id() . "\nUser Name: " . Auth::user()->name . "\n" .
"Active Profile ID: " . session('activeProfileId') . "\nActive Profile Type: " . session('activeProfileType') . "\nActive Profile Name: " . session('activeProfileName') . "\n" .
"Transaction Type: " . ucfirst($transactionType) . "\n" .
"IP address: {$ip}\nIP location: {$ipLocationInfo->cityName}, {$ipLocationInfo->regionName}, {$ipLocationInfo->countryName}\n" .
"Event Time: {$eventTime}\n\n{$error}";
Mail::raw($rawMessage, function ($message) use ($warningMessage) {
$message->to(timebank_config('mail.system_admin.email'))->subject($warningMessage);
});
return redirect()->back()->with('error', __($warningMessage) . '. ' . __('This event has been logged and reported to our system administrator') . '.');
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Traits\SwitchGuardTrait;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
class UserLoginController extends Controller
{
use SwitchGuardTrait;
/**
* Direct link to user login - can be used in emails
* Handles the authentication flow:
* 1. If user not authenticated -> redirect to user login with intended URL
* 2. If user authenticated but wrong user -> 403 forbidden
* 3. If correct user -> redirect to intended URL or main page
* 4. Supports custom intended URL via query parameter
*/
public function directLogin(Request $request, $userId)
{
\Log::info('UserLoginController: directLogin called', [
'user_id' => $userId,
'request_url' => $request->fullUrl(),
'all_params' => $request->all(),
]);
// Validate user exists
$user = User::find($userId);
if (!$user) {
abort(404, __('User not found'));
}
// Get optional intended destination after successful login.
// Validate it is a local URL to prevent open redirect attacks.
$intendedParam = $request->query('intended');
$intendedUrl = $this->validateLocalUrl($intendedParam, $request);
if (!$intendedUrl) {
$intendedUrl = LaravelLocalization::localizeURL(
route('main'),
$user->lang_preference ?? config('app.fallback_locale')
);
}
// Check if user is authenticated on web guard
\Log::info('UserLoginController: Checking authentication', [
'is_authenticated' => Auth::guard('web')->check(),
]);
if (!Auth::guard('web')->check()) {
// User not logged in - redirect to user login with return URL
$returnUrl = LaravelLocalization::localizeURL(
route('user.direct-login', ['userId' => $userId]),
$user->lang_preference ?? config('app.fallback_locale')
);
if ($intendedUrl) {
$returnUrl .= '?intended=' . urlencode($intendedUrl);
}
// Get the name parameter from the current request to pass along
$nameParam = $request->query('name', $user->name);
\Log::info('UserLoginController: Redirecting to login', [
'return_url' => $returnUrl,
'intended_url' => $intendedUrl,
'prefill_username' => $nameParam,
]);
// Store in session for Laravel to redirect after login
session()->put('url.intended', $returnUrl);
// Pass username as URL parameter to pre-fill login form
// Use LaravelLocalization to ensure the parameter is preserved through localization
$loginUrl = LaravelLocalization::localizeURL(
route('login'),
$user->lang_preference ?? config('app.fallback_locale')
);
$loginUrl .= '?name=' . urlencode($nameParam);
\Log::info('UserLoginController: Redirecting to login with name parameter', [
'login_url' => $loginUrl,
'username' => $nameParam,
]);
return redirect()->to($loginUrl, 302, [], false);
}
// User is authenticated - verify they are the correct user
$authenticatedUser = Auth::guard('web')->user();
if ($authenticatedUser->id !== $user->id) {
// Wrong user is logged in — log out all guards and redirect to login
// so the recipient can authenticate as the correct profile
foreach (['web', 'organization', 'bank', 'admin'] as $guard) {
Auth::guard($guard)->logout();
}
$request->session()->invalidate();
$request->session()->regenerateToken();
$returnUrl = route('user.direct-login', ['userId' => $userId]);
if ($intendedUrl) {
$returnUrl .= '?intended=' . urlencode($intendedUrl);
}
$nameParam = $request->query('name', $user->name);
session()->put('url.intended', $returnUrl);
$loginUrl = LaravelLocalization::localizeURL(
route('login'),
$user->lang_preference ?? config('app.fallback_locale')
);
$loginUrl .= '?name=' . urlencode($nameParam);
return redirect()->to($loginUrl, 302, [], false);
}
// Re-activate profile if inactive
if (timebank_config('profile_inactive.re-activate_at_login')) {
if (!$user->isActive()) {
$user->inactive_at = null;
$user->save();
info('User re-activated: ' . $user->name);
}
}
// Switch active guard back to 'web' (User profile).
// This is a no-op if already on web, but ensures that users arriving from an
// elevated profile (org/bank/admin) are switched back to their User profile.
$wasElevated = session('active_guard', 'web') !== 'web';
$this->switchGuard('web', $user);
session([
'activeProfileType' => get_class($user),
'activeProfileId' => $user->id,
'activeProfileName' => $user->name,
'activeProfilePhoto' => $user->profile_photo_path ?? null,
'activeProfileRoleLabel' => getProfileRoleLabel($user),
'last_activity' => now(),
'profile-switched-notification' => $wasElevated,
]);
\Log::info('UserLoginController: Authenticated user verified, redirecting', [
'user_id' => $authenticatedUser->id,
'target_user_id' => $user->id,
'intended_url' => $intendedUrl,
]);
// Redirect to intended URL
return redirect($intendedUrl);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Namu\WireChat\Facades\WireChat;
use Namu\WireChat\Models\Attachment;
class WireChatAttachmentController extends Controller
{
public function serve(Request $request, Attachment $attachment)
{
$profile = getActiveProfile();
abort_unless($profile !== null, 403);
// Verify the active profile is a participant in the conversation this attachment belongs to
$message = $attachment->attachable;
abort_unless($message !== null, 404);
$conversation = $message->conversation;
abort_unless($conversation !== null, 404);
abort_unless($profile->belongsToConversation($conversation), 403);
$disk = Storage::disk(WireChat::storageDisk());
abort_unless($disk->exists($attachment->file_path), 404);
$absolutePath = $disk->path($attachment->file_path);
$mimeType = $attachment->mime_type;
$fileSize = filesize($absolutePath);
// Use StreamedResponse so we can flush any stray OB content before sending binary data.
// BinaryFileResponse writes to php://output which respects active output buffers,
// so any pre-existing OB content (e.g. from middleware) would prepend to the binary file.
return response()->stream(function () use ($absolutePath) {
while (ob_get_level() > 0) {
ob_end_clean();
}
readfile($absolutePath);
}, 200, [
'Content-Type' => $mimeType,
'Content-Length' => $fileSize,
'Cache-Control' => 'private, max-age=3600',
'Accept-Ranges' => 'none',
]);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Http;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\LogErrors::class,
\App\Http\Middleware\DisableAssetCacheInDebug::class,
];
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\App\Http\Middleware\ProfileSessionTimeout::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\StoreUserLangPreference::class,
\App\Http\Middleware\ConditionalAuthenticateSession::class,
\App\Http\Middleware\CheckProfileInactivity::class,
\App\Http\Middleware\SetActiveGuard::class,
\App\Http\Middleware\TrackUserPresence::class,
\App\Http\Middleware\UpdateSessionGuard::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\TrackUserPresence::class . ':admin',
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array<string, class-string|string>
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'registration-complete' => \App\Http\Middleware\RegistrationComplete::class,
'principles-accepted' => \App\Http\Middleware\EnsurePrinciplesAccepted::class,
// Needed for mcamara/laravel-localization package
'localize' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRoutes::class,
'localizationRedirect' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRedirectFilter::class,
'localeSessionRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleSessionRedirect::class,
'localeCookieRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleCookieRedirect::class,
'localeViewPath' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationViewPath::class,
// Needed for Spatie Roles and Permissions
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
// Custom middleware to make sure the user is authenticated on the web guard to check roles and permissions
'user.can' => \App\Http\Middleware\CanOnWebGuard::class,
// Needed for Wirechat
'auth.any' => \App\Http\Middleware\AuthAnyGuard::class,
// Two-step authentication for Banks and Admins
'bank.auth' => \App\Http\Middleware\AuthenticateBank::class,
'admin.auth' => \App\Http\Middleware\AuthenticateAdmin::class,
// Admin profile authorization - prevents IDOR and cross-guard attacks
'admin.profile' => \App\Http\Middleware\RequireAdminProfile::class,
];
/**
* Define the application's command schedule.
*
* This method schedules the 'activitylog:clean' Artisan command to run daily.
* It deletes activity log records older than the number of days specified in config/activitylog.php
*
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule The scheduler instance.
* @return void
*/
protected function schedule(Schedule $schedule)
{
$schedule->command('activitylog:clean')->daily();
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
return (new Config())
->setParallelConfig(ParallelConfigFactory::detect()) // @TODO 4.0 no need to call this manually
->setRiskyAllowed(false)
->setRules([
'@auto' => true
])
// 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config
->setFinder(
(new Finder())
// 💡 root folder to check
->in(__DIR__)
// 💡 additional files, eg bin entry file
// ->append([__DIR__.'/bin-entry-file'])
// 💡 folders to exclude, if any
// ->exclude([/* ... */])
// 💡 path patterns to exclude, if any
// ->notPath([/* ... */])
// 💡 extra configs
// ->ignoreDotFiles(false) // true by default in v3, false in v4 or future mode
// ->ignoreVCS(true) // true by default
)
;
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace App\Http\Livewire;
use App\Models\Post;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
class AcceptPrinciples extends Component
{
use WireUiActions;
public bool $agreed = false;
public function mount()
{
$user = Auth::guard('web')->user();
// Pre-check only when user has already accepted the current version
if ($user && $user->hasAcceptedPrinciples() && !$user->needsToReacceptPrinciples()) {
$this->agreed = true;
}
}
public function accept()
{
$user = Auth::guard('web')->user();
if (!$user) {
$this->notification()->error(
$title = __('Error'),
$description = __('You must be logged in to accept the principles.')
);
return;
}
if (!$this->agreed) {
$this->notification()->warning(
$title = __('Confirmation required'),
$description = __('Please check the box to confirm you accept the principles.')
);
return;
}
// Get the current principles post with active translation
$principlesPost = $this->getCurrentPrinciplesPost();
if (!$principlesPost || !$principlesPost->translations->first()) {
$this->notification()->error(
$title = __('Error'),
$description = __('Unable to find the current principles document.')
);
return;
}
$translation = $principlesPost->translations->first();
// Save acceptance with version tracking
$user->update([
'principles_terms_accepted' => [
'post_id' => $principlesPost->id,
'post_translation_id' => $translation->id,
'locale' => $translation->locale,
'from' => $translation->from,
'updated_at' => $translation->updated_at->toDateTimeString(),
'accepted_at' => now()->toDateTimeString(),
]
]);
$this->notification()->success(
$title = __('Thank you'),
$description = __('Your acceptance has been recorded.')
);
// Sent here on the way to another page: continue there
$intended = session()->pull('principles_intended_url');
if ($intended) {
return $this->redirect($intended);
}
// Refresh the component to show the acceptance status
$this->dispatch('$refresh');
}
protected function getCurrentPrinciplesPost()
{
$locale = app()->getLocale();
$fallback = config('app.fallback_locale', 'en');
$activeTranslationQuery = function ($query) {
$query->where('from', '<=', now())
->where(function ($q) {
$q->where('till', '>', now())->orWhereNull('till');
});
};
$post = Post::whereHas('category', function ($query) {
$query->where('type', 'SiteContents\Static\Principles');
})
->whereHas('translations', $activeTranslationQuery)
->first();
if (!$post) {
return null;
}
// Load the best available translation: user locale → fallback locale → any active
$translation = $post->translations()
->where(function ($q) use ($locale) {
$q->where('locale', $locale)->orWhere('locale', 'like', $locale . '%');
})
->where('from', '<=', now())
->where(function ($q) { $q->where('till', '>', now())->orWhereNull('till'); })
->first();
if (!$translation) {
$translation = $post->translations()
->where(function ($q) use ($fallback) {
$q->where('locale', $fallback)->orWhere('locale', 'like', $fallback . '%');
})
->where('from', '<=', now())
->where(function ($q) { $q->where('till', '>', now())->orWhereNull('till'); })
->first();
}
if (!$translation) {
$translation = $post->translations()
->where('from', '<=', now())
->where(function ($q) { $q->where('till', '>', now())->orWhereNull('till'); })
->first();
}
$post->setRelation('translations', $translation ? collect([$translation]) : collect());
return $post;
}
public function render()
{
$user = Auth::guard('web')->user();
$hasAccepted = $user && $user->hasAcceptedPrinciples();
$needsReaccept = $user && $user->needsToReacceptPrinciples();
$acceptedData = $user?->principles_terms_accepted;
return view('livewire.accept-principles', [
'user' => $user,
'hasAccepted' => $hasAccepted,
'needsReaccept' => $needsReaccept,
'acceptedData' => $acceptedData,
]);
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace App\Http\Livewire;
use App\Helpers\ProfileAuthorizationHelper;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class AccountInfoModal extends Component
{
public $show = false;
public $accounts = [];
public $totalBalance = 0;
public $totalBalanceFormatted = '';
public $decimalFormat = false;
public $totalBalanceDecimal = '0,00';
protected $listeners = ['openAccountInfoModal' => 'open'];
public function open()
{
$this->loadAccounts();
$this->show = true;
}
public function close()
{
$this->show = false;
}
public function updatedDecimalFormat()
{
$this->reformatBalances();
}
private function formatBalance($minutes)
{
if ($this->decimalFormat) {
$isNegative = $minutes < 0;
$decimal = number_format(abs($minutes) / 60, 2, ',', '.');
$value = ($isNegative ? '-' : '') . $decimal . ' ' . __('h.');
return $value;
}
return tbFormat($minutes);
}
private function reformatBalances()
{
$this->accounts = array_map(function ($account) {
$account['balanceFormatted'] = $this->formatBalance($account['balance']);
return $account;
}, $this->accounts);
$this->totalBalanceFormatted = $this->formatBalance($this->totalBalance);
}
private function loadAccounts()
{
$profileType = session('activeProfileType');
$profileId = session('activeProfileId');
if (!$profileType || !$profileId) {
$this->accounts = [];
$this->totalBalance = 0;
$this->totalBalanceFormatted = $this->formatBalance(0);
return;
}
// Resolve profile class and verify it has accounts
$profile = $profileType::find($profileId);
if (!$profile || !method_exists($profile, 'accounts')) {
$this->accounts = [];
$this->totalBalance = 0;
$this->totalBalanceFormatted = $this->formatBalance(0);
return;
}
// Verify authenticated user owns this profile (IDOR prevention)
ProfileAuthorizationHelper::authorize($profile);
// Load only accounts belonging to the authenticated active profile (IDOR safe)
$profileAccounts = $profile->accounts()->get();
if ($profileAccounts->isEmpty()) {
$this->accounts = [];
$this->totalBalance = 0;
$this->totalBalanceFormatted = $this->formatBalance(0);
return;
}
$accountIds = $profileAccounts->pluck('id')->toArray();
// Fetch all balances in a single query (no cache — fresh DB state)
$balanceRows = DB::table('transactions')
->whereIn('from_account_id', $accountIds)
->orWhereIn('to_account_id', $accountIds)
->select('from_account_id', 'to_account_id', 'amount')
->get();
// Calculate per-account balance from query results
$balanceMap = array_fill_keys($accountIds, 0);
foreach ($balanceRows as $row) {
if (isset($balanceMap[$row->to_account_id])) {
$balanceMap[$row->to_account_id] += $row->amount;
}
if (isset($balanceMap[$row->from_account_id])) {
$balanceMap[$row->from_account_id] -= $row->amount;
}
}
$this->totalBalance = 0;
$this->accounts = $profileAccounts->map(function ($account) use ($balanceMap) {
$balance = $balanceMap[$account->id] ?? 0;
$this->totalBalance += $balance;
return [
'name' => account_name($account->name) . ' ' . __('account'),
'balance' => $balance,
'balanceFormatted' => $this->formatBalance($balance),
];
})->toArray();
$this->totalBalanceFormatted = $this->formatBalance($this->totalBalance);
$this->totalBalanceDecimal = number_format(abs($this->totalBalance) / 60, 2, ',', '.');
}
public function render()
{
return view('livewire.account-info-modal');
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Livewire;
use App\Models\Account;
use App\Models\Transaction;
use Livewire\Component;
class AccountUsageBar extends Component
{
public $selectedAccount;
public $balancePct = 1;
public $hasTransactions = false;
public $profileLimitMax = 0;
public $profileAccountCount = 1;
protected $listeners = [
'fromAccountId',
];
public function mount()
{
$this->selectedAccount = [
'id' => null,
'name' => '',
'balance' => 0,
'limitMin' => 0,
'limitMax' => 0,
'available' => 0,
'limitReceivable' => 0,
];
}
public function fromAccountId($selectedAccount)
{
$this->selectedAccount = $selectedAccount;
// Calculate balance percentage (set to 100% if limitMax is 0)
$this->balancePct = $selectedAccount['limitMax'] == 0 ? 100 : ($selectedAccount['balance'] / $selectedAccount['limitMax']) * 100;
$this->selectedAccount['available'] = $selectedAccount['limitMax'] - $selectedAccount['balance'];
$this->hasTransactions = Transaction::where('from_account_id', $selectedAccount['id'])
->orWhere('to_account_id', $selectedAccount['id'])
->exists();
$account = Account::with('accountable')->find($selectedAccount['id']);
$this->profileLimitMax = $account?->accountable?->limit_max ?? 0;
$this->profileAccountCount = $account?->accountable?->accounts()->whereNull('inactive_at')->count() ?? 1;
}
public function render()
{
return view('livewire.account-usage-bar');
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Livewire;
use App\Models\Post;
use Illuminate\Support\Facades\App;
use Livewire\Component;
class AccountUsageInfoModal extends Component
{
public $show = false;
public $post = null;
public $image = null;
public $imageCaption = null;
public $imageOwner = null;
public $fallbackTitle;
public $fallbackDescription;
protected $listeners = ['openAccountUsageInfoModal' => 'open'];
public function mount()
{
$this->fallbackTitle = __('Account usage');
$this->fallbackDescription = __('The account usage bar provides a visual representation of your currency holdings, similar to a disk space bar but for money. It displays both the amount of currency you currently possess and the maximum limit of your account.');
}
public function open()
{
$this->show = true;
$this->loadPost();
}
public function loadPost()
{
$locale = App::getLocale();
$this->post = Post::with([
'category',
'media',
'translations' => function ($query) use ($locale) {
$query->where('locale', $locale)
->orderBy('created_at', 'desc')
->limit(3);
}
])
->whereHas('category', function ($query) {
$query->where('type', 'SiteContents\AccountUsage\Info');
})
->whereHas('translations', function ($query) use ($locale) {
$query->where('locale', $locale)
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
})
->orderBy('updated_at', 'desc');
})
->orderBy('created_at', 'desc')
->limit(3)
->first();
if ($this->post && $this->post->hasMedia('posts')) {
$this->image = $this->post->getFirstMediaUrl('posts', 'half_hero');
$mediaItem = $this->post->getFirstMedia('posts');
if ($mediaItem) {
// Get owner
$this->imageOwner = $mediaItem->getCustomProperty('owner');
// Try to get caption for current locale
$this->imageCaption = $mediaItem->getCustomProperty('caption-' . $locale);
// If not found, try fallback locales
if (!$this->imageCaption) {
$fallbackLocales = ['en', 'nl', 'de', 'es', 'fr'];
foreach ($fallbackLocales as $fallbackLocale) {
$this->imageCaption = $mediaItem->getCustomProperty('caption-' . $fallbackLocale);
if ($this->imageCaption) {
break;
}
}
}
}
}
}
public function close()
{
$this->show = false;
$this->image = null;
$this->imageCaption = null;
$this->imageOwner = null;
}
public function render()
{
return view('livewire.account-usage-info-modal');
}
}
@@ -0,0 +1,79 @@
<?php
namespace App\Http\Livewire;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class AddTranslationSelectbox extends Component
{
public $options = [];
public $localeSelected;
public $emptyMessage;
protected $listeners = ['updateLocalesOptions'];
/**
* Prepare the component.
*
* @return void
*/
public function mount($locale = null, $options)
{
// Initially show "Loading..." while options are being fetched
$this->emptyMessage = __('Loading...');
$this->updateLocalesOptions($options);
$locale = $locale ?? session('locale');
// Extract lang_code values from the options collection
$availableLocales = $this->options ? $this->options->pluck('lang_code')->all() : [];
if (!in_array($locale, $availableLocales)) {
$locale = null;
}
$this->localeSelected = $locale;
}
public function updateLocalesOptions($options)
{
// Set loading message while fetching options
$this->emptyMessage = __('Loading...');
if ($options) {
$options = DB::table('languages')
->whereIn('lang_code', $options)
->orderBy('name')
->get(['id','lang_code','name']);
$this->options = $options->map(function ($item, $key) {
return [
'id' => $item->id,
'lang_code' => $item->lang_code,
'name' => __('messages.' . $item->name)];
});
}
}
/**
* When component is updated
*
* @return void
*/
public function updated()
{
if ($this->localeSelected) {
$this->dispatch('localeSelected', $this->localeSelected);
}
}
public function render()
{
return view('livewire.add-translation-selectbox');
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace App\Http\Livewire\Admin;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Livewire\Component;
class Log extends Component
{
public $logContent = '';
public $message = '';
public $diskUsage = '';
public $diskUsageClass = '';
public $availableRam;
public $availableRamClass;
public $queueWorkers = [];
public $queueWorkersCount = 0;
public $reverbConnected = false;
public function mount()
{
// Security check: user must be authenticated
if (!Auth::check()) {
abort(403, 'Unauthorized');
}
// Security check: activeProfileType must be 'App\Models\Admin'
if (getActiveProfileType() !== 'Admin') {
abort(403, 'Unauthorized');
}
// Security check: user must own the active profile
$activeProfile = getActiveProfile();
if (!$activeProfile) {
abort(403, 'Unauthorized');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
if (!$this->hasLogsPermission()) {
abort(403, 'Unauthorized');
}
// Load log content - support both daily (laravel-YYYY-MM-DD.log) and single (laravel.log) drivers
$logPath = storage_path('logs/laravel-' . date('Y-m-d') . '.log');
if (!file_exists($logPath)) {
$logPath = storage_path('logs/laravel.log');
}
if (file_exists($logPath)) {
$logLines = (int) timebank_config('admin_settings.log_lines', 100);
$this->logContent = shell_exec("tail -n {$logLines} " . escapeshellarg($logPath));
// Use tail output to check for warnings/errors (avoid file() which loads entire file into memory)
$recentLines = explode("\n", $this->logContent ?? '');
$messages = [];
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'WARNING') !== false)) {
$messages[] = '<span class="font-bold text-orange-500">Warning</span> detected in the recent log output';
}
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'ERROR') !== false)) {
$messages[] = '<span class="font-bold text-red-500">Error</span> detected in the recent log output';
}
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'CRITICAL') !== false)) {
$messages[] = '<span class="font-bold text-red-500">Critical</span> issue detected in the recent log output';
}
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'ALERT') !== false)) {
$messages[] = '<span class="font-bold text-red-500">Alert</span> detected in the recent log output - IMMEDIATE ACTION REQUIRED';
}
$this->message = implode('<br>', $messages);
}
// Get disk usage
$free = disk_free_space("/");
$total = disk_total_space("/");
$used = $total - $free;
$percent = $total > 0 ? round(($used / $total) * 100, 1) : 0;
// Determine disk usage color class
if ($percent > 90) {
$this->diskUsageClass = 'text-red-500';
} elseif ($percent > 75) {
$this->diskUsageClass = 'text-orange-500';
} else {
$this->diskUsageClass = 'text-green-500';
}
$this->diskUsage = sprintf(
'%s used of %s (%.1f%%)',
$this->formatBytes($used),
$this->formatBytes($total),
$percent
);
// Get RAM memory information
$meminfo = file_get_contents('/proc/meminfo');
preg_match('/MemTotal:\s+(\d+)/', $meminfo, $matchesTotal);
preg_match('/MemAvailable:\s+(\d+)/', $meminfo, $matchesAvailable);
$totalKb = $matchesTotal[1] ?? 0;
$availableKb = $matchesAvailable[1] ?? 0;
$usedKb = $totalKb - $availableKb;
$percentRam = $totalKb > 0 ? round(($usedKb / $totalKb) * 100, 1) : 0;
$this->availableRam = sprintf(
'%s used of %s (%.1f%%)',
$this->formatBytes($usedKb * 1024),
$this->formatBytes($totalKb * 1024),
$percentRam
);
if ($percentRam > 90) {
$this->availableRamClass = 'text-red-500';
} elseif ($percentRam > 75) {
$this->availableRamClass = 'text-orange-500';
} else {
$this->availableRamClass = 'text-green-500';
}
// Check running queue workers
$queueWorkers = [];
exec("ps aux | grep 'artisan queue:work' | grep -v grep", $output);
foreach ($output as $line) {
// Parse $line for more details
$queueWorkers[] = $line;
}
$this->queueWorkers = $queueWorkers;
$this->queueWorkersCount = count($queueWorkers);
// Check Reverb server connection (example: check if port is open)
$reverbPort = env('REVERB_PORT', 6001);
// Check Reverb server connection
$reverbConnected = false;
$connection = @fsockopen('127.0.0.1', $reverbPort, $errno, $errstr, 1);
if ($connection) {
$reverbConnected = true;
fclose($connection);
}
$this->reverbConnected = $reverbConnected;
}
protected function hasLogsPermission(): bool
{
$webUser = \Illuminate\Support\Facades\Auth::guard('web')->user();
$activeId = session('activeProfileId');
if (!$webUser || !$activeId) return false;
$prefix = 'Admin\\' . $activeId . '\\';
$scopedRoles = $webUser->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
foreach ($scopedRoles as $scopedRole) {
$roleName = str_replace($prefix, '', $scopedRole->name);
$adminRole = \App\Models\AdminRole::where('name', $roleName)->first();
if ($adminRole && in_array('view application-log', $adminRole->permissions ?? [])) {
return true;
}
}
return false;
}
// Helper to format bytes
public function formatBytes($bytes, $precision = 2)
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = $bytes > 0 ? floor(log($bytes) / log(1024)) : 0;
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow];
}
public function downloadLog()
{
$logPath = storage_path('logs/laravel-' . date('Y-m-d') . '.log');
if (!file_exists($logPath)) {
$logPath = storage_path('logs/laravel.log');
}
if (file_exists($logPath)) {
return response()->download($logPath, 'laravel-' . date('Y-m-d') . '.log');
}
session()->flash('error', 'Log file not found');
}
public function render()
{
$layout = Auth::check() ? 'app-layout' : 'guest-layout';
return view('livewire.admin.log', [
'layout' => $layout,
'logContent' => $this->logContent,
'message' => $this->message,
'diskUsage' => $this->diskUsage,
'diskUsageClass' => $this->diskUsageClass,
'availableRam' => $this->availableRam,
'availableRamClass' => $this->availableRamClass,
'queueWorkers' => $this->queueWorkers,
'queueWorkersCount' => $this->queueWorkersCount,
'reverbConnected' => $this->reverbConnected,
]);
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
namespace App\Http\Livewire\Admin;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\File;
use Livewire\Component;
class LogViewer extends Component
{
public $logFilename;
public $logContent = '';
public $message = '';
public $fileSize = '';
public $lastModified = '';
public $logTitle = '';
public function mount($logFilename, $logTitle = null)
{
// Security check: user must be authenticated
if (!Auth::check()) {
abort(403, 'Unauthorized');
}
// Security check: activeProfileType must be 'App\Models\Admin'
if (getActiveProfileType() !== 'Admin') {
abort(403, 'Unauthorized');
}
// Security check: user must own the active profile
$activeProfile = getActiveProfile();
if (!$activeProfile) {
abort(403, 'Unauthorized');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
$this->logFilename = $logFilename;
if (!$this->hasLogsPermission()) {
abort(403, 'Unauthorized');
}
$this->logTitle = $logTitle ?? $logFilename;
$this->loadLogContent();
}
protected function hasLogsPermission(): bool
{
$permission = match (true) {
str_contains($this->logFilename, 'inactive-profiles') => 'view inactive-profiles-log',
str_contains($this->logFilename, 'scout-reindex') => 'view search-reindex-log',
default => null,
};
if (!$permission) return false;
$webUser = \Illuminate\Support\Facades\Auth::guard('web')->user();
$activeId = session('activeProfileId');
if (!$webUser || !$activeId) return false;
$prefix = 'Admin\\' . $activeId . '\\';
$scopedRoles = $webUser->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
foreach ($scopedRoles as $scopedRole) {
$roleName = str_replace($prefix, '', $scopedRole->name);
$adminRole = \App\Models\AdminRole::where('name', $roleName)->first();
if ($adminRole && in_array($permission, $adminRole->permissions ?? [])) {
return true;
}
}
return false;
}
public function loadLogContent()
{
$logPath = storage_path('logs/' . $this->logFilename);
// Security: prevent directory traversal attacks
$realPath = realpath($logPath);
$logsDir = realpath(storage_path('logs'));
if (!$realPath || strpos($realPath, $logsDir) !== 0) {
$this->message = '<span class="font-bold text-red-500">Error:</span> Invalid log file path';
return;
}
if (file_exists($logPath)) {
// Get file info
$this->fileSize = $this->formatBytes(filesize($logPath));
$this->lastModified = date('Y-m-d H:i:s', filemtime($logPath));
// Load log content using tail (avoid file() which loads entire file into memory)
$logLines = (int) timebank_config('admin_settings.log_lines', 100);
$this->logContent = shell_exec("tail -n {$logLines} " . escapeshellarg($logPath));
$recentLines = explode("\n", $this->logContent ?? '');
// Check for warnings/errors in log content
$messages = [];
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'WARNING') !== false)) {
$messages[] = '<span class="font-bold text-orange-500">Warning</span> detected in the recent log output';
}
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'ERROR') !== false)) {
$messages[] = '<span class="font-bold text-red-500">Error</span> detected in the recent log output';
}
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'CRITICAL') !== false)) {
$messages[] = '<span class="font-bold text-red-500">Critical</span> issue detected in the recent log output';
}
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'ALERT') !== false)) {
$messages[] = '<span class="font-bold text-red-500">Alert</span> detected in the recent log output - IMMEDIATE ACTION REQUIRED';
}
if (!empty($messages)) {
$this->message = implode('<br>', $messages);
}
} else {
$this->message = '<span class="font-bold text-gray-500">Log file not found or empty</span>';
}
}
public function downloadLog()
{
$logPath = storage_path('logs/' . $this->logFilename);
// Security: prevent directory traversal attacks
$realPath = realpath($logPath);
$logsDir = realpath(storage_path('logs'));
if (!$realPath || strpos($realPath, $logsDir) !== 0) {
session()->flash('error', 'Invalid log file path');
return;
}
if (file_exists($logPath)) {
return response()->download($logPath, $this->logFilename . '-' . date('Y-m-d') . '.log');
}
session()->flash('error', 'Log file not found');
}
public function refreshLog()
{
$this->loadLogContent();
$this->dispatch('logRefreshed');
}
// Helper to format bytes
public function formatBytes($bytes, $precision = 2)
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = $bytes > 0 ? floor(log($bytes) / log(1024)) : 0;
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow];
}
public function render()
{
return view('livewire.admin.log-viewer');
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Livewire\Admin;
use Livewire\Attributes\On;
use Livewire\Component;
class MaintenanceBanner extends Component
{
public $maintenanceMode = false;
public function mount()
{
$this->maintenanceMode = isMaintenanceMode();
}
#[On('maintenance-mode-changed')]
public function refreshMaintenanceMode()
{
$this->maintenanceMode = isMaintenanceMode();
}
public function render()
{
return view('livewire.admin.maintenance-banner');
}
}
+166
View File
@@ -0,0 +1,166 @@
<?php
namespace App\Http\Livewire\Admin;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class MaintenanceMode extends Component
{
public $maintenanceMode = false;
public $showModal = false;
public function mount()
{
if (getActiveProfileType() !== 'Admin') {
abort(403, 'Only administrators can access this feature.');
}
if (!$this->hasMaintenancePermission()) {
abort(403, __('You do not have permission to perform this action.'));
}
$this->maintenanceMode = $this->getMaintenanceMode();
}
public function openModal()
{
$this->showModal = true;
}
public function closeModal()
{
$this->showModal = false;
}
public function toggleMaintenanceMode()
{
if (getActiveProfileType() !== 'Admin' || !$this->hasMaintenancePermission()) {
$this->dispatch('notify', [
'type' => 'error',
'message' => __('You do not have permission to perform this action.'),
]);
return;
}
// Toggle the value
$this->maintenanceMode = !$this->maintenanceMode;
// If enabling maintenance mode, log out all non-admin users
if ($this->maintenanceMode) {
$this->logoutNonAdminUsers();
}
// Update in database
DB::table('system_settings')
->where('key', 'maintenance_mode')
->update([
'value' => $this->maintenanceMode ? 'true' : 'false',
'updated_at' => now(),
]);
// Clear cache
Cache::forget('system_setting_maintenance_mode');
// Close modal
$this->showModal = false;
// Dispatch event to refresh the maintenance banner
$this->dispatch('maintenance-mode-changed');
// Notify user
$message = $this->maintenanceMode
? 'Maintenance mode has been enabled. Only users with admin relationships can now log in.'
: 'Maintenance mode has been disabled. All users can now log in.';
$this->dispatch('notify', [
'type' => 'success',
'message' => $message
]);
}
/**
* Log out all users who don't have admin relationships
*/
protected function logoutNonAdminUsers()
{
// Get the current authenticated user ID to exclude from logout
$currentUserId = auth()->id();
// Get all users without admin relationships, excluding current user
$usersToLogout = \App\Models\User::whereDoesntHave('admins')
->where('id', '!=', $currentUserId)
->get();
$logoutCount = 0;
// First, broadcast logout events to all users
// This gives browsers a chance to receive the WebSocket message before sessions are deleted
foreach ($usersToLogout as $user) {
// Determine the guard for this user
$guard = 'web'; // Default guard
// Broadcast forced logout event via WebSocket
broadcast(new \App\Events\UserForcedLogout($user->id, $guard));
$logoutCount++;
}
// Wait briefly for WebSocket messages to be delivered
// This helps ensure browsers receive the logout event before sessions are deleted
sleep(2);
// Now delete sessions and clear caches
foreach ($usersToLogout as $user) {
$guard = 'web';
// Delete all sessions for this user from database
\DB::connection(config('session.connection'))
->table(config('session.table', 'sessions'))
->where('user_id', $user->id)
->delete();
// Clear cached authentication data
Cache::forget('auth_' . $guard . '_' . $user->id);
// Clear presence cache
Cache::forget("presence_{$guard}_{$user->id}");
}
// Clear online users cache to force refresh
Cache::forget("online_users_web_" . \App\Services\PresenceService::ONLINE_THRESHOLD_MINUTES);
// Log the action for debugging
info("Maintenance mode enabled: Logged out {$logoutCount} non-admin users. Current admin user ID {$currentUserId} was preserved.");
}
protected function hasMaintenancePermission(): bool
{
$webUser = \Illuminate\Support\Facades\Auth::guard('web')->user();
$activeId = session('activeProfileId');
if (!$webUser || !$activeId) {
return false;
}
$prefix = 'Admin\\' . $activeId . '\\';
$scopedRoles = $webUser->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
foreach ($scopedRoles as $scopedRole) {
$roleName = str_replace($prefix, '', $scopedRole->name);
$adminRole = \App\Models\AdminRole::where('name', $roleName)->first();
if ($adminRole && in_array('manage maintenance', $adminRole->permissions ?? [])) {
return true;
}
}
return false;
}
protected function getMaintenanceMode()
{
return isMaintenanceMode();
}
public function render()
{
return view('livewire.admin.maintenance-mode');
}
}
+205
View File
@@ -0,0 +1,205 @@
<?php
namespace App\Http\Livewire\Admin;
use App\Helpers\SearchOptimizationHelper;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\District;
use App\Models\Locations\Division;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class SearchMetrics extends Component
{
public array $metrics = [];
public array $recentSearches = [];
public string $period = 'last_year';
public array $periods = [
'last_week' => 'Last week',
'last_month' => 'Last month',
'last_6months' => 'Last 6 months',
'last_year' => 'Last year',
];
public function mount()
{
if (!Auth::check()) {
abort(403, 'Unauthorized');
}
if (getActiveProfileType() !== 'Admin') {
abort(403, 'Unauthorized');
}
$activeProfile = getActiveProfile();
if (!$activeProfile) {
abort(403, 'Unauthorized');
}
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
if (!$this->hasLogsPermission()) {
abort(403, 'Unauthorized');
}
$this->loadMetrics();
}
protected function hasLogsPermission(): bool
{
$webUser = Auth::guard('web')->user();
$activeId = session('activeProfileId');
if (!$webUser || !$activeId) return false;
$prefix = 'Admin\\' . $activeId . '\\';
$scopedRoles = $webUser->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
foreach ($scopedRoles as $scopedRole) {
$roleName = str_replace($prefix, '', $scopedRole->name);
$adminRole = \App\Models\AdminRole::where('name', $roleName)->first();
if ($adminRole && in_array('view search-metrics', $adminRole->permissions ?? [])) {
return true;
}
}
return false;
}
public function refresh()
{
$this->loadMetrics();
}
public function updatedPeriod()
{
$this->loadMetrics();
}
private function periodSince(): \Carbon\Carbon
{
return match ($this->period) {
'last_week' => now()->subWeek(),
'last_month' => now()->subMonth(),
'last_6months' => now()->subMonths(6),
default => now()->subMonths(12)->startOfMonth(),
};
}
public function downloadLog()
{
$csv = implode(',', ['time', 'query', 'categories', 'results', 'exec_s', 'lang', 'type', 'location']) . "\n";
foreach ($this->recentSearches as $row) {
$categories = implode('|', array_column($row['category_tags'] ?? [], 'name'));
$csv .= implode(',', [
\Carbon\Carbon::parse($row['timestamp'])->format('Y-m-d'),
'"' . str_replace('"', '""', $row['search_string'] ?? '') . '"',
'"' . str_replace('"', '""', $categories) . '"',
$row['total_results'] ?? 0,
number_format($row['execution_time'] ?? 0, 4),
$row['locale'] ?? '',
$row['user_type'] ?? '',
'"' . str_replace('"', '""', $row['location_label'] ?? '') . '"',
]) . "\n";
}
$filename = 'search-metrics-' . now()->format('Y-m') . '.csv';
return response()->streamDownload(fn () => print($csv), $filename, [
'Content-Type' => 'text/csv',
]);
}
private function loadMetrics(): void
{
$since = $this->periodSince();
$this->metrics = SearchOptimizationHelper::getSearchMetrics($since);
$raw = SearchOptimizationHelper::readCurrentMonthEntries(50, $since);
// Collect all unique category IDs across all entries, then resolve names once
$allIds = array_values(array_unique(array_merge(...array_map(
fn($s) => $s['category_ids'] ?? [],
$raw
))));
$tagMap = [];
if (!empty($allIds)) {
$locale = app()->getLocale();
$categories = \App\Models\Category::whereIn('id', $allIds)
->with(['translations' => fn($q) => $q->where('locale', $locale)])
->get()
->keyBy('id');
foreach ($allIds as $id) {
$cat = $categories->get($id);
$tagMap[$id] = [
'name' => $cat?->translation?->name ?? $cat?->name ?? $id,
'color' => $cat?->relatedColor ?? 'gray',
];
}
}
// Collect all unique location IDs and resolve names in one query per type
$locale = app()->getLocale();
$locationNameMap = $this->resolveLocationNames($raw, $locale);
// Replace category_ids with resolved name+color tags, and add location label per entry
$this->recentSearches = array_map(function ($search) use ($tagMap, $locationNameMap) {
$search['category_tags'] = array_map(
fn($id) => $tagMap[$id] ?? ['name' => $id, 'color' => 'gray'],
$search['category_ids'] ?? []
);
// Build location short label matching LocationTrait::name_short logic:
// city + (district or division), or country if no city
$city = $locationNameMap['city'][$search['city_id'] ?? null] ?? null;
$district = $locationNameMap['district'][$search['district_id'] ?? null] ?? null;
$division = $locationNameMap['division'][$search['division_id'] ?? null] ?? null;
$country = $locationNameMap['country'][$search['country_id'] ?? null] ?? null;
$parts = [];
if ($city) $parts[] = $city;
if ($district && count($parts) < 2) $parts[] = $district;
if ($division && count($parts) < 2) $parts[] = $division;
if (!$city && $country) $parts[] = $country;
$search['location_label'] = preg_replace('/\s+,/', ',', implode(' ', $parts)) ?: null;
return $search;
}, $raw);
}
private function resolveLocationNames(array $entries, string $locale): array
{
$ids = ['country' => [], 'division' => [], 'city' => [], 'district' => []];
foreach ($entries as $entry) {
if (!empty($entry['country_id'])) $ids['country'][] = $entry['country_id'];
if (!empty($entry['division_id'])) $ids['division'][] = $entry['division_id'];
if (!empty($entry['city_id'])) $ids['city'][] = $entry['city_id'];
if (!empty($entry['district_id'])) $ids['district'][] = $entry['district_id'];
}
$resolve = function ($model, array $uniqueIds) use ($locale): array {
if (empty($uniqueIds)) return [];
return $model::whereIn('id', array_unique($uniqueIds))
->with(['translations' => fn($q) => $q->where('locale', $locale)])
->get()
->keyBy('id')
->map(fn($r) => $r->translations->first()?->name ?? $r->name ?? null)
->toArray();
};
return [
'country' => $resolve(Country::class, $ids['country']),
'division' => $resolve(Division::class, $ids['division']),
'city' => $resolve(City::class, $ids['city']),
'district' => $resolve(District::class, $ids['district']),
];
}
public function render()
{
return view('livewire.admin.search-metrics');
}
}
File diff suppressed because it is too large. Load diff
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Http\Livewire;
use Livewire\Component;
class AdminLoginModal extends Component
{
public function render()
{
return view('livewire.admin-login-modal');
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Http\Livewire;
use Livewire\Component;
class Amount extends Component
{
public $amount;
public $hours;
public $minutes;
public $label;
public $maxLengthHoursInput = 3;
protected $listeners = ['resetForm'];
public function mount()
{
if ($this->amount != null) {
// Ensure hours is a positive integer or set to null
if (!is_null($this->hours) && (!is_numeric($this->hours) || $this->hours <= 0 || intval($this->hours) != $this->hours)) {
$this->hours = null;
}
// Ensure minutes is a positive integer or set to null
if (!is_null($this->minutes) && (!is_numeric($this->minutes) || $this->minutes < 0 || intval($this->minutes) != $this->minutes)) {
$this->minutes = null;
$this->calculateAmount();
} elseif (!is_null($this->minutes) && is_numeric($this->minutes)) {
$minutesInt = (int) $this->minutes;
// Handle minutes overflow (e.g., 65 minutes → 1 hour 5 minutes, 1230 minutes → 20 hours 30 minutes)
if ($minutesInt > 59) {
$additionalHours = intdiv($minutesInt, 60);
$remainingMinutes = $minutesInt % 60;
$this->hours = is_null($this->hours) || !is_numeric($this->hours) ? 0 : (int) $this->hours;
$this->hours += $additionalHours;
$this->minutes = $remainingMinutes;
}
// Add leading zero to minutes if less than 10
if (!is_null($this->minutes) && (int) $this->minutes < 10) {
$this->minutes = str_pad((int) $this->minutes, 2, '0', STR_PAD_LEFT);
}
$this->calculateAmount();
}
$this->amount = 0;
}
$this->amount = $this->amount;
}
public function resetForm()
{
$this->reset();
}
public function updatedHours()
{
$this->calculateAmount();
}
public function updatedMinutes()
{
// Handle minutes overflow (e.g., 65 minutes → 1 hour 5 minutes)
if (!is_null($this->minutes) && is_numeric($this->minutes)) {
$minutesInt = (int) $this->minutes;
if ($minutesInt > 59) {
// Calculate additional hours from overflow
$additionalHours = intdiv($minutesInt, 60);
$remainingMinutes = $minutesInt % 60;
// Add to hours (default to 0 if hours is null)
$this->hours = is_null($this->hours) || !is_numeric($this->hours) ? 0 : (int) $this->hours;
$this->hours += $additionalHours;
// Set remaining minutes with leading zero if needed
$this->minutes = $remainingMinutes < 10 ? str_pad($remainingMinutes, 2, '0', STR_PAD_LEFT) : $remainingMinutes;
} elseif ($minutesInt < 10 && $minutesInt >= 0) {
// Add leading zero to single digit minutes
$this->minutes = str_pad($minutesInt, 2, '0', STR_PAD_LEFT);
}
}
$this->calculateAmount();
}
protected function calculateAmount()
{
$hours = is_numeric($this->hours) ? (int) $this->hours : 0;
$minutes = is_numeric($this->minutes) ? (int) $this->minutes : 0;
$this->amount = $hours * 60 + $minutes;
$this->dispatch('amount', $this->amount);
// Format the inputs for empty values
if ($this->amount === 0) {
$this->reset(['hours', 'minutes']);
} else {
if ($this->amount < 60) {
$this->hours = 0;
}
if ($this->amount % 60 === 0) {
$this->minutes = '00';
}
}
}
public function render()
{
return view('livewire.amount');
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Livewire\Calls;
use App\Models\Call;
class CallCarouselScorer
{
private array $cfg;
private ?int $profileCityId;
private ?int $profileDivisionId;
private ?int $profileCountryId;
private const UNKNOWN_COUNTRY_ID = 10;
private const REACTION_TYPE_LIKE = 3;
private const REACTION_TYPE_STAR = 1;
public function __construct(
array $carouselConfig,
?int $profileCityId,
?int $profileDivisionId,
?int $profileCountryId
) {
$this->cfg = $carouselConfig;
$this->profileCityId = $profileCityId;
$this->profileDivisionId = $profileDivisionId;
$this->profileCountryId = $profileCountryId;
}
public function score(Call $call): float
{
$score = 1.0;
$loc = $call->location;
// --- Location specificity (only best-matching tier applied) ---
if ($loc) {
if ($loc->country_id === self::UNKNOWN_COUNTRY_ID) {
$score *= (float) ($this->cfg['boost_location_unknown'] ?? 0.8);
} elseif ($loc->city_id) {
$score *= (float) ($this->cfg['boost_location_city'] ?? 2.0);
} elseif ($loc->division_id) {
$score *= (float) ($this->cfg['boost_location_division'] ?? 1.5);
} elseif ($loc->country_id) {
$score *= (float) ($this->cfg['boost_location_country'] ?? 1.1);
}
// Same-city (district) proximity bonus
if ($this->profileCityId && $loc->city_id === $this->profileCityId) {
$score *= (float) ($this->cfg['boost_same_district'] ?? 3.0);
}
}
// --- Engagement: likes on the call ---
$likeCount = $call->loveReactant?->reactionCounters
->firstWhere('reaction_type_id', self::REACTION_TYPE_LIKE)?->count ?? 0;
$score *= (1.0 + $likeCount * (float) ($this->cfg['boost_like_count'] ?? 0.05));
// --- Engagement: stars on the callable ---
$starCount = $call->callable?->loveReactant?->reactionCounters
->firstWhere('reaction_type_id', self::REACTION_TYPE_STAR)?->count ?? 0;
$score *= (1.0 + $starCount * (float) ($this->cfg['boost_star_count'] ?? 0.10));
// --- Recency (created_at) ---
$recentDays = (int) ($this->cfg['recent_days'] ?? 14);
if ($call->created_at && $call->created_at->gte(now()->subDays($recentDays))) {
$score *= (float) ($this->cfg['boost_recent_from'] ?? 1.3);
}
// --- Urgency (till expiry) ---
$soonDays = (int) ($this->cfg['soon_days'] ?? 7);
if ($call->till && $call->till->lte(now()->addDays($soonDays))) {
$score *= (float) ($this->cfg['boost_soon_till'] ?? 1.2);
}
// --- Callable type ---
$callableType = $call->callable_type ?? '';
if (str_ends_with($callableType, 'User')) {
$score *= (float) ($this->cfg['boost_callable_user'] ?? 1.0);
} elseif (str_ends_with($callableType, 'Organization')) {
$score *= (float) ($this->cfg['boost_callable_organization'] ?? 1.2);
} elseif (str_ends_with($callableType, 'Bank')) {
$score *= (float) ($this->cfg['boost_callable_bank'] ?? 1.0);
}
return $score;
}
}
+503
View File
@@ -0,0 +1,503 @@
<?php
namespace App\Http\Livewire\Calls;
use App\Helpers\StringHelper;
use App\Jobs\SendEmailNewTag;
use App\Models\Category;
use App\Models\Language;
use App\Models\Tag;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class CallSkillInput extends Component
{
// Tagify state
public string $tagsArray = '[]';
public array $suggestions = [];
// New tag creation modal
public bool $modalVisible = false;
public array $newTag = ['name' => ''];
public ?int $newTagCategory = null;
public array $categoryOptions = [];
public string $categoryColor = 'gray';
// Language detection
public bool $sessionLanguageOk = false;
public bool $sessionLanguageIgnored = false;
public bool $transLanguageOk = false;
public bool $transLanguageIgnored = false;
// Translation support
public bool $translationPossible = true;
public bool $translationAllowed = true;
public bool $translationVisible = false;
public array $translationLanguages = [];
public $selectTranslationLanguage = null;
public array $translationOptions = [];
public $selectTagTranslation = null;
public array $inputTagTranslation = [];
public bool $inputDisabled = true;
public $translateRadioButton = null;
protected $langDetector = null;
protected function rules(): array
{
return $this->createTagValidationRules();
}
public ?int $initialTagId = null;
public function mount(?int $initialTagId = null): void
{
$this->initialTagId = $initialTagId;
if ($initialTagId) {
$tag = \App\Models\Tag::find($initialTagId);
if ($tag) {
$color = $tag->contexts->first()?->category?->relatedColor ?? 'gray';
$tagDisplayName = $tag->translation?->name ?? $tag->name;
$this->tagsArray = json_encode([[
'value' => $tagDisplayName,
'tag_id' => $tag->tag_id,
'title' => $tagDisplayName,
'style' => '--tag-bg:' . tailwindColorToHex($color . '-400') . '; --tag-text-color:#000; --tag-hover:' . tailwindColorToHex($color . '-200'),
]]);
}
}
$this->suggestions = $this->getSuggestions();
$this->checkTranslationAllowed();
$this->checkTranslationPossible();
}
protected function getSuggestions(): array
{
return DB::table('taggable_tags as tt')
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
->join('taggable_contexts as tc', 'tlc.context_id', '=', 'tc.id')
->join('categories as c', 'tc.category_id', '=', 'c.id')
->join('categories as croot', DB::raw('COALESCE(c.parent_id, c.id)'), '=', 'croot.id')
->where('tl.locale', app()->getLocale())
->select('tt.tag_id', 'tt.name', 'croot.color')
->distinct()
->orderBy('tt.name')
->get()
->map(function ($t) {
$color = $t->color ?? 'gray';
return [
'value' => $t->name,
'tag_id' => $t->tag_id,
'style' => '--tag-bg:' . tailwindColorToHex($color . '-400') . '; --tag-text-color:#000; --tag-hover:' . tailwindColorToHex($color . '-200'),
'title' => $t->name,
];
})
->values()
->toArray();
}
/**
* Called from Alpine when the user selects a known tag from the whitelist.
* Notifies the parent Create component.
*/
public function notifyTagSelected(int $tagId): void
{
$this->dispatch('callTagSelected', tagId: $tagId);
}
/**
* Called from Alpine when the tag is removed (input cleared).
*/
public function notifyTagCleared(): void
{
$this->dispatch('callTagCleared');
}
/**
* Called from Alpine when the user types an unknown tag name and confirms it.
*/
public function openNewTagModal(string $name): void
{
$this->newTag['name'] = $name;
$this->categoryOptions = $this->loadCategoryOptions();
$this->modalVisible = true;
$this->sessionLanguageIgnored = true;
$this->checkSessionLanguage();
}
protected function loadCategoryOptions(): array
{
return Category::where('type', Tag::class)
->get()
->map(function ($category) {
return [
'category_id' => $category->id,
'name' => ucfirst($category->translation->name ?? ''),
'description' => $category->relatedPathExSelfTranslation ?? '',
'color' => $category->relatedColor ?? 'gray',
];
})
->sortBy('name')
->values()
->toArray();
}
public function checkTranslationAllowed(): void
{
$allowTranslations = timebank_config('tags.allow_tag_translations_for_non_admins', false);
$profileType = getActiveProfileType();
if (!$allowTranslations) {
$this->translationAllowed = ($profileType === 'admin');
} else {
$this->translationAllowed = true;
}
}
public function checkTranslationPossible(): void
{
$profile = getActiveProfile();
if (!$profile || !method_exists($profile, 'languages')) {
$this->translationPossible = false;
return;
}
$countNonBaseLanguages = $profile->languages()->where('lang_code', '!=', timebank_config('base_language'))->count();
if ($countNonBaseLanguages === 0 && app()->getLocale() === timebank_config('base_language')) {
$this->translationPossible = false;
}
}
protected function getLanguageDetector()
{
if (!$this->langDetector) {
$this->langDetector = new \Text_LanguageDetect();
$this->langDetector->setNameMode(2);
}
return $this->langDetector;
}
public function checkSessionLanguage(): void
{
$this->getLanguageDetector();
$detectedLanguage = $this->langDetector->detectSimple($this->newTag['name'] ?? '');
if ($detectedLanguage === session('locale')) {
$this->sessionLanguageOk = true;
$this->sessionLanguageIgnored = false;
} else {
$this->sessionLanguageOk = false;
}
$this->validateOnly('newTag.name');
}
public function checkTransLanguage(): void
{
$this->getLanguageDetector();
$detectedLanguage = $this->langDetector->detectSimple($this->inputTagTranslation['name'] ?? '');
if ($detectedLanguage === $this->selectTranslationLanguage) {
$this->transLanguageOk = true;
$this->transLanguageIgnored = false;
} else {
$this->transLanguageOk = false;
}
$this->validateOnly('inputTagTranslation.name');
}
public function updatedNewTagCategory(): void
{
$this->categoryColor = collect($this->categoryOptions)
->firstWhere('category_id', $this->newTagCategory)['color'] ?? 'gray';
$this->selectTagTranslation = null;
$this->translationOptions = $this->getTranslationOptions($this->selectTranslationLanguage);
$this->resetErrorBag('inputTagTranslationCategory');
}
public function updatedSessionLanguageIgnored(): void
{
if (!$this->sessionLanguageIgnored) {
$this->checkSessionLanguage();
}
$this->validateOnly('newTag.name');
}
public function updatedTransLanguageIgnored(): void
{
if (!$this->transLanguageIgnored) {
$this->checkTransLanguage();
} else {
$this->resetErrorBag('inputTagTranslation.name');
}
}
public function updatedSelectTranslationLanguage(): void
{
$this->selectTagTranslation = null;
$this->translationOptions = $this->getTranslationOptions($this->selectTranslationLanguage);
}
public function updatedTranslationVisible(): void
{
if ($this->translationVisible && $this->translationAllowed) {
$this->updatedNewTagCategory();
$profile = getActiveProfile();
if (!$profile || !method_exists($profile, 'languages')) {
return;
}
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
$this->translationLanguages = $profile
->languages()
->wherePivot('competence', 1)
->where('lang_code', '!=', app()->getLocale())
->get()
->map(function ($language) {
$language->name = trans($language->name);
return $language;
})
->toArray();
if (!collect($this->translationLanguages)->contains('lang_code', 'en')) {
$transLanguage = Language::where('lang_code', timebank_config('base_language'))->first();
if ($transLanguage) {
$transLanguage->name = trans($transLanguage->name);
$this->translationLanguages = collect($this->translationLanguages)->push($transLanguage)->toArray();
}
if (app()->getLocale() != timebank_config('base_language')) {
$this->selectTranslationLanguage = timebank_config('base_language');
}
}
}
}
public function updatedTranslateRadioButton(): void
{
if ($this->translateRadioButton === 'select') {
$this->inputDisabled = true;
$this->dispatch('disableInput');
} elseif ($this->translateRadioButton === 'input') {
$this->inputDisabled = false;
}
$this->resetErrorBag('selectTagTranslation');
$this->resetErrorBag('inputTagTranslation.name');
$this->resetErrorBag('newTagCategory');
}
public function updatedSelectTagTranslation(): void
{
if ($this->selectTagTranslation) {
$this->categoryColor = Tag::find($this->selectTagTranslation)->categories->first()->relatedColor ?? 'gray';
$this->translateRadioButton = 'select';
$this->dispatch('disableInput');
}
}
public function updatedInputTagTranslation(): void
{
$this->translateRadioButton = 'input';
$this->inputDisabled = false;
$this->checkTransLanguage();
}
public function getTranslationOptions(?string $locale): array
{
if (!$locale) {
return [];
}
$appLocale = app()->getLocale();
$contextIdsInAppLocale = DB::table('taggable_locale_context')
->whereIn('tag_id', function ($query) use ($appLocale) {
$query->select('taggable_tag_id')
->from('taggable_locales')
->where('locale', $appLocale);
})
->pluck('context_id');
$tags = Tag::with(['locale', 'contexts.category'])
->whereHas('locale', function ($query) use ($locale) {
$query->where('locale', $locale);
})
->whereNotIn('tag_id', function ($subquery) use ($contextIdsInAppLocale) {
$subquery->select('tag_id')
->from('taggable_locale_context')
->whereIn('context_id', $contextIdsInAppLocale);
})
->get();
return $tags->map(function ($tag) use ($locale) {
$category = optional($tag->contexts->first())->category;
$description = optional(optional($category)->translation)->name ?? '';
return [
'tag_id' => $tag->tag_id,
'name' => $locale == 'de' ? $tag->name : StringHelper::DutchTitleCase($tag->normalized),
'description' => $description,
];
})->sortBy('name')->values()->toArray();
}
protected function createTagValidationRules(): array
{
return [
'newTag.name' => array_merge(
timebank_config('tags.name_rule'),
timebank_config('tags.exists_in_current_locale_rule', []),
[
'sometimes',
function ($attribute, $value, $fail) {
if (!$this->sessionLanguageOk && !$this->sessionLanguageIgnored) {
$locale = app()->getLocale();
$localeName = \Locale::getDisplayName($locale, $locale);
$fail(__('Is this :locale? Please confirm here below', ['locale' => $localeName]));
}
},
]
),
'newTagCategory' => 'required|int',
'selectTagTranslation' => ($this->translationVisible && $this->translateRadioButton == 'select')
? 'required|int'
: 'nullable',
'inputTagTranslation.name' => ($this->translationVisible && $this->translateRadioButton === 'input')
? array_merge(
timebank_config('tags.name_rule'),
timebank_config('tags.exists_in_current_locale_rule', []),
[
'sometimes',
function ($attribute, $value, $fail) {
if (!$this->transLanguageOk && !$this->transLanguageIgnored) {
$baseLocale = $this->selectTranslationLanguage;
$locale = \Locale::getDisplayName($baseLocale, $baseLocale);
$fail(__('Is this :locale? Please confirm here below', ['locale' => $locale]));
}
},
function ($attribute, $value, $fail) {
$existsInTransLationLanguage = DB::table('taggable_tags')
->join('taggable_locales', 'taggable_tags.tag_id', '=', 'taggable_locales.taggable_tag_id')
->where('taggable_locales.locale', $this->selectTranslationLanguage)
->where(function ($query) use ($value) {
$query->where('taggable_tags.name', $value)
->orWhere('taggable_tags.normalized', $value);
})
->exists();
if ($existsInTransLationLanguage) {
$fail(__('This tag already exists.'));
}
},
]
)
: 'nullable',
];
}
public function createTag(): void
{
$this->validate($this->createTagValidationRules());
$this->resetErrorBag();
$name = app()->getLocale() == 'de'
? trim($this->newTag['name'])
: StringHelper::DutchTitleCase(trim($this->newTag['name']));
$normalized = call_user_func(config('taggable.normalizer'), $name);
$existing = Tag::whereHas('locale', fn ($q) => $q->where('locale', app()->getLocale()))
->where('name', $name)
->first();
if ($existing) {
$tag = $existing;
} else {
$tag = Tag::create(['name' => $name, 'normalized' => $normalized]);
$tag->locale()->create(['locale' => app()->getLocale()]);
}
$context = [
'category_id' => $this->newTagCategory,
'updated_by_user' => Auth::guard('web')->id(),
];
if ($this->translationVisible) {
if ($this->translateRadioButton === 'select') {
$tagContext = Tag::find($this->selectTagTranslation)->contexts()->first();
$tag->contexts()->attach($tagContext->id);
} elseif ($this->translateRadioButton === 'input') {
$tagContext = $tag->contexts()->create($context);
$this->inputTagTranslation['name'] = $this->selectTranslationLanguage == 'de'
? $this->inputTagTranslation['name']
: StringHelper::DutchTitleCase($this->inputTagTranslation['name']);
$nameTranslation = $this->inputTagTranslation['name'];
$normalizedTranslation = call_user_func(config('taggable.normalizer'), $nameTranslation);
$tagTranslation = Tag::create([
'name' => $nameTranslation,
'normalized' => $normalizedTranslation,
]);
$tagTranslation->locale()->create(['locale' => $this->selectTranslationLanguage]);
$tagTranslation->contexts()->attach($tagContext->id);
}
} else {
if (!$tag->contexts()->where('category_id', $this->newTagCategory)->exists()) {
$tag->contexts()->create($context);
}
}
$color = collect($this->categoryOptions)->firstWhere('category_id', $this->newTagCategory)['color'] ?? 'gray';
$this->tagsArray = json_encode([[
'value' => $tag->translation?->name ?? $tag->name,
'tag_id' => $tag->tag_id,
'title' => $name,
'style' => '--tag-bg:' . tailwindColorToHex($color . '-400') . '; --tag-text-color:#000; --tag-hover:' . tailwindColorToHex($color . '-200'),
]]);
$this->modalVisible = false;
$this->newTag = ['name' => ''];
$this->newTagCategory = null;
$this->categoryColor = 'gray';
$this->translationVisible = false;
$this->translateRadioButton = null;
$this->selectTagTranslation = null;
$this->inputTagTranslation = [];
$this->sessionLanguageOk = false;
$this->sessionLanguageIgnored = false;
$this->transLanguageOk = false;
$this->transLanguageIgnored = false;
$this->resetErrorBag(['newTag.name', 'newTagCategory']);
// Reload Tagify badge in this component's input
$this->dispatch('callTagifyReload', tagsArray: $this->tagsArray);
// Notify parent (Create or Edit) of the selected tag
$this->dispatch('callTagSelected', tagId: $tag->tag_id);
SendEmailNewTag::dispatch($tag->tag_id);
}
public function cancelCreateTag(): void
{
$this->resetErrorBag();
$this->newTag = ['name' => ''];
$this->newTagCategory = null;
$this->categoryColor = 'gray';
$this->modalVisible = false;
$this->translationVisible = false;
$this->translateRadioButton = null;
$this->selectTagTranslation = null;
$this->inputTagTranslation = [];
$this->sessionLanguageOk = false;
$this->sessionLanguageIgnored = false;
$this->transLanguageOk = false;
$this->transLanguageIgnored = false;
$this->dispatch('removeLastCallTag');
}
public function render()
{
return view('livewire.calls.call-skill-input');
}
}
+304
View File
@@ -0,0 +1,304 @@
<?php
namespace App\Http\Livewire\Calls;
use App\Models\Account;
use App\Models\Call;
use App\Models\CallTranslation;
use App\Models\Locations\Location;
use App\Models\Tag;
use App\Services\CallCreditService;
use App\Traits\AccountInfoTrait;
use Illuminate\Support\Facades\App;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
class Create extends Component
{
use WireUiActions, AccountInfoTrait;
public string $content = '';
public ?string $till = null;
public ?int $tagId = null;
public bool $isPublic = false;
// Additional-language descriptions, keyed by locale (only locales the selected Tag is translated into)
public array $translationContents = [];
public array $availableTagLocales = [];
public string $newTranslationLocale = '';
// Location fields
public $country = null;
public $division = null;
public $city = null;
public $district = null;
public bool $showModal = false;
public bool $showNoCreditsModal = false;
protected $listeners = [
'countryToParent',
'divisionToParent',
'cityToParent',
'districtToParent',
'callTagSelected',
'callTagCleared',
];
protected function messages(): array
{
return [
'tagId.required' => __('Skill tag is required.'),
'till.required' => __('Expire date is required.'),
'till.date' => __('Expire date must be a valid date.'),
'till.after' => __('Expire date must be in the future.'),
'till.before_or_equal'=> __('Expire date exceeds the maximum allowed period.'),
];
}
public function getTillMaxDays(): ?int
{
$activeProfileType = session('activeProfileType');
if ($activeProfileType && $activeProfileType !== \App\Models\User::class) {
return timebank_config('calls.till_max_days_non_user');
}
return timebank_config('calls.till_max_days');
}
protected function rules(): array
{
$tillMaxDays = $this->getTillMaxDays();
$tillRule = 'required|date|after:today';
if ($tillMaxDays !== null) {
$tillRule .= '|before_or_equal:' . now()->addDays($tillMaxDays)->format('Y-m-d');
}
return [
'content' => ['required', 'string', 'max:' . timebank_config('calls.content_max_input', 200)],
'till' => $tillRule,
'tagId' => 'required|integer|exists:taggable_tags,tag_id',
'country' => 'required|integer|exists:countries,id',
'city' => 'nullable|integer|exists:cities,id',
'district'=> 'nullable|integer|exists:districts,id',
'translationContents.*' => ['nullable', 'string', 'max:' . timebank_config('calls.content_max_input', 200)],
];
}
/**
* Recompute the locales the selected Tag has translations for (excluding the base locale),
* and drop any already-added translation whose locale is no longer available.
*/
public function refreshAvailableTagLocales(): void
{
if (!$this->tagId) {
$this->availableTagLocales = [];
$this->translationContents = [];
return;
}
$tag = Tag::find($this->tagId);
$this->availableTagLocales = $tag
? $tag->translations()->pluck('locale')->unique()->reject(fn($locale) => $locale === App::getLocale())->values()->all()
: [];
$this->translationContents = collect($this->translationContents)
->only($this->availableTagLocales)
->all();
}
public function getRemainingTagLocalesProperty(): array
{
return collect($this->availableTagLocales)
->reject(fn($locale) => array_key_exists($locale, $this->translationContents))
->values()
->all();
}
public function addTranslation(): void
{
if (
$this->newTranslationLocale
&& in_array($this->newTranslationLocale, $this->availableTagLocales)
&& !array_key_exists($this->newTranslationLocale, $this->translationContents)
) {
$this->translationContents[$this->newTranslationLocale] = '';
}
$this->newTranslationLocale = '';
}
public function removeTranslation(string $locale): void
{
unset($this->translationContents[$locale]);
}
public function openModal(): void
{
$activeProfileType = session('activeProfileType');
if (!$activeProfileType || !in_array($activeProfileType, [
\App\Models\User::class,
\App\Models\Organization::class,
\App\Models\Bank::class,
])) {
abort(403, __('Only platform profiles (User, Organization, Bank) can create Calls.'));
}
$activeProfileId = session('activeProfileId');
if (!CallCreditService::profileHasCredits($activeProfileType, (int) $activeProfileId)) {
$this->showNoCreditsModal = true;
return;
}
$this->reset(['content', 'till', 'tagId', 'country', 'division', 'city', 'district', 'isPublic', 'translationContents', 'availableTagLocales', 'newTranslationLocale']);
$this->resetValidation();
// Pre-fill expiry date from platform config default
$defaultExpiryDays = timebank_config('calls.default_expiry_days');
if ($defaultExpiryDays) {
$this->till = now()->addDays($defaultExpiryDays)->format('Y-m-d');
}
// Pre-fill location from the callable profile's primary location
$profile = $activeProfileType::find(session('activeProfileId'));
$location = $profile?->locations()->with(['country', 'division', 'city', 'district'])->first();
if ($location) {
$this->country = $location->country_id;
$this->division = $location->division_id;
$this->city = $location->city_id;
$this->district = $location->district_id;
}
$this->showModal = true;
}
/**
* Called by the CallSkillInput child component when a tag is selected or created.
*/
public function callTagSelected(int $tagId): void
{
$this->tagId = $tagId;
$this->resetValidation('tagId');
$this->refreshAvailableTagLocales();
}
/**
* Called by the CallSkillInput child component when the tag input is cleared.
*/
public function callTagCleared(): void
{
$this->tagId = null;
$this->refreshAvailableTagLocales();
}
public function countryToParent($value): void { $this->country = $value ?: null; }
public function divisionToParent($value): void { $this->division = $value ?: null; }
public function cityToParent($value): void { $this->city = $value ?: null; }
public function districtToParent($value): void { $this->district = $value ?: null; }
public function save(): void
{
$activeProfileType = session('activeProfileType');
if (!$activeProfileType || !in_array($activeProfileType, [
\App\Models\User::class,
\App\Models\Organization::class,
\App\Models\Bank::class,
])) {
abort(403);
}
$this->validate();
// Resolve or create a standalone Location record
$locationId = null;
if ($this->country || $this->city) {
$attributes = array_filter([
'country_id' => $this->country ?: null,
'division_id' => $this->division ?: null,
'city_id' => $this->city ?: null,
'district_id' => $this->district ?: null,
]);
$location = Location::whereNull('locatable_id')
->whereNull('locatable_type')
->where($attributes)
->first();
if (!$location) {
$location = new Location($attributes);
$location->save();
}
$locationId = $location->id;
}
$call = Call::create([
'callable_id' => session('activeProfileId'),
'callable_type' => $activeProfileType,
'tag_id' => $this->tagId ?: null,
'location_id' => $locationId,
'from' => now()->utc(),
'till' => $this->till ?: null,
'is_public' => $this->isPublic,
]);
CallTranslation::create([
'call_id' => $call->id,
'locale' => App::getLocale(),
'content' => $this->content ?: null,
]);
foreach ($this->translationContents as $locale => $translatedContent) {
if ($translatedContent !== '' && $translatedContent !== null) {
CallTranslation::create([
'call_id' => $call->id,
'locale' => $locale,
'content' => $translatedContent,
]);
}
}
$call->searchable();
$this->showModal = false;
$this->reset(['content', 'till', 'tagId', 'country', 'division', 'city', 'district', 'isPublic', 'translationContents', 'availableTagLocales', 'newTranslationLocale']);
$this->dispatch('callSaved');
$this->notification()->success(
title: __('Saved'),
description: __('Your Call has been published.')
);
}
public function render()
{
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
$profileName = $activeProfileType
? ($activeProfileType::find($activeProfileId)?->name ?? '')
: '';
$canCreate = $activeProfileType && $activeProfileId
? CallCreditService::profileHasCredits($activeProfileType, (int) $activeProfileId)
: false;
// Calculate total spendable balance across all accounts for the active profile
$spendableBalance = null;
if ($activeProfileType && $activeProfileId) {
$profile = $activeProfileType::find($activeProfileId);
if ($profile) {
$total = 0;
foreach ($profile->accounts()->notRemoved()->get() as $account) {
$total += $this->getBalance($account->id) - $account->limit_min;
}
$spendableBalance = $total;
}
}
return view('livewire.calls.create', [
'profileName' => $profileName,
'canCreate' => $canCreate,
'spendableBalance' => $spendableBalance,
]);
}
}
+285
View File
@@ -0,0 +1,285 @@
<?php
namespace App\Http\Livewire\Calls;
use App\Models\Call;
use App\Models\CallTranslation;
use App\Models\Locations\Location;
use App\Models\Tag;
use Illuminate\Support\Facades\App;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
class Edit extends Component
{
use WireUiActions;
public Call $call;
public string $content = '';
public ?string $till = null;
public ?int $tagId = null;
public bool $isPublic = false;
// Additional-language descriptions, keyed by locale (only locales the selected Tag is translated into)
public array $translationContents = [];
public array $availableTagLocales = [];
public string $newTranslationLocale = '';
// Location fields
public $country = null;
public $division = null;
public $city = null;
public $district = null;
public bool $showModal = false;
public bool $showDeleteConfirm = false;
public bool $compact = false;
protected $listeners = [
'countryToParent',
'divisionToParent',
'cityToParent',
'districtToParent',
'callTagSelected',
'callTagCleared',
];
protected function messages(): array
{
return [
'tagId.required' => __('Skill tag is required.'),
'till.required' => __('Expire date is required.'),
'till.date' => __('Expire date must be a valid date.'),
'till.after' => __('Expire date must be in the future.'),
'till.before_or_equal'=> __('Expire date exceeds the maximum allowed period.'),
];
}
public function getTillMaxDays(): ?int
{
$callableType = $this->call->callable_type ?? session('activeProfileType');
if ($callableType && $callableType !== \App\Models\User::class) {
return timebank_config('calls.till_max_days_non_user');
}
return timebank_config('calls.till_max_days');
}
protected function rules(): array
{
$tillMaxDays = $this->getTillMaxDays();
$tillRule = 'required|date|after:today';
if ($tillMaxDays !== null) {
$tillRule .= '|before_or_equal:' . now()->addDays($tillMaxDays)->format('Y-m-d');
}
return [
'content' => ['required', 'string', 'max:' . timebank_config('calls.content_max_input', 200)],
'till' => $tillRule,
'tagId' => 'required|integer|exists:taggable_tags,tag_id',
'country' => 'required|integer|exists:countries,id',
'city' => 'nullable|integer|exists:cities,id',
'district'=> 'nullable|integer|exists:districts,id',
'translationContents.*' => ['nullable', 'string', 'max:' . timebank_config('calls.content_max_input', 200)],
];
}
/**
* Recompute the locales the selected Tag has translations for (excluding the base locale),
* and drop any already-added translation whose locale is no longer available.
*/
public function refreshAvailableTagLocales(): void
{
if (!$this->tagId) {
$this->availableTagLocales = [];
$this->translationContents = [];
return;
}
$tag = Tag::find($this->tagId);
$this->availableTagLocales = $tag
? $tag->translations()->pluck('locale')->unique()->reject(fn($locale) => $locale === App::getLocale())->values()->all()
: [];
$this->translationContents = collect($this->translationContents)
->only($this->availableTagLocales)
->all();
}
public function getRemainingTagLocalesProperty(): array
{
return collect($this->availableTagLocales)
->reject(fn($locale) => array_key_exists($locale, $this->translationContents))
->values()
->all();
}
public function addTranslation(): void
{
if (
$this->newTranslationLocale
&& in_array($this->newTranslationLocale, $this->availableTagLocales)
&& !array_key_exists($this->newTranslationLocale, $this->translationContents)
) {
$this->translationContents[$this->newTranslationLocale] = '';
}
$this->newTranslationLocale = '';
}
public function removeTranslation(string $locale): void
{
unset($this->translationContents[$locale]);
}
public function openModal(): void
{
$this->resetValidation();
// Pre-fill from existing call
$this->tagId = $this->call->tag_id;
$this->content = $this->call->translations->where('locale', App::getLocale())->first()?->content
?? $this->call->translations->first()?->content
?? '';
$this->till = $this->call->till?->format('Y-m-d');
$this->isPublic = (bool) $this->call->is_public;
$location = $this->call->location;
$this->country = $location?->country_id;
$this->division = $location?->division_id;
$this->city = $location?->city_id;
$this->district = $location?->district_id;
$this->refreshAvailableTagLocales();
// Pre-fill any existing translations for locales the tag currently supports
$this->translationContents = $this->call->translations
->whereIn('locale', $this->availableTagLocales)
->pluck('content', 'locale')
->map(fn($content) => $content ?? '')
->all();
$this->showModal = true;
}
public function callTagSelected(int $tagId): void
{
$this->tagId = $tagId;
$this->resetValidation('tagId');
$this->refreshAvailableTagLocales();
}
public function callTagCleared(): void
{
$this->tagId = null;
$this->refreshAvailableTagLocales();
}
public function countryToParent($value): void { $this->country = $value ?: null; }
public function divisionToParent($value): void { $this->division = $value ?: null; }
public function cityToParent($value): void { $this->city = $value ?: null; }
public function districtToParent($value): void { $this->district = $value ?: null; }
public function confirmDelete(): void
{
$this->showDeleteConfirm = true;
}
public function delete(): void
{
$activeProfile = getActiveProfile();
if (!$activeProfile ||
get_class($activeProfile) !== $this->call->callable_type ||
$activeProfile->id !== $this->call->callable_id) {
abort(403);
}
$this->call->unsearchable();
$this->call->translations()->delete();
$this->call->delete();
$this->redirect(request()->header('Referer') ?: route(\Illuminate\Support\Facades\Auth::check() ? 'main' : 'welcome'));
}
public function save(): void
{
// Only the callable owner may edit
$activeProfile = getActiveProfile();
if (!$activeProfile ||
get_class($activeProfile) !== $this->call->callable_type ||
$activeProfile->id !== $this->call->callable_id) {
abort(403);
}
$this->validate();
// Resolve or create a standalone Location record
$locationId = null;
if ($this->country || $this->city) {
$attributes = array_filter([
'country_id' => $this->country ?: null,
'division_id' => $this->division ?: null,
'city_id' => $this->city ?: null,
'district_id' => $this->district ?: null,
]);
$location = Location::whereNull('locatable_id')
->whereNull('locatable_type')
->where($attributes)
->first();
if (!$location) {
$location = new Location($attributes);
$location->save();
}
$locationId = $location->id;
}
$this->call->update([
'tag_id' => $this->tagId,
'location_id' => $locationId,
'till' => $this->till ?: null,
'is_public' => $this->isPublic,
]);
// Update or create translation for current locale
CallTranslation::updateOrCreate(
['call_id' => $this->call->id, 'locale' => App::getLocale()],
['content' => $this->content ?: null]
);
foreach ($this->translationContents as $locale => $translatedContent) {
if ($translatedContent !== '' && $translatedContent !== null) {
CallTranslation::updateOrCreate(
['call_id' => $this->call->id, 'locale' => $locale],
['content' => $translatedContent]
);
} else {
CallTranslation::where('call_id', $this->call->id)->where('locale', $locale)->delete();
}
}
// Remove translations for locales that were removed from the form (but keep the base locale)
CallTranslation::where('call_id', $this->call->id)
->where('locale', '!=', App::getLocale())
->whereNotIn('locale', array_keys($this->translationContents))
->delete();
$this->call->searchable();
$this->showModal = false;
$this->notification()->success(
title: __('Saved'),
description: __('Your Call has been updated.')
);
$this->redirect(request()->header('Referer') ?: route('call.show', ['id' => $this->call->id]));
}
public function render()
{
return view('livewire.calls.edit', [
'profileName' => $this->call->callable?->name ?? '',
]);
}
}
+800
View File
@@ -0,0 +1,800 @@
<?php
namespace App\Http\Livewire\Calls;
use App\Helpers\ProfileAuthorizationHelper;
use App\Http\Livewire\Traits\RequiresAdminAuthorization;
use App\Mail\CallBlockedMail;
use App\Models\Call;
use App\Models\CallTranslation;
use App\Models\Locations\Location;
use App\Models\Tag;
use App\Services\CallCreditService;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithPagination;
use WireUi\Traits\WireUiActions;
class Manage extends Component
{
use WithPagination;
use WireUiActions;
use RequiresAdminAuthorization;
public string $search = '';
public ?string $statusFilter = ''; // 'active', 'expired', 'deleted'
public ?string $callableFilter = ''; // 'user', 'organization', 'bank'
public ?string $localeFilter = ''; // 'en', 'nl', 'de', etc.
public array $bulkSelected = [];
public bool $selectAll = false;
public int $perPage = 10;
public string $sortField = 'id';
public string $sortDirection = 'desc';
// Locked: must not be client-settable — escalating this to true would grant admin privileges
#[Locked] public bool $isAdminView = false;
// Inline edit state
public ?int $editCallId = null;
public string $editContent = '';
public ?string $editTill = null;
public ?int $editTagId = null;
public bool $editIsPublic = false;
public $editCountry = null;
public $editDivision = null;
public $editCity = null;
public $editDistrict = null;
// Additional-language descriptions for inline edit, keyed by locale
public array $editTranslationContents = [];
public array $editAvailableTagLocales = [];
public string $editNewTranslationLocale = '';
public bool $showEditModal = false;
public bool $showDeleteConfirm = false;
// Admin pause/publish confirmation
public bool $showAdminActionConfirm = false;
public ?int $adminActionCallId = null;
public string $adminActionType = ''; // 'pause' or 'publish'
public string $adminActionCallableName = '';
// Admin bulk delete confirmation
public bool $showAdminDeleteConfirm = false;
public string $adminDeleteCallableNames = '';
// Bulk print modal
public bool $showBulkPrintModal = false;
public int $bulkPrintPerPage = 1;
public string $bulkPrintColor = 'bw';
// Non-admin bulk delete confirmation
public bool $showDeleteConfirmModal = false;
// Per-row delete confirmation
public bool $showDeleteRowConfirm = false;
// Per-row restore confirmation
public bool $showRestoreRowConfirm = false;
protected $listeners = [
'countryToParent',
'divisionToParent',
'cityToParent',
'districtToParent',
'callTagSelected',
'callTagCleared',
'callSaved' => '$refresh',
];
protected $queryString = [
'search' => ['except' => ''],
'statusFilter' => ['except' => ''],
'callableFilter' => ['except' => ''],
'localeFilter' => ['except' => ''],
'perPage' => ['except' => 10],
'sortField' => ['except' => 'id'],
'sortDirection' => ['except' => 'desc'],
];
public function mount(): void
{
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
if (!$activeProfileType || !$activeProfileId) {
abort(403);
}
$profile = $activeProfileType::find($activeProfileId);
if (!$profile) {
abort(403);
}
ProfileAuthorizationHelper::authorize($profile);
// Admin and Central Bank get full view of all calls
if ($profile instanceof \App\Models\Admin) {
$this->isAdminView = true;
} elseif ($profile instanceof \App\Models\Bank && $profile->level === 0) {
$this->isAdminView = true;
} elseif ($profile instanceof \App\Models\User
|| $profile instanceof \App\Models\Organization
|| $profile instanceof \App\Models\Bank) {
$this->isAdminView = false;
} else {
abort(403);
}
}
// Listeners for location dropdown child component
public function countryToParent($value): void { $this->editCountry = $value ?: null; }
public function divisionToParent($value): void { $this->editDivision = $value ?: null; }
public function cityToParent($value): void { $this->editCity = $value ?: null; }
public function districtToParent($value): void { $this->editDistrict = $value ?: null; }
public function callTagSelected(int $tagId): void
{
$this->editTagId = $tagId;
$this->resetValidation('editTagId');
$this->refreshEditAvailableTagLocales();
}
public function callTagCleared(): void
{
$this->editTagId = null;
$this->refreshEditAvailableTagLocales();
}
/**
* Recompute the locales the selected Tag has translations for (excluding the base locale),
* and drop any already-added translation whose locale is no longer available.
*/
public function refreshEditAvailableTagLocales(): void
{
if (!$this->editTagId) {
$this->editAvailableTagLocales = [];
$this->editTranslationContents = [];
return;
}
$tag = Tag::find($this->editTagId);
$this->editAvailableTagLocales = $tag
? $tag->translations()->pluck('locale')->unique()->reject(fn($locale) => $locale === App::getLocale())->values()->all()
: [];
$this->editTranslationContents = collect($this->editTranslationContents)
->only($this->editAvailableTagLocales)
->all();
}
public function getEditRemainingTagLocalesProperty(): array
{
return collect($this->editAvailableTagLocales)
->reject(fn($locale) => array_key_exists($locale, $this->editTranslationContents))
->values()
->all();
}
public function addEditTranslation(): void
{
if (
$this->editNewTranslationLocale
&& in_array($this->editNewTranslationLocale, $this->editAvailableTagLocales)
&& !array_key_exists($this->editNewTranslationLocale, $this->editTranslationContents)
) {
$this->editTranslationContents[$this->editNewTranslationLocale] = '';
}
$this->editNewTranslationLocale = '';
}
public function removeEditTranslation(string $locale): void
{
unset($this->editTranslationContents[$locale]);
}
public function updatedShowEditModal(bool $value): void
{
if (!$value) {
$this->dispatch('edit-done');
}
}
public function openEdit(int $id): void
{
$call = $this->findCall($id);
$this->editCallId = $call->id;
$this->editTagId = $call->tag_id;
$this->editContent = $call->translations->firstWhere('locale', App::getLocale())?->content
?? $call->translations->first()?->content
?? '';
$this->editTill = $call->till?->format('Y-m-d');
$this->editIsPublic = (bool) $call->is_public;
$location = $call->location;
$this->editCountry = $location?->country_id;
$this->editDivision = $location?->division_id;
$this->editCity = $location?->city_id;
$this->editDistrict = $location?->district_id;
$this->refreshEditAvailableTagLocales();
// Pre-fill any existing translations for locales the tag currently supports
$this->editTranslationContents = $call->translations
->whereIn('locale', $this->editAvailableTagLocales)
->pluck('content', 'locale')
->map(fn($content) => $content ?? '')
->all();
$this->resetValidation();
$this->showEditModal = true;
$callableType = $call->callable_type ?? session('activeProfileType');
$tillMaxDays = ($callableType && $callableType !== \App\Models\User::class)
? timebank_config('calls.till_max_days_non_user')
: timebank_config('calls.till_max_days');
$this->dispatch('manage-edit-opened', [
'value' => $this->editTill,
'minDate' => now()->addDay()->format('Y/m/d'),
'maxDate' => $tillMaxDays !== null ? now()->addDays($tillMaxDays)->format('Y/m/d') : null,
]);
}
protected function messages(): array
{
return [
'editTagId.required' => __('Skill tag is required.'),
];
}
protected function editRules(): array
{
$call = $this->editCallId ? Call::find($this->editCallId) : null;
$callableType = $call?->callable_type ?? session('activeProfileType');
$tillMaxDays = ($callableType && $callableType !== \App\Models\User::class)
? timebank_config('calls.till_max_days_non_user')
: timebank_config('calls.till_max_days');
$tillRule = 'required|date|after:today';
if ($tillMaxDays !== null) {
$tillRule .= '|before_or_equal:' . now()->addDays($tillMaxDays)->format('Y-m-d');
}
return [
'editContent' => ['required', 'string', 'max:' . timebank_config('calls.content_max_input', 200)],
'editTill' => $tillRule,
'editTagId' => 'required|integer|exists:taggable_tags,tag_id',
'editCountry' => 'required|integer|exists:countries,id',
'editCity' => 'nullable|integer|exists:cities,id',
'editDistrict'=> 'nullable|integer|exists:districts,id',
'editTranslationContents.*' => ['nullable', 'string', 'max:' . timebank_config('calls.content_max_input', 200)],
];
}
public function saveEdit(): void
{
$this->authorizeWrite();
$this->authorizePermission('update calls');
$this->validate($this->editRules());
$call = $this->findCall($this->editCallId);
$locationId = null;
if ($this->editCountry || $this->editCity) {
$attributes = array_filter([
'country_id' => $this->editCountry ?: null,
'division_id' => $this->editDivision ?: null,
'city_id' => $this->editCity ?: null,
'district_id' => $this->editDistrict ?: null,
]);
$location = Location::whereNull('locatable_id')
->whereNull('locatable_type')
->where($attributes)
->first();
if (!$location) {
$location = new Location($attributes);
$location->save();
}
$locationId = $location->id;
}
$call->update([
'tag_id' => $this->editTagId,
'location_id' => $locationId,
'till' => $this->editTill ?: null,
'is_public' => $this->editIsPublic,
]);
// Hard-delete all existing translations for this locale before creating a fresh one.
// Soft-delete would leave ghost records visible via withTrashed() in the table.
CallTranslation::withTrashed()
->where('call_id', $call->id)
->where('locale', App::getLocale())
->forceDelete();
CallTranslation::create([
'call_id' => $call->id,
'locale' => App::getLocale(),
'content' => $this->editContent ?: null,
]);
foreach ($this->editTranslationContents as $locale => $translatedContent) {
CallTranslation::withTrashed()
->where('call_id', $call->id)
->where('locale', $locale)
->forceDelete();
if ($translatedContent !== '' && $translatedContent !== null) {
CallTranslation::create([
'call_id' => $call->id,
'locale' => $locale,
'content' => $translatedContent,
]);
}
}
// Remove translations for locales that were removed from the form (but keep the base locale)
CallTranslation::withTrashed()
->where('call_id', $call->id)
->where('locale', '!=', App::getLocale())
->whereNotIn('locale', array_keys($this->editTranslationContents))
->forceDelete();
$call->searchable();
$this->showEditModal = false;
$this->editCallId = null;
$this->dispatch('edit-done');
$this->notification()->success(title: __('Saved'), description: __('Your Call has been updated.'));
}
public function confirmDelete(): void
{
$this->showDeleteConfirm = true;
}
public function confirmDeleteRow(int $id): void
{
$this->findCall($id); // authorization check
$this->editCallId = $id;
$this->showDeleteRowConfirm = true;
}
public function deleteRowCall(): void
{
$this->authorizePermission('delete calls');
$call = $this->findCall($this->editCallId);
$call->unsearchable();
$call->translations()->delete();
$call->delete();
$this->showDeleteRowConfirm = false;
$this->editCallId = null;
$this->notification()->success(title: __('Deleted'), description: __('The call has been deleted.'));
}
public function confirmRestoreRow(int $id): void
{
// Authorization: admin sees all trashed, non-admin scoped to own
if ($this->isAdminView) {
Call::onlyTrashed()->findOrFail($id);
} else {
Call::onlyTrashed()
->where('callable_type', session('activeProfileType'))
->where('callable_id', session('activeProfileId'))
->findOrFail($id);
}
$this->editCallId = $id;
$this->showRestoreRowConfirm = true;
}
public function restoreRowCall(): void
{
$this->authorizePermission('update calls');
if ($this->isAdminView) {
$call = Call::onlyTrashed()->findOrFail($this->editCallId);
} else {
$call = Call::onlyTrashed()
->where('callable_type', session('activeProfileType'))
->where('callable_id', session('activeProfileId'))
->findOrFail($this->editCallId);
}
$call->translations()->withTrashed()->restore();
$call->restore();
$call->update(['is_paused' => true]);
$call->unsearchable();
$this->showRestoreRowConfirm = false;
$this->editCallId = null;
$this->statusFilter = '';
$this->notification()->success(title: __('Restored'), description: __('The call has been restored.'));
}
public function deleteCall(): void
{
$this->authorizePermission('delete calls');
$call = $this->findCall($this->editCallId);
$call->unsearchable();
$call->translations()->delete();
$call->delete();
$this->showEditModal = false;
$this->showDeleteConfirm = false;
$this->editCallId = null;
$this->notification()->success(title: __('Deleted'), description: __('The call has been deleted.'));
}
public function sortBy(string $field): void
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'desc';
}
$this->resetPage();
}
public function updatedSearch(): void { $this->resetPage(); }
public function updatedStatusFilter(): void { $this->resetPage(); $this->bulkSelected = []; }
public function updatedCallableFilter(): void { $this->resetPage(); $this->bulkSelected = []; }
public function updatedLocaleFilter(): void { $this->resetPage(); $this->bulkSelected = []; }
public function updatedPerPage(): void { $this->resetPage(); }
public function updatedSelectAll(bool $value): void
{
if ($value) {
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
$query = Call::query();
if (!$this->isAdminView) {
$query->where('callable_type', $activeProfileType)->where('callable_id', $activeProfileId);
}
if ($this->statusFilter === 'deleted') {
$query->onlyTrashed();
}
$this->bulkSelected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
} else {
$this->bulkSelected = [];
}
}
public function confirmBulkDelete(): void
{
$this->showDeleteConfirmModal = true;
}
public function confirmBulkPrint(): void
{
$this->showBulkPrintModal = true;
}
public function executeBulkPrint(): void
{
$ids = implode(',', array_map('intval', $this->bulkSelected));
$url = route('call.pdf-bulk', [
'ids' => $ids,
'per_page' => $this->bulkPrintPerPage,
'color' => $this->bulkPrintColor,
]);
$this->showBulkPrintModal = false;
$this->dispatch('open-bulk-pdf', url: $url);
}
public function confirmAdminDelete(): void
{
$calls = Call::whereIn('id', $this->bulkSelected)
->with('callable')
->get();
$nameList = $calls->map(fn ($c) => $c->callable?->name ?? '?')->unique()->values()->all();
$names = count($nameList) > 1
? implode(', ', array_slice($nameList, 0, -1)) . ' ' . __('and') . ' ' . end($nameList)
: ($nameList[0] ?? '?');
$this->adminDeleteCallableNames = $names;
$this->showAdminDeleteConfirm = true;
}
public function deleteSelected(): void
{
$this->authorizeWrite();
$query = Call::whereIn('id', $this->bulkSelected);
if (!$this->isAdminView) {
$query->where('callable_type', session('activeProfileType'))
->where('callable_id', session('activeProfileId'));
}
$calls = $query->get();
foreach ($calls as $call) {
$call->unsearchable();
$call->translations()->delete();
$call->delete();
}
$this->bulkSelected = [];
$this->selectAll = false;
$this->showAdminDeleteConfirm = false;
$this->adminDeleteCallableNames = '';
$this->showDeleteConfirmModal = false;
$this->notification()->success(title: __('Deleted'), description: __('Selected calls have been deleted.'));
}
public function undeleteSelected(): void
{
$this->authorizeWrite();
$query = Call::onlyTrashed()->whereIn('id', $this->bulkSelected);
if (!$this->isAdminView) {
$query->where('callable_type', session('activeProfileType'))
->where('callable_id', session('activeProfileId'));
}
$calls = $query->get();
foreach ($calls as $call) {
$call->translations()->withTrashed()->restore();
$call->restore();
$call->update(['is_paused' => true]);
$call->unsearchable();
}
$this->bulkSelected = [];
$this->selectAll = false;
$this->statusFilter = '';
$this->notification()->success(title: __('Restored'), description: __('Selected calls have been restored.'));
}
public function confirmAdminAction(int $id, string $type): void
{
$call = $this->findCall($id);
$this->adminActionCallId = $id;
$this->adminActionType = $type;
$this->adminActionCallableName = $call->callable?->name ?? '?';
$this->showAdminActionConfirm = true;
$this->dispatch('admin-action-ready');
}
public function executeAdminAction(): void
{
if (!$this->adminActionCallId || !$this->adminActionType) {
return;
}
if ($this->adminActionType === 'pause') {
$this->adminPause($this->adminActionCallId);
} elseif ($this->adminActionType === 'publish') {
$this->adminPublish($this->adminActionCallId);
}
$this->showAdminActionConfirm = false;
$this->adminActionCallId = null;
$this->adminActionType = '';
$this->adminActionCallableName = '';
}
public function adminPause(int $id): void
{
if (!$this->isAdminView) abort(403);
$this->authorizePermission('unpublish calls');
$call = Call::findOrFail($id);
$call->update(['is_paused' => true]);
$call->unsearchable();
$this->dispatch('pause-publish-done');
$this->notification()->success(title: __('Paused'), description: __('The call has been paused.'));
}
public function adminPublish(int $id): void
{
if (!$this->isAdminView) abort(403);
$this->authorizePermission('publish calls');
$call = Call::findOrFail($id);
$call->update(['is_paused' => false]);
if ($call->shouldBeSearchable()) {
$call->searchable();
}
$this->dispatch('pause-publish-done');
$this->notification()->success(title: __('Published'), description: __('The call has been published.'));
}
public function pause(int $id): void
{
$this->authorizeWrite();
$this->authorizePermission('unpublish calls');
$call = $this->findCall($id);
$call->update(['is_paused' => true]);
$call->unsearchable();
$this->dispatch('pause-publish-done');
$this->notification()->success(title: __('Paused'), description: __('The call has been paused.'));
}
public function publish(int $id): void
{
$this->authorizeWrite();
$this->authorizePermission('publish calls');
$call = $this->findCall($id);
if (!CallCreditService::profileHasCredits($call->callable_type, $call->callable_id)) {
$this->notification()->error(
title: __('Cannot publish'),
description: trans_with_platform(__('You need @PLATFORM_CURRENCY_NAME_PLURAL@ to post a call.'))
);
$this->dispatch('pause-publish-done');
return;
}
// If till has expired, reset it to the max allowed duration
$updates = ['is_paused' => false];
if ($call->till === null || $call->till->isPast()) {
$updates['till'] = now()->addDays(timebank_config('calls.till_max_days', 90));
}
$call->update($updates);
$call->searchable();
$this->dispatch('pause-publish-done');
$this->notification()->success(title: __('Published'), description: __('The call has been published.'));
}
public function block(int $id): void
{
if (!$this->isAdminView) {
abort(403);
}
$this->authorizePermission('manage calls');
$call = Call::findOrFail($id);
$call->update(['is_suppressed' => true]);
$call->unsearchable();
$callable = $call->callable;
if ($callable && $callable->email) {
Mail::to($callable->email)->queue(
new CallBlockedMail(
$call->load(['tag']),
$callable,
class_basename($callable)
)
);
}
$this->notification()->error(title: __('Blocked'), description: __('The call has been blocked.'));
}
public function unblock(int $id): void
{
if (!$this->isAdminView) {
abort(403);
}
$this->authorizePermission('manage calls');
$call = Call::findOrFail($id);
$call->update(['is_suppressed' => false]);
if ($call->shouldBeSearchable()) {
$call->searchable();
}
$this->notification()->success(title: __('Unblocked'), description: __('The call has been unblocked.'));
}
private function findCall(int $id): Call
{
if ($this->isAdminView) {
return Call::findOrFail($id);
}
return Call::where('callable_type', session('activeProfileType'))
->where('callable_id', session('activeProfileId'))
->findOrFail($id);
}
private function authorizeWrite(): void
{
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
if (!$activeProfileType || !$activeProfileId) {
abort(403);
}
$profile = $activeProfileType::find($activeProfileId);
ProfileAuthorizationHelper::authorize($profile);
}
public function render()
{
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
$query = Call::with([
'callable',
'tag.contexts.category',
'location.city.translations',
'location.country.translations',
'translations' => fn($q) => $q->withTrashed(),
]);
// Scope to own calls for non-admin
if (!$this->isAdminView) {
$query->where('callable_type', $activeProfileType)
->where('callable_id', $activeProfileId);
}
// Callable type filter (admin view)
if ($this->isAdminView && $this->callableFilter) {
$map = [
'user' => \App\Models\User::class,
'organization' => \App\Models\Organization::class,
'bank' => \App\Models\Bank::class,
];
if (isset($map[$this->callableFilter])) {
$query->where('callable_type', $map[$this->callableFilter]);
}
}
// Status / deleted
if ($this->statusFilter === 'deleted') {
$query->onlyTrashed();
} elseif ($this->statusFilter === 'active') {
$query->where('is_paused', false)
->where(fn ($q) => $q->whereNull('till')->orWhere('till', '>=', now()));
} elseif ($this->statusFilter === 'expired') {
$query->where('till', '<', now());
} elseif ($this->statusFilter === 'paused') {
$query->where('is_paused', true);
}
// Search
if ($this->search) {
$search = '%' . $this->search . '%';
$query->where(function ($q) use ($search) {
$q->whereHas('translations', fn ($t) => $t->where('content', 'like', $search))
->orWhereHas('tag', fn ($t) => $t->where('name', 'like', $search))
->orWhereHas('callable', fn ($t) => $t->where('name', 'like', $search));
});
}
// Locale filter
if ($this->localeFilter) {
$query->whereHas('translations', fn ($q) => $q->where('locale', $this->localeFilter));
}
$allowedSorts = ['id', 'created_at', 'till', 'updated_at'];
if (in_array($this->sortField, $allowedSorts)) {
$query->orderBy($this->sortField, $this->sortDirection);
} else {
// Sort by first locale via subquery to avoid duplicate rows
$localeSubquery = DB::table('call_translations')
->select('call_id', DB::raw('MIN(locale) as first_locale'))
->whereNull('deleted_at')
->groupBy('call_id');
$query->leftJoinSub($localeSubquery, 'ct_locale', 'ct_locale.call_id', '=', 'calls.id')
->select('calls.*')
->orderBy('ct_locale.first_locale', $this->sortDirection)
->orderBy('calls.created_at', 'desc');
}
$calls = $query->paginate($this->perPage);
$editCall = $this->editCallId ? Call::find($this->editCallId) : null;
// For non-admin view, check if the active profile has credits
$canPublish = true;
if (!$this->isAdminView && $activeProfileType && $activeProfileId) {
$canPublish = CallCreditService::profileHasCredits($activeProfileType, (int) $activeProfileId);
}
$availableLocales = \App\Models\CallTranslation::select('locale')
->distinct()
->orderBy('locale')
->pluck('locale')
->map(fn ($locale) => ['id' => $locale, 'name' => strtoupper($locale)])
->values()
->all();
return view('livewire.calls.manage', [
'calls' => $calls,
'bulkDisabled' => empty($this->bulkSelected),
'editCall' => $editCall,
'canPublish' => $canPublish,
'availableLocales' => $availableLocales,
]);
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace App\Http\Livewire\Calls;
use App\Models\Call;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
class ProfileCalls
{
public static function getCallsForProfile($profile, bool $showPrivate = false): Collection
{
$calls = Call::with([
'tag.contexts.category.translations',
'tag.contexts.category.ancestors.translations',
'translations',
'location.city.translations',
'location.country.translations',
'callable.locations.city.translations',
'callable.locations.division.translations',
'callable.locations.country.translations',
'loveReactant.reactionCounters',
])
->where('callable_type', get_class($profile))
->where('callable_id', $profile->id)
->when(!$showPrivate, fn ($q) => $q->where('is_public', true))
->where('is_paused', false)
->where('is_suppressed', false)
->whereNull('deleted_at')
->where(fn ($q) => $q->whereNull('till')->orWhere('till', '>=', now()))
->orderBy('till')
->get();
$locale = App::getLocale();
return $calls->map(function (Call $model) use ($locale) {
$translation = $model->translations->firstWhere('locale', $locale)
?? $model->translations->first();
$tag = $model->tag;
$tagContext = $tag?->contexts->first();
$tagCategory = $tagContext?->category;
$tagColor = $tagCategory?->relatedColor ?? 'gray';
$tagName = $tag?->translation?->name ?? $tag?->name;
$locationStr = null;
if ($model->location) {
$loc = $model->location;
$parts = [];
if ($loc->city) {
$cityName = optional($loc->city->translations->first())->name;
if ($cityName) $parts[] = $cityName;
}
if ($loc->country) {
if ($loc->country->code === 'XX') {
$parts[] = __('Location not specified');
} elseif ($loc->country->code) {
$parts[] = strtoupper($loc->country->code);
}
}
$locationStr = $parts ? implode(', ', $parts) : null;
}
$tagCategories = [];
if ($tagCategory) {
$ancestors = $tagCategory->relationLoaded('ancestors')
? $tagCategory->ancestors->push($tagCategory)->sortBy('id')
: $tagCategory->ancestorsAndSelf()->get()->reverse();
foreach ($ancestors as $cat) {
$catName = $cat->translations->firstWhere('locale', $locale)?->name
?? $cat->translations->first()?->name
?? '';
if ($catName) {
$tagCategories[] = [
'id' => $cat->id,
'name' => $catName,
'color' => $cat->relatedColor ?? 'gray',
];
}
}
}
return [
'id' => $model->id,
'model' => Call::class,
'title' => $tagName ?? '',
'excerpt' => $translation?->content ?? '',
'photo' => $model->callable?->profile_photo_url ?? '',
'location' => $locationStr,
'tag_color' => $tagColor,
'tag_categories' => $tagCategories,
'callable_name' => $model->callable?->name ?? '',
'callable_location' => self::buildCallableLocation($model->callable),
'till' => $model->till,
'expiry_badge_text' => self::buildExpiryBadgeText($model->till),
'like_count' => $model->loveReactant?->reactionCounters->firstWhere('reaction_type_id', 3)?->count ?? 0,
'score' => 0,
];
});
}
public static function buildCallableLocation($callable): ?string
{
if (!$callable || !method_exists($callable, 'locations')) {
return null;
}
$firstLoc = $callable->locations->first();
if (!$firstLoc) {
return null;
}
$cCity = optional($firstLoc->city?->translations->first())->name;
$cDivision = optional($firstLoc->division?->translations->first())->name;
$cCountry = optional($firstLoc->country?->translations->first())->name;
return $cCity ?? $cDivision ?? $cCountry ?? null;
}
public static function buildExpiryBadgeText($till): ?string
{
if (!$till) {
return null;
}
$expiryWarningDays = timebank_config('calls.expiry_warning_days', 7);
if ($expiryWarningDays === null) {
return null;
}
$daysLeft = (int) now()->startOfDay()->diffInDays(\Carbon\Carbon::parse($till)->startOfDay(), false);
if ($daysLeft > $expiryWarningDays) {
return null;
}
if ($daysLeft <= 0) {
return __('Expires today');
} elseif ($daysLeft === 1) {
return __('Expires tomorrow');
} else {
return __('Expires in :days days', ['days' => $daysLeft]);
}
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Livewire\Calls;
use Livewire\Component;
class SendMessageButton extends Component
{
public $callable;
public $call;
public function mount($callable, $call)
{
$this->callable = $callable;
$this->call = $call;
}
public function createConversation()
{
if (!$this->callable || $this->callable->isRemoved()) {
return;
}
$activeProfile = getActiveProfile();
$conversation = $activeProfile->createConversationWith($this->callable);
$locale = $activeProfile->lang_preference ?? app()->getLocale();
$firstName = explode(' ', trim($this->callable->name ?? ''))[0];
$callTitle = $this->call->tag?->translation?->name ?? $this->call->tag?->name ?? '';
$prefillMessage = __('Hello :name,', ['name' => $firstName], $locale) . "\n" . __('About your :title call, ', ['title' => $callTitle], $locale);
return redirect()->route('chat', ['conversation' => $conversation->id, 'prefill' => $prefillMessage]);
}
public function render()
{
return view('livewire.calls.send-message-button');
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Livewire\Categories;
use Livewire\Component;
class ColorPicker extends Component
{
public $selectedColor = 'gray';
public $label = 'Color';
public $required = false;
public $previewName = 'Category name';
protected $listeners = ['colorSelected'];
public function mount($color = 'gray', $label = 'Color', $required = false, $previewName = 'Category name')
{
$this->selectedColor = $color;
$this->label = $label;
$this->required = $required;
$this->previewName = $previewName;
}
public function updatedSelectedColor($value)
{
$this->dispatch('colorUpdated', $value);
}
public function updatePreviewName($name)
{
$this->previewName = $name ?: __('Category name');
}
public function getAvailableColorsProperty()
{
$colors = [
'slate', 'gray', 'zinc', 'neutral', 'stone',
'red', 'orange', 'amber', 'yellow', 'lime',
'green', 'emerald', 'teal', 'cyan', 'sky',
'blue', 'indigo', 'violet', 'purple', 'fuchsia',
'pink', 'rose'
];
return collect($colors)->map(function ($color) {
return [
'value' => $color,
'label' => ucfirst($color)
];
})->toArray();
}
public function render()
{
return view('livewire.categories.color-picker');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Http\Livewire\Categories;
use Livewire\Component;
class Create extends Component
{
public function render()
{
return view('livewire.categories.create');
}
}
File diff suppressed because it is too large. Load diff
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Livewire;
use App\Models\Category;
use Livewire\Component;
class CategorySelectbox extends Component
{
public $categoryOptions = [];
public $categorySelected;
/**
* Prepare the component.
*
* @return void
*/
public function mount($categorySelected)
{
$this->categoryOptions = Category::with('translations')
->whereNot('type', 'App\Models\Tag')
->get()
->sortBy(function ($category) {
// Use the translated name (accessor) for sorting, checking if translation exists
return $category->translation->name ?? __('Untitled category');
})
->mapWithKeys(function ($category) {
return [
$category->id => [
'category_id' => $category->id,
// Check if translation exists before trying to access its name property
'name' => $category->translation ? $category->translation->name : __('Category') . ' ' . __('id') . ' ' . $category->id . ' ' . $category->type,
]
];
})
->toArray();
$this->categorySelected = $categorySelected;
$this->updated();
}
/**
* When component is updated
*
* @return void
*/
public function updated()
{
$this->dispatch('categorySelected', $this->categorySelected);
}
public function render()
{
return view('livewire.category-selectbox');
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
namespace App\Http\Livewire;
use Livewire\Component;
use App\Mail\ContactFormMailable;
use App\Mail\ContactFormCopyMailable;
use Illuminate\Support\Facades\Mail;
use WireUi\Traits\WireUiActions;
/**
* Generic contact form component with context-aware content
*
* Usage examples:
* - @livewire('contact-form', ['context' => 'contact'])
* - @livewire('contact-form', ['context' => 'report-issue'])
* - @livewire('contact-form', ['context' => 'report-error', 'url' => request()->fullUrl()])
* - @livewire('contact-form', ['context' => 'delete-profile'])
*/
class ContactForm extends Component
{
use WireUiActions;
// Form context: 'contact', 'report-issue', 'report-error', 'delete-profile'
public $context = 'contact';
// Form fields
public $name;
public $full_name;
public $email;
public $subject;
public $message;
public $url; // For error/issue reporting - URL where issue occurred
// UI state
public $showSuccessMessage = false;
protected $rules = [
'name' => 'required|string|max:255',
'full_name' => 'nullable|string|max:255',
'email' => 'required|email|max:255',
'subject' => 'nullable|string|max:255',
'message' => 'required|string|min:10|max:2000',
'url' => 'nullable|url|max:500',
];
public function mount($context = 'contact', $url = null, $subject = null, $message = null)
{
$this->context = $context;
$this->url = $url;
// Pre-fill subject/message from params or query string
$this->subject = $subject ?? request('subject');
$this->message = $message ?? request('message');
// Pre-fill user data if authenticated
if (auth()->check()) {
$profile = getActiveProfile();
if ($profile) {
$this->name = $profile->name ?? '';
$this->full_name = $profile->full_name ?? $profile->name ?? '';
$this->email = $profile->email ?? '';
}
}
}
public function updated($propertyName)
{
$this->validateOnly($propertyName);
}
public function submitForm()
{
$data = $this->validate();
$data['context'] = $this->context;
// Ensure full_name is set, fallback to name if not
if (empty($data['full_name'])) {
$data['full_name'] = $data['name'];
}
// Add authentication and profile data
$data['is_authenticated'] = auth()->check();
if ($data['is_authenticated']) {
$profile = getActiveProfile();
if ($profile) {
$data['profile_url'] = $profile->profile_url ?? null;
$data['profile_type'] = class_basename(get_class($profile));
$data['profile_lang_preference'] = $profile->lang_preference ?? null;
}
}
// Add browser locale (current app locale) for non-authenticated users
if (!$data['is_authenticated']) {
$data['browser_locale'] = app()->getLocale();
}
try {
// Get recipient email from config or default
$recipientEmail = config('mail.from.address') ?: timebank_config('mail.support.email');
\Log::info('ContactForm: Queueing emails', [
'context' => $this->context,
'recipient' => $recipientEmail,
'submitter' => $data['email'],
]);
// Queue email to the recipient on 'emails' queue
Mail::to($recipientEmail)->queue((new ContactFormMailable($data))->onQueue('emails'));
\Log::info('ContactForm: Email queued to recipient');
// Queue a copy to the submitter on 'emails' queue
Mail::to($data['email'])->queue((new ContactFormCopyMailable($data))->onQueue('emails'));
\Log::info('ContactForm: Copy queued to submitter');
// Show success notification
$this->notification()->success(
title: __('Message sent'),
description: __('We received your message successfully and will get back to you shortly!')
);
$this->showSuccessMessage = true;
$this->resetForm();
// Dispatch event for parent components
$this->dispatch('contact-form-submitted');
} catch (\Exception $e) {
$this->notification()->error(
title: __('Error'),
description: __('Sorry, there was an error sending your message. Please try again later.')
);
\Log::error('Contact form submission failed', [
'error' => $e->getMessage(),
'context' => $this->context,
'email' => $this->email
]);
}
}
private function resetForm()
{
$this->message = '';
$this->subject = '';
$this->url = '';
// Don't reset name/email if user is authenticated
if (!auth()->check()) {
$this->name = '';
$this->full_name = '';
$this->email = '';
}
}
public function getSubmitButtonTextProperty()
{
return match($this->context) {
'report-issue' => __('Submit report'),
'report-error' => __('Report error'),
'delete-profile' => __('Request deletion'),
'contact' => __('Send message'),
default => __('Submit'),
};
}
public function render()
{
return view('livewire.contact-form');
}
}
+918
View File
@@ -0,0 +1,918 @@
<?php
namespace App\Http\Livewire;
use App\Models\Transaction;
use App\Models\User;
use App\Models\Organization;
use App\Models\Bank;
use App\Models\Admin;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Livewire\Component;
use Livewire\WithPagination;
use Namu\WireChat\Models\Conversation;
use Namu\WireChat\Models\Participant;
use Namu\WireChat\Enums\ConversationType;
use Spatie\Permission\Models\Role;
class Contacts extends Component
{
use WithPagination;
public $showSearchSection = false;
public $search;
public $searchInput = ''; // Temporary input for search field
public $filterType = []; // Array of selected filter types
public $filterTypeInput = []; // Temporary input for filter multiselect
public $filterRole = '';
public $filterRoleInput = '';
public $perPage = 20;
public $sortField = 'last_interaction';
public $sortAsc = false;
public $confirmingRemoveCoordinator = false;
public $removingProfileId = null;
public $removingProfileName = '';
/**
* Sort by a specific field.
*/
public function sortBy($field)
{
if ($this->sortField === $field) {
$this->sortAsc = !$this->sortAsc;
} else {
$this->sortField = $field;
$this->sortAsc = true;
}
$this->resetPage();
}
protected $rules = [
'search' => 'nullable|string|min:2|max:100',
];
protected $messages = [
'search.min' => 'Search must be at least 2 characters.',
'search.max' => 'Search cannot exceed 100 characters.',
];
/**
* Apply search and filter when button is clicked.
*/
public function applySearch()
{
// Validate search input if provided
if (!empty($this->searchInput) && strlen($this->searchInput) < 2) {
$this->addError('search', 'Search must be at least 2 characters.');
return;
}
if (!empty($this->searchInput) && strlen($this->searchInput) > 100) {
$this->addError('search', 'Search cannot exceed 100 characters.');
return;
}
// Apply the input values to the actual search properties
$this->search = $this->searchInput;
$this->filterType = $this->filterTypeInput;
// Validate filterRole against the allowed set to prevent arbitrary value injection
$allowedRoles = ['any', 'organization-coordinator', 'organization-manager', 'bank-coordinator', 'bank-manager', 'administrator'];
$this->filterRole = in_array($this->filterRoleInput, $allowedRoles, true) ? $this->filterRoleInput : '';
// Reset to first page when searching
$this->resetPage();
}
/**
* Get all contacts (profiles) the active profile has interacted with.
* Includes interactions from:
* - Laravel-love reactions (bookmarks, stars)
* - Transactions (sent to or received from)
* - WireChat private conversations
*
* @return \Illuminate\Pagination\LengthAwarePaginator
*/
public function getContacts()
{
$activeProfile = getActiveProfile();
if (!$activeProfile) {
// Return empty paginator instead of null
return new \Illuminate\Pagination\LengthAwarePaginator(
collect([]),
0,
$this->perPage,
1,
['path' => request()->url(), 'pageName' => 'page']
);
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
// Initialize contacts collection
$contactsData = collect();
// Get the reacter_id and reactant_id for the active profile
$reacterId = $activeProfile->love_reacter_id;
$reactantId = $activeProfile->love_reactant_id;
// If no filters selected, show all
$showAll = empty($this->filterType);
// 1. Get profiles the active profile has reacted to (stars, bookmarks)
if ($showAll || in_array('stars', $this->filterType) || in_array('bookmarks', $this->filterType)) {
if ($reacterId) {
$reactedProfiles = $this->getReactedProfiles($reacterId);
// Filter by specific reaction types if selected
if (!$showAll && (in_array('stars', $this->filterType) || in_array('bookmarks', $this->filterType))) {
$reactedProfiles = $reactedProfiles->filter(function ($profile) {
if (in_array('stars', $this->filterType) && $profile['interaction_type'] === 'star') {
return true;
}
if (in_array('bookmarks', $this->filterType) && $profile['interaction_type'] === 'bookmark') {
return true;
}
return false;
});
}
$contactsData = $contactsData->merge($reactedProfiles);
}
}
// 2. Get profiles that have transacted with the active profile
if ($showAll || in_array('transactions', $this->filterType)) {
$transactionProfiles = $this->getTransactionProfiles($activeProfile);
$contactsData = $contactsData->merge($transactionProfiles);
}
// 3. Get profiles from private WireChat conversations
if ($showAll || in_array('conversations', $this->filterType)) {
$conversationProfiles = $this->getConversationProfiles($activeProfile);
$contactsData = $contactsData->merge($conversationProfiles);
}
// 4. For Organization, Bank, and Admin profiles: include linked users
$activeProfileType = getActiveProfileType();
if (in_array($activeProfileType, ['Organization', 'Bank', 'Admin'])) {
if ($showAll || in_array('linked', $this->filterType)) {
$linkedProfiles = $this->getLinkedProfiles($activeProfile, $activeProfileType);
$contactsData = $contactsData->merge($linkedProfiles);
}
}
// Group by profile and merge interaction data
$contacts = $contactsData->groupBy('profile_key')->map(function ($group) {
$first = $group->first();
// Construct profile path using the localized route
$profileTypeLower = strtolower($first['profile_type_name']);
$profilePath = route('profile.show_by_type_and_id', ['type' => __($profileTypeLower), 'id' => $first['profile_id']]);
// Resolve org_role: prefer linked entry which carries the actual role
$linkedEntry = $group->firstWhere('interaction_type', 'linked');
$orgRole = $linkedEntry['org_role'] ?? null;
return [
'profile_id' => $first['profile_id'],
'profile_type' => $first['profile_type'],
'profile_type_name' => $first['profile_type_name'],
'name' => $first['name'],
'full_name' => $first['full_name'],
'location' => $first['location'],
'profile_photo' => $first['profile_photo'],
'profile_path' => $profilePath,
'has_star' => $group->contains('interaction_type', 'star'),
'has_bookmark' => $group->contains('interaction_type', 'bookmark'),
'has_transaction' => $group->contains('interaction_type', 'transaction'),
'has_conversation' => $group->contains('interaction_type', 'conversation'),
'org_role' => $orgRole,
'last_interaction' => $group->max('last_interaction'),
'star_count' => $group->where('interaction_type', 'star')->sum('count'),
'bookmark_count' => $group->where('interaction_type', 'bookmark')->sum('count'),
'transaction_count' => $group->where('interaction_type', 'transaction')->sum('count'),
'message_count' => $group->where('interaction_type', 'conversation')->sum('count'),
];
})->values();
// Apply role filter (only meaningful for Org/Bank/Admin profiles)
if (!empty($this->filterRole)) {
if ($this->filterRole === 'any') {
// Show only contacts that have any linked role
$contacts = $contacts->filter(function ($contact) {
return !empty($contact['org_role']);
})->values();
} else {
// Show only contacts with the specific role
$contacts = $contacts->filter(function ($contact) {
return ($contact['org_role'] ?? null) === $this->filterRole;
})->values();
}
}
// Apply search filter
if (!empty($this->search) && strlen(trim($this->search)) >= 2) {
$search = strtolower(trim($this->search));
$contacts = $contacts->filter(function ($contact) use ($search) {
$name = strtolower($contact['name'] ?? '');
$fullName = strtolower($contact['full_name'] ?? '');
$location = strtolower($contact['location'] ?? '');
return str_contains($name, $search) ||
str_contains($fullName, $search) ||
str_contains($location, $search);
})->values();
}
// Sort contacts
$contacts = $this->sortContacts($contacts);
// Paginate manually
$currentPage = $this->paginators['page'] ?? 1;
$total = $contacts->count();
$items = $contacts->forPage($currentPage, $this->perPage);
return new \Illuminate\Pagination\LengthAwarePaginator(
$items,
$total,
$this->perPage,
$currentPage,
['path' => request()->url(), 'pageName' => 'page']
);
}
/**
* Get linked users (coordinators/managers) for Organization, Bank, or Admin profiles.
* Each linked user entry carries an org_role resolved from Spatie Permission roles.
*/
private function getLinkedProfiles($activeProfile, string $activeProfileType): \Illuminate\Support\Collection
{
$profiles = collect();
$parentId = $activeProfile->id;
$relationMethod = $activeProfileType === 'Bank' ? 'managers' : 'users';
$users = $activeProfile->$relationMethod()
->select('users.id', 'users.name', 'users.full_name', 'users.profile_photo_path')
->with('roles')
->get();
$userIds = $users->pluck('id');
$locations = $this->batchLoadLocations(User::class, $userIds);
foreach ($users as $user) {
$role = $this->resolveLinkedUserRole($user, $activeProfileType, $parentId);
$profiles->push([
'profile_key' => 'App\\Models\\User_' . $user->id,
'profile_id' => $user->id,
'profile_type' => User::class,
'profile_type_name' => 'User',
'name' => $user->name,
'full_name' => $user->full_name,
'location' => $locations[$user->id] ?? '',
'profile_photo' => $user->profile_photo_path,
'interaction_type' => 'linked',
'org_role' => $role,
'last_interaction' => null,
'count' => 0,
]);
}
return $profiles;
}
/**
* Resolve the role of a linked user for the given parent profile type/id.
* Mirrors the logic in Profiles\Manage::resolveLinkedUserRole().
*/
private function resolveLinkedUserRole($user, string $parentType, int $parentId): string
{
$coordinatorMap = [
'Organization' => 'organization-coordinator',
'Bank' => 'bank-coordinator',
];
$managerMap = [
'Organization' => 'organization-manager',
'Bank' => 'bank-manager',
'Admin' => 'administrator',
];
$coordinatorRole = isset($coordinatorMap[$parentType])
? "{$parentType}\\{$parentId}\\{$coordinatorMap[$parentType]}"
: null;
if ($coordinatorRole && $user->hasRole($coordinatorRole)) {
return $coordinatorMap[$parentType];
}
return $managerMap[$parentType] ?? 'organization-manager';
}
/**
* Get profiles the active profile has reacted to.
*/
private function getReactedProfiles($reacterId)
{
// Get all reactions by this reacter, grouped by reactant type
$reactions = DB::table('love_reactions')
->join('love_reactants', 'love_reactions.reactant_id', '=', 'love_reactants.id')
->where('love_reactions.reacter_id', $reacterId)
->select(
'love_reactants.type as reactant_type',
DB::raw('CAST(SUBSTRING_INDEX(love_reactants.type, "\\\\", -1) AS CHAR) as reactant_model')
)
->groupBy('love_reactants.type')
->get();
$profiles = collect();
foreach ($reactions as $reaction) {
// Only process User, Organization, and Bank models
if (!in_array($reaction->reactant_model, ['User', 'Organization', 'Bank'])) {
continue;
}
$modelClass = "App\\Models\\{$reaction->reactant_model}";
// Get all profiles of this type that were reacted to, with reaction type breakdown
$reactedToProfiles = DB::table('love_reactions')
->join('love_reactants', 'love_reactions.reactant_id', '=', 'love_reactants.id')
->join(
DB::raw("(SELECT id, love_reactant_id, name,
full_name,
profile_photo_path
FROM " . strtolower($reaction->reactant_model) . "s) as profiles"),
'love_reactants.id',
'=',
'profiles.love_reactant_id'
)
->where('love_reactions.reacter_id', $reacterId)
->where('love_reactants.type', $reaction->reactant_type)
->select(
'profiles.id as profile_id',
'profiles.name',
'profiles.full_name',
'profiles.profile_photo_path',
DB::raw("'{$modelClass}' as profile_type"),
DB::raw("'{$reaction->reactant_model}' as profile_type_name"),
'love_reactions.reaction_type_id',
DB::raw('MAX(love_reactions.created_at) as last_interaction'),
DB::raw('COUNT(*) as count')
)
->groupBy('profiles.id', 'profiles.name', 'profiles.full_name', 'profiles.profile_photo_path', 'love_reactions.reaction_type_id')
->get();
// Batch load locations for all profiles of this type
$profileIds = $reactedToProfiles->pluck('profile_id');
$locations = $this->batchLoadLocations($modelClass, $profileIds);
foreach ($reactedToProfiles as $profile) {
// Get location from batch-loaded data
$location = $locations[$profile->profile_id] ?? '';
// Determine reaction type (1 = Star, 2 = Bookmark)
$interactionType = $profile->reaction_type_id == 1 ? 'star' : ($profile->reaction_type_id == 2 ? 'bookmark' : 'reaction');
$profiles->push([
'profile_key' => $modelClass . '_' . $profile->profile_id,
'profile_id' => $profile->profile_id,
'profile_type' => $profile->profile_type,
'profile_type_name' => $profile->profile_type_name,
'name' => $profile->name,
'full_name' => $profile->full_name,
'location' => $location,
'profile_photo' => $profile->profile_photo_path,
'interaction_type' => $interactionType,
'last_interaction' => $profile->last_interaction,
'count' => $profile->count,
]);
}
}
return $profiles;
}
/**
* Get profiles that have transacted with the active profile.
*/
private function getTransactionProfiles($activeProfile)
{
// Get all accounts belonging to the active profile
$accountIds = DB::table('accounts')
->where('accountable_type', get_class($activeProfile))
->where('accountable_id', $activeProfile->id)
->pluck('id');
if ($accountIds->isEmpty()) {
return collect();
}
// Get all transactions involving these accounts
$transactions = DB::table('transactions')
->whereIn('from_account_id', $accountIds)
->orWhereIn('to_account_id', $accountIds)
->select(
'from_account_id',
'to_account_id',
DB::raw('MAX(created_at) as last_interaction'),
DB::raw('COUNT(*) as count')
)
->groupBy('from_account_id', 'to_account_id')
->get();
// Group counter accounts by type for batch loading
$counterAccountsByType = collect();
foreach ($transactions as $transaction) {
// Determine the counter account (the other party in the transaction)
$counterAccountId = null;
if ($accountIds->contains($transaction->from_account_id) && !$accountIds->contains($transaction->to_account_id)) {
$counterAccountId = $transaction->to_account_id;
} elseif ($accountIds->contains($transaction->to_account_id) && !$accountIds->contains($transaction->from_account_id)) {
$counterAccountId = $transaction->from_account_id;
}
if ($counterAccountId) {
$transaction->counter_account_id = $counterAccountId;
}
}
// Get all counter account details in one query
$counterAccountIds = $transactions->pluck('counter_account_id')->filter()->unique();
$accounts = DB::table('accounts')
->whereIn('id', $counterAccountIds)
->select('id', 'accountable_type', 'accountable_id')
->get()
->keyBy('id');
// Group profile IDs by type
$profileIdsByType = [];
foreach ($accounts as $account) {
$profileTypeName = class_basename($account->accountable_type);
if (!isset($profileIdsByType[$profileTypeName])) {
$profileIdsByType[$profileTypeName] = [];
}
$profileIdsByType[$profileTypeName][] = $account->accountable_id;
}
// Batch load profile data and locations for each type
$profileDataByType = [];
$locationsByType = [];
foreach ($profileIdsByType as $typeName => $ids) {
$tableName = strtolower($typeName) . 's';
$modelClass = "App\\Models\\{$typeName}";
// Load profile data
$profileDataByType[$typeName] = DB::table($tableName)
->whereIn('id', $ids)
->select('id', 'name', 'full_name', 'profile_photo_path')
->get()
->keyBy('id');
// Batch load locations
$locationsByType[$typeName] = $this->batchLoadLocations($modelClass, $ids);
}
// Build final profiles collection
$profiles = collect();
foreach ($transactions as $transaction) {
if (!isset($transaction->counter_account_id)) {
continue; // Skip self-transactions
}
$account = $accounts->get($transaction->counter_account_id);
if (!$account) {
continue;
}
$profileModel = $account->accountable_type;
$profileId = $account->accountable_id;
$profileTypeName = class_basename($profileModel);
$profile = $profileDataByType[$profileTypeName][$profileId] ?? null;
if (!$profile) {
continue;
}
$location = $locationsByType[$profileTypeName][$profileId] ?? '';
$profileKey = $profileModel . '_' . $profileId;
$profiles->push([
'profile_key' => $profileKey,
'profile_id' => $profileId,
'profile_type' => $profileModel,
'profile_type_name' => $profileTypeName,
'name' => $profile->name,
'full_name' => $profile->full_name,
'location' => $location,
'profile_photo' => $profile->profile_photo_path,
'interaction_type' => 'transaction',
'last_interaction' => $transaction->last_interaction,
'count' => $transaction->count,
]);
}
return $profiles;
}
/**
* Get profiles from private WireChat conversations.
*/
private function getConversationProfiles($activeProfile)
{
// Get all private conversations the active profile is participating in
$participantType = get_class($activeProfile);
$participantId = $activeProfile->id;
// Get participant record for active profile
$myParticipants = DB::table('wirechat_participants')
->join('wirechat_conversations', 'wirechat_participants.conversation_id', '=', 'wirechat_conversations.id')
->where('wirechat_participants.participantable_type', $participantType)
->where('wirechat_participants.participantable_id', $participantId)
->where('wirechat_conversations.type', ConversationType::PRIVATE->value)
->whereNull('wirechat_participants.deleted_at')
->select(
'wirechat_participants.conversation_id',
'wirechat_participants.last_active_at'
)
->get();
if ($myParticipants->isEmpty()) {
return collect();
}
$conversationIds = $myParticipants->pluck('conversation_id');
// Get all other participants in one query
$otherParticipants = DB::table('wirechat_participants')
->whereIn('conversation_id', $conversationIds)
->where(function ($query) use ($participantType, $participantId) {
$query->where('participantable_type', '!=', $participantType)
->orWhere('participantable_id', '!=', $participantId);
})
->whereNull('deleted_at')
->get()
->keyBy('conversation_id');
// Get message counts for all conversations in one query
$messageCounts = DB::table('wirechat_messages')
->whereIn('conversation_id', $conversationIds)
->whereNull('deleted_at')
->select(
'conversation_id',
DB::raw('COUNT(DISTINCT DATE(created_at)) as day_count')
)
->groupBy('conversation_id')
->get()
->keyBy('conversation_id');
// Get last messages for all conversations in one query
$lastMessages = DB::table('wirechat_messages as wm1')
->whereIn('wm1.conversation_id', $conversationIds)
->whereNull('wm1.deleted_at')
->whereRaw('wm1.created_at = (SELECT MAX(wm2.created_at) FROM wirechat_messages wm2 WHERE wm2.conversation_id = wm1.conversation_id AND wm2.deleted_at IS NULL)')
->select('wm1.conversation_id', 'wm1.created_at')
->get()
->keyBy('conversation_id');
// Group profile IDs by type
$profileIdsByType = [];
foreach ($otherParticipants as $participant) {
$profileTypeName = class_basename($participant->participantable_type);
if (!isset($profileIdsByType[$profileTypeName])) {
$profileIdsByType[$profileTypeName] = [];
}
$profileIdsByType[$profileTypeName][] = $participant->participantable_id;
}
// Batch load profile data and locations for each type
$profileDataByType = [];
$locationsByType = [];
foreach ($profileIdsByType as $typeName => $ids) {
$tableName = strtolower($typeName) . 's';
$modelClass = "App\\Models\\{$typeName}";
// Load profile data
$profileDataByType[$typeName] = DB::table($tableName)
->whereIn('id', $ids)
->select('id', 'name', 'full_name', 'profile_photo_path')
->get()
->keyBy('id');
// Batch load locations
$locationsByType[$typeName] = $this->batchLoadLocations($modelClass, $ids);
}
// Build final profiles collection
$profiles = collect();
foreach ($myParticipants as $myParticipant) {
$otherParticipant = $otherParticipants->get($myParticipant->conversation_id);
if (!$otherParticipant) {
continue;
}
$profileModel = $otherParticipant->participantable_type;
$profileId = $otherParticipant->participantable_id;
$profileTypeName = class_basename($profileModel);
$profile = $profileDataByType[$profileTypeName][$profileId] ?? null;
if (!$profile) {
continue;
}
$messageCount = $messageCounts->get($myParticipant->conversation_id)->day_count ?? 0;
$lastMessage = $lastMessages->get($myParticipant->conversation_id);
$location = $locationsByType[$profileTypeName][$profileId] ?? '';
$profileKey = $profileModel . '_' . $profileId;
$profiles->push([
'profile_key' => $profileKey,
'profile_id' => $profileId,
'profile_type' => $profileModel,
'profile_type_name' => $profileTypeName,
'name' => $profile->name,
'full_name' => $profile->full_name,
'location' => $location,
'profile_photo' => $profile->profile_photo_path,
'interaction_type' => 'conversation',
'last_interaction' => $lastMessage ? $lastMessage->created_at : $myParticipant->last_active_at,
'count' => $messageCount,
]);
}
return $profiles;
}
/**
* Batch load locations for multiple profiles of the same type.
* This replaces the N+1 query problem in getProfileLocation().
*
* @param string $modelClass The model class (e.g., 'App\Models\User')
* @param array|\Illuminate\Support\Collection $profileIds Array of profile IDs
* @return array Associative array of profile_id => location_name
*/
private function batchLoadLocations($modelClass, $profileIds)
{
if (empty($profileIds)) {
return [];
}
// Ensure it's an array
if ($profileIds instanceof \Illuminate\Support\Collection) {
$profileIds = $profileIds->toArray();
}
// Load all profiles with their location relationships
$profiles = $modelClass::with([
'locations.city.translations',
'locations.district.translations',
'locations.division.translations',
'locations.country.translations'
])
->whereIn('id', $profileIds)
->get();
// Build location map
$locationMap = [];
foreach ($profiles as $profile) {
if (method_exists($profile, 'getLocationFirst')) {
$locationData = $profile->getLocationFirst(false);
$locationMap[$profile->id] = $locationData['name'] ?? $locationData['name_short'] ?? '';
} else {
$locationMap[$profile->id] = '';
}
}
return $locationMap;
}
/**
* Get location for a profile (deprecated, use batchLoadLocations instead).
* Kept for backwards compatibility.
*/
private function getProfileLocation($modelClass, $profileId)
{
$locations = $this->batchLoadLocations($modelClass, [$profileId]);
return $locations[$profileId] ?? '';
}
/**
* Sort contacts based on sort field and direction.
*/
private function sortContacts($contacts)
{
$sortField = $this->sortField;
$sortAsc = $this->sortAsc;
return $contacts->sort(function ($a, $b) use ($sortField, $sortAsc) {
$aVal = $a[$sortField] ?? '';
$bVal = $b[$sortField] ?? '';
if ($sortField === 'last_interaction') {
$aTime = $aVal ? strtotime($aVal) : 0;
$bTime = $bVal ? strtotime($bVal) : 0;
$comparison = $bTime <=> $aTime; // Default: most recent first
return $sortAsc ? -$comparison : $comparison;
}
// For count fields, use numeric comparison
if (in_array($sortField, ['transaction_count', 'message_count'])) {
$comparison = ($aVal ?? 0) <=> ($bVal ?? 0);
return $sortAsc ? $comparison : -$comparison;
}
// For boolean fields
if (in_array($sortField, ['has_star', 'has_bookmark'])) {
$comparison = ($aVal ? 1 : 0) <=> ($bVal ? 1 : 0);
return $sortAsc ? $comparison : -$comparison;
}
// String comparison for name, location, org_role, etc.
$comparison = strcasecmp((string)($aVal ?? ''), (string)($bVal ?? ''));
return $sortAsc ? $comparison : -$comparison;
})->values();
}
/**
* Reset search and filters.
*/
public function resetSearch()
{
$this->resetPage();
$this->showSearchSection = false;
$this->search = null;
$this->searchInput = '';
$this->filterType = [];
$this->filterTypeInput = [];
$this->filterRole = '';
$this->filterRoleInput = '';
}
/**
* Scroll to top when page changes.
*/
public function updatedPage()
{
$this->dispatch('scroll-to-top');
}
/**
* Open the confirmation dialog for removing a coordinator.
* Profile name is fetched server-side — never trusted from the client.
*/
public function confirmRemoveCoordinator(int $profileId)
{
$activeProfile = getActiveProfile();
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
$activeProfileType = getActiveProfileType();
$user = Auth::guard('web')->user();
// Only managers may remove coordinators
$managerRoles = [
'Organization' => "Organization\\{$activeProfile->id}\\organization-manager",
'Bank' => "Bank\\{$activeProfile->id}\\bank-manager",
];
if (!$user || !isset($managerRoles[$activeProfileType]) || !$user->hasRole($managerRoles[$activeProfileType])) {
return;
}
// Verify the target user is actually linked to the active profile
$relationMethod = $activeProfileType === 'Bank' ? 'managers' : 'users';
$isLinked = $activeProfile->$relationMethod()->where('users.id', $profileId)->exists();
if (!$isLinked) {
return;
}
// Fetch name server-side — never trust client-supplied strings
$coordinator = User::find($profileId);
if (!$coordinator) {
return;
}
$this->removingProfileId = $profileId;
$this->removingProfileName = $coordinator->name;
$this->confirmingRemoveCoordinator = true;
}
/**
* Remove the coordinator after confirmation.
*/
public function removeCoordinator()
{
$activeProfile = getActiveProfile();
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
$activeProfileType = getActiveProfileType();
$user = Auth::guard('web')->user();
$managerRoles = [
'Organization' => "Organization\\{$activeProfile->id}\\organization-manager",
'Bank' => "Bank\\{$activeProfile->id}\\bank-manager",
];
if (!$user || !isset($managerRoles[$activeProfileType]) || !$user->hasRole($managerRoles[$activeProfileType])) {
$this->confirmingRemoveCoordinator = false;
return;
}
$coordinator = User::find($this->removingProfileId);
if (!$coordinator) {
$this->confirmingRemoveCoordinator = false;
return;
}
// Re-verify the target user is actually linked to the active profile
// (guards against state manipulation between confirm and execute)
$relationMethod = $activeProfileType === 'Bank' ? 'managers' : 'users';
$isLinked = $activeProfile->$relationMethod()->where('users.id', $coordinator->id)->exists();
if (!$isLinked) {
$this->confirmingRemoveCoordinator = false;
return;
}
// Detach from the pivot table
$activeProfile->$relationMethod()->detach($coordinator->id);
// Remove all coordinator/manager Spatie roles for this profile
$roleBasePrefixes = [
'Organization' => ['organization-coordinator', 'organization-manager'],
'Bank' => ['bank-coordinator', 'bank-manager'],
];
foreach ($roleBasePrefixes[$activeProfileType] as $base) {
$roleName = "{$activeProfileType}\\{$activeProfile->id}\\{$base}";
if ($coordinator->hasRole($roleName)) {
$coordinator->removeRole($roleName);
}
}
$this->confirmingRemoveCoordinator = false;
$this->removingProfileId = null;
$this->removingProfileName = '';
}
public function render()
{
$activeProfileType = getActiveProfileType();
$roleLabels = [
'organization-coordinator' => __('Coordinator'),
'organization-manager' => __('Manager'),
'bank-coordinator' => __('Coordinator'),
'bank-manager' => __('Manager'),
'administrator' => __('Administrator'),
];
$availableRolesByType = [
'Organization' => ['organization-coordinator', 'organization-manager'],
'Bank' => ['bank-coordinator', 'bank-manager'],
'Admin' => ['administrator'],
];
$availableRoles = [];
if (isset($availableRolesByType[$activeProfileType])) {
foreach ($availableRolesByType[$activeProfileType] as $roleKey) {
$availableRoles[$roleKey] = $roleLabels[$roleKey] ?? $roleKey;
}
}
// Determine if auth web user is a manager for the active profile
$authIsManager = false;
$activeProfile = getActiveProfile();
if ($activeProfile && in_array($activeProfileType, ['Organization', 'Bank'])) {
$webUser = Auth::guard('web')->user();
$managerRoles = [
'Organization' => "Organization\\{$activeProfile->id}\\organization-manager",
'Bank' => "Bank\\{$activeProfile->id}\\bank-manager",
];
$authIsManager = $webUser && $webUser->hasRole($managerRoles[$activeProfileType]);
}
return view('livewire.contacts.show', [
'contacts' => $this->getContacts(),
'showRoleColumn' => in_array($activeProfileType, ['Organization', 'Bank', 'Admin']),
'availableRoles' => $availableRoles,
'roleLabels' => $roleLabels,
'authIsManager' => $authIsManager,
]);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Livewire;
use Livewire\Component;
class Description extends Component
{
public $description;
public $requiredError = false;
protected $listeners = ['resetForm'];
/**
* Extra check if field is empty on blur textarea
*
* @return void
*/
public function checkRequired()
{
$this->dispatch('description', $this->description);
if ($this->description === null || '')
{
$this->requiredError = true;
}
}
public function resetForm()
{
$this->description = null;
}
public function updated()
{
$this->dispatch('description', $this->description);
}
public function render()
{
return view('livewire.description');
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Http\Livewire;
use App\Models\Language;
use App\Models\Post;
use Illuminate\Support\Facades\App;
use Livewire\Component;
class EventCalendarPost extends Component
{
public $type;
public int $limit;
public bool $hideAuthor = false;
public bool $showFallback = false;
public bool $fallbackExists = false; // Add this new property
public function mount($type, $limit = 1, $hideAuthor = false)
{
$this->type = $type;
if ($limit) {
$this->limit = $limit;
}
$this->hideAuthor = $hideAuthor ?? false;
}
public function loadFallback()
{
$this->showFallback = true;
}
public function getPosts($locale)
{
return Post::with([
'category',
'images' => function ($query) {
$query->select('images.id', 'caption', 'path');
},
// Eager load meeting with related data for event display
'meeting' => function ($query) {
$query->with(['meetingable', 'transactionType']);
},
// Eager load all active translations for the given locale, ordered by most recent
'translations' => function ($query) use ($locale) {
$query->where('locale', 'like', $locale . '%') // Use LIKE for locale flexibility
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
})
->orderBy('updated_at', 'desc'); // No limit here for robustness
}
])
->whereHas('category', function ($query) {
$query->where('type', $this->type);
})
// Filter for posts that have at least one active translation for the given locale
->whereHas('translations', function ($query) use ($locale) {
$query->where('locale', 'like', $locale . '%') // Use LIKE for locale flexibility
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
});
})
->orderBy('created_at', 'desc')
->limit($this->limit)
->get();
}
/**
* The render method prepares all data needed by the view.
*/
public function render()
{
$locale = $this->showFallback ? config('app.fallback_locale') : App::getLocale();
$posts = $this->getPosts($locale);
// If no posts are found and we're not already showing the fallback,
// perform an efficient check to see if any fallback content exists.
if ($posts->isEmpty() && !$this->showFallback) {
$fallbackLocale = config('app.fallback_locale');
if (trim(App::getLocale()) !== trim($fallbackLocale)) {
$this->fallbackExists = Post::whereHas('category', function ($query) {
$query->where('type', $this->type);
})
->whereHas('translations', function ($query) use ($fallbackLocale) {
$query->where('locale', 'like', $fallbackLocale . '%')
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
});
})
->exists();
}
}
$photo = null;
if ($posts->isNotEmpty()) {
$firstPost = $posts->first();
if ($firstPost->hasMedia('*')) {
$photo = $firstPost->getFirstMediaUrl('*');
}
}
return view('livewire.event-calendar-post', [
'posts' => $posts,
'photo' => $photo,
'fallbackLocale' => __(Language::where('lang_code', config('app.fallback_locale'))->first()->name ?? 'Fallback Language'),
]);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Livewire;
use Livewire\Component;
class ForcedLogoutModal extends Component
{
public $showModal = false;
public $message = '';
public $title = '';
protected $listeners = ['showForcedLogoutModal'];
public function showForcedLogoutModal($message, $title = 'Logged Out')
{
$this->message = $message;
$this->title = $title;
$this->showModal = true;
}
public function confirmLogout()
{
// Dispatch browser event to submit logout form
$this->dispatch('proceed-logout');
}
public function render()
{
return view('livewire.forced-logout-modal');
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Http\Livewire;
use App\Http\Controllers\TransactionController;
use Livewire\Component;
class FromAccount extends Component
{
public $profileAccounts = [];
public $fromAccountId;
public $selectedAccount = null;
public $label;
public $active = true;
#[\Livewire\Attributes\On('fromAccountRefresh')]
public function onRefresh()
{
\Log::info('[FROM-ACCOUNT] Received fromAccountRefresh event');
$this->resetForm();
}
public function mount()
{
$this->profileAccounts = $this->getProfileAccounts();
$this->preSelected();
}
public function getProfileAccounts()
{
\Log::info('[FROM-ACCOUNT] getProfileAccounts() called');
$transactionController = new TransactionController();
$accounts = $transactionController->getAccountsInfo();
\Log::info('[FROM-ACCOUNT] getAccountsInfo() returned', [
'accountCount' => $accounts->count(),
'firstAccountBalance' => $accounts->first()['balance'] ?? null,
'allBalances' => $accounts->map(fn($acc) => ['id' => $acc['id'], 'balance' => $acc['balance']])->toArray(),
]);
// Return empty collection if no accounts (e.g., Admin profiles)
if (!$accounts || $accounts->isEmpty()) {
return collect();
}
// Always filter out deleted accounts
$accounts = $accounts->filter(function ($account) {
return is_null($account['deletedAt']) || \Illuminate\Support\Carbon::parse($account['deletedAt'])->isFuture();
});
// If $active is true, also filter out inactive accounts
if ($this->active) {
$accounts = $accounts->filter(function ($account) {
return is_null($account['inactiveAt']) || \Illuminate\Support\Carbon::parse($account['inactiveAt'])->isFuture();
});
}
return $accounts;
}
public function resetForm()
{
\Log::info('[FROM-ACCOUNT] resetForm() called - refreshing account data');
// Force cache expiration to ensure fresh data
// (TransactionObserver has already cleared caches, but we ensure it here for synchronous refresh)
$profileType = session('activeProfileType');
$profileId = session('activeProfileId');
if ($profileType && $profileId) {
$cacheKey = "accounts_info_{$profileType}_{$profileId}";
// Force a fresh fetch by clearing cache right before getProfileAccounts()
\Illuminate\Support\Facades\Cache::forget($cacheKey);
\Log::info('[FROM-ACCOUNT] Cache cleared before refresh', ['cacheKey' => $cacheKey]);
}
// Now fetch fresh account data
$this->profileAccounts = $this->getProfileAccounts();
\Log::info('[FROM-ACCOUNT] Updated profileAccounts with fresh data', [
'count' => count($this->profileAccounts),
'firstBalance' => $this->profileAccounts->first()['balance'] ?? null,
]);
$this->preSelected();
}
public function preSelected()
{
if ($this->profileAccounts && count($this->profileAccounts) > 0) {
$firstActiveAccount = $this->profileAccounts->first(function ($account) {
return is_null($account['inactiveAt']) || \Illuminate\Support\Carbon::parse($account['inactiveAt'])->isFuture();
});
if ($firstActiveAccount) {
$activeState = $firstActiveAccount['inactive'] === true ? ' (' . strtolower(__('Inactive')) . ')' : '';
$firstActiveAccount['name'] = $firstActiveAccount['name'] . ' ' . __('account') . $activeState;
$this->fromAccountId = $firstActiveAccount['id'];
$this->selectedAccount = $firstActiveAccount;
$this->dispatch('fromAccountId', $this->selectedAccount);
}
}
}
public function fromAccountSelected($fromAccountId)
{
$this->fromAccountId = $fromAccountId;
$selectedAccount = collect($this->profileAccounts)->firstWhere('id', $fromAccountId);
$activeState = $selectedAccount['inactive'] === true ? ' (' . strtolower(__('Inactive')) . ')' : '';
$selectedAccount['name'] = $selectedAccount['name'] . ' ' . __('account') . $activeState;
$this->selectedAccount = $selectedAccount;
$this->dispatch('fromAccountId', $this->selectedAccount);
}
public function render()
{
return view('livewire.from-account');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Http\Livewire;
use Livewire\Component;
class FullPost extends Component
{
public function render()
{
return view('livewire.full-post');
}
}
@@ -0,0 +1,176 @@
<?php
namespace App\Http\Livewire\Locations;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\District;
use App\Models\Locations\Division;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
class LocationsDropdown extends Component
{
public $country;
public $division;
public $city;
public $district;
public $hideLabel = false;
public bool $showCountry = true;
public bool $showDivision = true;
public bool $showCity = true;
public bool $showDistrict = true;
protected $listeners = ['countryToChildren', 'divisionToChildren', 'cityToChildren', 'districtToChildren'];
/**
* Receive country value from parent component.
* Does NOT reset downstream values - that would clear values being set by subsequent ToChildren calls.
*/
public function countryToChildren($value)
{
$this->country = $value;
// Don't call updatedCountry() as it resets division/city/district
// Parent is responsible for setting all values correctly
}
/**
* Receive division value from parent component.
* Does NOT reset downstream values - that would clear values being set by subsequent ToChildren calls.
*/
public function divisionToChildren($value)
{
$this->division = $value ?: null;
// Don't call updatedDivision() as it resets city/district
}
/**
* Receive city value from parent component.
* Does NOT reset downstream values - that would clear values being set by subsequent ToChildren calls.
*/
public function cityToChildren($value)
{
$this->city = $value ?: null;
// Don't call updatedCity() as it resets district
}
/**
* Receive district value from parent component.
*/
public function districtToChildren($value)
{
$this->district = $value ?: null;
// No downstream values to reset
}
public function updatedCountry()
{
$this->reset(['division', 'city', 'district']);
$this->dispatch('countryToParent', $this->country);
$this->dispatch('divisionToParent', null);
$this->dispatch('cityToParent', null);
$this->dispatch('districtToParent', null);
}
public function updatedDivision()
{
$this->reset(['city', 'district']);
if ($this->division === '') {
$this->division = null;
}
$this->dispatch('divisionToParent', $this->division);
$this->dispatch('cityToParent', null);
$this->dispatch('districtToParent', null);
}
public function updatedCity()
{
$this->reset(['district']);
if ($this->city === '') {
$this->city = null;
}
$this->dispatch('cityToParent', $this->city);
$this->dispatch('districtToParent', null);
}
public function updatedDistrict()
{
if ($this->district === '') {
$this->district = null;
}
$this->dispatch('districtToParent', $this->district);
}
public function countrySelected()
{
$this->dispatch('countryToParent', $this->country);
}
public function divisionSelected()
{
$this->dispatch('divisionToParent', $this->division);
}
public function citySelected()
{
$this->dispatch('cityToParent', $this->city);
}
public function districtSelected()
{
$this->dispatch('districtToParent', $this->district);
}
public function render()
{
$countries = Cache::remember(
'location_countries_all', 86400,
fn() => Country::with(['translations'])->get()
);
$divisions = collect();
$cities = collect();
$districts = collect();
if (!empty($this->country)) {
$divisions = Cache::remember(
'location_divisions_' . $this->country, 3600,
fn() => Division::with(['translations'])->where('country_id', $this->country)->get()
);
if (!empty($this->division)) {
$cities = Cache::remember(
'location_cities_division_' . $this->division, 3600,
fn() => City::with(['translations'])->where('division_id', $this->division)->get()
);
} else {
$cities = Cache::remember(
'location_cities_country_' . $this->country, 3600,
fn() => City::with(['translations'])->where('country_id', $this->country)->get()
);
}
}
if (!empty($this->city)) {
$districts = Cache::remember(
'location_districts_city_' . $this->city, 3600,
fn() => District::with(['translations'])->where('city_id', $this->city)->get()
);
}
return view('livewire.locations.locations-dropdown', compact('countries', 'divisions', 'cities', 'districts'));
}
}
@@ -0,0 +1,211 @@
<?php
namespace App\Http\Livewire\Locations;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\District;
use App\Models\Locations\Location;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
class UpdateProfileLocationForm extends Component
{
use WireUiActions;
public $state;
public $country;
public $division;
public $city;
public $district;
public $validateCountry = true;
public $validateDivision = true;
public $validateCity = true;
protected $listeners = ['countryToParent', 'divisionToParent', 'cityToParent', 'districtToParent'];
public function rules()
{
return [
'country' => 'required_if:validateCountry,true|integer',
'division' => 'required_if:validateDivision,true|nullable|integer',
'city' => 'required_if:validateCity,true|nullable|integer',
'district' => 'nullable|integer'
];
}
/**
* Prepare the component.
*
* @return void
*/
public function mount()
{
$this->state = session('activeProfileType')::find(session('activeProfileId'))
->load([
// 'locations',
'locations.country',
'locations.division',
'locations.city',
'locations.district']);
if ($this->state->locations->isNotEmpty()) {
if ($this->state->locations->first()->country) {
// For now we only use a single location. In the future this can become an array of locations.
$this->country = $this->state->locations->first()->country->id;
$this->dispatch('countryToChildren', $this->country);
}
if ($this->state->locations->first()->division) {
// For now we only use a single location. In the future this can become an array of locations.
$this->division = $this->state->locations->first()->division->id;
}
if ($this->state->locations->first()->city) {
// For now we only use a single location. In the future this can become an array of locations.
$this->city = $this->state->locations->first()->city->id;
// In case a location has a city without a country in the db:
if (!$this->state->locations->first()->country) {
$this->country = City::find($this->city)->country_id;
}
}
if ($this->state->locations->first()->district) {
// For now we only use a single location. In the future this can become an array of locations.
$this->district = $this->state->locations->first()->district->id;
// In case a location has a district without a city in the db:
if (!$this->state->locations->first()->city) {
$this->city = District::find($this->district)->city_id;
}
}
}
$this->setValidationOptions();
}
public function countryToParent($value)
{
$this->country = $value;
$this->setValidationOptions();
}
public function divisionToParent($value)
{
$this->division = $value;
$this->setValidationOptions();
}
public function cityToParent($value)
{
$this->city = $value;
$this->setValidationOptions();
}
public function districtToParent($value)
{
$this->district = $value;
$this->setValidationOptions();
}
public function emitLocationToChildren()
{
$this->dispatch('countryToChildren', $this->country);
$this->dispatch('divisionToChildren', $this->division);
$this->dispatch('cityToChildren', $this->city);
$this->dispatch('districtToChildren', $this->district);
}
public function setValidationOptions()
{
$this->validateCountry = $this->validateDivision = $this->validateCity = true;
// In case no cities or divisions for selected country are seeded in database
if ($this->country) {
$countDivisions = Country::find($this->country)->divisions()->count();
$countCities = Country::find($this->country)->cities()->count();
if ($countDivisions > 0 && $countCities < 1) {
$this->validateDivision = true;
$this->validateCity = false;
} elseif ($countDivisions < 1 && $countCities > 1) {
$this->validateDivision = false;
$this->validateCity = true;
} elseif ($countDivisions < 1 && $countCities < 1) {
$this->validateDivision = false;
$this->validateCity = false;
} elseif ($countDivisions > 0 && $countCities > 0) {
$this->validateDivision = false;
$this->validateCity = true;
}
}
// In case no country is selected, no need to show other validation errors
if (!$this->country) {
$this->validateCountry = true;
$this->validateDivision = $this->validateCity = false;
}
}
public function updateProfileInformation()
{
$this->validate();
// Use a transaction.
DB::transaction(function (): void {
$activeProfile = getActiveProfile();
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
// For now we only use a single location. In the future this can become an array of locations.
if ($this->state->locations && $this->state->locations->isNotEmpty()) {
$location = Location::find($this->state->locations->first()->id);
} else {
$location = new Location();
$activeProfile->locations()->save($location); // create a new location
}
// Set country, division, and city IDs on the location
$location->country_id = $this->country;
$location->division_id = $this->division;
$location->city_id = $this->city;
$location->district_id = $this->district;
// Save the location with the updated relationship IDs
$location->save();
$activeProfile->touch(); // Observer catches this and reindexes search index
// Invalidate and immediately repopulate the session location cache
// so the new location is available without waiting for the next search mount.
invalidateActiveProfileLocationCache();
getActiveProfileLocationHierarchy();
$this->dispatch('saved');
});
}
public function render()
{
return view('livewire.locations.update-profile-location-form');
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
return (new Config())
->setParallelConfig(ParallelConfigFactory::detect()) // @TODO 4.0 no need to call this manually
->setRiskyAllowed(false)
->setRules([
'@auto' => true
])
// 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config
->setFinder(
(new Finder())
// 💡 root folder to check
->in(__DIR__)
// 💡 additional files, eg bin entry file
// ->append([__DIR__.'/bin-entry-file'])
// 💡 folders to exclude, if any
// ->exclude([/* ... */])
// 💡 path patterns to exclude, if any
// ->notPath([/* ... */])
// 💡 extra configs
// ->ignoreDotFiles(false) // true by default in v3, false in v4 or future mode
// ->ignoreVCS(true) // true by default
)
;
@@ -0,0 +1,166 @@
<?php
namespace App\Http\Livewire\Mailings;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\District;
use App\Models\Locations\Division;
use Livewire\Attributes\On;
use Livewire\Component;
class LocationFilter extends Component
{
public array $selectedCountries = [];
public array $selectedDivisions = [];
public array $selectedCities = [];
public array $selectedDistricts = [];
public function mount(
array $selectedCountries = [],
array $selectedDivisions = [],
array $selectedCities = [],
array $selectedDistricts = []
) {
$this->selectedCountries = $selectedCountries;
$this->selectedDivisions = $selectedDivisions;
$this->selectedCities = $selectedCities;
$this->selectedDistricts = $selectedDistricts;
}
// ---------------------------------------------------------------------------
// Setters — called from the view via x-on:change="@this.setSelected*(…)"
// ---------------------------------------------------------------------------
public function setSelectedCountries(array $values): void
{
$removed = array_diff($this->selectedCountries, $values);
$this->selectedCountries = $values;
// When countries are removed, cascade-remove divisions/cities/districts
// that no longer belong to any selected country.
if (!empty($removed)) {
$keptDivisionIds = !empty($values)
? Division::whereIn('country_id', $values)->pluck('id')->toArray()
: [];
$this->selectedDivisions = array_values(array_intersect($this->selectedDivisions, $keptDivisionIds));
$keptCityIds = !empty($this->selectedDivisions)
? City::whereIn('division_id', $this->selectedDivisions)->pluck('id')->toArray()
: [];
$this->selectedCities = array_values(array_intersect($this->selectedCities, $keptCityIds));
$keptDistrictIds = !empty($this->selectedCities)
? District::whereIn('city_id', $this->selectedCities)->pluck('id')->toArray()
: [];
$this->selectedDistricts = array_values(array_intersect($this->selectedDistricts, $keptDistrictIds));
}
$this->emitSelectionUpdate();
}
public function setSelectedDivisions(array $values): void
{
$removed = array_diff($this->selectedDivisions, $values);
$this->selectedDivisions = $values;
// When divisions are removed, cascade-remove cities/districts that no
// longer belong to any selected division.
if (!empty($removed)) {
$keptCityIds = !empty($values)
? City::whereIn('division_id', $values)->pluck('id')->toArray()
: [];
$this->selectedCities = array_values(array_intersect($this->selectedCities, $keptCityIds));
$keptDistrictIds = !empty($this->selectedCities)
? District::whereIn('city_id', $this->selectedCities)->pluck('id')->toArray()
: [];
$this->selectedDistricts = array_values(array_intersect($this->selectedDistricts, $keptDistrictIds));
}
$this->emitSelectionUpdate();
}
public function setSelectedCities(array $values): void
{
$removed = array_diff($this->selectedCities, $values);
$this->selectedCities = $values;
// When cities are removed, cascade-remove districts that no longer
// belong to any selected city.
if (!empty($removed)) {
$keptDistrictIds = !empty($values)
? District::whereIn('city_id', $values)->pluck('id')->toArray()
: [];
$this->selectedDistricts = array_values(array_intersect($this->selectedDistricts, $keptDistrictIds));
}
$this->emitSelectionUpdate();
}
public function setSelectedDistricts(array $values): void
{
$this->selectedDistricts = $values;
$this->emitSelectionUpdate();
}
#[On('resetLocationFilter')]
public function resetLocationFilter(): void
{
$this->selectedCountries = [];
$this->selectedDivisions = [];
$this->selectedCities = [];
$this->selectedDistricts = [];
$this->emitSelectionUpdate();
}
#[On('loadLocationFilterData')]
public function loadLocationFilterData(array $data = []): void
{
$this->selectedCountries = $data['countries'] ?? [];
$this->selectedDivisions = $data['divisions'] ?? [];
$this->selectedCities = $data['cities'] ?? [];
$this->selectedDistricts = $data['districts'] ?? [];
}
private function emitSelectionUpdate(): void
{
$this->dispatch('locationFilterUpdated', [
'countries' => $this->selectedCountries,
'divisions' => $this->selectedDivisions,
'cities' => $this->selectedCities,
'districts' => $this->selectedDistricts,
]);
}
public function render()
{
// Always derive display data fresh from the current selected state.
// Never store Eloquent collections as public properties — Livewire serializes
// them to plain arrays, breaking ->sortBy() and ->count() in the blade view.
$countries = Country::with('translations')->get();
$divisions = !empty($this->selectedCountries)
? Division::with('translations')->whereIn('country_id', $this->selectedCountries)->get()
: collect();
if (!empty($this->selectedDivisions)) {
$cities = City::with('translations')->whereIn('division_id', $this->selectedDivisions)->get();
} elseif (!empty($this->selectedCountries)) {
$cities = City::with('translations')->whereIn('country_id', $this->selectedCountries)->get();
} else {
$cities = collect();
}
$districts = !empty($this->selectedCities)
? District::with('translations')->whereIn('city_id', $this->selectedCities)->get()
: collect();
return view('livewire.mailings.location-filter', compact(
'countries', 'divisions', 'cities', 'districts'
));
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,970 @@
<?php
namespace App\Http\Livewire;
use App\Models\Call;
use App\Models\Category;
use App\Overrides\Matchish\ScoutElasticSearch\ElasticSearch\EloquentHitsIteratorAggregate;
use App\Traits\ProfilePermissionTrait;
use Elastic\Elasticsearch\Client;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
use Matchish\ScoutElasticSearch\MixedSearch;
use ONGR\ElasticsearchDSL\Aggregation\Bucketing\TermsAggregation;
use ONGR\ElasticsearchDSL\Highlight\Highlight;
use ONGR\ElasticsearchDSL\Query\Compound\BoolQuery;
use ONGR\ElasticsearchDSL\Query\FullText\MatchQuery;
use ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery;
use ONGR\ElasticsearchDSL\Query\TermLevel\TermsQuery;
use ONGR\ElasticsearchDSL\Search;
class MainBrowseTagCategories extends Component
{
use ProfilePermissionTrait;
public $tagCategories = [];
public $selectedCategories = [];
public $fetchedResults = [];
public $results = [];
public int $total = 0;
// Location-based search properties
public $profileLocation = null;
public $locationHierarchy = [];
// Caching keys
private const CACHE_CATEGORIES_KEY = 'tag_categories_hierarchy';
private const CACHE_TTL = 3600; // 1 hour
public function mount()
{
$this->loadTagCategories();
$this->loadProfileLocation();
if ($categoryId = request()->query('category')) {
if ($this->validateCategoryId($categoryId)) {
$this->selectCategory((int)$categoryId);
return $this->showCategoryResults();
}
}
}
public function render()
{
return view('livewire.main-browse-tag-categories');
}
private function loadTagCategories()
{
// Use caching for category hierarchy to improve performance
$this->tagCategories = Cache::remember(
self::CACHE_CATEGORIES_KEY . '_' . app()->getLocale(),
self::CACHE_TTL,
function () {
$categories = Category::where('type', 'App\Models\Tag')
->with(['translations' => function ($query) {
$query->where('locale', app()->getLocale());
}])
->get();
return $this->buildHierarchy($categories);
}
);
}
private function loadProfileLocation()
{
$this->locationHierarchy = getActiveProfileLocationHierarchy();
$this->profileLocation = !empty($this->locationHierarchy) ? true : null;
}
private function buildHierarchy($categories, $parentId = null)
{
$hierarchy = [];
foreach ($categories as $category) {
if ($category->parent_id == $parentId) {
$categoryData = [
'id' => $category->id,
'name' => $category->translations->first()?->name ?? $category->name,
'color' => $category->relatedColor,
'parent_id' => $category->parent_id,
'children' => $this->buildHierarchy($categories, $category->id)
];
$hierarchy[] = $categoryData;
}
}
return $hierarchy;
}
public function selectCategory($categoryId)
{
if (!$this->validateCategoryId($categoryId)) {
return;
}
$categoryId = (int)$categoryId;
// Toggle category selection
if (in_array($categoryId, $this->selectedCategories)) {
$this->selectedCategories = array_values(array_filter(
$this->selectedCategories,
fn($id) => $id != $categoryId
));
} else {
$this->selectedCategories[] = $categoryId;
}
$this->selectedCategories = array_map('intval', $this->selectedCategories);
$this->performSearch();
}
public function removeCategory($categoryId)
{
if (!$this->validateCategoryId($categoryId)) {
return;
}
$categoryId = (int)$categoryId;
$this->selectedCategories = array_values(array_filter(
$this->selectedCategories,
fn($id) => $id != $categoryId
));
$this->selectedCategories = array_map('intval', $this->selectedCategories);
$this->performSearch();
}
private function validateCategoryId($categoryId): bool
{
return is_numeric($categoryId) && $categoryId > 0;
}
private function performSearch()
{
if (!empty($this->selectedCategories)) {
$this->searchByCategories($this->selectedCategories);
} else {
$this->resetResults();
}
}
private function resetResults()
{
$this->fetchedResults = collect([]);
$this->total = 0;
}
private function searchByCategories($categoryIds)
{
$validCategoryIds = array_filter($categoryIds, [$this, 'validateCategoryId']);
if (empty($validCategoryIds)) {
$this->resetResults();
return;
}
// Get all child category IDs including the selected ones
$allCategoryIds = $this->expandCategoryIds($validCategoryIds);
if (empty($allCategoryIds)) {
$this->resetResults();
return;
}
// Get category names with caching
$categoryNames = $this->getCategoryNames($allCategoryIds);
if (empty($categoryNames)) {
$this->resetResults();
return;
}
$this->executeElasticsearchQuery($categoryNames);
}
private function expandCategoryIds(array $categoryIds): array
{
$allCategoryIds = [];
foreach ($categoryIds as $categoryId) {
$childIds = $this->getAllCategoryIds($categoryId);
$allCategoryIds = array_merge($allCategoryIds, $childIds);
}
return array_values(array_unique(array_filter($allCategoryIds, [$this, 'validateCategoryId'])));
}
private function getCategoryNames(array $categoryIds): array
{
$cacheKey = 'category_names_' . md5(serialize($categoryIds)) . '_' . app()->getLocale();
return Cache::remember($cacheKey, 300, function () use ($categoryIds) {
$appLocale = app()->getLocale();
$fallbackLocale = config('app.fallback_locale');
$categories = Category::whereIn('id', $categoryIds)
->with(['translations'])
->get();
return $categories->map(function ($category) use ($appLocale, $fallbackLocale) {
$translation = $category->translations->firstWhere('locale', $appLocale)
?? $category->translations->firstWhere('locale', $fallbackLocale)
?? $category->translations->first();
return $translation?->name ?? null;
})->filter()->values()->toArray();
});
}
private function executeElasticsearchQuery(array $categoryNames)
{
$startTime = microtime(true);
try {
$rawOutput = MixedSearch::search('', function (Client $client, Search $body) use ($categoryNames) {
$this->buildElasticsearchQuery($body, $categoryNames);
$this->addHighlighting($body);
$this->addAggregations($body);
// Use your configured max_results
$body->setSize(timebank_config('main_search_bar.search.max_results', 50) * 1.5);
\Log::info('Elasticsearch query', ['query' => $body->toArray()]);
return $client->search([
'index' => timebank_config('main_search_bar.model_indices'),
'body' => $body->toArray()
])->asArray();
})->raw();
$executionTime = microtime(true) - $startTime;
$this->processSearchResults($rawOutput);
// Track analytics if helper exists
if (class_exists('App\Helpers\SearchOptimizationHelper')) {
\App\Helpers\SearchOptimizationHelper::trackLocationSearchAnalytics(
$this->selectedCategories,
$this->total,
$executionTime,
$this->locationHierarchy
);
}
} catch (\Exception $e) {
$this->handleSearchError($e, $categoryNames);
}
}
private function buildElasticsearchQuery(Search $body, array $categoryNames)
{
$body->setSource(['id', '__class_name', '_score']);
$locale = app()->getLocale();
$mainBoolQuery = new BoolQuery();
// Primary category matching with boost from your config
$categoryBoolQuery = new BoolQuery();
$categoryField = 'tags.contexts.categories.name_' . $locale;
$categoryBoost = timebank_config('main_search_bar.boosted_fields.categories', 1.5);
\Log::info('Building category query', [
'category_field' => $categoryField,
'category_names' => $categoryNames,
'locale' => $locale
]);
foreach ($categoryNames as $categoryName) {
// Exact match with your configured boost
$exactMatch = new MatchQuery($categoryField, $categoryName);
$exactMatch->addParameter('boost', $categoryBoost * 2);
$categoryBoolQuery->add($exactMatch, BoolQuery::SHOULD);
// Fuzzy match using your configured fuzziness
$fuzzyMatch = new MatchQuery($categoryField, $categoryName);
$fuzzyMatch->addParameter('fuzziness', timebank_config('main_search_bar.search.fuzziness', '1'));
$fuzzyMatch->addParameter('prefix_length', timebank_config('main_search_bar.search.prefix_length', 2));
$fuzzyMatch->addParameter('boost', $categoryBoost);
$categoryBoolQuery->add($fuzzyMatch, BoolQuery::SHOULD);
}
// Also match calls by tag name (calls index uses tag.name_{locale}, not the categories field)
$tagField = 'tag.name_' . $locale;
foreach ($categoryNames as $categoryName) {
$exactTagMatch = new MatchQuery($tagField, $categoryName);
$exactTagMatch->addParameter('boost', $categoryBoost * 2);
$categoryBoolQuery->add($exactTagMatch, BoolQuery::SHOULD);
$fuzzyTagMatch = new MatchQuery($tagField, $categoryName);
$fuzzyTagMatch->addParameter('fuzziness', timebank_config('main_search_bar.search.fuzziness', '1'));
$fuzzyTagMatch->addParameter('prefix_length', timebank_config('main_search_bar.search.prefix_length', 2));
$fuzzyTagMatch->addParameter('boost', $categoryBoost);
$categoryBoolQuery->add($fuzzyTagMatch, BoolQuery::SHOULD);
}
$categoryBoolQuery->addParameter('minimum_should_match', 1);
$mainBoolQuery->add($categoryBoolQuery, BoolQuery::MUST);
// Add location-based boosting (but make it optional)
$this->addLocationBoosts($mainBoolQuery);
// Add model-specific boosts using your configuration
$this->addModelBoosts($mainBoolQuery, $locale);
$body->addQuery($mainBoolQuery);
}
private function addLocationBoosts(BoolQuery $mainBoolQuery)
{
// Re-enable location boosting since basic search is working
if (empty($this->locationHierarchy)) {
\Log::info('No location hierarchy available');
return;
}
$locationBoolQuery = new BoolQuery();
$addedQueries = 0;
if (!empty($this->locationHierarchy['district_name'])) {
$districtQuery = new MatchQuery('locations.district', $this->locationHierarchy['district_name']);
$districtQuery->addParameter('boost', 5.0);
$locationBoolQuery->add($districtQuery, BoolQuery::SHOULD);
$addedQueries++;
}
if (!empty($this->locationHierarchy['city_name'])) {
$cityQuery = new MatchQuery('locations.city', $this->locationHierarchy['city_name']);
$cityQuery->addParameter('boost', 3.0);
$locationBoolQuery->add($cityQuery, BoolQuery::SHOULD);
$addedQueries++;
}
if (!empty($this->locationHierarchy['division_name'])) {
$divisionQuery = new MatchQuery('locations.division', $this->locationHierarchy['division_name']);
$divisionQuery->addParameter('boost', 2.0);
$locationBoolQuery->add($divisionQuery, BoolQuery::SHOULD);
$addedQueries++;
}
if (!empty($this->locationHierarchy['country_name'])) {
$countryQuery = new MatchQuery('locations.country', $this->locationHierarchy['country_name']);
$countryQuery->addParameter('boost', 1.5);
$locationBoolQuery->add($countryQuery, BoolQuery::SHOULD);
$addedQueries++;
}
if ($addedQueries > 0) {
$mainBoolQuery->add($locationBoolQuery, BoolQuery::SHOULD);
}
}
private function getLocationName($location)
{
if (!$location) {
return null;
}
// Try to get translated name for current locale
$locale = app()->getLocale();
if ($location->translations && $location->translations->count() > 0) {
$translation = $location->translations->where('locale', $locale)->first();
if ($translation && $translation->name) {
return $translation->name;
}
}
// Fallback to default name or base language
return $location->name ?? null;
}
private function addModelBoosts(BoolQuery $mainBoolQuery, string $locale)
{
// Temporarily disable model boosts to avoid ExistsQuery issues
// Model boosting will be handled in PHP during result processing
\Log::info('Model boosting disabled - will be applied during result processing');
return;
}
private function addHighlighting(Search $body)
{
$highlight = new Highlight();
$locale = app()->getLocale();
$fields = [
'tags.contexts.categories.name_' . $locale,
'tags.contexts.tags.name_' . $locale,
'name',
'about_short_' . $locale,
'about_' . $locale,
'motivation_' . $locale,
'post_translations.title_' . $locale,
'post_translations.excerpt_' . $locale,
'post_category.name_' . $locale,
];
// Use your existing highlight configuration
$highlightConfig = [
'fragment_size' => timebank_config('main_search_bar.search.fragment_size', 50),
'fragmenter' => timebank_config('main_search_bar.search.fragmenter', 'simple'),
'number_of_fragments' => timebank_config('main_search_bar.search.number_of_fragments', 1),
'pre_tags' => [timebank_config('main_search_bar.search.pre-tags', '<span class="font-semibold text-white leading-tight">')],
'post_tags' => [timebank_config('main_search_bar.search.post-tags', '</span>')],
'order' => timebank_config('main_search_bar.search.order', 'score'),
];
foreach ($fields as $field) {
$highlight->addField($field, $highlightConfig);
}
$body->addHighlight($highlight);
}
private function addAggregations(Search $body)
{
// Add aggregation to get model type distribution
$modelTypeAgg = new TermsAggregation('model_types', '__class_name');
$modelTypeAgg->addParameter('size', 10);
$body->addAggregation($modelTypeAgg);
}
private function processSearchResults($rawOutput)
{
$rawData = $rawOutput['hits']['hits'] ?? [];
$this->total = $rawOutput['hits']['total']['value'] ?? 0;
\Log::info('Elasticsearch response', [
'total_hits' => $this->total,
'returned_hits' => count($rawData),
'raw_query_time' => $rawOutput['took'] ?? 'unknown'
]);
if ($this->total === 0) {
\Log::warning('No search results found', [
'selected_categories' => $this->selectedCategories,
'location_hierarchy_present' => !empty($this->locationHierarchy)
]);
$this->fetchedResults = collect([]);
return;
}
// Process results efficiently
$results = $this->processHitsWithOptimization($rawOutput);
\Log::info('After hit processing', [
'processed_results_count' => count($results)
]);
// Apply final filtering and sorting
$filteredResults = $this->applyBusinessLogicFiltering($results);
\Log::info('After business logic filtering', [
'filtered_results_count' => count($filteredResults)
]);
$this->fetchedResults = $this->sortAndLimitResults($filteredResults);
\Log::info('Final results', [
'final_results_count' => $this->fetchedResults->count()
]);
}
private function processHitsWithOptimization($rawOutput): array
{
$includedCategoryIds = timebank_config('main_search_bar.category_ids_posts');
$canManageProfiles = $this->getCanManageProfiles();
$callback = function ($model) use ($includedCategoryIds, $canManageProfiles) {
$modelClass = get_class($model);
// Handle Posts
if ($modelClass === 'App\Models\Post') {
$categoryId = optional($model->category)->id;
return in_array($categoryId, $includedCategoryIds) ? $model : null;
}
// Handle Calls
if ($modelClass === 'App\Models\Call') {
if ($model->trashed() || $model->is_suppressed || $model->is_paused) return null;
if ($model->till !== null && $model->till->isPast()) return null;
if (!$model->is_public && !\Illuminate\Support\Facades\Auth::check()) return null;
return $model;
}
// Handle Profiles (User, Organization, Bank)
if (in_array($modelClass, ['App\Models\User', 'App\Models\Organization', 'App\Models\Bank'])) {
return $this->filterProfile($model, $canManageProfiles);
}
return null;
};
$results = [];
$hitsIterator = new EloquentHitsIteratorAggregate($rawOutput, $callback);
foreach ($hitsIterator as $model) {
if ($model) {
$results[] = $this->transformModelToResult($model, $rawOutput);
}
}
return $results;
}
private function filterProfile($model, bool $canManageProfiles)
{
if ($canManageProfiles) {
return $model;
}
// Check inactive profiles
if (
timebank_config('profile_inactive.profile_search_hidden')
&& !empty($model->inactive_at)
&& \Carbon\Carbon::parse($model->inactive_at)->isPast()
) {
return null;
}
// Check unverified emails
if (
timebank_config('profile_email_unverified.profile_search_hidden')
&& empty($model->email_verified_at)
) {
return null;
}
// Check deleted profiles
if (
!empty($model->deleted_at)
&& \Carbon\Carbon::parse($model->deleted_at)->isPast()
) {
return null;
}
// Check incomplete profiles
if (
timebank_config('profile_incomplete.profile_search_hidden')
&& method_exists($model, 'hasIncompleteProfile')
&& $model->hasIncompleteProfile($model)
) {
return null;
}
// Hide banks by level for the current viewer type
if ($model instanceof \App\Models\Bank && !canViewerSeeBankLevel($model->level, 'search_visible')) {
return null;
}
return $model;
}
private function transformModelToResult($model, $rawOutput): array
{
$modelClass = get_class($model);
$modelId = $model->id;
// Find corresponding raw data for highlights
$rawItem = collect($rawOutput['hits']['hits'])->first(function ($hit) use ($modelId) {
return ($hit['_source']['id'] ?? null) == $modelId;
});
$result = [
'id' => $modelId,
'model' => $modelClass,
'score' => $rawItem['_score'] ?? 1,
'highlight' => $rawItem['highlight'] ?? [],
];
// Add model-specific data
if ($modelClass === 'App\Models\Post') {
$result = array_merge($result, $this->getPostData($model));
} elseif ($modelClass === 'App\Models\Call') {
$result = array_merge($result, $this->getCallData($model));
} else {
$result = array_merge($result, $this->getProfileData($model));
}
// Add location proximity information for debugging/display
$result['location_proximity'] = $this->calculateLocationProximity($model);
return $result;
}
private function calculateLocationProximity($model): array
{
if (empty($this->locationHierarchy)) {
return ['level' => 'unknown', 'distance' => null];
}
// For posts, check the post author's location
if (get_class($model) === 'App\Models\Post') {
$modelLocation = optional($model->user)->locations->first();
} elseif (get_class($model) === 'App\Models\Call') {
// Calls have a single location (belongsTo), not a collection
$modelLocation = $model->location;
} else {
// For profiles (User, Organization, Bank)
$modelLocation = $model->locations->first();
}
if (!$modelLocation) {
return ['level' => 'no_location', 'distance' => null];
}
// Get model's location hierarchy
try {
$modelHierarchy = $modelLocation->getCompleteHierarchy();
} catch (\Exception $e) {
return ['level' => 'error', 'distance' => null];
}
if (!empty($this->locationHierarchy['district_id']) && isset($modelHierarchy['district']) && $modelHierarchy['district']
&& $this->locationHierarchy['district_id'] === $modelHierarchy['district']->id
) {
return ['level' => 'same_district', 'distance' => 1];
}
if (!empty($this->locationHierarchy['city_id']) && isset($modelHierarchy['city']) && $modelHierarchy['city']
&& $this->locationHierarchy['city_id'] === $modelHierarchy['city']->id
) {
return ['level' => 'same_city', 'distance' => 2];
}
if (!empty($this->locationHierarchy['division_id']) && isset($modelHierarchy['division']) && $modelHierarchy['division']
&& $this->locationHierarchy['division_id'] === $modelHierarchy['division']->id
) {
return ['level' => 'same_division', 'distance' => 3];
}
if (!empty($this->locationHierarchy['country_id']) && isset($modelHierarchy['country']) && $modelHierarchy['country']
&& $this->locationHierarchy['country_id'] === $modelHierarchy['country']->id
) {
return ['level' => 'same_country', 'distance' => 4];
}
return ['level' => 'different_country', 'distance' => 5];
}
private function getCallData(Call $call): array
{
$call->loadMissing([
'callable',
'translations',
'tag.contexts.category.translations',
'tag.contexts.category.ancestors.translations',
'location.city.translations',
'location.country.translations',
'loveReactant.reactionCounters',
]);
$tag = $call->tag;
$tagName = $tag?->translation?->name ?? $tag?->name;
$tagCategory = $tag?->contexts->first()?->category;
$tagColor = $tagCategory?->relatedColor ?? 'gray';
$city = optional($call->location?->city?->translations->first())->name;
$country = optional($call->location?->country?->translations->first())->name;
$tagCategories = [];
if ($tagCategory) {
$locale = app()->getLocale();
$ancestors = Cache::remember(
'category_ancestors_' . $tagCategory->id,
now()->addHours(6),
fn () => $tagCategory->ancestorsAndSelf()->get()->reverse()
);
foreach ($ancestors as $cat) {
$catName = $cat->translations->firstWhere('locale', $locale)?->name
?? $cat->translations->first()?->name
?? '';
if ($catName) {
$tagCategories[] = [
'id' => $cat->id,
'name' => $catName,
'color' => $cat->relatedColor ?? 'gray',
];
}
}
}
return [
'title' => $tagName ?? '',
'tag_name' => $tagName,
'tag_color' => $tagColor,
'tag_categories' => $tagCategories,
'callable_name' => $call->callable?->name ?? '',
'photo' => $call->callable?->profile_photo_url ?? '',
'location' => collect([$city, $country])->filter()->implode(', '),
'location_short' => $city ?? '',
'till' => $call->till,
'expiry_badge_text' => \App\Http\Livewire\Calls\ProfileCalls::buildExpiryBadgeText($call->till),
'like_count' => $call->loveReactant?->reactionCounters
->firstWhere('reaction_type_id', 3)?->count ?? 0,
'excerpt' => $call->translations->first()?->content ?? '',
'status' => '',
];
}
private function getPostData($post): array
{
$categoryId = optional($post->category)->id;
$categoryName = optional($post->category->translations
->where('locale', app()->getLocale())->first())->name;
return [
'category_id' => $categoryId,
'category' => $categoryName,
'title' => $post->title,
'subtitle' => $post->excerpt,
'photo' => $post->getFirstMediaUrl('posts', 'hero'),
'location_short' => '',
'location' => '',
'status' => '',
];
}
private function getProfileData($profile): array
{
// Eager load relationships for better performance
$profile->load([
'locations.district.translations',
'locations.city.translations',
'locations.division.translations',
'locations.country.translations',
]);
return [
'name' => $profile->name,
'photo' => $profile->profile_photo_url ?? '',
'location' => $this->formatProfileLocation($profile),
'type' => strtolower(class_basename($profile)),
];
}
private function formatProfileLocation($profile): string
{
$location = $profile->locations->first();
if (!$location) {
return '';
}
$parts = [];
if ($location->city && $city = optional($location->city->translations->where('locale', app()->getLocale())->first())->name) {
$parts[] = $city;
}
if ($location->division && $division = optional($location->division->translations->where('locale', app()->getLocale())->first())->name) {
$parts[] = $division;
}
return implode(', ', $parts);
}
private function applyBusinessLogicFiltering(array $results): array
{
// Apply any additional business logic filtering here
return array_filter($results, function ($result) {
// Example: Filter out results with very low scores
return ($result['score'] ?? 0) > 0.1;
});
}
private function sortAndLimitResults(array $results): \Illuminate\Support\Collection
{
// Apply model-specific score boosts using your configuration
$results = array_map(function ($result) {
$type = strtolower(class_basename($result['model']));
$boost = timebank_config('main_search_bar.boosted_models.' . $type, 1);
// Apply location proximity boost
$locationBoost = $this->getLocationProximityBoost($result['location_proximity'] ?? []);
$result['score'] = ($result['score'] ?? 1) * $boost * $locationBoost;
return $result;
}, $results);
// Sort by score and location proximity, then limit results using your configuration
return collect($results)
->sortBy([
// Primary sort: location proximity (closer locations first)
['location_proximity.distance', 'asc'],
// Secondary sort: score (higher scores first)
['score', 'desc']
])
->take(timebank_config('main_search_bar.search.max_results', 50))
->map(function ($result) {
return [
'model' => $result['model'] ?? '',
'id' => $result['id'] ?? '',
'score' => $result['score'] ?? '',
'highlight' => $this->extractBestHighlight($result['highlight'] ?? []),
'suggest' => [],
'location_proximity' => $result['location_proximity'] ?? [],
];
});
}
private function getLocationProximityBoost(array $locationProximity): float
{
if (empty($locationProximity) || !isset($locationProximity['level'])) {
return 1.0; // No location data, neutral boost
}
// Location-based boost multipliers (closer = higher boost)
$boostMap = [
'same_district' => 2.5, // Highest boost for same district
'same_city' => 2.0, // High boost for same city
'same_division' => 1.5, // Medium boost for same division/state
'same_country' => 1.2, // Small boost for same country
'different_country' => 1.0, // Neutral for different countries
'no_location' => 0.9, // Slight penalty for no location
'unknown' => 1.0, // Neutral for unknown
'error' => 1.0, // Neutral for errors
];
return $boostMap[$locationProximity['level']] ?? 1.0;
}
private function extractBestHighlight(array $highlights): array
{
if (empty($highlights)) {
return [];
}
// Return the first highlight from the most relevant field
$priorityFields = [
'tags.contexts.categories.name_' . app()->getLocale(),
'name',
'about_short_' . app()->getLocale(),
'post_translations.title_' . app()->getLocale(),
];
foreach ($priorityFields as $field) {
if (isset($highlights[$field]) && !empty($highlights[$field])) {
return $highlights[$field];
}
}
// Fallback to first available highlight
return $highlights[array_key_first($highlights)] ?? [];
}
private function handleSearchError(\Exception $e, array $categoryNames)
{
\Log::error('MainBrowseTagCategories search error', [
'error' => $e->getMessage(),
'categoryNames' => $categoryNames,
'locale' => app()->getLocale(),
'trace' => $e->getTraceAsString()
]);
$this->resetResults();
}
private function getAllCategoryIds($categoryId)
{
if (!$this->validateCategoryId($categoryId)) {
return [];
}
$cacheKey = 'category_descendants_' . $categoryId;
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($categoryId) {
$category = Category::find($categoryId);
if (!$category) {
return [(int)$categoryId];
}
try {
$descendants = $category->descendants()->pluck('id')->toArray();
$allIds = array_merge([(int)$categoryId], array_map('intval', $descendants));
return array_values(array_filter($allIds, [$this, 'validateCategoryId']));
} catch (\Exception $e) {
return [(int)$categoryId];
}
});
}
public function showCategoryResults()
{
if (empty($this->selectedCategories)) {
return null;
}
$validCategoryIds = array_filter($this->selectedCategories, [$this, 'validateCategoryId']);
if (empty($validCategoryIds)) {
return null;
}
$searchTerm = $this->buildSearchTermFromCategories($validCategoryIds);
// Ensure fetchedResults is a Collection before calling map()
$fetchedResults = is_array($this->fetchedResults) ? collect($this->fetchedResults) : $this->fetchedResults;
$results = $fetchedResults->map(fn($r) => [
'model' => $r['model'],
'id' => $r['id'],
'highlight' => $r['highlight'] ?? [],
'score' => $r['score'] ?? null,
])->values()->all();
// Cache results for search.show view using your configuration
$key = 'main_search_bar_results_' . Auth::guard('web')->id();
$cacheData = [
'results' => $results,
'searchTerm' => $searchTerm,
'total' => $this->total
];
Cache::put($key, $cacheData, now()->addMinutes(
timebank_config('main_search_bar.cache_results', 5)
));
return redirect()->route('search.show');
}
private function buildSearchTermFromCategories(array $categoryIds): string
{
$categories = Category::whereIn('id', $categoryIds)
->with(['translations' => function ($query) {
$query->where('locale', app()->getLocale());
}])
->get();
$searchTerm = $categories->map(function ($category) {
return $category->translation ? $category->translation->name : $category->name;
})->filter()->implode(', ');
return $searchTerm ?: 'Selected Categories (' . count($categoryIds) . ')';
}
// Keep the original renderHorizontalCategoryTree method if still needed
public function renderHorizontalCategoryTree($categories, $level = 0)
{
$html = '';
$baseSize = $level === 0 ? 'text-sm' : 'text-xs';
$padding = $level === 0 ? 'px-3 py-1.5' : 'px-2 py-1';
foreach ($categories as $category) {
$isSelected = in_array($category['id'], $this->selectedCategories);
$selectedClass = $isSelected ? 'ring-2 ring-blue-500 ring-offset-1' : '';
$opacity = $level > 0 ? 'opacity-90' : '';
$html .= '<button wire:click="selectCategory(' . $category['id'] . ')" class="bg-' . $category['color'] . '-400 hover:bg-' . $category['color'] . '-500 text-black inline-flex items-center rounded-md ' . $padding . ' ' . $baseSize . ' font-normal transition duration-150 ease-in-out cursor-pointer ' . $selectedClass . ' ' . $opacity . '">';
if ($level > 0) {
$html .= str_repeat('・', $level) . ' ';
}
$html .= htmlspecialchars($category['name']);
$html .= '</button>';
if (!empty($category['children'])) {
$html .= $this->renderHorizontalCategoryTree($category['children'], $level + 1);
}
}
return $html;
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Livewire;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
class MainPage extends Component
{
public $lastLoginAt;
public $user;
public $profileType;
public function mount()
{
$this->user = [
'name' => getActiveProfile()->name,
'firstName' => Str::words(getActiveProfile()->full_name, 1, ''),
];
$profile = getActiveProfile();
// For User profiles, skip current login to get truly previous login
// For other profiles (Organization, Bank, etc.), show the most recent login
$query = Activity::where('subject_id', $profile->id)
->where('subject_type', get_class($profile))
->where('event', 'login')
->whereNotNull('properties->old->login_at')
->latest('id');
if (get_class($profile) === 'App\\Models\\User') {
$query->skip(1);
}
$activityLog = $query->first();
if (
$activityLog &&
isset($activityLog->properties['old']['login_at']) &&
!empty($activityLog->properties['old']['login_at'])
) {
$lastLoginAt = $activityLog->properties['old']['login_at'];
$timestamp = strtotime($lastLoginAt);
if ($timestamp !== false) {
$causer = null;
if (!empty($activityLog->causer_type) && !empty($activityLog->causer_id) && class_exists($activityLog->causer_type)) {
$causer = $activityLog->causer_type::find($activityLog->causer_id);
}
$causerName = $causer && isset($causer->name) ? $causer->name : __('unknown user');
$showBy = !(
$activityLog->subject_type === $activityLog->causer_type &&
$activityLog->subject_id === $activityLog->causer_id
);
$this->lastLoginAt = Carbon::createFromTimestamp($timestamp, config('app.timezone'))->diffForHumans()
. ($showBy ? ' ' . __('by') . ' ' . $causerName : '');
} else {
$this->lastLoginAt = '';
}
} else {
$this->lastLoginAt = '';
}
$this->profileType = getActiveProfileType();
}
public function render()
{
return view('livewire.main-page');
}
}
@@ -0,0 +1,180 @@
<?php
namespace App\Http\Livewire\MainPage;
use App\Models\Category;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\Division;
use App\Models\News;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ArticleCardFull extends Component
{
#[Locked]
public $author;
#[Locked]
public $post = [];
#[Locked]
public $posts;
#[Locked]
public $media;
#[Locked]
public $postNr;
#[Locked]
public $related;
public function mount($postNr, $related, Request $request)
{
$this->postNr = $postNr;
$this->related = $related;
$profile = getActiveProfile();
// SECURITY: If a profile is active, validate the user has access to it
// This prevents session manipulation while allowing public access (null profile)
if ($profile) {
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
}
$location = $profile && $profile->locations
? $profile->locations()->with(['country', 'division', 'city'])->first()
: null;
$skipLocationFilter = false;
if ($location) {
// If no division and no city as location set
if (!$location->division && !$location->city) {
$categoryable_id = $location->country?->id;
$categoryable_type = Country::class;
// Include also all other countries if $related is set in view
if ($related) {
$categoryable_id = Country::pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
// Division without city is set as location
} elseif ($location->division && !$location->city) {
$categoryable_id = $location->division->id;
$categoryable_type = Division::class;
// Include also all other divisions in the same country if $related is set in view
if ($related) {
$categoryable_id = Division::find($categoryable_id)->parent->divisions->pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
// City is set as location
} elseif ($location->city) {
$categoryable_id = $location->city->id;
$categoryable_type = City::class;
// Include also all other cities in the same division if $related is set in view
if ($related) {
$categoryable_id = City::find($categoryable_id)->parent->cities->pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
}
// No matching location is set - skip location filtering
} else {
$skipLocationFilter = true;
$categoryable_id = [];
$categoryable_type = '';
}
// TODO: check what happens when multiple locations per user are used!
$post =
Post::with([
'postable' => function ($query) {
$query->select(['id', 'name']);
},
'category' => function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter) {
$query->where('type', 'App\Models\Article');
if (!$skipLocationFilter && !empty($categoryable_id)) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query
->whereIn('categoryable_id', $categoryable_id)
->where('categoryable_type', $categoryable_type);
})->orWhere(function ($query) {
// Also include categories with no location set
$query->whereNull('categoryable_id')
->whereNull('categoryable_type');
});
});
}
},
'translations' => function ($query) {
$query->where('locale', App::getLocale());
},
'author',
'media',
])
->whereHas('category', function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter) {
$query->where('type', 'App\Models\Article');
if (!$skipLocationFilter && !empty($categoryable_id)) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query
->whereIn('categoryable_id', $categoryable_id)
->where('categoryable_type', $categoryable_type);
})->orWhere(function ($query) {
// Also include categories with no location set
$query->whereNull('categoryable_id')
->whereNull('categoryable_type');
});
});
}
})
->whereHas('translations', function ($query) {
$query
->where('locale', App::getLocale())
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
})
->orderBy('updated_at', 'desc');
})
->get()->sortByDesc(function ($query) {
if (isset($query->translations)) {
return $query->translations->first()->updated_at;
};
})->values(); // Use values() method to reset the collection keys after sortBy
$lastNr = $post->count() - 1;
if ($postNr > $lastNr) {
$post = null;
} else {
$post = $post[$postNr];
}
if (isset($post->translations)) {
$translation = $post->translations->first();
$this->post = $translation;
$this->post['from'] = $translation->from;
$this->post['category'] = Category::find($post->category_id)->translations->where('locale', App::getLocale())->first()->name;
$this->post['author'] = $post->author ? $post->author->name : timebank_config('posts.site-content-writer');
$this->post['post_id'] = $post->id;
if ($post->media) {
$this->media = Post::find($post->id)->getFirstMedia('posts');
}
}
}
public function render()
{
return view('livewire.main-page.article-card-full');
}
}
@@ -0,0 +1,246 @@
<?php
namespace App\Http\Livewire\MainPage;
use App\Http\Livewire\Calls\CallCarouselScorer;
use App\Http\Livewire\Calls\ProfileCalls;
use App\Models\Call;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\Division;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Locked;
use Livewire\Component;
class CallCardCarousel extends Component
{
#[Locked]
public array $calls = [];
public bool $related = true;
public bool $random = false;
public int $maxCards = 0;
public bool $showScore = false;
private const UNKNOWN_COUNTRY_ID = 10;
public function mount(bool $related, bool $random = false, int $maxCards = 0): void
{
$this->related = $related;
$this->random = $random;
$this->maxCards = $maxCards;
$carouselCfg = timebank_config('calls.carousel', []);
$this->maxCards = (int) ($carouselCfg['max_cards'] ?? ($maxCards ?: 12));
$locale = App::getLocale();
// --- Resolve active profile and its location ---
$profile = getActiveProfile();
if ($profile) {
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
}
$location = $profile?->locations
? $profile->locations()->with(['country', 'division', 'city'])->first()
: null;
$profileCityId = $location?->city?->id;
$profileDivisionId = $location?->division?->id;
$profileCountryId = $location?->country?->id;
// Expand location IDs based on $related flag (same sibling logic as EventCardFull)
$locationCityIds = [];
$locationDivisionIds = [];
$locationCountryIds = [];
if ($location) {
if (!$location->division && !$location->city) {
$country = $location->country;
$locationCountryIds = $related
? Country::pluck('id')->toArray()
: ($country ? [$country->id] : []);
} elseif ($location->division && !$location->city) {
$divisionId = $location->division->id;
$locationDivisionIds = $related
? Division::find($divisionId)->parent->divisions->pluck('id')->toArray()
: [$divisionId];
} elseif ($location->city) {
$cityId = $location->city->id;
$locationCityIds = $related
? City::find($cityId)->parent->cities->pluck('id')->toArray()
: [$cityId];
}
}
// --- Base query — safety exclusions are always hardcoded ---
$query = Call::with([
'tag.contexts.category.translations',
'tag.contexts.category.ancestors.translations',
'translations',
'location.city.translations',
'location.country.translations',
'callable.locations.city.translations',
'callable.locations.division.translations',
'callable.locations.country.translations',
'callable.loveReactant.reactionCounters',
'loveReactant.reactionCounters',
])
->whereNull('deleted_at')
->where('is_paused', false)
->where('is_suppressed', false)
->where(fn ($q) => $q->whereNull('till')->orWhere('till', '>=', now()));
// Enforce is_public for guests or when config requires it
if (!Auth::check() || ($carouselCfg['exclude_non_public'] ?? true)) {
$query->where('is_public', true);
}
// Configurable: exclude the active profile's own calls
if ($profile && ($carouselCfg['exclude_own_calls'] ?? true)) {
$query->where(function ($q) use ($profile) {
$q->where('callable_type', '!=', get_class($profile))
->orWhere('callable_id', '!=', $profile->id);
});
}
// --- Locality filter ---
$includeUnknown = $carouselCfg['include_unknown_location'] ?? true;
$includeDivision = $carouselCfg['include_same_division'] ?? true;
$includeCountry = $carouselCfg['include_same_country'] ?? true;
$hasLocalityFilter = $locationCityIds || $locationDivisionIds || $locationCountryIds
|| ($includeDivision && $locationDivisionIds)
|| ($includeCountry && $locationCountryIds)
|| $includeUnknown;
if ($hasLocalityFilter) {
$query->where(function ($q) use (
$locationCityIds, $locationDivisionIds, $locationCountryIds,
$includeDivision, $includeCountry, $includeUnknown
) {
// Always include calls matching the profile's city
if ($locationCityIds) {
$q->orWhereHas('location', fn ($lq) => $lq->whereIn('city_id', $locationCityIds));
}
if ($includeDivision && $locationDivisionIds) {
$q->orWhereHas('location', fn ($lq) => $lq->whereIn('division_id', $locationDivisionIds));
}
if ($includeCountry && $locationCountryIds) {
$q->orWhereHas('location', fn ($lq) => $lq->whereIn('country_id', $locationCountryIds));
}
if ($includeUnknown) {
$q->orWhereHas('location', fn ($lq) => $lq->where('country_id', self::UNKNOWN_COUNTRY_ID));
}
});
}
// Fetch pool for scoring
$isAdmin = getActiveProfileType() === 'Admin';
$this->showScore = (bool) ($carouselCfg['show_score'] ?? false)
|| ($isAdmin && (bool) ($carouselCfg['show_score_for_admins'] ?? true));
$poolSize = $this->maxCards * max(1, (int) ($carouselCfg['pool_multiplier'] ?? 5));
$calls = $query->limit($poolSize)->get();
// --- Score, sort, take top $maxCards ---
$scorer = new CallCarouselScorer(
$carouselCfg,
$profileCityId,
$profileDivisionId,
$profileCountryId
);
$this->calls = $calls->map(function (Call $call) use ($locale, $scorer) {
$translation = $call->translations->firstWhere('locale', $locale)
?? $call->translations->first();
$tag = $call->tag;
$tagContext = $tag?->contexts->first();
$tagCategory = $tagContext?->category;
$tagColor = $tagCategory?->relatedColor ?? 'gray';
$tagName = $tag?->translation?->name ?? $tag?->name;
$locationStr = null;
if ($call->location) {
$loc = $call->location;
$parts = [];
if ($loc->city) {
$cityName = optional($loc->city->translations->first())->name;
if ($cityName) $parts[] = $cityName;
}
if ($loc->country) {
if ($loc->country->code === 'XX') {
$parts[] = __('Location not specified');
} elseif ($loc->country->code) {
$parts[] = strtoupper($loc->country->code);
}
}
$locationStr = $parts ? implode(', ', $parts) : null;
}
$tagCategories = [];
if ($tagCategory) {
$ancestors = Cache::remember(
'category_ancestors_' . $tagCategory->id,
now()->addHours(6),
fn () => $tagCategory->ancestorsAndSelf()->get()->reverse()
);
foreach ($ancestors as $cat) {
$catName = $cat->translations->firstWhere('locale', $locale)?->name
?? $cat->translations->first()?->name
?? '';
if ($catName) {
$tagCategories[] = [
'id' => $cat->id,
'name' => $catName,
'color' => $cat->relatedColor ?? 'gray',
];
}
}
}
$score = $scorer->score($call);
// Add random jitter when $random=true to vary order while preserving scoring preference
if ($this->random) {
$score *= (random_int(85, 115) / 100);
}
return [
'id' => $call->id,
'model' => Call::class,
'title' => $tagName ?? '',
'excerpt' => $translation?->content ?? '',
'photo' => $call->callable?->profile_photo_url ?? '',
'location' => $locationStr,
'tag_color' => $tagColor,
'tag_categories' => $tagCategories,
'callable_name' => $call->callable?->name ?? '',
'callable_location' => ProfileCalls::buildCallableLocation($call->callable),
'till' => $call->till,
'expiry_badge_text' => ProfileCalls::buildExpiryBadgeText($call->till),
'like_count' => $call->loveReactant?->reactionCounters
->firstWhere('reaction_type_id', 3)?->count ?? 0,
'score' => $score,
'_callable_key' => ($call->callable_type ?? '') . ':' . ($call->callable_id ?? ''),
];
})
->sortByDesc('score')
->pipe(function ($sorted) use ($carouselCfg) {
$maxPerProfile = (int) ($carouselCfg['max_cards_per_profile'] ?? 2);
$counts = [];
return $sorted->filter(function ($item) use ($maxPerProfile, &$counts) {
$key = $item['_callable_key'];
$counts[$key] = ($counts[$key] ?? 0) + 1;
return $counts[$key] <= $maxPerProfile;
});
})
->take($this->maxCards)
->map(fn ($item) => array_diff_key($item, ['_callable_key' => null]))
->values()
->toArray();
}
public function render()
{
return view('livewire.main-page.call-card-carousel');
}
}
+192
View File
@@ -0,0 +1,192 @@
<?php
namespace App\Http\Livewire\MainPage;
use App\Http\Livewire\Calls\ProfileCalls;
use App\Models\Call;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\Division;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Locked;
use Livewire\Component;
class CallCardFull extends Component
{
#[Locked]
public $call = null;
#[Locked]
public int $postNr;
#[Locked]
public bool $related;
#[Locked]
public bool $random;
public function mount(int $postNr, bool $related, bool $random = false, Request $request): void
{
$this->postNr = $postNr;
$this->related = $related;
$this->random = $random;
$profile = getActiveProfile();
if ($profile) {
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
}
$location = $profile && $profile->locations
? $profile->locations()->with(['country', 'division', 'city'])->first()
: null;
// Resolve location filter arrays (same pattern as EventCardFull)
$locationCityIds = [];
$locationDivisionIds = [];
$locationCountryIds = [];
if ($location) {
if (!$location->division && !$location->city) {
$country = $location->country;
if ($related) {
$locationCountryIds = Country::pluck('id')->toArray();
} else {
$locationCountryIds = $country ? [$country->id] : [];
}
} elseif ($location->division && !$location->city) {
$divisionId = $location->division->id;
if ($related) {
$locationDivisionIds = Division::find($divisionId)->parent->divisions->pluck('id')->toArray();
} else {
$locationDivisionIds = [$divisionId];
}
} elseif ($location->city) {
$cityId = $location->city->id;
if ($related) {
$locationCityIds = City::find($cityId)->parent->cities->pluck('id')->toArray();
} else {
$locationCityIds = [$cityId];
}
}
}
$locale = App::getLocale();
$query = Call::with([
'tag.contexts.category.translations',
'tag.contexts.category.ancestors.translations',
'translations',
'location.city.translations',
'location.country.translations',
'callable.locations.city.translations',
'callable.locations.division.translations',
'callable.locations.country.translations',
'loveReactant.reactionCounters',
])
->where('is_public', true)
->where('is_paused', false)
->where('is_suppressed', false)
->whereNull('deleted_at')
->where(fn ($q) => $q->whereNull('till')->orWhere('till', '>=', now()));
// Apply location filter when a profile location is known
if ($locationCityIds || $locationDivisionIds || $locationCountryIds) {
$query->whereHas('location', function ($q) use ($locationCityIds, $locationDivisionIds, $locationCountryIds) {
$q->where(function ($q) use ($locationCityIds, $locationDivisionIds, $locationCountryIds) {
if ($locationCityIds) {
$q->orWhereIn('city_id', $locationCityIds);
}
if ($locationDivisionIds) {
$q->orWhereIn('division_id', $locationDivisionIds);
}
if ($locationCountryIds) {
$q->orWhereIn('country_id', $locationCountryIds);
}
});
});
}
if ($random) {
$query->inRandomOrder();
$call = $query->first();
} else {
$query->orderBy('till');
$calls = $query->get();
$call = $calls->count() > $postNr ? $calls[$postNr] : null;
}
if (!$call) {
return;
}
$translation = $call->translations->firstWhere('locale', $locale)
?? $call->translations->first();
$tag = $call->tag;
$tagContext = $tag?->contexts->first();
$tagCategory = $tagContext?->category;
$tagColor = $tagCategory?->relatedColor ?? 'gray';
$tagName = $tag?->translation?->name ?? $tag?->name;
$locationStr = null;
if ($call->location) {
$loc = $call->location;
$parts = [];
if ($loc->city) {
$cityName = optional($loc->city->translations->first())->name;
if ($cityName) $parts[] = $cityName;
}
if ($loc->country) {
if ($loc->country->code === 'XX') {
$parts[] = __('Location not specified');
} elseif ($loc->country->code) {
$parts[] = strtoupper($loc->country->code);
}
}
$locationStr = $parts ? implode(', ', $parts) : null;
}
$tagCategories = [];
if ($tagCategory) {
$ancestors = Cache::remember(
'category_ancestors_' . $tagCategory->id,
now()->addHours(6),
fn () => $tagCategory->ancestorsAndSelf()->get()->reverse()
);
foreach ($ancestors as $cat) {
$catName = $cat->translations->firstWhere('locale', $locale)?->name
?? $cat->translations->first()?->name
?? '';
if ($catName) {
$tagCategories[] = [
'id' => $cat->id,
'name' => $catName,
'color' => $cat->relatedColor ?? 'gray',
];
}
}
}
$this->call = [
'id' => $call->id,
'model' => Call::class,
'title' => $tagName ?? '',
'excerpt' => $translation?->content ?? '',
'photo' => $call->callable?->profile_photo_url ?? '',
'location' => $locationStr,
'tag_color' => $tagColor,
'tag_categories' => $tagCategories,
'callable_name' => $call->callable?->name ?? '',
'callable_location' => ProfileCalls::buildCallableLocation($call->callable),
'till' => $call->till,
'expiry_badge_text' => ProfileCalls::buildExpiryBadgeText($call->till),
'like_count' => $call->loveReactant?->reactionCounters->firstWhere('reaction_type_id', 3)?->count ?? 0,
'score' => 0,
];
}
public function render()
{
return view('livewire.main-page.call-card-full');
}
}
+231
View File
@@ -0,0 +1,231 @@
<?php
namespace App\Http\Livewire\MainPage;
use App\Http\Livewire\Calls\CallCarouselScorer;
use App\Http\Livewire\Calls\ProfileCalls;
use App\Models\Call;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\Division;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Locked;
use Livewire\Component;
class CallCardHalf extends Component
{
#[Locked]
public array $calls = [];
public bool $related = true;
public bool $random = false;
public int $rows = 1;
public bool $showScore = false;
private const UNKNOWN_COUNTRY_ID = 10;
public function mount(bool $related, bool $random = false, int $rows = 1): void
{
$this->related = $related;
$this->random = $random;
$this->rows = max(1, $rows);
$carouselCfg = timebank_config('calls.carousel', []);
$locale = App::getLocale();
// --- Resolve active profile and its location ---
$profile = getActiveProfile();
if ($profile) {
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
}
$location = $profile?->locations
? $profile->locations()->with(['country', 'division', 'city'])->first()
: null;
$profileCityId = $location?->city?->id;
$profileDivisionId = $location?->division?->id;
$profileCountryId = $location?->country?->id;
$locationCityIds = [];
$locationDivisionIds = [];
$locationCountryIds = [];
if ($location) {
if (!$location->division && !$location->city) {
$country = $location->country;
$locationCountryIds = $related
? Country::pluck('id')->toArray()
: ($country ? [$country->id] : []);
} elseif ($location->division && !$location->city) {
$divisionId = $location->division->id;
$locationDivisionIds = $related
? Division::find($divisionId)->parent->divisions->pluck('id')->toArray()
: [$divisionId];
} elseif ($location->city) {
$cityId = $location->city->id;
$locationCityIds = $related
? City::find($cityId)->parent->cities->pluck('id')->toArray()
: [$cityId];
}
}
// --- Base query ---
$query = Call::with([
'tag.contexts.category.translations',
'tag.contexts.category.ancestors.translations',
'translations',
'location.city.translations',
'location.country.translations',
'callable.locations.city.translations',
'callable.locations.division.translations',
'callable.locations.country.translations',
'callable.loveReactant.reactionCounters',
'loveReactant.reactionCounters',
])
->whereNull('deleted_at')
->where('is_paused', false)
->where('is_suppressed', false)
->where(fn ($q) => $q->whereNull('till')->orWhere('till', '>=', now()));
if (!Auth::check() || ($carouselCfg['exclude_non_public'] ?? true)) {
$query->where('is_public', true);
}
if ($profile && ($carouselCfg['exclude_own_calls'] ?? true)) {
$query->where(function ($q) use ($profile) {
$q->where('callable_type', '!=', get_class($profile))
->orWhere('callable_id', '!=', $profile->id);
});
}
// --- Locality filter ---
$includeUnknown = $carouselCfg['include_unknown_location'] ?? true;
$includeDivision = $carouselCfg['include_same_division'] ?? true;
$includeCountry = $carouselCfg['include_same_country'] ?? true;
$hasLocalityFilter = $locationCityIds || $locationDivisionIds || $locationCountryIds
|| ($includeDivision && $locationDivisionIds)
|| ($includeCountry && $locationCountryIds)
|| $includeUnknown;
if ($hasLocalityFilter) {
$query->where(function ($q) use (
$locationCityIds, $locationDivisionIds, $locationCountryIds,
$includeDivision, $includeCountry, $includeUnknown
) {
if ($locationCityIds) {
$q->orWhereHas('location', fn ($lq) => $lq->whereIn('city_id', $locationCityIds));
}
if ($includeDivision && $locationDivisionIds) {
$q->orWhereHas('location', fn ($lq) => $lq->whereIn('division_id', $locationDivisionIds));
}
if ($includeCountry && $locationCountryIds) {
$q->orWhereHas('location', fn ($lq) => $lq->whereIn('country_id', $locationCountryIds));
}
if ($includeUnknown) {
$q->orWhereHas('location', fn ($lq) => $lq->where('country_id', self::UNKNOWN_COUNTRY_ID));
}
});
}
$isAdmin = getActiveProfileType() === 'Admin';
$this->showScore = (bool) ($carouselCfg['show_score'] ?? false)
|| ($isAdmin && (bool) ($carouselCfg['show_score_for_admins'] ?? true));
$limit = $this->rows * 2;
$poolSize = $limit * max(1, (int) ($carouselCfg['pool_multiplier'] ?? 5));
$scorer = new CallCarouselScorer(
$carouselCfg,
$profileCityId,
$profileDivisionId,
$profileCountryId
);
$calls = $query->limit($poolSize)->get();
$this->calls = $calls->map(function (Call $call) use ($locale, $scorer) {
$translation = $call->translations->firstWhere('locale', $locale)
?? $call->translations->first();
$tag = $call->tag;
$tagContext = $tag?->contexts->first();
$tagCategory = $tagContext?->category;
$tagColor = $tagCategory?->relatedColor ?? 'gray';
$tagName = $tag?->translation?->name ?? $tag?->name;
$locationStr = null;
if ($call->location) {
$loc = $call->location;
$parts = [];
if ($loc->city) {
$cityName = optional($loc->city->translations->first())->name;
if ($cityName) $parts[] = $cityName;
}
if ($loc->country) {
if ($loc->country->code === 'XX') {
$parts[] = __('Location not specified');
} elseif ($loc->country->code) {
$parts[] = strtoupper($loc->country->code);
}
}
$locationStr = $parts ? implode(', ', $parts) : null;
}
$tagCategories = [];
if ($tagCategory) {
$ancestors = Cache::remember(
'category_ancestors_' . $tagCategory->id,
now()->addHours(6),
fn () => $tagCategory->ancestorsAndSelf()->get()->reverse()
);
foreach ($ancestors as $cat) {
$catName = $cat->translations->firstWhere('locale', $locale)?->name
?? $cat->translations->first()?->name
?? '';
if ($catName) {
$tagCategories[] = [
'id' => $cat->id,
'name' => $catName,
'color' => $cat->relatedColor ?? 'gray',
];
}
}
}
$score = $scorer->score($call);
if ($this->random) {
$score *= (random_int(85, 115) / 100);
}
return [
'id' => $call->id,
'model' => Call::class,
'title' => $tagName ?? '',
'excerpt' => $translation?->content ?? '',
'photo' => $call->callable?->profile_photo_url ?? '',
'location' => $locationStr,
'tag_color' => $tagColor,
'tag_categories' => $tagCategories,
'callable_name' => $call->callable?->name ?? '',
'callable_location' => ProfileCalls::buildCallableLocation($call->callable),
'till' => $call->till,
'expiry_badge_text' => ProfileCalls::buildExpiryBadgeText($call->till),
'like_count' => $call->loveReactant?->reactionCounters
->firstWhere('reaction_type_id', 3)?->count ?? 0,
'score' => $score,
];
})
->sortByDesc('score')
->take($limit)
->values()
->toArray();
}
public function render()
{
return view('livewire.main-page.call-card-half');
}
}
@@ -0,0 +1,173 @@
<?php
namespace App\Http\Livewire\MainPage;
use App\Models\Category;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\Division;
use App\Models\Meeting;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Livewire\Attributes\Locked;
use Livewire\Component;
class EventCardFull extends Component
{
#[Locked]
public $author;
#[Locked]
public $post = [];
#[Locked]
public $posts;
#[Locked]
public $media;
#[Locked]
public $postNr;
#[Locked]
public $related;
public function mount($postNr, $related, Request $request)
{
$this->postNr = $postNr;
$this->related = $related;
$profile = getActiveProfile();
// SECURITY: If a profile is active, validate the user has access to it
// This prevents session manipulation while allowing public access (null profile)
if ($profile) {
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
}
$location = $profile && $profile->locations
? $profile->locations()->with(['country', 'division', 'city'])->first()
: null;
if ($location) {
// If no division and no city as location set
if (!$location->division && !$location->city) {
$country = $location->country;
$categoryable_id = $country ? $country->id : null;
$categoryable_type = Country::class;
// Include also all other countries if $related is set in view
if ($related) {
$categoryable_id = Country::pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
// Division without city is set as location
} elseif ($location && $location->division && !$location->city) {
$categoryable_id = $location->division->id;
$categoryable_type = Division::class;
// Include also all other divisions in the same country if $related is set in view
if ($related) {
$categoryable_id = Division::find($categoryable_id)->parent->divisions->pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
// City is set as location
} elseif ($location && $location->city) {
$categoryable_id = $location->city->id;
$categoryable_type = City::class;
// Include also all other cities in the same division if $related is set in view
if ($related) {
$categoryable_id = City::find($categoryable_id)->parent->cities->pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
}
// No matching location is set
} else {
$categoryable_id = [];
$categoryable_type = '';
}
$post =
Post::with([
'postable' => function ($query) {
$query->select(['id', 'name']);
},
'category' => function ($query) use ($categoryable_id, $categoryable_type) {
$query
->where('type', Meeting::class)
->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query
->whereIn('categoryable_id', $categoryable_id)
->where('categoryable_type', $categoryable_type);
});
},
'translations' => function ($query) {
$query
->where('locale', App::getLocale())
;
},
'meeting',
'media',
])
->whereHas('category', function ($query) use ($categoryable_id, $categoryable_type) {
$query
->where('type', Meeting::class)
->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query
->whereIn('categoryable_id', $categoryable_id)
->where('categoryable_type', $categoryable_type);
});
})
->whereHas('translations', function ($query) {
$query
->where('locale', App::getLocale())
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
})
->orderBy('updated_at', 'desc');
})
->get()->sortBy(function ($query) {
if (isset($query->meeting->from)) {
return $query->meeting->from;
};
})->values(); // Use values() method to reset the collection keys after sortBy
$lastNr = $post->count() - 1;
if ($postNr > $lastNr) {
$post = null;
} else {
$post = $post[$postNr];
}
if (isset($post->translations)) {
$this->post = $post->translations->first();
$this->post['start'] = Carbon::parse($post->translations->first()->updated_at)->isoFormat('LL');
$this->post['category'] = Category::find($post->category_id)->translations->where('locale', App::getLocale())->first()->name;
$this->post['author'] = $post->postable->name;
$this->post['id'] = $post->id;
$this->post['model'] = get_class($post);
$this->post['like_count'] = $post->like_count ?? 0;
if ($post->meeting) {
$this->post['address'] = ($post->meeting->address) ? $post->meeting->address : '';
$this->post['from'] = ($post->meeting->from) ? $post->meeting->from : '';
$this->post['till'] = ($post->meeting->till) ? $post->meeting->till : '';
$this->post['venue'] = ($post->meeting->venue) ? $post->meeting->venue : '';
$this->post['city'] = $post->meeting->location?->first()?->city?->translations?->first()?->name ?? '';
$this->post['price'] = ($post->meeting->price) ? tbFormat($post->meeting->price) : '';
}
if ($post->media) {
$this->media = Post::find($post->id)->getFirstMedia('posts');
}
}
}
public function render()
{
return view('livewire.main-page.event-card-full');
}
}
@@ -0,0 +1,208 @@
<?php
namespace App\Http\Livewire\MainPage;
use App\Models\Category;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\Division;
use App\Models\News;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ImageCardFull extends Component
{
#[Locked]
public $author;
#[Locked]
public $post = [];
#[Locked]
public $posts;
#[Locked]
public $media;
#[Locked]
public $postNr;
#[Locked]
public $related;
#[Locked]
public bool $random = false;
public function mount(Request $request, $postNr, $related, $random = null)
{
$this->postNr = $postNr;
$this->related = $related;
if ($random) {
$this->random = true;
}
$profile = getActiveProfile();
// SECURITY: If a profile is active, validate the user has access to it
// This prevents session manipulation while allowing public access (null profile)
if ($profile) {
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
}
$location = $profile && $profile->locations
? $profile->locations()->with(['country', 'division', 'city'])->first()
: null;
$skipLocationFilter = false;
if ($location) {
// If no division and no city as location set
if (!$location->division && !$location->city) {
$categoryable_id = $location->country?->id;
$categoryable_type = Country::class;
// Include also all other countries if $related is set in view
if ($related) {
$categoryable_id = Country::pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
// Division without city is set as location
} elseif ($location->division && !$location->city) {
$categoryable_id = $location->division->id;
$categoryable_type = Division::class;
// Include also all other divisions in the same country if $related is set in view
if ($related) {
$categoryable_id = Division::find($categoryable_id)->parent->divisions->pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
// City is set as location
} elseif ($location->city) {
$categoryable_id = $location->city->id;
$categoryable_type = City::class;
// Include also all other cities in the same division if $related is set in view
if ($related) {
$categoryable_id = City::find($categoryable_id)->parent->cities->pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
}
// No matching location is set - skip location filtering
} else {
$skipLocationFilter = true;
$categoryable_id = [];
$categoryable_type = '';
}
// TODO: check what happens when multiple locations per user are used!
$locale = App::getLocale();
$categoryTypes = ['App\Models\ImagePost', 'App\Models\ImagePost\\' . $locale];
$post =
Post::with([
'postable' => function ($query) {
$query->select(['id', 'name']);
},
'category' => function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter, $categoryTypes) {
$query->whereIn('type', $categoryTypes);
if (!$skipLocationFilter && !empty($categoryable_id)) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query
->whereIn('categoryable_id', $categoryable_id)
->where('categoryable_type', $categoryable_type);
})->orWhere(function ($query) {
// Also include categories with no location set
$query->whereNull('categoryable_id')
->whereNull('categoryable_type');
});
});
}
},
'translations' => function ($query) {
$query->where('locale', App::getLocale());
},
'author',
'media',
])
->whereHas('category', function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter, $categoryTypes) {
$query->whereIn('type', $categoryTypes);
if (!$skipLocationFilter && !empty($categoryable_id)) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query
->whereIn('categoryable_id', $categoryable_id)
->where('categoryable_type', $categoryable_type);
})->orWhere(function ($query) {
// Also include categories with no location set
$query->whereNull('categoryable_id')
->whereNull('categoryable_type');
});
});
}
})
->whereHas('translations', function ($query) {
$query
->where('locale', App::getLocale())
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
})
->orderBy('updated_at', 'desc');
});
// Apply random or sorted ordering
if ($this->random) {
$post = $post->inRandomOrder()->get();
} else {
$post = $post->get()->sortByDesc(function ($query) {
if (isset($query->translations)) {
return $query->translations->first()->updated_at;
};
})->values(); // Use values() method to reset the collection keys after sortBy
}
$lastNr = $post->count() - 1;
if ($postNr > $lastNr || $post->isEmpty()) {
$post = null;
$this->post = null;
} else {
$post = $post[$postNr];
}
if ($post && isset($post->translations) && $post->translations->isNotEmpty()) {
$translation = $post->translations->first();
$this->post = $translation;
$this->post['slug'] = $translation->slug;
$this->post['from'] = $translation->from;
$category = Category::find($post->category_id);
$categoryTranslation = $category ? $category->translations->where('locale', App::getLocale())->first() : null;
$this->post['category'] = $categoryTranslation ? $categoryTranslation->name : '';
$this->post['author'] = $post->author ? $post->author->name : timebank_config('posts.site-content-writer');
$this->post['author_id'] = $post->author ? $post->author->id : null;
$this->post['author_profile_photo_path'] = $post->author && $post->author->profile_photo_path ? $post->author->profile_photo_path : null;
$this->post['post_id'] = $post->id;
if ($post->media && $post->media->isNotEmpty()) {
$this->media = Post::find($post->id)->getFirstMedia('posts');
// Get media owner and caption
$this->post['media_owner'] = $this->media->getCustomProperty('owner', '');
$captionKey = 'caption-' . App::getLocale();
$this->post['media_caption'] = $this->media->getCustomProperty($captionKey, '');
}
}
}
public function render()
{
return view('livewire.main-page.image-card-full');
}
}
@@ -0,0 +1,208 @@
<?php
namespace App\Http\Livewire\MainPage;
use App\Models\Category;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\Division;
use App\Models\News;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ImageLocalizedCardFull extends Component
{
#[Locked]
public $author;
#[Locked]
public $post = [];
#[Locked]
public $posts;
#[Locked]
public $media;
#[Locked]
public $postNr;
#[Locked]
public $related;
#[Locked]
public bool $random = false;
public function mount(Request $request, $postNr, $related, $random = null)
{
$this->postNr = $postNr;
$this->related = $related;
if ($random) {
$this->random = true;
}
$profile = getActiveProfile();
// SECURITY: If a profile is active, validate the user has access to it
// This prevents session manipulation while allowing public access (null profile)
if ($profile) {
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
}
$location = $profile && $profile->locations
? $profile->locations()->with(['country', 'division', 'city'])->first()
: null;
$skipLocationFilter = false;
if ($location) {
// If no division and no city as location set
if (!$location->division && !$location->city) {
$categoryable_id = $location->country?->id;
$categoryable_type = Country::class;
// Include also all other countries if $related is set in view
if ($related) {
$categoryable_id = Country::pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
// Division without city is set as location
} elseif ($location->division && !$location->city) {
$categoryable_id = $location->division->id;
$categoryable_type = Division::class;
// Include also all other divisions in the same country if $related is set in view
if ($related) {
$categoryable_id = Division::find($categoryable_id)->parent->divisions->pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
// City is set as location
} elseif ($location->city) {
$categoryable_id = $location->city->id;
$categoryable_type = City::class;
// Include also all other cities in the same division if $related is set in view
if ($related) {
$categoryable_id = City::find($categoryable_id)->parent->cities->pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
}
// No matching location is set - skip location filtering
} else {
$skipLocationFilter = true;
$categoryable_id = [];
$categoryable_type = '';
}
// TODO: check what happens when multiple locations per user are used!
$locale = App::getLocale();
$categoryType = 'App\\Models\\ImagePost\\' . $locale;
$post =
Post::with([
'postable' => function ($query) {
$query->select(['id', 'name']);
},
'category' => function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter, $categoryType) {
$query->where('type', $categoryType);
if (!$skipLocationFilter && !empty($categoryable_id)) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query
->whereIn('categoryable_id', $categoryable_id)
->where('categoryable_type', $categoryable_type);
})->orWhere(function ($query) {
// Also include categories with no location set
$query->whereNull('categoryable_id')
->whereNull('categoryable_type');
});
});
}
},
'translations' => function ($query) {
$query->where('locale', App::getLocale());
},
'author',
'media',
])
->whereHas('category', function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter, $categoryType) {
$query->where('type', $categoryType);
if (!$skipLocationFilter && !empty($categoryable_id)) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query
->whereIn('categoryable_id', $categoryable_id)
->where('categoryable_type', $categoryable_type);
})->orWhere(function ($query) {
// Also include categories with no location set
$query->whereNull('categoryable_id')
->whereNull('categoryable_type');
});
});
}
})
->whereHas('translations', function ($query) {
$query
->where('locale', App::getLocale())
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
})
->orderBy('updated_at', 'desc');
});
// Apply random or sorted ordering
if ($this->random) {
$post = $post->inRandomOrder()->get();
} else {
$post = $post->get()->sortByDesc(function ($query) {
if (isset($query->translations)) {
return $query->translations->first()->updated_at;
};
})->values(); // Use values() method to reset the collection keys after sortBy
}
$lastNr = $post->count() - 1;
if ($postNr > $lastNr || $post->isEmpty()) {
$post = null;
$this->post = null;
} else {
$post = $post[$postNr];
}
if ($post && isset($post->translations) && $post->translations->isNotEmpty()) {
$translation = $post->translations->first();
$this->post = $translation;
$this->post['slug'] = $translation->slug;
$this->post['from'] = $translation->from;
$category = Category::find($post->category_id);
$categoryTranslation = $category ? $category->translations->where('locale', App::getLocale())->first() : null;
$this->post['category'] = $categoryTranslation ? $categoryTranslation->name : '';
$this->post['author'] = $post->author ? $post->author->name : timebank_config('posts.site-content-writer');
$this->post['author_id'] = $post->author ? $post->author->id : null;
$this->post['author_profile_photo_path'] = $post->author && $post->author->profile_photo_path ? $post->author->profile_photo_path : null;
$this->post['post_id'] = $post->id;
if ($post->media && $post->media->isNotEmpty()) {
$this->media = Post::find($post->id)->getFirstMedia('posts');
// Get media owner and caption
$this->post['media_owner'] = $this->media->getCustomProperty('owner', '');
$captionKey = 'caption-' . App::getLocale();
$this->post['media_caption'] = $this->media->getCustomProperty($captionKey, '');
}
}
}
public function render()
{
return view('livewire.main-page.image-localized-card-full');
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
namespace App\Http\Livewire\MainPage;
use App\Models\Category;
use App\Models\Locations\City;
use App\Models\Locations\Country;
use App\Models\Locations\Division;
use App\Models\News;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Livewire\Attributes\Locked;
use Livewire\Component;
class NewsCardFull extends Component
{
#[Locked]
public $author;
#[Locked]
public $post = [];
#[Locked]
public $posts;
#[Locked]
public $media;
#[Locked]
public $postNr;
#[Locked]
public $related;
public function mount($postNr, $related, Request $request)
{
$this->postNr = $postNr;
$this->related = $related;
$profile = getActiveProfile();
// SECURITY: If a profile is active, validate the user has access to it
// This prevents session manipulation while allowing public access (null profile)
if ($profile) {
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
}
$location = $profile && $profile->locations
? $profile->locations()->with(['country', 'division', 'city'])->first()
: null;
$skipLocationFilter = false;
if ($location) {
// If no division and no city as location set
if (!$location->division && !$location->city) {
$categoryable_id = $location->country?->id;
$categoryable_type = Country::class;
// Include also all other countries if $related is set in view
if ($related) {
$categoryable_id = Country::pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
// Division without city is set as location
} elseif ($location->division && !$location->city) {
$categoryable_id = $location->division->id;
$categoryable_type = Division::class;
// Include also all other divisions in the same country if $related is set in view
if ($related) {
$categoryable_id = Division::find($categoryable_id)->parent->divisions->pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
// City is set as location
} elseif ($location->city) {
$categoryable_id = $location->city->id;
$categoryable_type = City::class;
// Include also all other cities in the same division if $related is set in view
if ($related) {
$categoryable_id = City::find($categoryable_id)->parent->cities->pluck('id');
} else {
$categoryable_id = [$categoryable_id];
}
}
// No matching location is set - skip location filtering
} else {
$skipLocationFilter = true;
$categoryable_id = [];
$categoryable_type = '';
}
// TODO: check what happens when multiple locations per user are used!
$post =
Post::with([
'postable' => function ($query) {
$query->select(['id', 'name']);
},
'category' => function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter) {
$query->where('type', News::class)->with('categoryable.translations');
if (!$skipLocationFilter && !empty($categoryable_id)) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query
->whereIn('categoryable_id', $categoryable_id)
->where('categoryable_type', $categoryable_type);
})->orWhere(function ($query) {
// Also include categories with no location set
$query->whereNull('categoryable_id')
->whereNull('categoryable_type');
});
});
}
},
'translations' => function ($query) {
$query->where('locale', App::getLocale());
},
'author',
'media',
])
->whereHas('category', function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter) {
$query->where('type', News::class);
if (!$skipLocationFilter && !empty($categoryable_id)) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
$query
->whereIn('categoryable_id', $categoryable_id)
->where('categoryable_type', $categoryable_type);
})->orWhere(function ($query) {
// Also include categories with no location set
$query->whereNull('categoryable_id')
->whereNull('categoryable_type');
});
});
}
})
->whereHas('translations', function ($query) {
$query
->where('locale', App::getLocale())
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
})
->orderBy('updated_at', 'desc');
})
->get()->sortByDesc(function ($query) {
if (isset($query->translations)) {
return $query->translations->first()->updated_at;
};
})->values(); // Use values() method to reset the collection keys after sortBy
$lastNr = $post->count() - 1;
if ($postNr > $lastNr) {
$post = null;
} else {
$post = $post[$postNr];
}
// if ($post != null) { // Show only posts if it has the category type of this model's class
if (isset($post->translations)) {
$translation = $post->translations->first();
$this->post = $translation;
$this->post['start'] = Carbon::parse(strtotime($translation->updated_at))->isoFormat('LL');
$this->post['category'] = Category::find($post->category_id)->translations->where('locale', App::getLocale())->first()->name;
$this->post['author'] = $post->author ? $post->author->name : timebank_config('posts.site-content-writer');
// Prepend location name to excerpt if available
$excerpt = $translation->excerpt;
if ($post->category && $post->category->categoryable && $post->category->categoryable->translations) {
$locationTranslation = $post->category->categoryable->translations->where('locale', App::getLocale())->first();
if ($locationTranslation && $locationTranslation->name) {
$locationName = strtoupper($locationTranslation->name);
$excerpt = $locationName . ' - ' . $excerpt;
}
}
$this->post['excerpt'] = $excerpt;
if ($post->media) {
$this->media = Post::find($post->id)->getFirstMedia('posts');
}
}
// }
}
public function render()
{
return view('livewire.main-page.news-card-full');
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Http\Livewire\MainPage;
use App\Models\Poll;
use App\Models\User;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Main-page card with the first open poll the active profile may answer and has not
* answered yet (soonest closing first). Disappears once voted, on the next page load.
*/
class PollCardFull extends Component
{
#[Locked]
public ?int $pollId = null;
#[Locked]
public ?string $title = null;
#[Locked]
public ?string $excerpt = null;
#[Locked]
public ?string $slug = null;
/** More than one question: the card links to the poll page instead of showing the form */
#[Locked]
public int $questionCount = 0;
/** The post has a longer text: "Read more" above the single question */
#[Locked]
public bool $hasContent = false;
public function mount(): void
{
$profile = getActiveProfile();
$user = Auth::guard('web')->user();
if (!$profile || !$user instanceof User) {
return;
}
$locale = App::getLocale();
$published = function ($query) use ($locale) {
$query->where('locale', $locale)
->whereDate('from', '<=', now())
->where(fn ($q) => $q->whereDate('till', '>', now())->orWhereNull('till'));
};
$polls = Poll::openNow()
->whereHas('post.translations', $published)
->with(['post.translations' => $published])
->orderByRaw('closes_at IS NULL')
->orderBy('closes_at')
->orderByDesc('id')
->limit(20)
->get();
foreach ($polls as $poll) {
if ($poll->isTargetedAt($profile, $user) && $poll->responseFor($profile, $user->id) === null) {
$translation = $poll->post->translations->first();
$this->pollId = $poll->id;
$this->title = $translation->title;
$this->excerpt = $translation->excerpt;
$this->slug = $translation->slug;
$this->questionCount = $poll->questions()->count();
$this->hasContent = trim(strip_tags((string) $translation->content)) !== '';
return;
}
}
}
public function render()
{
return view('livewire.main-page.poll-card-full');
}
}
@@ -0,0 +1,863 @@
<?php
namespace App\Http\Livewire\MainPage;
use App\Helpers\StringHelper;
use App\Http\Livewire\MainPage;
use App\Jobs\SendEmailNewTag;
use App\Models\Category;
use App\Models\Language;
use App\Models\Tag;
use App\Models\TaggableLocale;
use App\Traits\TaggableWithLocale;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Component;
use Throwable;
use WireUi\Traits\WireUiActions;
class SkillsCardFull extends Component
{
use TaggableWithLocale;
use WireUiActions;
public $label;
public $tagsArray = [];
public bool $tagsArrayChanged = false;
public bool $saveDisabled = false;
public $initTagIds = [];
public $initTagsArray = [];
public $initTagsArrayTranslated = [];
public $newTagsArray;
public $suggestions = [];
public $modalVisible = false;
public $newTag = [];
public $newTagCategory;
public $categoryOptions = [];
public $categoryColor = 'gray';
public bool $translationPossible = true;
public bool $translationAllowed = true;
public bool $translationVisible = false;
public $translationLanguages = [];
public $selectTranslationLanguage;
public $translationOptions = [];
public $selectTagTranslation;
public $inputTagTranslation = [];
public bool $inputDisabled = true;
public $translateRadioButton = null;
public bool $sessionLanguageOk = false;
public bool $sessionLanguageIgnored = false;
public bool $transLanguageOk = false;
public bool $transLanguageIgnored = false;
protected $langDetector = null;
protected $listeners = [
'save',
'cancelCreateTag',
'refreshComponent' => '$refresh',
'tagifyFocus',
'tagifyBlur',
'saveCard'=> 'save',
];
protected function rules()
{
return [
'newTagsArray' => 'array',
'newTag' => 'array',
'newTag.name' => Rule::when(
function ($input) {
// Check if newTag is not an empty array
return count($input['newTag']) > 0;
},
array_merge(
timebank_config('tags.name_rule'),
timebank_config('tags.exists_in_current_locale_rule'),
[
'sometimes',
function ($attribute, $value, $fail) {
if (!$this->sessionLanguageOk && !$this->sessionLanguageIgnored) {
$locale = app()->getLocale();
$localeName = \Locale::getDisplayName($locale, $locale);
$fail(
__('Is this :locale? Please confirm here below', [
'locale' => $localeName
])
);
}
},
]
),
),
'newTagCategory' => Rule::when(
function ($input) {
if (count($input['newTag']) > 0 && $this->translationVisible === true && $this->translateRadioButton == 'input') {
return true;
}
if (count($input['newTag']) > 0 && $this->translationVisible === false) {
return true;
}
},
['required', 'int'],
),
'selectTagTranslation' => Rule::when(
function ($input) {
// Check if existing tag translation is selected
return $this->translationVisible === true && $this->translateRadioButton == 'select';
},
['required', 'int'],
),
'inputTagTranslation' => 'array',
'inputTagTranslation.name' => Rule::when(
fn ($input) => $this->translationVisible === true && $this->translateRadioButton === 'input',
array_merge(
timebank_config('tags.name_rule'),
timebank_config('tags.exists_in_current_locale_rule'),
[
'sometimes',
function ($attribute, $value, $fail) {
if (!$this->transLanguageOk && !$this->transLanguageIgnored) {
$baseLocale = $this->selectTranslationLanguage;
$locale = \Locale::getDisplayName($baseLocale, $baseLocale);
$fail(__('Is this :locale? Please confirm here below', [
'locale' => $locale
]));
}
},
function ($attribute, $value, $fail) {
$existsInTransLationLanguage = DB::table('taggable_tags')
->join('taggable_locales', 'taggable_tags.tag_id', '=', 'taggable_locales.taggable_tag_id')
->where('taggable_locales.locale', $this->selectTranslationLanguage)
->where(function ($query) use ($value) {
$query->where('taggable_tags.name', $value)
->orWhere('taggable_tags.normalized', $value);
})
->exists();
if ($existsInTransLationLanguage) {
$fail(__('This tag already exists.'));
}
},
]
),
[]
),
];
}
public function mount($label = null)
{
if ($label === null) {
$label = str_replace('@PLATFORM_USERS@', platform_users(), __('Activities or skills you offer to other @PLATFORM_USERS@'));
}
$this->label = $label;
$owner = getActiveProfile();
if (!$owner) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($owner);
$owner->cleanTaggables();
$this->checkTranslationAllowed();
$this->checkTranslationPossible();
$this->getSuggestions();
$this->getInitialTags();
$this->getLanguageDetector();
$this->dispatch('load');
}
protected function getSuggestions()
{
$suggestions = (new Tag())->localTagArray(app()->getLocale());
$this->suggestions = collect($suggestions)->map(function ($value) {
return app()->getLocale() == 'de' ? $value : StringHelper::DutchTitleCase($value);
});
}
protected function getInitialTags()
{
$this->initTagIds = getActiveProfile()->tags()->get()->pluck('tag_id');
$this->initTagsArray = TaggableLocale::whereIn('taggable_tag_id', $this->initTagIds)
->select('taggable_tag_id', 'locale', 'updated_by_user')
->get()
->toArray();
$translatedTags = collect((new Tag())->translateTagIdsWithContexts($this->initTagIds));
$tags = $translatedTags->map(function ($item, $key) {
return [
'original_tag_id' => $item['original_tag_id'],
'tag_id' => $item['tag_id'],
'value' => $item['locale'] == App::getLocale() ? $item['tag'] : $item['tag'] . ' (' . strtoupper($item['locale']) . ')',
'readonly' => false, // Tags are never readonly so the remove button is always visible
'class' => $item['locale'] == App::getLocale() ? '' : 'tag-foreign-locale', // Mark foreign-locale tags with a class for diagonal stripe styling
'locale' => $item['locale'],
'category' => $item['category'],
'category_path' => $item['category_path'],
'category_color' => $item['category_color'],
'title' => $item['category_path'], // 'title' is used by Tagify script for text that shows on hover
'style' =>
'--tag-bg:' .
tailwindColorToHex($item['category_color'] . '-400') .
'; --tag-text-color:#000' . // #111827 is gray-900
'; --tag-hover:' .
tailwindColorToHex($item['category_color'] . '-200'), // 'style' is used by Tagify script for background color, tailwindColorToHex is a helper function in app/Helpers/StyleHelper.php
];
});
$tags = $tags->sortBy('category_color')->values();
$this->initTagsArrayTranslated = $tags->toArray();
$this->tagsArray = json_encode($tags->toArray());
}
public function checkSessionLanguage()
{
// Ensure the language detector is initialized
$this->getLanguageDetector();
$detectedLanguage = $this->langDetector->detectSimple($this->newTag['name']);
if ($detectedLanguage === session('locale')) {
$this->sessionLanguageOk = true;
// No need to ignore language detection when session locale is detected
$this->sessionLanguageIgnored = false;
} else {
$this->sessionLanguageOk = false;
}
$this->validateOnly('newTag.name');
}
public function checkTransLanguage()
{
// Ensure the language detector is initialized
$this->getLanguageDetector();
$detectedLanguage = $this->langDetector->detectSimple($this->inputTagTranslation['name']);
if ($detectedLanguage === $this->selectTranslationLanguage) {
$this->transLanguageOk = true;
// No need to ignore language detection when base locale is detected
$this->transLanguageIgnored = false;
} else {
$this->transLanguageOk = false;
}
$this->validateOnly('inputTagTranslation.name');
}
public function checkTranslationAllowed()
{
// Check if translations are allowed based on config and profile type
$allowTranslations = timebank_config('tags.allow_tag_translations_for_non_admins', false);
$profileType = getActiveProfileType();
// If config is false, only admins can add translations
if (!$allowTranslations) {
$this->translationAllowed = ($profileType === 'admin');
} else {
// If config is true, all profile types can add translations
$this->translationAllowed = true;
}
}
public function checkTranslationPossible()
{
// Check if profile is capable to do any translations
$countNonBaseLanguages = getActiveProfile()->languages()->where('lang_code', '!=', timebank_config('base_language'))->count();
if ($countNonBaseLanguages === 0 && app()->getLocale() === timebank_config('base_language')) {
$this->translationPossible = false;
}
}
protected function getLanguageDetector()
{
if (!$this->langDetector) {
$this->langDetector = new \Text_LanguageDetect();
$this->langDetector->setNameMode(2); // iso language code with 2 characters
}
return $this->langDetector;
}
public function updatedNewTagName()
{
$this->resetErrorBag('newTag.name');
// Check if name is the profiles's session's locale
$this->checkSessionLanguage();
// Only fall back to initTagsArray if newTagsArray has not been set yet,
// to preserve any tags the user already added before opening the create modal
if ($this->newTagsArray === null) {
$this->newTagsArray = collect($this->initTagsArray);
}
}
public function updatedSessionLanguageIgnored()
{
if (!$this->sessionLanguageIgnored) {
$this->checkSessionLanguage();
}
// Revalidate the newTag.name field
$this->validateOnly('newTag.name');
}
public function updatedTransLanguageIgnored()
{
if (!$this->transLanguageIgnored) {
$this->checkTransLanguage();
} else {
$this->resetErrorBag('inputTagTranslation.name');
}
}
public function updatedSelectTranslationLanguage()
{
$this->selectTagTranslation = [];
// Suggest related tags in the selected translation language
$this->translationOptions = $this->getTranslationOptions($this->selectTranslationLanguage);
}
public function updatedNewTagCategory()
{
$this->categoryColor = collect($this->categoryOptions)
->firstWhere('category_id', $this->newTagCategory)['color'] ?? 'gray';
$this->selectTagTranslation = [];
// Suggest related tags in the selected translation language
$this->translationOptions = $this->getTranslationOptions($this->selectTranslationLanguage);
$this->resetErrorBag('inputTagTranslationCategory');
}
public function updatedInputTagTranslationName()
{
$this->validateOnly('inputTagTranslation.name');
}
public function updatedTagsArray()
{
// Prevent save during updating cycle of the tagsArray
$this->saveDisabled = true;
$this->newTagsArray = collect(json_decode($this->tagsArray, true));
$localesToCheck = [app()->getLocale(), '']; // Only current locale and tags without locale should be checked for any new tag keywords
$newTagsArrayLocal = $this->newTagsArray->whereIn('locale', $localesToCheck);
// map suggestion to lower case for search normalization of the $newEntries
$suggestions = collect($this->suggestions)->map(function ($value) {
return strtolower($value);
});
// Retrieve new tag entries not present in suggestions
$newEntries = $newTagsArrayLocal->filter(function ($newItem) use ($suggestions) {
return !$suggestions->contains(strtolower($newItem['value']));
});
// Add a new tag modal if there are new entries
if (count($newEntries) > 0) {
$this->newTag['name'] = app()->getLocale() == 'de' ? $newEntries->flatten()->first() : ucfirst($newEntries->flatten()->first());
$this->categoryOptions = Category::where('type', Tag::class)
->get()
->map(function ($category) {
// Include all attributes, including appended ones
return [
'category_id' => $category->id,
'name' => ucfirst($category->translation->name ?? ''), // Use the appended 'translation' attribute
'description' => $category->relatedPathExSelfTranslation ?? '', // Appended attribute
'color' => $category->relatedColor ?? 'gray',
];
})
->sortBy('name')
->values();
// Open the create tag modal
$this->modalVisible = true;
// For proper validation, this needs to be done after the netTag.name input of the modal is visible
$this->checkSessionLanguage();
} else {
$newEntries = false;
// Enable save button as updating cycle tagsArray is finished by now
$this->saveDisabled = false;
}
$this->checkChangesTagsArray();
}
public function checkChangesTagsArray()
{
// Check if tagsArray has been changed, to display 'unsaved changes' message next to save button
$initTagIds = collect($this->initTagIds);
$newTagIds = $this->newTagsArray->pluck('tag_id');
$diffFromNew = $newTagIds->diff($initTagIds);
$diffFromInit = $initTagIds->diff($newTagIds);
if ($diffFromNew->isNotEmpty() || $diffFromInit->isNotEmpty()) {
$this->tagsArrayChanged = true;
} else {
$this->tagsArrayChanged = false;
}
}
// When tagify raises focus, disable the save button
public function tagifyFocus()
{
$this->saveDisabled = true;
}
// When tagify looses focus, enable the save button
public function tagifyBlur()
{
$this->saveDisabled = false;
}
public function renderedModalVisible()
{
// Enable save button as updating cycle tagsArray is finished by now
$this->saveDisabled = false;
}
public function updatedTranslationVisible()
{
if ($this->translationVisible && $this->translationAllowed) {
$this->updatedNewTagCategory();
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
// Get all languages of the profile with good competence
$this->translationLanguages = $profile
->languages()
->wherePivot('competence', 1)
->where('lang_code', '!=', app()->getLocale())
->get()
->map(function ($language) {
$language->name = trans($language->name); // Map the name property to a translation key
return $language;
});
// Make sure that always the base language is included even if the profile does not has it as a competence 1
if (!$this->translationLanguages->contains('lang_code', 'en')) {
$transLanguage = Language::where('lang_code', timebank_config('base_language'))->first();
if ($transLanguage) {
$transLanguage->name = trans($transLanguage->name); // Map the name property to a translation key
// Add the base language to the translationLanguages collection
$this->translationLanguages = collect($this->translationLanguages)
->push($transLanguage);
}
// Set the default selection to base language
if (app()->getLocale() != timebank_config('base_language')) {
$this->selectTranslationLanguage = timebank_config('base_language');
}
}
}
}
public function updatedTranslateRadioButton()
{
if ($this->translateRadioButton === 'select') {
$this->inputDisabled = true;
$this->dispatch('disableInput');
} elseif ($this->translateRadioButton === 'input') {
$this->inputDisabled = false;
// $this->dispatch('disableSelect'); // Script inside view skills-form.blade.php
}
$this->resetErrorBag('selectTagTranslation');
$this->resetErrorBag('inputTagTranslation.name');
$this->resetErrorBag('newTagCategory');
}
public function updatedSelectTagTranslation()
{
if ($this->selectTagTranslation) {
$this->categoryColor = Tag::find($this->selectTagTranslation)->categories->first()->relatedColor ?? 'gray';
$this->translateRadioButton = 'select';
$this->dispatch('disableInput');
}
}
public function updatedInputTagTranslation()
{
$this->translateRadioButton = 'input';
$this->inputDisabled = false;
// $this->dispatch('disableSelect'); // Script inside view skills-form.blade.php
$this->checkTransLanguage();
}
/**
* Updates the visibility of the modal. If the modal becomes invisible, dispatches the 'remove' event to remove the last value of the tags array on the front-end.
*/
public function updatedModalVisible()
{
if ($this->modalVisible == false) {
$this->dispatch('remove'); // Removes last value of the tagsArray on front-end only
$this->dispatch('reinitializeComponent');
}
}
/**
* Retrieves a list of related tags based on the specified category and locale.
* Get all translation options in the choosen locale,
* but exclude all tags already have a similar context in the current $appLocal.
*
* @param int|null $category The ID of the category to filter related tags. If null, all tags in the locale are suggested.
* @param string|null $locale The locale to use for tag names. If not provided, the application's current locale is used.
*
* @return \Illuminate\Support\Collection A collection of tags containing 'tag_id' and 'name' keys, sorted by name.
*/
public function getTranslationOptions($locale)
{
$appLocale = app()->getLocale();
// 1) Get all context_ids used by tags that match app()->getLocale()
$contextIdsInAppLocale = DB::table('taggable_locale_context')
->whereIn('tag_id', function ($query) use ($appLocale) {
$query->select('taggable_tag_id')
->from('taggable_locales')
->where('locale', $appLocale);
})
->pluck('context_id');
// 2) Exclude tags that share these context_ids
$tags = Tag::with(['locale', 'contexts.category'])
->whereHas('locale', function ($query) use ($locale) {
$query->where('locale', $locale);
})
->whereNotIn('tag_id', function ($subquery) use ($contextIdsInAppLocale) {
$subquery->select('tag_id')
->from('taggable_locale_context')
->whereIn('context_id', $contextIdsInAppLocale);
})
->get();
// 3) Build the options array. Adjust the name logic to your preference.
$options = $tags->map(function ($tag) use ($locale) {
$category = optional($tag->contexts->first())->category;
$description = optional(optional($category)->translation)->name ?? '';
return [
'tag_id' => $tag->tag_id,
'name' => $locale == 'de' ? $tag->name : StringHelper::DutchTitleCase($tag->normalized),
'description' => $description,
];
})->sortBy('name')->values();
return $options;
}
/**
* Cancels the creation of a new tag by resetting error messages,
* clearing input fields, hiding translation and modal visibility,
* and resetting tag arrays to their initial state.
*/
public function cancelCreateTag()
{
$this->resetErrorBag();
$this->newTag = [];
$this->newTagCategory = null;
$this->translationVisible = false;
$this->translateRadioButton = null;
$this->sessionLanguageOk = false;
$this->sessionLanguageIgnored = false;
$this->transLanguageOk = false;
$this->transLanguageIgnored = false;
$this->categoryColor = 'gray';
$this->selectTagTranslation = null;
$this->inputTagTranslation = [];
$this->inputDisabled = true;
// $this->newTagsArray = collect($this->initTagsArray);
// $this->tagsArray = json_encode($this->initTagsArray);
// Remove last value of the tagsArray
$tagsArray = is_string($this->tagsArray) ? json_decode($this->tagsArray, true) : $this->tagsArray;
array_pop($tagsArray);
$this->tagsArray = json_encode($tagsArray);
// Check of there were also other unsaved new tags in the tagsArray
$hasNoTagId = false;
if (is_array($tagsArray)) {
$this->tagsArrayChanged = count(array_filter($tagsArray, function ($tag) {
return !array_key_exists('tag_id', $tag) || $tag['tag_id'] === null;
})) > 0;
} else {
$this->tagsArrayChanged = false;
}
$this->modalVisible = false;
$this->updatedModalVisible();
}
/**
* Handles the save button of the create tag modal.
*
* Creates a new tag for the currently active profile and optionally
* associates it with a category or base-language translation.
*
* @return void
*/
public function createTag()
{
// TODO: MAKE A TRANSACTION
$this->validate();
$this->resetErrorBag();
// Format strings to correct case
$this->newTag['name'] = app()->getLocale() == 'de' ? $this->newTag['name'] : StringHelper::DutchTitleCase($this->newTag['name']);
$name = $this->newTag['name'];
$normalized = call_user_func(config('taggable.normalizer'), $name);
// Create the tag and attach the owner and context
$tag = Tag::create([
'name' => $name,
'normalized' => $normalized,
]);
$owner = getActiveProfile();
if (!$owner) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($owner);
$owner->tagById($tag->tag_id);
$context = [
'category_id' => $this->newTagCategory,
'updated_by_user' => Auth::guard('web')->user()->id, // use the logged user, not the active profile
];
if ($this->translationVisible) {
if ($this->translateRadioButton === 'select') {
// Attach an existing context in the base language to the new tag. See timebank_config('base_language')
// Note that the category_id and updated_by_user is not updated when selecting an existing context
$tagContext = Tag::find($this->selectTagTranslation)
->contexts()
->first();
$tag->contexts()->attach($tagContext->id);
} elseif ($this->translateRadioButton === 'input') {
// Create a new context for the new tag
$tagContext = $tag->contexts()->create($context);
// Create a new translation of the tag
$this->inputTagTranslation['name'] = $this->selectTranslationLanguage == 'de' ? $this->inputTagTranslation['name'] : StringHelper::DutchTitleCase($this->inputTagTranslation['name']);
// $owner->tag($this->inputTagTranslation['name']);
$nameTranslation = $this->inputTagTranslation['name'];
$normalizedTranslation = call_user_func(config('taggable.normalizer'), $nameTranslation);
$locale = ['locale' => $this->selectTranslationLanguage ];
// Create the translation tag with the locale and attach the context
$tagTranslation = Tag::create([
'name' => $nameTranslation,
'normalized' => $normalizedTranslation,
]);
$tagTranslation->locale()->create($locale);
$tagTranslation->contexts()->attach($tagContext->id);
// The translation now has been recorded. Next, detach owner from this translation as only the locale tag should be attached to the owner
$owner->untagById([$tagTranslation->tag_id]);
// Also clean up owner's tags that have similar context but have different locale. Only the tag in owner's app()->getLocale() should remain in db.
$owner->cleanTaggables(); // In TaggableWithLocale trait
}
} else {
// Create a new context for the new tag without translation
$tagContext = $tag->contexts()->create($context);
}
$this->modalVisible = false;
$this->saveDisabled = false;
// Attach the new collection of tags to the active profile
$this->save();
$this->tagsArrayChanged = false;
// Dispatch the SendEmailNewTag job
SendEmailNewTag::dispatch($tag->tag_id);
}
/**
* Saves the newTagsArray: attaches the current tags to the profile model.
* Ignores the tags that are marked read-only (no app locale and no base language locale).
* Dispatches notification on success or error.
*
* @return void
*/
public function save()
{
if ($this->saveDisabled === false) {
if ($this->newTagsArray) {
try {
// Use a transaction for saving skill tags
DB::transaction(function () {
// Make sure we can count newTag for conditional validation rules
if ($this->newTag === null) {
$this->newTag = [];
}
$owner = getActiveProfile();
if (!$owner) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($owner);
$this->validate();
// try {
// $this->validate();
// } catch (ValidationException $e) {
// // Dump all validation errors to the log or screen
// logger()->error('Validation failed', $e->errors());
// dd($e->errors()); // or use dump() if you prefer
// }
$this->resetErrorBag();
$initTags = collect($this->initTagsArray)->pluck('taggable_tag_id');
$newTagsArray = collect($this->newTagsArray);
$newTags = $newTagsArray
->where('tag_id', null)
->pluck('value')->toArray();
$owner->tag($newTags);
$remainingTags = $this->newTagsArray
->where('tag_id')
->pluck('tag_id')->toArray();
$removedTags = $initTags->diff($remainingTags)->toArray();
$owner->untagById($removedTags);
// Finaly clean up taggables table: remove duplicate contexts and any orphaned taggables
// In TaggableWithLocale trait
$owner->cleanTaggables();
$owner->touch(); // Observer catches this and reindexes search index
// WireUI notification
$this->notification()->success($title = __('Your have updated your profile successfully!'));
});
// end of transaction
} catch (Throwable $e) {
// WireUI notification
// TODO!: create event to send error notification to admin
$this->notification([
'title' => __('Update failed!'),
'description' => __('Sorry, your data could not be saved!') . '<br /><br />' . __('Our team has ben notified about this error. Please try again later.') . '<br /><br />' . $e->getMessage(),
'icon' => 'error',
'timeout' => 100000,
]);
}
$this->tagsArrayChanged = false;
$this->dispatch('saved');
$this->forgetCachedSkills();
$this->cacheSkills();
$this->initTagsArray = [];
$this->newTag = null;
$this->newTagsArray = null;
$this->newTagCategory = null;
$this->dispatch('refreshComponent');
$this->dispatch('reinitializeTagify');
$this->dispatch('reloadPage');
}
}
}
public function forgetCachedSkills()
{
// Get the profile type (user / organization) from the session and convert to lowercase
$profileType = strtolower(basename(str_replace('\\', '/', session('activeProfileType'))));
// Get the supported locales from the config
$locales = config('app.supported_locales', [app()->getLocale()]);
// Iterate over each locale and forget the cache
foreach ($locales as $locale) {
Cache::forget('skills-' . $profileType . '-' . session('activeProfileId') . '-lang-' . $locale);
}
}
public function cacheSkills()
{
$profileType = strtolower(basename(str_replace('\\', '/', session('activeProfileType')))); // Get the profile type (user / organization) from the session and convert to lowercase
$skillsCache = Cache::remember('skills-' . $profileType . '-' . session('activeProfileId') . '-lang-' . app()->getLocale(), now()->addDays(7), function () {
// remember cache for 7 days
$tagIds = session('activeProfileType')::find(session('activeProfileId'))->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, $key) {
return [
'original_tag_id' => $item['original_tag_id'],
'tag_id' => $item['tag_id'],
'name' => $item['tag'],
'foreign' => $item['locale'] == App::getLocale() ? false : true, // Mark all tags in a foreign language read-only, as users need to switch locale to edit/update/etc foreign tags
'locale' => $item['locale'],
'category_id' => $item['category_id'],
'category' => $item['category'],
'category_path' => $item['category_path'],
'category_color' => $item['category_color'],
];
});
$skills = collect($skills);
return $skills;
});
$this->tagsArray = json_encode($skillsCache->toArray());
}
public function render()
{
return view('livewire.main-page.skills-card-full');
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
namespace App\Http\Livewire;
use App\Models\Post;
use Illuminate\Support\Facades\App;
use Livewire\Component;
class MainPost extends Component
{
public $type;
public bool $sticky = false;
public bool $random = false;
public bool $latest = false;
public $fallbackTitle = null;
public $fallbackDescription = null;
public function mount($type, $sticky = null, $random = null, $latest = null, $fallbackTitle = null, $fallbackDescription = null)
{
$this->type = $type;
$this->fallbackTitle = $fallbackTitle;
$this->fallbackDescription = $fallbackDescription;
if ($sticky) {
$this->sticky = true;
}
if ($random) {
$this->random = true;
}
if ($latest) {
$this->latest = true;
}
}
public function render()
{
// Sticky post
if ($this->sticky) {
$locale = App::getLocale();
$posts = Post::with([
'category',
'images' => function ($query) {
$query->select('images.id', 'caption', 'path');
},
'translations' => function ($query) use ($locale) {
$query->where('locale', $locale)
->orderBy('created_at', 'desc')
->limit(3);
}
])
->whereHas('category', function ($query) {
$query->where('type', $this->type);
})
->whereHas('translations', function ($query) use ($locale) {
$query->where('locale', $locale)
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
})
->orderBy('updated_at', 'desc');
})
->orderBy('created_at', 'desc')
->limit(3)
->first();
}
// Random post
if ($this->random) {
$locale = App::getLocale();
$posts = Post::with([
'category',
'translations' => function ($query) use ($locale) {
$query->where('locale', $locale)
->orderBy('created_at', 'desc')
->limit(1);
}
])
->whereHas('category', function ($query) {
$query->where('type', $this->type);
})
->whereHas('translations', function ($query) use ($locale) {
$query->where('locale', $locale)
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
})
->orderBy('updated_at', 'desc');
})
->inRandomOrder() // This replaces the orderBy() method
->first();
}
// Latest post
if ($this->latest) {
$locale = App::getLocale();
$posts = Post::with([
'category',
'translations' => function ($query) use ($locale) {
$query->where('locale', $locale)
->orderBy('created_at', 'desc')
->limit(1);
}
])
->whereHas('category', function ($query) {
$query->where('type', $this->type);
})
->whereHas('translations', function ($query) use ($locale) {
$query->where('locale', $locale)
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
})
->orderBy('updated_at', 'desc');
})
->orderBy('created_at', 'desc')
->first();
}
$image = null;
if ($posts && $posts->hasMedia('*')) {
$image = $posts->getFirstMediaUrl('*', 'half_hero');
}
return view('livewire.main-post', [
'posts' => $posts,
'image' => $image,
]);
}
}
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Livewire;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class NavigationMenuGuest extends Component
{
protected $listeners = ['authStateChanged' => '$refresh'];
public function render()
{
return view('livewire.navigation-menu-guest', [
'isAuthenticated' => Auth::check()
]);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Livewire;
use Livewire\Component;
class Notification extends Component
{
public function dehydrate()
{
// Clear the session key after the component is rendered
session()->forget('notification');
}
public function render()
{
return view('livewire.notification');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Livewire;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
class NotifyEmailVerified extends Component
{
use WireUiActions;
public function mount()
{
$this->notify();
}
public function notify()
{
// WireUI notification
$this->notification()->success(
$title = __('Email verified'),
$description = __('Your email has been verified successfully')
);
}
public function dehydrate()
{
// Clear the session key after the component is rendered
session()->forget('email-verified');
session()->forget('email-profile');
}
public function render()
{
return view('livewire.notify-email-verified');
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Livewire;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
class NotifySwitchProfile extends Component
{
use WireUiActions;
public function mount()
{
$this->notify();
}
public function notify()
{
// WireUI notification
$this->notification()->success(
$title = __('Profile switch'),
$description = __('Your profile has been switched successfully')
);
// Check if the profile's email needs verification
$this->checkEmailVerification();
}
/**
* Check if the current profile's email needs verification and show a warning
*/
private function checkEmailVerification()
{
// Don't show unverified warning if we just verified the email
if (session('email-verified')) {
return;
}
$currentProfile = getActiveProfile();
// Check if profile implements MustVerifyEmail and has unverified email
if ($currentProfile instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && !$currentProfile->hasVerifiedEmail()) {
$profileName = $currentProfile->name ?? __('Your profile');
$this->notification()->warning(
$title = __('Email Verification Required'),
$description = __('The email address of :profile_name is unverified. Please verify it to ensure you can receive important notifications.', ['profile_name' => $profileName])
);
}
}
public function render()
{
return view('livewire.notify-switch-profile');
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Livewire;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
class NotifyUnauthorizedAction extends Component
{
use WireUiActions;
public function mount()
{
$this->notify();
}
public function notify()
{
// WireUI notification
$this->notification()->warning(
$title = __('Unauthorized action'),
$description = session('unauthorizedAction'),
);
}
public function dehydrate()
{
// Clear the session key after the component is rendered
session()->forget('unauthorizedAction');
}
public function render()
{
return view('livewire.notify-unauthorized-action');
}
}
+684
View File
@@ -0,0 +1,684 @@
<?php
namespace App\Http\Livewire;
use App\Services\PresenceService;
use Cog\Laravel\Love\ReactionType\Models\ReactionType;
use Cog\Laravel\Love\Reaction\Models\Reaction;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\On;
use Livewire\Component;
use Namu\WireChat\Enums\ConversationType;
class OnlineReactedProfiles extends Component
{
// Configuration properties
public $reactionTypes = []; // ['Bookmark', 'Star', 'Like'] or single 'Bookmark'
public $guards = ['web']; // Which guards to check for online users
public $authGuard; // Current user's guard
public $showCount = true;
public $showAvatars = true;
public $maxDisplay = 20;
public $headerText; // Header text for the component
public $refreshInterval = 60; // Fallback poll — primary updates come via Reverb echo-presence event
public $groupByModel = true; // Group results by model type
public $lastSeen = false; // Show last seen time
public $modelLabels = [
'App\Models\User' => 'Persons',
'App\Models\Organization' => 'Organizations',
'App\Models\Bank' => 'Banks',
'App\Models\Admin' => 'Admins',
];
// Data properties
public $onlineReactedProfiles = [];
public $totalCount = 0;
public $countByType = [];
// Logout modal properties
public $showLogoutModal = false;
public $selectedProfileId = null;
public $selectedProfileType = null;
public $selectedProfileName = null;
public function mount(
$reactionTypes = null,
$guards = null,
$showCount = true,
$showAvatars = true,
$maxDisplay = 20,
$header = true,
$headerText = null,
$groupByModel = false,
$lastSeen = false
) {
// Setup reaction types
if ($reactionTypes !== null) {
if (is_string($reactionTypes)) {
$this->reactionTypes = array_map('trim', explode(',', $reactionTypes));
} else {
$this->reactionTypes = is_array($reactionTypes) ? $reactionTypes : [$reactionTypes];
}
} else {
// Keep as null to show all online users without filtering
$this->reactionTypes = null;
}
// Setup guards to check
if ($guards) {
$this->guards = is_array($guards) ? $guards : [$guards];
} else {
// Check all guards by default
$this->guards = ['web', 'organization', 'bank'];
}
// Determine current auth guard
$this->authGuard = session('active_guard') ?? 'web';
// Set display options
$this->showCount = $showCount;
$this->showAvatars = $showAvatars;
$this->maxDisplay = $maxDisplay;
$this->groupByModel = $groupByModel;
$this->lastSeen = $lastSeen;
// Load initial data
$this->loadOnlineReactedProfiles();
// Set header text if required
if ($header) {
if ($headerText) {
$this->headerText = $headerText;
} else {
$this->headerText = $this->getHeaderText($this->reactionTypes);
}
} else {
$this->headerText = null;
}
}
/**
* Get the header text based on reaction types.
* If no reaction types, return a default message.
*/
public function getHeaderText($reactionTypes)
{
if ($this->reactionTypes === null) {
$isAdmin = session('activeProfileType') === 'App\Models\Admin';
$key = $isAdmin ? 'messages.profiles_online' : 'messages.reactions_contacts_online';
return trans_choice($key, $this->totalCount, ['count' => $this->totalCount]);
} elseif (!empty($reactionTypes)) {
if (count($reactionTypes) === 1) {
return trans_choice('messages.' . strtolower($reactionTypes[0]) . '_contacts_online', $this->totalCount, ['count' => $this->totalCount]);
} elseif (count($reactionTypes) > 1) {
return trans_choice('messages.reactions_contacts_online', $this->totalCount, ['count' => $this->totalCount]);
}
}
return 'Error: no reaction types found.';
}
public function loadOnlineReactedProfiles()
{
try {
// Get authenticated user/profile
$authUser = auth($this->authGuard)->user();
if (!$authUser) {
$this->onlineReactedProfiles = [];
$this->totalCount = 0;
return;
}
// Only require viaLoveReacter if filtering by reactions
if ($this->reactionTypes !== null && !method_exists($authUser, 'viaLoveReacter')) {
$this->onlineReactedProfiles = [];
$this->totalCount = 0;
return;
}
// Collect all online users from specified guards
$allOnlineProfiles = collect();
$locationRelations = [
'locations.city.translations',
'locations.division.translations',
'locations.country.translations',
];
foreach ($this->guards as $guard) {
try {
$presenceService = app(PresenceService::class);
$onlineUsers = $presenceService->getOnlineUsers($guard);
// Batch-load all models for this guard in one query
$modelClass = $this->getModelClassForGuard($guard);
if (!$modelClass || $onlineUsers->isEmpty()) continue;
$ids = $onlineUsers->map(fn($u) => is_array($u) ? ($u['id'] ?? null) : ($u->id ?? null))->filter();
$modelsById = $modelClass::with($locationRelations)->whereIn('id', $ids)->get()->keyBy('id');
foreach ($onlineUsers as $userData) {
$userId = is_array($userData) ? ($userData['id'] ?? null) : ($userData->id ?? null);
if (!$userId) continue;
$model = $modelsById->get($userId);
if ($model && ($this->reactionTypes === null || method_exists($model, 'viaLoveReactant'))) {
$profileData = $this->buildProfileData($model, $userData, $guard);
if ($profileData) {
$allOnlineProfiles->push($profileData);
}
}
}
} catch (\Exception $e) {
\Log::error('Error loading online profiles for guard ' . $guard . ': ' . $e->getMessage());
}
}
// Exclude the current active profile from results
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
$allOnlineProfiles = $allOnlineProfiles->filter(function ($profile) use ($activeProfileType, $activeProfileId) {
return !($profile['model_type'] === $activeProfileType && $profile['id'] === $activeProfileId);
});
// Filter profiles based on active profile type
$activeProfileType = session('activeProfileType');
if ($activeProfileType === 'App\Models\Admin') {
// Admins see all online profiles
$reactedProfiles = $allOnlineProfiles;
} else {
// Non-admins see only their contacts (reactions + transactions + conversations)
$contactKeys = $this->getContactProfileKeys();
$reactedProfiles = $allOnlineProfiles->filter(function ($profile) use ($contactKeys) {
$key = $profile['model_type'] . '_' . $profile['id'];
return in_array($key, $contactKeys);
});
}
// Apply display limit
$limitedProfiles = $reactedProfiles->take($this->maxDisplay);
// Group by model if requested
if ($this->groupByModel) {
$this->onlineReactedProfiles = $limitedProfiles
->groupBy('model_type')
->toArray();
} else {
$this->onlineReactedProfiles = $limitedProfiles->values()->toArray();
}
// Update counts
$this->totalCount = $reactedProfiles->count();
$this->countByType = $reactedProfiles->groupBy('model_type')->map->count()->toArray();
// Dispatch event for other components
$this->dispatch('online-reacted-profiles-updated', [
'count' => $this->totalCount,
'profiles' => $this->onlineReactedProfiles
]);
} catch (\Exception $e) {
\Log::error('Error loading online reacted profiles: ' . $e->getMessage());
$this->onlineReactedProfiles = [];
$this->totalCount = 0;
}
}
protected function getModelFromUserData($userData, $guard)
{
try {
// Get the model class for this guard
$modelClass = $this->getModelClassForGuard($guard);
if (!$modelClass) {
return null;
}
// Handle both array and object formats
$userId = is_array($userData) ? ($userData['id'] ?? null) : ($userData->id ?? null);
if (!$userId) {
return null;
}
return $modelClass::find($userId);
} catch (\Exception $e) {
\Log::error('Error getting model from user data: ' . $e->getMessage());
return null;
}
}
protected function getModelClassForGuard($guard)
{
// Map guards to model classes
$guardModelMap = [
'web' => \App\Models\User::class,
'organization' => \App\Models\Organization::class,
'bank' => \App\Models\Bank::class,
'admin' => \App\Models\Admin::class,
];
return $guardModelMap[$guard] ?? null;
}
protected function buildProfileData($model, $userData, $guard)
{
try {
// Get reactions for this profile
$reactions = $this->getReactionsForProfile($model);
// Build profile data array
return [
'id' => $model->id,
'model' => $model,
'model_type' => get_class($model),
'model_label' => $this->modelLabels[get_class($model)] ?? class_basename($model),
'guard' => $guard,
'name' => $model->name ?? 'Unknown',
'location' => $model->getLocationFirst()['name_short'] ?? '',
'avatar' => $model->profile_photo_path ?? null,
'last_seen' => is_array($userData) ?
($userData['last_seen'] ?? now()) : ($userData->last_seen ?? now()),
'reactions' => $reactions,
'profile_url' => route('profile.show_by_type_and_id', ['type' => __(strtolower(class_basename($model))), 'id' => $model->id]),
'is_online' => true,
];
} catch (\Exception $e) {
\Log::error('Error building profile data: ' . $e->getMessage());
return null;
}
}
protected function hasAuthUserReacted($model)
{
try {
// If no reaction types specified, return true (show all)
if ($this->reactionTypes === null) {
return true;
}
if (!method_exists($model, 'viaLoveReactant')) {
return false;
}
$reactantFacade = $model->viaLoveReactant();
// Get the authenticated user directly
$authUser = auth($this->authGuard)->user();
if (!$authUser) {
return false;
}
// Check if empty array (different from null)
if (is_array($this->reactionTypes) && empty($this->reactionTypes)) {
return false;
}
foreach ($this->reactionTypes as $reactionType) {
if ($reactantFacade->isReactedBy($authUser, $reactionType)) {
return true;
}
}
return false;
} catch (\Exception $e) {
\Log::error('Error checking reactions: ' . $e->getMessage());
return false;
}
}
protected function getReactionsForProfile($model)
{
try {
if (!method_exists($model, 'viaLoveReactant')) {
return [];
}
$reactantFacade = $model->viaLoveReactant();
$reactions = [];
// Get the authenticated user directly
$authUser = auth($this->authGuard)->user();
if (!$authUser) {
return [];
}
if ($this->reactionTypes !== null) {
foreach ($this->reactionTypes as $reactionType) {
if ($reactantFacade->isReactedBy($authUser, $reactionType)) {
$reactions[] = [
'type' => $reactionType,
];
}
}
}
return $reactions;
} catch (\Exception $e) {
\Log::error('Error getting reactions for profile: ' . $e->getMessage());
return [];
}
}
public function toggleReaction($profileId, $modelType, $reactionType)
{
try {
$authUser = auth($this->authGuard)->user();
if (!$authUser) {
return;
}
$model = $modelType::find($profileId);
if (!$model) {
return;
}
$reacterFacade = $authUser->viaLoveReacter();
$reactantFacade = $model->viaLoveReactant();
if ($reactantFacade->isReactedBy($authUser, $reactionType)) {
$reacterFacade->unreactTo($model, $reactionType);
} else {
$reacterFacade->reactTo($model, $reactionType);
}
$this->loadOnlineReactedProfiles();
} catch (\Exception $e) {
\Log::error('Error toggling reaction: ' . $e->getMessage());
}
}
#[On('presence-updated')]
#[On('echo-presence:presence-web-users,.UserPresenceUpdated')]
public function refreshProfiles()
{
$this->loadOnlineReactedProfiles();
}
#[On('user-activity')]
public function onUserActivity()
{
$this->loadOnlineReactedProfiles();
}
public function isCurrentUserProfile($profileId, $modelType)
{
// Get the active profile using getActiveProfile() helper
$activeProfile = getActiveProfile();
if (!$activeProfile) {
return false;
}
// Check if this is the current active profile (admin or any other type)
if (get_class($activeProfile) === $modelType || get_class($activeProfile) === '\\' . $modelType) {
if ($activeProfile->id === $profileId) {
return true;
}
}
// If the active profile is an Admin, check for related users
if (get_class($activeProfile) === \App\Models\Admin::class) {
if (method_exists($activeProfile, 'users')) {
$relatedUsers = $activeProfile->users()->pluck('users.id')->toArray();
if ($modelType === 'App\Models\User' || $modelType === \App\Models\User::class) {
return in_array($profileId, $relatedUsers);
}
}
}
return false;
}
public function openLogoutModal($profileId, $modelType)
{
// Verify admin access
if (getActiveProfileType() !== 'Admin') {
$this->dispatch('notify', [
'type' => 'error',
'message' => 'Unauthorized action. Only administrators can log out users.'
]);
return;
}
// Prevent logging out current admin or their related profiles
if ($this->isCurrentUserProfile($profileId, $modelType)) {
$this->dispatch('notify', [
'type' => 'error',
'message' => 'You cannot log out your own profile or related user accounts.'
]);
return;
}
$this->selectedProfileId = $profileId;
$this->selectedProfileType = $modelType;
// Get profile name for display
try {
$model = $modelType::find($profileId);
$this->selectedProfileName = $model ? $model->name : null;
} catch (\Exception $e) {
$this->selectedProfileName = null;
}
$this->showLogoutModal = true;
}
public function closeLogoutModal()
{
$this->showLogoutModal = false;
$this->selectedProfileId = null;
$this->selectedProfileType = null;
$this->selectedProfileName = null;
}
public function logoutUser()
{
// Verify admin access
if (getActiveProfileType() !== 'Admin') {
$this->dispatch('notify', [
'type' => 'error',
'message' => 'Unauthorized action. Only administrators can log out users.'
]);
$this->closeLogoutModal();
return;
}
try {
if (!$this->selectedProfileId || !$this->selectedProfileType) {
throw new \Exception('Invalid profile data');
}
$model = $this->selectedProfileType::find($this->selectedProfileId);
if (!$model) {
throw new \Exception('User not found');
}
// Get the guard for this model type
$guard = $this->getGuardForModelType($this->selectedProfileType);
if (!$guard) {
throw new \Exception('Invalid guard');
}
// Broadcast logout event via websocket - this will force the user's browser to logout
broadcast(new \App\Events\UserForcedLogout($this->selectedProfileId, $guard));
// Delete all sessions for this user from the database
\DB::connection(config('session.connection'))
->table(config('session.table', 'sessions'))
->where('user_id', $this->selectedProfileId)
->delete();
// Clear any cached authentication data
\Cache::forget('auth_' . $guard . '_' . $this->selectedProfileId);
// Clear presence cache
\Cache::forget("presence_{$guard}_{$this->selectedProfileId}");
// Clear online users cache to force refresh
\Cache::forget("online_users_{$guard}_" . \App\Services\PresenceService::ONLINE_THRESHOLD_MINUTES);
// Set user offline in presence service
$presenceService = app(\App\Services\PresenceService::class);
$presenceService->setUserOffline($model, $guard);
$this->dispatch('notify', [
'type' => 'success',
'message' => __('User has been logged out successfully.')
]);
// Refresh the online profiles list
$this->loadOnlineReactedProfiles();
} catch (\Exception $e) {
\Log::error('Error logging out user: ' . $e->getMessage());
$this->dispatch('notify', [
'type' => 'error',
'message' => __('Failed to log out user. Please try again.')
]);
}
$this->closeLogoutModal();
}
protected function getGuardForModelType($modelType)
{
// Map model types to guards
$modelGuardMap = [
\App\Models\User::class => 'web',
'App\Models\User' => 'web',
\App\Models\Organization::class => 'organization',
'App\Models\Organization' => 'organization',
\App\Models\Bank::class => 'bank',
'App\Models\Bank' => 'bank',
\App\Models\Admin::class => 'admin',
'App\Models\Admin' => 'admin',
];
return $modelGuardMap[$modelType] ?? null;
}
/**
* Get profile keys (ModelClass_id) for all contacts of the active profile.
* Mirrors the logic in Contacts.php: reactions (Star/Bookmark) + transactions + conversations.
*/
protected function getContactProfileKeys(): array
{
$activeProfile = getActiveProfile();
if (!$activeProfile) {
return [];
}
$keys = collect();
$reacterId = $activeProfile->love_reacter_id;
// 1. Reactions (Star + Bookmark)
if ($reacterId) {
$reacted = DB::table('love_reactions')
->join('love_reactants', 'love_reactions.reactant_id', '=', 'love_reactants.id')
->where('love_reactions.reacter_id', $reacterId)
->whereIn(DB::raw('SUBSTRING_INDEX(love_reactants.type, "\\\\", -1)'), ['User', 'Organization', 'Bank'])
->select('love_reactants.type as reactant_type', 'love_reactants.id as reactant_id')
->get();
foreach ($reacted as $row) {
// Look up the profile id via the reactant
$profileId = DB::table('love_reactants')
->join(DB::raw('(' . $this->buildProfileSubquery($row->reactant_type) . ') as p'), 'love_reactants.id', '=', 'p.love_reactant_id')
->where('love_reactants.id', $row->reactant_id)
->value('p.id');
if ($profileId) {
$modelClass = $this->resolveModelClassFromReactantType($row->reactant_type);
if ($modelClass) {
$keys->push($modelClass . '_' . $profileId);
}
}
}
}
// 2. Transactions
$accountIds = DB::table('accounts')
->where('accountable_type', get_class($activeProfile))
->where('accountable_id', $activeProfile->id)
->pluck('id');
if ($accountIds->isNotEmpty()) {
$counterAccountIds = DB::table('transactions')
->where(function ($q) use ($accountIds) {
$q->whereIn('from_account_id', $accountIds)->whereNotIn('to_account_id', $accountIds);
})
->orWhere(function ($q) use ($accountIds) {
$q->whereIn('to_account_id', $accountIds)->whereNotIn('from_account_id', $accountIds);
})
->get()
->map(fn($t) => $accountIds->contains($t->from_account_id) ? $t->to_account_id : $t->from_account_id)
->unique();
DB::table('accounts')
->whereIn('id', $counterAccountIds)
->select('accountable_type', 'accountable_id')
->get()
->each(fn($a) => $keys->push($a->accountable_type . '_' . $a->accountable_id));
}
// 3. WireChat private conversations
$participantType = get_class($activeProfile);
$participantId = $activeProfile->id;
$myConversationIds = DB::table('wirechat_participants')
->join('wirechat_conversations', 'wirechat_participants.conversation_id', '=', 'wirechat_conversations.id')
->where('wirechat_participants.participantable_type', $participantType)
->where('wirechat_participants.participantable_id', $participantId)
->where('wirechat_conversations.type', ConversationType::PRIVATE->value)
->whereNull('wirechat_participants.deleted_at')
->pluck('wirechat_participants.conversation_id');
if ($myConversationIds->isNotEmpty()) {
DB::table('wirechat_participants')
->whereIn('conversation_id', $myConversationIds)
->where(function ($q) use ($participantType, $participantId) {
$q->where('participantable_type', '!=', $participantType)
->orWhere('participantable_id', '!=', $participantId);
})
->whereNull('deleted_at')
->select('participantable_type', 'participantable_id')
->get()
->each(fn($p) => $keys->push($p->participantable_type . '_' . $p->participantable_id));
}
return $keys->unique()->values()->toArray();
}
private function buildProfileSubquery(string $reactantType): string
{
$modelName = class_basename($reactantType);
$table = strtolower($modelName) . 's';
return "SELECT id, love_reactant_id FROM {$table}";
}
private function resolveModelClassFromReactantType(string $reactantType): ?string
{
$modelName = class_basename($reactantType);
if (!in_array($modelName, ['User', 'Organization', 'Bank'])) {
return null;
}
return "App\\Models\\{$modelName}";
}
public function render()
{
return view('livewire.online-reacted-profiles');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
// app/Http/Livewire/OnlineUsersList.php
namespace App\Http\Livewire;
use App\Services\PresenceService;
use Livewire\Attributes\On;
use Livewire\Component;
class OnlineUsersList extends Component
{
public $onlineUsers = [];
public $guard = 'web';
public $showCount = true;
public $showAvatars = true;
public $maxDisplay = 10;
public $refreshInterval = 60; // Fallback poll — primary updates come via Reverb echo-presence event
public function mount($guard = 'web', $showCount = true, $showAvatars = true, $maxDisplay = 10)
{
$this->guard = $guard;
$this->showCount = $showCount;
$this->showAvatars = $showAvatars;
$this->maxDisplay = $maxDisplay;
$this->loadOnlineUsers();
}
#[On('echo-presence:presence-{guard}-users,.UserPresenceUpdated')]
public function loadOnlineUsers()
{
try {
$presenceService = app(PresenceService::class);
$users = $presenceService->getOnlineUsers($this->guard);
// Limit the display count
$this->onlineUsers = $users->take($this->maxDisplay)->toArray();
} catch (\Exception $e) {
$this->onlineUsers = [];
}
}
public function render()
{
return view('livewire.online-users-list');
}
}
Loaded 100 of 2580 files, more files were not shown because too many files have changed in this diff. Show more