Files
ladill-transfer/app/Services/Qr/QrPayloadValidator.php
T
isaaccladandCursor c1e3d8b3ac Initial Ladill Transfer app — file sharing with QR links.
Scaffolded from the Ladill mini/events extraction pattern: multi-file transfers,
retention controls, public landing pages, analytics, and Gitea deploy workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 09:25:30 +00:00

664 lines
26 KiB
PHP

<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Support\Qr\QrDateFormatter;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\UploadedFile;
use RuntimeException;
class QrPayloadValidator
{
/**
* @param array<string, mixed> $input
* @return array{content: array<string, mixed>, destination_url: ?string}
*/
public function validateForCreate(string $type, array $input): array
{
if (! QrTypeCatalog::isValid($type)) {
throw new RuntimeException('Invalid QR type selected.');
}
return match ($type) {
QrCode::TYPE_URL => $this->validateUrl($input),
QrCode::TYPE_DOCUMENT => $this->validateDocument($input),
QrCode::TYPE_LINK_LIST => $this->validateLinkList($input),
QrCode::TYPE_VCARD => $this->validateVcard($input),
QrCode::TYPE_BUSINESS => $this->validateBusiness($input),
QrCode::TYPE_CHURCH => $this->validateChurch($input),
QrCode::TYPE_EVENT => $this->validateEvent($input),
QrCode::TYPE_ITINERARY => $this->validateItinerary($input),
QrCode::TYPE_IMAGE => ['content' => [], 'destination_url' => null],
QrCode::TYPE_MENU => $this->validateMenu($input),
QrCode::TYPE_SHOP => $this->validateShop($input),
QrCode::TYPE_APP => $this->validateApp($input),
QrCode::TYPE_BOOK => $this->validateBook($input),
QrCode::TYPE_WIFI => $this->validateWifi($input),
QrCode::TYPE_PAYMENT => $this->validatePayment($input),
default => throw new RuntimeException('Unsupported QR type.'),
};
}
/**
* @param array<string, mixed> $input
* @return array{content: array<string, mixed>, destination_url: ?string}
*/
public function validateForUpdate(QrCode $qrCode, array $input): array
{
$merged = array_merge($qrCode->content(), $input);
if ($qrCode->isUrlType() && empty($merged['destination_url']) && ! empty($merged['url'])) {
$merged['destination_url'] = $merged['url'];
}
return $this->validateForCreate($qrCode->type, $merged);
}
/** @param array<string, mixed> $input */
private function validateDocument(array $input): array
{
$allowDownload = filter_var($input['allow_download'] ?? true, FILTER_VALIDATE_BOOL);
return ['content' => ['allow_download' => $allowDownload], 'destination_url' => null];
}
/** @param array<string, mixed> $input */
private function validateUrl(array $input): array
{
$url = $this->requireUrl($input['destination_url'] ?? null, 'Enter a valid destination URL.');
return ['content' => ['url' => $url], 'destination_url' => $url];
}
/** @param array<string, mixed> $input */
private function validateLinkList(array $input): array
{
$links = $this->normalizeLinks($input['links'] ?? []);
if ($links === []) {
throw new RuntimeException('Add at least one link.');
}
return ['content' => ['links' => $links], 'destination_url' => null];
}
/** @param array<string, mixed> $input */
private function validateVcard(array $input): array
{
$first = trim((string) ($input['first_name'] ?? ''));
$last = trim((string) ($input['last_name'] ?? ''));
if ($first === '' && $last === '') {
throw new RuntimeException('Enter a first or last name for the vCard.');
}
$social = [];
foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) {
$raw = trim((string) (($input['social'] ?? [])[$platform] ?? ''));
if ($raw !== '') {
$social[$platform] = static::normalizeSocialUrl($platform, $raw);
}
}
return [
'content' => [
'first_name' => $first,
'last_name' => $last,
'phone' => trim((string) ($input['phone'] ?? '')),
'email' => trim((string) ($input['email'] ?? '')),
'company' => trim((string) ($input['company'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'address' => trim((string) ($input['address'] ?? '')),
'note' => trim((string) ($input['note'] ?? '')),
'avatar_path' => $input['avatar_path'] ?? null,
'social' => $social,
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateBusiness(array $input): array
{
$name = trim((string) ($input['name'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter a business name.');
}
$social = [];
foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) {
$raw = trim((string) (($input['social'] ?? [])[$platform] ?? ''));
if ($raw !== '') {
$social[$platform] = static::normalizeSocialUrl($platform, $raw);
}
}
return [
'content' => [
'name' => $name,
'tagline' => trim((string) ($input['tagline'] ?? '')),
'phone' => trim((string) ($input['phone'] ?? '')),
'email' => trim((string) ($input['email'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'address' => trim((string) ($input['address'] ?? '')),
'hours' => trim((string) ($input['hours'] ?? '')),
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
'social' => $social,
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateChurch(array $input): array
{
$name = trim((string) ($input['name'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter the church name.');
}
$orgTypes = ['church', 'school', 'mosque', 'ngo', 'club'];
$orgType = in_array($input['org_type'] ?? 'church', $orgTypes) ? $input['org_type'] : 'church';
// Normalise legacy lowercase slugs → display strings
$legacyMap = ['offering' => 'Offering', 'tithe' => 'Tithe', 'donation' => 'Donation', 'harvest' => 'Harvest'];
$collectionTypes = array_values(array_unique(array_filter(
array_map(fn ($t) => $legacyMap[strtolower(trim((string) $t))] ?? ucwords(trim((string) $t)), (array) ($input['collection_types'] ?? [])),
fn ($t) => $t !== '' && strlen($t) <= 60
)));
if (empty($collectionTypes)) {
$collectionTypes = ['Offering', 'Tithe', 'Donation', 'Harvest'];
}
return [
'content' => [
'name' => $name,
'denomination' => trim((string) ($input['denomination'] ?? '')),
'description' => trim((string) ($input['description'] ?? '')),
'phone' => trim((string) ($input['phone'] ?? '')),
'email' => trim((string) ($input['email'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'address' => trim((string) ($input['address'] ?? '')),
'service_times' => trim((string) ($input['service_times'] ?? '')),
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#1a3a5c',
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
'org_type' => $orgType,
'accepts_payment' => filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL),
'currency' => 'GHS',
'collection_types' => $collectionTypes,
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateEvent(array $input): array
{
$name = trim((string) ($input['name'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter the event name.');
}
// Ticket tiers: [{ name, price, capacity }]
$tiers = [];
foreach ((array) ($input['tiers'] ?? []) as $tier) {
if (! is_array($tier)) {
continue;
}
$tierName = trim((string) ($tier['name'] ?? ''));
if ($tierName === '') {
continue;
}
$price = round((float) ($tier['price'] ?? 0), 2);
$capacity = (int) ($tier['capacity'] ?? 0);
$tiers[] = [
'name' => mb_substr($tierName, 0, 80),
'price' => max(0, $price),
'capacity' => max(0, $capacity), // 0 = unlimited
];
}
if (empty($tiers)) {
$tiers = [['name' => 'General Admission', 'price' => 0.0, 'capacity' => 0]];
}
// Extra registration/badge fields the organiser wants captured.
$badgeFields = [];
foreach ((array) ($input['badge_fields'] ?? []) as $field) {
$label = trim((string) (is_array($field) ? ($field['label'] ?? '') : $field));
if ($label !== '' && strlen($label) <= 60) {
$badgeFields[] = mb_substr($label, 0, 60);
}
}
// Mode: sell tickets, collect cash contributions (weddings, non-profits),
// or a free event (registration only — no tickets, no contributions).
$mode = in_array($input['mode'] ?? 'ticketing', ['ticketing', 'contributions', 'free'], true)
? ($input['mode'] ?? 'ticketing')
: 'ticketing';
// Contribution categories: ['Wedding Gift', 'Donation', …]
$categories = [];
foreach ((array) ($input['contribution_categories'] ?? []) as $cat) {
$label = trim((string) (is_array($cat) ? ($cat['name'] ?? '') : $cat));
if ($label !== '') {
$categories[] = mb_substr($label, 0, 80);
}
}
if ($mode === 'contributions' && empty($categories)) {
$categories = ['Contribution'];
}
// Free events collect neither tickets nor contributions — a single free
// registration. Forced deterministically so switching from a paid setup
// can never leave a chargeable tier behind.
if ($mode === 'free') {
$tiers = [['name' => 'Registration', 'price' => 0.0, 'capacity' => 0]];
$categories = [];
}
// Contributions always take payment; ticketing only for paid tiers; free never.
$acceptsPayment = match ($mode) {
'contributions' => true,
'free' => false,
default => $this->eventHasPaidTier($tiers),
};
return [
'content' => [
'name' => mb_substr($name, 0, 120),
'tagline' => trim((string) ($input['tagline'] ?? '')),
'description' => trim((string) ($input['description'] ?? '')),
'location' => trim((string) ($input['location'] ?? '')),
'starts_at' => QrDateFormatter::normalize((string) ($input['starts_at'] ?? '')),
'ends_at' => QrDateFormatter::normalize((string) ($input['ends_at'] ?? '')),
'organizer' => trim((string) ($input['organizer'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#4f46e5',
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
'currency' => 'GHS',
'mode' => $mode,
'tiers' => array_values($tiers),
'contribution_categories' => array_values($categories),
'badge_fields' => array_values($badgeFields),
'badge_size' => in_array($input['badge_size'] ?? '4x3', ['4x3', '4x6', 'cr80'], true) ? ($input['badge_size'] ?? '4x3') : '4x3',
'accepts_payment' => $acceptsPayment,
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null,
],
'destination_url' => null,
];
}
/** @param array<int, array{price: float}> $tiers */
private function eventHasPaidTier(array $tiers): bool
{
foreach ($tiers as $tier) {
if ((float) ($tier['price'] ?? 0) > 0) {
return true;
}
}
return false;
}
/** @param array<string, mixed> $input */
private function validateItinerary(array $input): array
{
$title = trim((string) ($input['title'] ?? ''));
if ($title === '') {
throw new RuntimeException('Enter the itinerary title.');
}
// Days: [{ label, date, items: [{ time, title, description, location, host }] }]
$days = [];
foreach ((array) ($input['days'] ?? []) as $day) {
if (! is_array($day)) {
continue;
}
$items = [];
foreach ((array) ($day['items'] ?? []) as $item) {
if (! is_array($item)) {
continue;
}
$itemTitle = trim((string) ($item['title'] ?? ''));
if ($itemTitle === '') {
continue;
}
$items[] = [
'time' => mb_substr(trim((string) ($item['time'] ?? '')), 0, 40),
'title' => mb_substr($itemTitle, 0, 140),
'description' => mb_substr(trim((string) ($item['description'] ?? '')), 0, 400),
'location' => mb_substr(trim((string) ($item['location'] ?? '')), 0, 120),
'host' => mb_substr(trim((string) ($item['host'] ?? '')), 0, 120),
];
}
if (empty($items) && trim((string) ($day['label'] ?? '')) === '') {
continue;
}
$days[] = [
'label' => mb_substr(trim((string) ($day['label'] ?? '')), 0, 80),
'date' => QrDateFormatter::normalize((string) ($day['date'] ?? '')),
'items' => array_values($items),
];
}
if (empty($days)) {
throw new RuntimeException('Add at least one programme item.');
}
return [
'content' => [
'title' => mb_substr($title, 0, 120),
'subtitle' => trim((string) ($input['subtitle'] ?? '')),
'description' => trim((string) ($input['description'] ?? '')),
'event_date' => QrDateFormatter::normalize((string) ($input['event_date'] ?? '')),
'location' => trim((string) ($input['location'] ?? '')),
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#b45309',
'cover_path' => $input['cover_path'] ?? null,
'days' => array_values($days),
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateMenu(array $input): array
{
$title = trim((string) ($input['menu_title'] ?? 'Menu'));
$sections = $this->normalizeMenuSections($input['sections'] ?? []);
$acceptsPayment = filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL);
$shippingType = in_array($input['shipping_type'] ?? 'none', ['none', 'flat'], true)
? (string) ($input['shipping_type'] ?? 'none')
: 'none';
$shippingFee = $shippingType === 'flat' ? round(max(0, (float) ($input['shipping_fee'] ?? 0)), 2) : 0.0;
$freeShippingAbove = round(max(0, (float) ($input['free_shipping_above'] ?? 0)), 2);
if ($sections === []) {
throw new RuntimeException('Add at least one menu section with items.');
}
return [
'content' => [
'title' => $title,
'sections' => $sections,
'accepts_payment' => $acceptsPayment,
'shipping_type' => $shippingType,
'shipping_fee' => $shippingFee,
'free_shipping_above' => $freeShippingAbove,
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateShop(array $input): array
{
$title = trim((string) ($input['shop_title'] ?? $input['menu_title'] ?? 'Shop'));
$currency = trim((string) ($input['currency'] ?? 'GHS'));
$sections = $this->normalizeMenuSections($input['sections'] ?? []);
$acceptsPayment = filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL);
$shippingType = in_array($input['shipping_type'] ?? 'none', ['none', 'flat'], true)
? (string) ($input['shipping_type'] ?? 'none')
: 'none';
$shippingFee = $shippingType === 'flat' ? round(max(0, (float) ($input['shipping_fee'] ?? 0)), 2) : 0.0;
$freeShippingAbove = round(max(0, (float) ($input['free_shipping_above'] ?? 0)), 2);
if ($sections === []) {
throw new RuntimeException('Add at least one category with products.');
}
return [
'content' => [
'title' => $title,
'currency' => $currency,
'sections' => $sections,
'accepts_payment' => $acceptsPayment,
'shipping_type' => $shippingType,
'shipping_fee' => $shippingFee,
'free_shipping_above' => $freeShippingAbove,
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
],
'destination_url' => null,
];
}
private function normalizeBrandColor(mixed $value): ?string
{
if ($value === null) {
return null;
}
$hex = trim((string) $value);
return preg_match('/^#[0-9A-Fa-f]{6}$/', $hex) ? $hex : null;
}
/** @param array<string, mixed> $input */
private function validateApp(array $input): array
{
$name = trim((string) ($input['app_name'] ?? ''));
$ios = trim((string) ($input['ios_url'] ?? ''));
$android = trim((string) ($input['android_url'] ?? ''));
$web = trim((string) ($input['web_url'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter an app name.');
}
if ($ios === '' && $android === '' && $web === '') {
throw new RuntimeException('Add at least one app store or website link.');
}
foreach (['ios' => $ios, 'android' => $android, 'web' => $web] as $label => $url) {
if ($url !== '' && ! filter_var($url, FILTER_VALIDATE_URL)) {
throw new RuntimeException("Enter a valid {$label} URL.");
}
}
return [
'content' => [
'name' => $name,
'ios_url' => $ios,
'android_url' => $android,
'web_url' => $web,
'icon_path' => $input['icon_path'] ?? null,
],
'destination_url' => $web ?: ($ios ?: $android),
];
}
/** @param array<string, mixed> $input */
private function validateBook(array $input): array
{
$title = trim((string) ($input['book_title'] ?? ''));
$author = trim((string) ($input['author'] ?? ''));
$price = (float) ($input['price_ghs'] ?? 0);
if ($title === '') {
throw new RuntimeException('Enter the book title.');
}
if ($author === '') {
throw new RuntimeException('Enter the author name.');
}
if ($price <= 0) {
throw new RuntimeException('Enter a price greater than zero.');
}
return [
'content' => [
'book_title' => $title,
'author' => $author,
'description' => trim((string) ($input['description'] ?? '')),
'price_ghs' => round($price, 2),
'cover_path' => $input['cover_path'] ?? null,
'file_path' => $input['file_path'] ?? null,
'file_type' => $input['file_type'] ?? null,
'file_size' => (int) ($input['file_size'] ?? 0),
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateWifi(array $input): array
{
$ssid = trim((string) ($input['ssid'] ?? ''));
if ($ssid === '') {
throw new RuntimeException('Enter a WiFi network name (SSID).');
}
$encryption = strtoupper(trim((string) ($input['encryption'] ?? 'WPA')));
if (! in_array($encryption, ['WPA', 'WEP', 'NOPASS'], true)) {
$encryption = 'WPA';
}
return [
'content' => [
'ssid' => $ssid,
'password' => (string) ($input['password'] ?? ''),
'encryption' => $encryption,
'hidden' => filter_var($input['hidden'] ?? false, FILTER_VALIDATE_BOOL),
],
'destination_url' => null,
];
}
public static function normalizeSocialUrl(string $platform, string $value): string
{
// Already a full URL — leave as-is
if (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) {
return $value;
}
$handle = ltrim($value, '@');
return match ($platform) {
'linkedin' => 'https://linkedin.com/in/' . $handle,
'twitter' => 'https://x.com/' . $handle,
'instagram' => 'https://instagram.com/' . $handle,
'facebook' => 'https://facebook.com/' . $handle,
'tiktok' => 'https://tiktok.com/@' . $handle,
'youtube' => 'https://youtube.com/@' . $handle,
'snapchat' => 'https://snapchat.com/add/' . $handle,
'whatsapp' => 'https://wa.me/' . preg_replace('/\D+/', '', $value),
default => $value,
};
}
private function requireUrl(mixed $value, string $message): string
{
$url = trim((string) $value);
if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
throw new RuntimeException($message);
}
return $url;
}
/** @return list<array{title: string, url: string}> */
private function normalizeLinks(mixed $links): array
{
if (! is_array($links)) {
return [];
}
$normalized = [];
foreach ($links as $link) {
if (! is_array($link)) {
continue;
}
$title = trim((string) ($link['title'] ?? ''));
$url = trim((string) ($link['url'] ?? ''));
if ($title === '' || $url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
continue;
}
$normalized[] = ['title' => $title, 'url' => $url];
}
return $normalized;
}
/**
* Normalize menu/shop sections. Preserves existing image_path on items so that
* the manager can inject freshly uploaded paths after validation.
*
* @return list<array{name: string, items: list<array{name: string, description: string, price: string, image_path: ?string}>}>
*/
private function normalizeMenuSections(mixed $sections): array
{
if (! is_array($sections)) {
return [];
}
$normalized = [];
foreach ($sections as $section) {
if (! is_array($section)) {
continue;
}
$name = trim((string) ($section['name'] ?? ''));
$items = [];
foreach (($section['items'] ?? []) as $item) {
if (! is_array($item)) {
continue;
}
$itemName = trim((string) ($item['name'] ?? ''));
if ($itemName === '') {
continue;
}
$items[] = [
'name' => $itemName,
'description' => trim((string) ($item['description'] ?? '')),
'price' => trim((string) ($item['price'] ?? '')),
'image_path' => ($item['image_path'] ?? null) ?: null,
];
}
if ($name !== '' && $items !== []) {
$normalized[] = ['name' => $name, 'items' => $items];
}
}
return $normalized;
}
public function validateImageUpload(?UploadedFile $file): void
{
if (! $file instanceof UploadedFile) {
throw new RuntimeException('Upload at least one image.');
}
$mime = $file->getMimeType() ?: '';
if (! str_starts_with($mime, 'image/')) {
throw new RuntimeException('Only image files are supported.');
}
}
/** @param array<string, mixed> $input */
private function validatePayment(array $input): array
{
$businessName = trim((string) ($input['business_name'] ?? ''));
if ($businessName === '') {
throw new RuntimeException('Enter your business or display name.');
}
return [
'content' => [
'business_name' => mb_substr($businessName, 0, 120),
'branch_label' => mb_substr(trim((string) ($input['branch_label'] ?? '')), 0, 80) ?: null,
'currency' => strtoupper(trim((string) ($input['currency'] ?? 'GHS'))) ?: 'GHS',
],
'destination_url' => null,
];
}
}