Files

451 lines
18 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
}
}