Deploy Ladill QR Plus / deploy (push) Successful in 2m2s
Create was making three sequential public HTTPS billing calls (~400ms each) while holding a DB transaction open for image generation. Use loopback DNS forcing, cache balances briefly, return balance from debit, regenerate images only when style changes, and keep remote work outside the DB transaction.
651 lines
27 KiB
PHP
651 lines
27 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Qr;
|
|
|
|
use App\Models\QrCode;
|
|
use App\Models\QrDocument;
|
|
use App\Models\QrWallet;
|
|
use App\Models\User;
|
|
use App\Support\Qr\QrStyleDefaults;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
class QrCodeManagerService
|
|
{
|
|
public function __construct(
|
|
private QrWalletBillingService $billing,
|
|
private QrImageGeneratorService $imageGenerator,
|
|
private QrPayloadValidator $payloadValidator,
|
|
) {}
|
|
|
|
public function walletFor(User $user): QrWallet
|
|
{
|
|
return $user->qrWallet()->firstOrCreate(
|
|
[],
|
|
['credit_balance' => 0, 'status' => QrWallet::STATUS_ACTIVE],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function create(User $user, array $data): QrCode
|
|
{
|
|
$wallet = $this->walletFor($user);
|
|
|
|
// Fast path: one cached balance read. Debit is still authoritative (402 if
|
|
// funds are short). Avoids a second can-afford HTTP round-trip.
|
|
if (! $wallet->canCreateQr()) {
|
|
throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.');
|
|
}
|
|
|
|
$type = (string) ($data['type'] ?? '');
|
|
$validated = $this->payloadValidator->validateForCreate($type, $data);
|
|
$style = QrStyleDefaults::merge($data['style'] ?? null);
|
|
|
|
// Persist the code first (short DB transaction — no remote billing/image work).
|
|
$qrCode = DB::transaction(function () use ($user, $data, $type, $validated, $style) {
|
|
$documentId = null;
|
|
$content = $validated['content'];
|
|
|
|
if ($type === QrCode::TYPE_DOCUMENT) {
|
|
$file = $data['document'] ?? null;
|
|
if (! $file instanceof UploadedFile) {
|
|
throw new RuntimeException('A PDF document is required for PDF QR codes.');
|
|
}
|
|
$documentId = $this->storeDocument($user, $file, $data['label'] ?? 'Document')->id;
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_IMAGE) {
|
|
$images = $this->storeImages($user, $data);
|
|
$content['images'] = $images;
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) {
|
|
$content['avatar_path'] = $this->storeVcardAvatar($user, $data['avatar']);
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_BOOK) {
|
|
$bookFile = $data['book_file'] ?? null;
|
|
if (! $bookFile instanceof UploadedFile) {
|
|
throw new RuntimeException('Upload the book file (PDF or EPUB).');
|
|
}
|
|
$bookData = $this->storeBookFile($user, $bookFile);
|
|
$content['file_path'] = $bookData['path'];
|
|
$content['file_type'] = $bookData['type'];
|
|
$content['file_size'] = $bookData['size'];
|
|
|
|
if (($data['cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeBookCover($user, $data['cover']);
|
|
}
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) {
|
|
$content['icon_path'] = $this->storeMenuBrandImage($user, $data['app_icon'], 'app-icons');
|
|
}
|
|
|
|
if (in_array($type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) {
|
|
$content['sections'] = $this->injectItemImages($user, $content['sections'] ?? [], $data, $type);
|
|
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['menu_logo'], 'menu-logos');
|
|
}
|
|
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['menu_cover'], 'menu-covers');
|
|
}
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_BUSINESS) {
|
|
if (($data['business_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['business_logo'], 'business-logos');
|
|
}
|
|
if (($data['business_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['business_cover'], 'business-covers');
|
|
}
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_PORTFOLIO) {
|
|
if (($data['portfolio_avatar'] ?? null) instanceof UploadedFile) {
|
|
$content['avatar_path'] = $this->storeMenuBrandImage($user, $data['portfolio_avatar'], 'portfolio-avatars');
|
|
}
|
|
if (($data['portfolio_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['portfolio_cover'], 'portfolio-covers');
|
|
}
|
|
$content['projects'] = $this->injectListImages(
|
|
$user,
|
|
$content['projects'] ?? [],
|
|
$data['project_images'] ?? [],
|
|
'portfolio-projects',
|
|
'image_path',
|
|
);
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_BOOKSHOP) {
|
|
if (($data['bookshop_avatar'] ?? null) instanceof UploadedFile) {
|
|
$content['avatar_path'] = $this->storeMenuBrandImage($user, $data['bookshop_avatar'], 'bookshop-avatars');
|
|
}
|
|
if (($data['bookshop_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['bookshop_cover'], 'bookshop-covers');
|
|
}
|
|
$content['books'] = $this->injectListImages(
|
|
$user,
|
|
$content['books'] ?? [],
|
|
$data['book_covers'] ?? [],
|
|
'bookshop-books',
|
|
'cover_path',
|
|
);
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_CHURCH) {
|
|
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'church-logos');
|
|
}
|
|
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['church_cover'], 'church-covers');
|
|
}
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_EVENT) {
|
|
if (($data['event_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['event_logo'], 'event-logos');
|
|
}
|
|
if (($data['event_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['event_cover'], 'event-covers');
|
|
}
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['itinerary_cover'], 'itinerary-covers');
|
|
}
|
|
|
|
if ($style['logo_path'] === null && ($data['logo'] ?? null) instanceof UploadedFile) {
|
|
$style['logo_path'] = $this->storeLogo($user, $data['logo']);
|
|
}
|
|
|
|
$shortCode = (isset($data['custom_short_code']) && $data['custom_short_code'] !== '')
|
|
? (string) $data['custom_short_code']
|
|
: $this->generateUniqueShortCode();
|
|
|
|
$payload = [
|
|
'content' => $content,
|
|
'style' => $style,
|
|
];
|
|
|
|
// Freeze the WiFi auto-join payload so the printed code never changes on edit.
|
|
if ($type === QrCode::TYPE_WIFI) {
|
|
$payload['wifi_encoded'] = \App\Support\Qr\QrWifiPayload::encode($content);
|
|
}
|
|
|
|
return QrCode::create([
|
|
'user_id' => $user->id,
|
|
'short_code' => $shortCode,
|
|
'type' => $type,
|
|
'label' => trim((string) $data['label']),
|
|
'destination_url' => $validated['destination_url'],
|
|
'qr_document_id' => $documentId,
|
|
'payload' => $payload,
|
|
'is_active' => true,
|
|
'destination_updated_at' => now(),
|
|
]);
|
|
});
|
|
|
|
try {
|
|
$this->billing->debitForQrCreation($wallet, $qrCode);
|
|
} catch (RuntimeException $e) {
|
|
// Roll back the unpaid code so a failed debit cannot leave a free QR.
|
|
$qrCode->delete();
|
|
throw $e;
|
|
}
|
|
|
|
// Image render is local CPU work — keep it outside the DB transaction.
|
|
$this->imageGenerator->generateAndStore($qrCode);
|
|
|
|
return $qrCode->fresh(['document']);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function update(QrCode $qrCode, array $data): QrCode
|
|
{
|
|
$content = $qrCode->content();
|
|
$style = $qrCode->style();
|
|
$destinationUrl = $qrCode->destination_url;
|
|
$regenerate = false;
|
|
|
|
if (isset($data['label']) && trim((string) $data['label']) !== '') {
|
|
$qrCode->label = trim((string) $data['label']);
|
|
}
|
|
|
|
if (array_key_exists('is_active', $data)) {
|
|
$qrCode->is_active = (bool) $data['is_active'];
|
|
}
|
|
|
|
$typeFields = array_merge($content, $data);
|
|
if ($this->hasContentChanges($qrCode, $data)) {
|
|
$validated = $this->payloadValidator->validateForUpdate($qrCode, $typeFields);
|
|
$content = $validated['content'];
|
|
$destinationUrl = $validated['destination_url'];
|
|
$qrCode->destination_updated_at = now();
|
|
}
|
|
|
|
if ($qrCode->isDocumentType() && isset($data['document']) && $data['document'] instanceof UploadedFile) {
|
|
$document = $this->storeDocument($qrCode->user, $data['document'], $data['label'] ?? $qrCode->label);
|
|
$qrCode->qr_document_id = $document->id;
|
|
$qrCode->destination_updated_at = now();
|
|
}
|
|
|
|
if ($qrCode->isImageType()) {
|
|
$newImages = $this->storeImages($qrCode->user, $data, false);
|
|
if ($newImages !== []) {
|
|
$content['images'] = array_merge($content['images'] ?? [], $newImages);
|
|
$qrCode->destination_updated_at = now();
|
|
}
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) {
|
|
if ($oldPath = $content['avatar_path'] ?? null) {
|
|
Storage::disk('qr')->delete($oldPath);
|
|
}
|
|
$content['avatar_path'] = $this->storeVcardAvatar($qrCode->user, $data['avatar']);
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_BOOK) {
|
|
if (($data['book_file'] ?? null) instanceof UploadedFile) {
|
|
$bookData = $this->storeBookFile($qrCode->user, $data['book_file']);
|
|
$content['file_path'] = $bookData['path'];
|
|
$content['file_type'] = $bookData['type'];
|
|
$content['file_size'] = $bookData['size'];
|
|
$qrCode->destination_updated_at = now();
|
|
}
|
|
if (($data['cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeBookCover($qrCode->user, $data['cover']);
|
|
}
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) {
|
|
$content['icon_path'] = $this->storeMenuBrandImage($qrCode->user, $data['app_icon'], 'app-icons');
|
|
}
|
|
|
|
if (in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) {
|
|
$content['sections'] = $this->injectItemImages($qrCode->user, $content['sections'] ?? [], $data, $qrCode->type);
|
|
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_logo'], 'menu-logos');
|
|
}
|
|
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_cover'], 'menu-covers');
|
|
}
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_BUSINESS) {
|
|
if (($data['business_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_logo'], 'business-logos');
|
|
}
|
|
if (($data['business_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_cover'], 'business-covers');
|
|
}
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_PORTFOLIO) {
|
|
if (($data['portfolio_avatar'] ?? null) instanceof UploadedFile) {
|
|
$content['avatar_path'] = $this->storeMenuBrandImage($qrCode->user, $data['portfolio_avatar'], 'portfolio-avatars');
|
|
}
|
|
if (($data['portfolio_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['portfolio_cover'], 'portfolio-covers');
|
|
}
|
|
$content['projects'] = $this->injectListImages(
|
|
$qrCode->user,
|
|
$content['projects'] ?? [],
|
|
$data['project_images'] ?? [],
|
|
'portfolio-projects',
|
|
'image_path',
|
|
);
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_BOOKSHOP) {
|
|
if (($data['bookshop_avatar'] ?? null) instanceof UploadedFile) {
|
|
$content['avatar_path'] = $this->storeMenuBrandImage($qrCode->user, $data['bookshop_avatar'], 'bookshop-avatars');
|
|
}
|
|
if (($data['bookshop_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['bookshop_cover'], 'bookshop-covers');
|
|
}
|
|
$content['books'] = $this->injectListImages(
|
|
$qrCode->user,
|
|
$content['books'] ?? [],
|
|
$data['book_covers'] ?? [],
|
|
'bookshop-books',
|
|
'cover_path',
|
|
);
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_CHURCH) {
|
|
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_logo'], 'church-logos');
|
|
}
|
|
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_cover'], 'church-covers');
|
|
}
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_EVENT) {
|
|
if (($data['event_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_logo'], 'event-logos');
|
|
}
|
|
if (($data['event_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_cover'], 'event-covers');
|
|
}
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['itinerary_cover'], 'itinerary-covers');
|
|
}
|
|
|
|
$previousStyle = $style;
|
|
|
|
if (isset($data['style']) && is_array($data['style'])) {
|
|
$style = QrStyleDefaults::merge(array_merge($style, $data['style']));
|
|
}
|
|
|
|
if (($data['logo'] ?? null) instanceof UploadedFile) {
|
|
$style['logo_path'] = $this->storeLogo($qrCode->user, $data['logo']);
|
|
$regenerate = true;
|
|
}
|
|
|
|
if (($data['remove_logo'] ?? false) && ($style['logo_path'] ?? null)) {
|
|
$style['logo_path'] = null;
|
|
$regenerate = true;
|
|
}
|
|
|
|
// Only re-render the PNG/SVG when style actually changed (forms always post style[]).
|
|
if (! $regenerate && $this->styleChanged($previousStyle, $style)) {
|
|
$regenerate = true;
|
|
}
|
|
|
|
$qrCode->destination_url = $destinationUrl;
|
|
// Preserve frozen keys (e.g. wifi_encoded) so the printed WiFi code stays put.
|
|
$payload = (array) ($qrCode->payload ?? []);
|
|
$payload['content'] = $content;
|
|
$payload['style'] = $style;
|
|
$qrCode->payload = $payload;
|
|
$qrCode->save();
|
|
|
|
if ($regenerate) {
|
|
$this->imageGenerator->generateAndStore($qrCode);
|
|
}
|
|
|
|
return $qrCode->fresh(['document']);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $before
|
|
* @param array<string, mixed> $after
|
|
*/
|
|
private function styleChanged(array $before, array $after): bool
|
|
{
|
|
$before = QrStyleDefaults::merge($before);
|
|
$after = QrStyleDefaults::merge($after);
|
|
ksort($before);
|
|
ksort($after);
|
|
|
|
return $before !== $after;
|
|
}
|
|
|
|
/**
|
|
* Inject uploaded item images into the sections array.
|
|
* Form field: item_images[sectionIndex][itemIndex] (UploadedFile)
|
|
*
|
|
* @param array<int, mixed> $sections
|
|
* @param array<string, mixed> $data
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function injectItemImages(User $user, array $sections, array $data, string $type): array
|
|
{
|
|
$uploads = $data['item_images'] ?? [];
|
|
if (! is_array($uploads) || $uploads === []) {
|
|
return $sections;
|
|
}
|
|
|
|
foreach ($uploads as $sIndex => $itemUploads) {
|
|
if (! is_array($itemUploads)) {
|
|
continue;
|
|
}
|
|
foreach ($itemUploads as $iIndex => $file) {
|
|
if (! ($file instanceof UploadedFile)) {
|
|
continue;
|
|
}
|
|
if (! isset($sections[$sIndex]['items'][$iIndex])) {
|
|
continue;
|
|
}
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
continue;
|
|
}
|
|
$subdir = $type === QrCode::TYPE_SHOP ? 'shop-items' : 'menu-items';
|
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
|
$path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext;
|
|
$file->storeAs('', $path, 'qr');
|
|
$sections[$sIndex]['items'][$iIndex]['image_path'] = $path;
|
|
}
|
|
}
|
|
|
|
return $sections;
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
private function hasContentChanges(QrCode $qrCode, array $data): bool
|
|
{
|
|
$keys = match ($qrCode->type) {
|
|
QrCode::TYPE_URL => ['destination_url'],
|
|
QrCode::TYPE_LINK_LIST => ['links'],
|
|
QrCode::TYPE_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'],
|
|
QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'],
|
|
QrCode::TYPE_CHURCH => ['name', 'denomination', 'description', 'phone', 'email', 'website', 'address', 'service_times', 'org_type', 'accepts_payment', 'collection_types', 'brand_color'],
|
|
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open'],
|
|
QrCode::TYPE_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'],
|
|
QrCode::TYPE_DOCUMENT => ['allow_download'],
|
|
QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
|
|
QrCode::TYPE_SHOP => ['shop_title', 'sections', 'currency', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
|
|
QrCode::TYPE_APP => ['app_name', 'ios_url', 'android_url', 'web_url', 'app_icon'],
|
|
QrCode::TYPE_BOOK => ['book_title', 'author', 'description', 'price_ghs'],
|
|
QrCode::TYPE_PORTFOLIO => ['name', 'role', 'bio', 'email', 'phone', 'website', 'brand_color', 'projects', 'social'],
|
|
QrCode::TYPE_BOOKSHOP => ['author_name', 'tagline', 'bio', 'email', 'website', 'brand_color', 'books', 'social'],
|
|
QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'],
|
|
default => [],
|
|
};
|
|
|
|
foreach ($keys as $key) {
|
|
if (array_key_exists($key, $data)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function storeDocument(User $user, UploadedFile $file, string $title): QrDocument
|
|
{
|
|
$maxBytes = (int) config('qr.max_pdf_bytes', 104857600);
|
|
|
|
if ($file->getSize() > $maxBytes) {
|
|
throw new RuntimeException('PDF must be 100 MB or smaller.');
|
|
}
|
|
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! in_array($mime, ['application/pdf', 'application/x-pdf'], true)) {
|
|
throw new RuntimeException('Only PDF documents are supported.');
|
|
}
|
|
|
|
$uuid = Str::uuid()->toString();
|
|
$path = $user->id . '/documents/' . $uuid . '.pdf';
|
|
|
|
$file->storeAs('', $path, 'qr');
|
|
|
|
return QrDocument::create([
|
|
'user_id' => $user->id,
|
|
'title' => $title,
|
|
'disk' => 'qr',
|
|
'path' => $path,
|
|
'mime_type' => 'application/pdf',
|
|
'size_bytes' => (int) $file->getSize(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
* @return list<array{path: string, title: string}>
|
|
*/
|
|
private function storeImages(User $user, array $data, bool $required = true): array
|
|
{
|
|
$files = [];
|
|
if (($data['image'] ?? null) instanceof UploadedFile) {
|
|
$files[] = $data['image'];
|
|
}
|
|
if (is_array($data['images'] ?? null)) {
|
|
foreach ($data['images'] as $file) {
|
|
if ($file instanceof UploadedFile) {
|
|
$files[] = $file;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($required && $files === []) {
|
|
$this->payloadValidator->validateImageUpload(null);
|
|
}
|
|
|
|
$stored = [];
|
|
foreach ($files as $index => $file) {
|
|
$this->payloadValidator->validateImageUpload($file);
|
|
$uuid = Str::uuid()->toString();
|
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
|
$path = $user->id . '/images/' . $uuid . '.' . $ext;
|
|
$file->storeAs('', $path, 'qr');
|
|
$stored[] = [
|
|
'path' => $path,
|
|
'title' => $file->getClientOriginalName() ?: ('Image ' . ($index + 1)),
|
|
];
|
|
}
|
|
|
|
return $stored;
|
|
}
|
|
|
|
private function storeVcardAvatar(User $user, UploadedFile $file): string
|
|
{
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
throw new RuntimeException('Avatar must be an image file.');
|
|
}
|
|
|
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
|
$path = $user->id . '/vcards/' . Str::uuid()->toString() . '.' . $ext;
|
|
$file->storeAs('', $path, 'qr');
|
|
|
|
return $path;
|
|
}
|
|
|
|
private function storeBookFile(User $user, UploadedFile $file): array
|
|
{
|
|
$ext = strtolower($file->getClientOriginalExtension() ?: '');
|
|
$mime = $file->getMimeType() ?: '';
|
|
|
|
if ($ext === 'pdf' || in_array($mime, ['application/pdf', 'application/x-pdf'], true)) {
|
|
$type = 'pdf';
|
|
} elseif ($ext === 'epub' || str_contains($mime, 'epub')) {
|
|
$type = 'epub';
|
|
} else {
|
|
throw new RuntimeException('Only PDF and EPUB files are supported for books.');
|
|
}
|
|
|
|
$path = $user->id . '/books/' . Str::uuid()->toString() . '.' . $type;
|
|
$file->storeAs('', $path, 'qr');
|
|
|
|
return ['path' => $path, 'type' => $type, 'size' => (int) $file->getSize()];
|
|
}
|
|
|
|
private function storeMenuBrandImage(User $user, UploadedFile $file, string $subdir): string
|
|
{
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
throw new RuntimeException('Only image files are supported.');
|
|
}
|
|
|
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
|
$path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext;
|
|
$file->storeAs('', $path, 'qr');
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* Inject uploaded list images (project images / book covers) into a flat list.
|
|
*
|
|
* @param array<int, mixed> $items
|
|
* @param array<int, mixed> $uploads
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function injectListImages(User $user, array $items, array $uploads, string $subdir, string $pathKey): array
|
|
{
|
|
if ($uploads === []) {
|
|
return $items;
|
|
}
|
|
|
|
foreach ($uploads as $index => $file) {
|
|
if (! ($file instanceof UploadedFile) || ! isset($items[$index])) {
|
|
continue;
|
|
}
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
continue;
|
|
}
|
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
|
$path = $user->id.'/'.$subdir.'/'.Str::uuid()->toString().'.'.$ext;
|
|
$file->storeAs('', $path, 'qr');
|
|
$items[$index][$pathKey] = $path;
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
private function storeBookCover(User $user, UploadedFile $file): string
|
|
{
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
throw new RuntimeException('Book cover must be an image file.');
|
|
}
|
|
|
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
|
$path = $user->id . '/book-covers/' . Str::uuid()->toString() . '.' . $ext;
|
|
$file->storeAs('', $path, 'qr');
|
|
|
|
return $path;
|
|
}
|
|
|
|
private function storeLogo(User $user, UploadedFile $file): string
|
|
{
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
throw new RuntimeException('Logo must be an image file.');
|
|
}
|
|
|
|
$path = $user->id . '/logos/' . Str::uuid()->toString() . '.' . ($file->getClientOriginalExtension() ?: 'png');
|
|
$file->storeAs('', $path, 'qr');
|
|
|
|
return $path;
|
|
}
|
|
|
|
private function generateUniqueShortCode(): string
|
|
{
|
|
$length = (int) config('qr.short_code_length', 8);
|
|
|
|
for ($attempt = 0; $attempt < 20; $attempt++) {
|
|
$code = Str::lower(Str::random($length));
|
|
if (! QrCode::query()->where('short_code', $code)->exists()) {
|
|
return $code;
|
|
}
|
|
}
|
|
|
|
throw new RuntimeException('Could not generate a unique QR short code.');
|
|
}
|
|
}
|