Deploy Ladill Events / deploy (push) Successful in 41s
Programme sync to Meet rooms, wallet-billed mass comms, webhook lifecycle updates, and registration access bridge with join-window gating on public pages. Co-authored-by: Cursor <cursoragent@cursor.com>
537 lines
22 KiB
PHP
537 lines
22 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,
|
|
private \App\Services\Meet\EventMeetSyncService $meetSync,
|
|
) {}
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
return DB::transaction(function () use ($user, $wallet, $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_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);
|
|
}
|
|
|
|
$qrCode = 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(),
|
|
]);
|
|
|
|
$this->billing->debitForQrCreation($wallet, $qrCode);
|
|
$this->imageGenerator->generateAndStore($qrCode);
|
|
|
|
if ($type === QrCode::TYPE_EVENT) {
|
|
$qrCode = $this->meetSync->sync($qrCode->fresh(), $user);
|
|
}
|
|
|
|
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_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');
|
|
}
|
|
|
|
if (isset($data['style']) && is_array($data['style'])) {
|
|
$style = QrStyleDefaults::merge(array_merge($style, $data['style']));
|
|
$regenerate = true;
|
|
}
|
|
|
|
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']) {
|
|
$style['logo_path'] = null;
|
|
$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);
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_EVENT) {
|
|
$qrCode = $this->meetSync->sync($qrCode->fresh(), $qrCode->user);
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_ITINERARY) {
|
|
$this->meetSync->syncEventsForProgramme($qrCode->fresh(), $qrCode->user);
|
|
}
|
|
|
|
return $qrCode->fresh(['document']);
|
|
}
|
|
|
|
/**
|
|
* 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', 'format', 'virtual_sessions'],
|
|
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_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;
|
|
}
|
|
|
|
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.');
|
|
}
|
|
}
|