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

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