451 lines
18 KiB
PHP
451 lines
18 KiB
PHP
<?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');
|
||
}
|
||
}
|