From cfd1fba5ab1c86cc10fb7a33d335be3ff028bb41 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Thu, 18 Jun 2026 14:50:48 -0400 Subject: [PATCH] - Email verification (regular, business owner), CLI administration --- admin/settings.php | 90 ++++++++++ assets/css/style.css | 6 + includes/db.php | 48 ++++- includes/functions.php | 167 ++++++++++++++++++ index.php | 4 +- login.php | 14 +- register.php | 47 +++-- scripts/keyser-admin.php | 366 +++++++++++++++++++++++++++++++++++++++ verify-email.php | 64 +++++++ 9 files changed, 778 insertions(+), 28 deletions(-) create mode 100644 scripts/keyser-admin.php create mode 100644 verify-email.php diff --git a/admin/settings.php b/admin/settings.php index deab8a0..a202d76 100644 --- a/admin/settings.php +++ b/admin/settings.php @@ -13,6 +13,19 @@ if (isPost()) { setSetting('registration_enabled',p('registration_enabled','0')); setSetting('events_require_approval',p('events_require_approval','0')); setSetting('home_random_interval',p('home_random_interval','2hours') === 'day' ? 'day' : '2hours'); + if (array_key_exists('mailgun_domain', $_POST)) { + setSetting('site_base_url', rtrim(ps('site_base_url'), '/')); + setSetting('mailgun_region', p('mailgun_region','us') === 'eu' ? 'eu' : 'us'); + setSetting('mailgun_domain', ps('mailgun_domain')); + setSetting('mailgun_from_name', ps('mailgun_from_name') ?: 'Discover Keyser WV'); + setSetting('mailgun_from_email', ps('mailgun_from_email')); + setSetting('mailgun_send_mode', p('mailgun_send_mode','html') === 'text' ? 'text' : 'html'); + if (p('clear_mailgun_api_key','0') === '1') { + setSetting('mailgun_api_key', ''); + } elseif (ps('mailgun_api_key') !== '') { + setSetting('mailgun_api_key', ps('mailgun_api_key')); + } + } flash('Settings saved!','success'); go('/admin/settings.php'); } @@ -35,6 +48,13 @@ $siteName = setting('site_name','Discover Keyser WV'); $siteTagline = setting('site_tagline','The Friendliest City in the U.S.A.'); $contactEmail = setting('contact_email','info@discoverkeyser.com'); $homeRandomInterval = setting('home_random_interval','2hours'); +$siteBaseUrl = setting('site_base_url',''); +$mailgunRegion = setting('mailgun_region','us'); +$mailgunDomain = setting('mailgun_domain',''); +$mailgunFromName = setting('mailgun_from_name','Discover Keyser WV'); +$mailgunFromEmail = setting('mailgun_from_email',''); +$mailgunSendMode = setting('mailgun_send_mode','html'); +$mailgunHasKey = setting('mailgun_api_key','') !== ''; $me = qone("SELECT * FROM users WHERE id=?",[uid()]); ?> @@ -98,6 +118,76 @@ $me = qone("SELECT * FROM users WHERE id=?",[uid()]); + +
+
✉️ EMAIL VERIFICATION & MAILGUN
+
+ + + + + + + +
+ + +
Used to build verification links. Leave blank to detect the current host.
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + +
+ +
+ +
+ + +
+
+ + +
+
diff --git a/assets/css/style.css b/assets/css/style.css index 08e858d..52d1e47 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -294,6 +294,12 @@ address{font-style:normal} .business-video-box{border-color:rgba(38,244,255,.24)} .embed-shell{position:relative;width:100%;aspect-ratio:16/9;overflow:hidden;border-radius:var(--r);background:#000;border:1px solid rgba(38,244,255,.22);box-shadow:0 0 26px rgba(38,244,255,.08)} .embed-shell iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:#000} +.verify-card{max-width:680px;margin:1rem auto;background:linear-gradient(180deg,rgba(14,23,43,.96),rgba(8,13,27,.96));border:1px solid rgba(38,244,255,.2);border-radius:var(--r-lg);padding:2.2rem;text-align:center;box-shadow:var(--sh)} +.verify-card h1{font-size:clamp(2rem,4vw,3rem);margin:.35rem 0 .75rem} +.verify-card p{color:var(--text-m);line-height:1.75;max-width:520px;margin:0 auto} +.verify-card .verify-detail{margin-top:.8rem;color:var(--text-d);font-size:.94rem} +.verify-success{border-color:rgba(77,255,155,.38)} +.verify-error{border-color:rgba(255,95,123,.34)} /* ── Search ─────────────────────────────────────────────── */ .search-bar{background:var(--bg2);padding:1.75rem 1.5rem;border-bottom:1px solid var(--bdr)} diff --git a/includes/db.php b/includes/db.php index 33aae35..fe7e690 100644 --- a/includes/db.php +++ b/includes/db.php @@ -41,6 +41,13 @@ function _hasColumn(PDO $db, string $table, string $column): bool { } function _ensureAppUpgrades(PDO $db): void { + if (!_hasColumn($db, 'users', 'email_verified_at')) { + $db->exec("ALTER TABLE users ADD COLUMN email_verified_at TEXT"); + $db->exec("UPDATE users SET email_verified_at=datetime('now') WHERE is_active=1"); + } + if (!_hasColumn($db, 'users', 'member_type')) { + $db->exec("ALTER TABLE users ADD COLUMN member_type TEXT NOT NULL DEFAULT 'member'"); + } if (!_hasColumn($db, 'businesses', 'video_url')) { $db->exec("ALTER TABLE businesses ADD COLUMN video_url TEXT NOT NULL DEFAULT ''"); } @@ -69,10 +76,27 @@ function _ensureAppUpgrades(PDO $db): void { reviewed_at TEXT ); CREATE INDEX IF NOT EXISTS idx_claim_requests_status ON business_claim_requests(status, created_at); + CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + purpose TEXT NOT NULL DEFAULT 'email_verify', + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT(datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_email_tokens_user ON email_verification_tokens(user_id, purpose, used_at); "); $defaults = [ 'home_random_interval' => '2hours', + 'site_base_url' => '', + 'mailgun_region' => 'us', + 'mailgun_domain' => '', + 'mailgun_api_key' => '', + 'mailgun_from_name' => 'Discover Keyser WV', + 'mailgun_from_email' => '', + 'mailgun_send_mode' => 'html', ]; $stmt = $db->prepare("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)"); foreach ($defaults as $k => $v) $stmt->execute([$k, $v]); @@ -93,6 +117,8 @@ function _schema(PDO $db): void { password TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, + email_verified_at TEXT, + member_type TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL DEFAULT(datetime('now')), last_login TEXT ); @@ -148,6 +174,15 @@ function _schema(PDO $db): void { created_at TEXT NOT NULL DEFAULT(datetime('now')), reviewed_at TEXT ); + CREATE TABLE IF NOT EXISTS email_verification_tokens( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + purpose TEXT NOT NULL DEFAULT 'email_verify', + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT(datetime('now')) + ); CREATE TABLE IF NOT EXISTS reviews( id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, @@ -239,13 +274,20 @@ function _seed(PDO $db): void { 'site_tagline'=>'The Friendliest City in the U.S.A.', 'contact_email'=>'info@discoverkeyser.com', 'events_require_approval'=>'1', - 'home_random_interval'=>'2hours'] as $k=>$v) + 'home_random_interval'=>'2hours', + 'site_base_url'=>'', + 'mailgun_region'=>'us', + 'mailgun_domain'=>'', + 'mailgun_api_key'=>'', + 'mailgun_from_name'=>'Discover Keyser WV', + 'mailgun_from_email'=>'', + 'mailgun_send_mode'=>'html'] as $k=>$v) qrun("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)",[$k,$v]); /* Admin user — password "password123" */ $hash = password_hash('password123', PASSWORD_BCRYPT); - qrun("INSERT OR IGNORE INTO users(username,email,password,is_admin) - VALUES('administrator','admin@discoverkeyser.com',?,1)",[$hash]); + qrun("INSERT OR IGNORE INTO users(username,email,password,is_admin,email_verified_at,member_type) + VALUES('administrator','admin@discoverkeyser.com',?,1,datetime('now'),'member')",[$hash]); /* Categories */ foreach ([ diff --git a/includes/functions.php b/includes/functions.php index 61369ca..ec7e56a 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -149,3 +149,170 @@ function validBusinessVideoUrl(string $url): bool { $scheme = strtolower((string)(parse_url($url, PHP_URL_SCHEME) ?? '')); return in_array($scheme, ['http','https'], true) && businessVideoProvider($url) !== ''; } + +/* ── Email verification / Mailgun ─────────────────────── */ +function requestBaseUrl(): string { + $configured = trim(setting('site_base_url', '')); + if ($configured !== '') return rtrim($configured, '/'); + $host = (string)($_SERVER['HTTP_HOST'] ?? ''); + if ($host === '') return ''; + $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (($_SERVER['SERVER_PORT'] ?? '') === '443'); + return ($https ? 'https' : 'http').'://'.$host; +} +function absoluteUrl(string $path): string { + $base = requestBaseUrl(); + if ($base === '') return $path; + return rtrim($base, '/').'/'.ltrim($path, '/'); +} +function mailgunEndpoint(): string { + $region = strtolower(setting('mailgun_region', 'us')); + $base = $region === 'eu' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net'; + return $base.'/v3/'.rawurlencode(setting('mailgun_domain', '')).'/messages'; +} +function mailgunConfigured(): bool { + return setting('mailgun_domain', '') !== '' && setting('mailgun_api_key', '') !== ''; +} +function emailFromAddress(): string { + $from = trim(setting('mailgun_from_email', '')); + if ($from !== '') return $from; + $domain = setting('mailgun_domain', ''); + return $domain !== '' ? 'postmaster@'.$domain : ''; +} +function formattedFromAddress(): string { + $name = trim(setting('mailgun_from_name', 'My Keyser')); + $email = emailFromAddress(); + return $name !== '' ? "$name <$email>" : $email; +} +function createEmailVerificationToken(int $userId): string { + qrun("UPDATE email_verification_tokens SET used_at=datetime('now') WHERE user_id=? AND purpose='email_verify' AND used_at IS NULL", [$userId]); + $token = bin2hex(random_bytes(32)); + qrun( + "INSERT INTO email_verification_tokens(user_id,token_hash,expires_at)VALUES(?,?,datetime('now','+24 hours'))", + [$userId, hash('sha256', $token)] + ); + return $token; +} +function mailgunSend(string $to, string $subject, string $text, string $html = ''): array { + if (!mailgunConfigured()) return [false, 'Mailgun domain and API key are required.']; + $from = formattedFromAddress(); + if ($from === '' || !filter_var(emailFromAddress(), FILTER_VALIDATE_EMAIL)) { + return [false, 'A valid Mailgun from email is required.']; + } + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) return [false, 'Recipient email is invalid.']; + + $fields = [ + 'from' => $from, + 'to' => $to, + 'subject' => $subject, + 'text' => $text, + ]; + if (setting('mailgun_send_mode', 'html') === 'html' && $html !== '') { + $fields['html'] = $html; + } + + $endpoint = mailgunEndpoint(); + $apiKey = setting('mailgun_api_key', ''); + + if (function_exists('curl_init')) { + $ch = curl_init($endpoint); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $fields, + CURLOPT_USERPWD => 'api:'.$apiKey, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 20, + ]); + $body = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + if ($errno) return [false, $error ?: 'Mailgun request failed.']; + if ($status < 200 || $status >= 300) return [false, 'Mailgun returned HTTP '.$status.': '.substr((string)$body, 0, 180)]; + return [true, '']; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Authorization: Basic ".base64_encode('api:'.$apiKey)."\r\n". + "Content-Type: application/x-www-form-urlencoded\r\n", + 'content' => http_build_query($fields), + 'timeout' => 20, + 'ignore_errors' => true, + ], + ]); + $body = @file_get_contents($endpoint, false, $context); + $statusLine = $http_response_header[0] ?? ''; + if (!preg_match('/\s(2\d\d)\s/', $statusLine)) { + return [false, trim(($statusLine ?: 'Mailgun request failed.').' '.substr((string)$body, 0, 180))]; + } + return [true, '']; +} +function emailShellHtml(string $title, string $intro, string $bodyHtml, string $buttonText = '', string $buttonUrl = ''): string { + $site = e(setting('site_name', 'My Keyser')); + $titleEsc = e($title); + $introEsc = e($intro); + $button = ''; + if ($buttonText !== '' && $buttonUrl !== '') { + $button = ''.e($buttonText).''; + } + return ''. + '
'.$introEsc.'
'. + ''. + '
'. + '
'. + '
'.$site.'
'. + '

'.$titleEsc.'

'. + '
'. + $bodyHtml. + ''.$button.'
'. + '

Keyser, West Virginia - local businesses, events, and community information.

'. + '
'; +} +function verificationEmailContent(array $user, string $verifyUrl): array { + $name = (string)($user['username'] ?? 'there'); + $isOwner = ($user['member_type'] ?? 'member') === 'business_owner'; + $title = $isOwner ? 'Verify Your Business Owner Account' : 'Verify Your Account'; + $subject = $isOwner ? 'Verify your My Keyser business owner account' : 'Verify your My Keyser account'; + $text = "Hi $name,\n\n". + "Thanks for registering with My Keyser. Please verify your email address before signing in:\n\n". + "$verifyUrl\n\n". + "This link expires in 24 hours.\n\n". + ($isOwner ? "After verification, your business claim will remain queued and we will contact you within a couple of business days.\n\n" : ''). + "My Keyser"; + $htmlBody = '

Hi '.e($name).',

'. + '

Thanks for registering with My Keyser. Please verify your email address before signing in.

'. + ($isOwner ? '

After verification, your business claim will stay queued for review and our team will contact you within a couple of business days.

' : ''). + '

This link expires in 24 hours.

'; + return [$subject, $text, emailShellHtml($title, 'Verify your My Keyser account.', $htmlBody, 'Verify Email', $verifyUrl)]; +} +function registrationCompleteEmailContent(array $user): array { + $name = (string)($user['username'] ?? 'there'); + $isOwner = ($user['member_type'] ?? 'member') === 'business_owner'; + if ($isOwner) { + $subject = 'Thank you for claiming your My Keyser business page'; + $text = "Hi $name,\n\nThank you for registering as a business owner with My Keyser.\n\nYour email is verified and you can now log in. Our team will contact you within a couple of business days for a verification call before assigning your business page.\n\nThank you for helping keep Keyser's directory accurate.\n\nMy Keyser"; + $body = '

Hi '.e($name).',

'. + '

Thank you for registering as a business owner with My Keyser. Your email is verified and you can now log in.

'. + '

Our team will contact you within a couple of business days for a verification call before assigning your business page.

'. + '

Thank you for helping keep Keyser’s directory accurate.

'; + return [$subject, $text, emailShellHtml('Thanks, Business Owner', 'Your business owner account is verified.', $body, 'Log In', absoluteUrl('/login.php'))]; + } + $subject = 'Thank you for registering with My Keyser'; + $text = "Hi $name,\n\nThank you for registering with My Keyser. Your email is verified and you can now log in.\n\nWe are glad to have you in the Keyser community.\n\nMy Keyser"; + $body = '

Hi '.e($name).',

'. + '

Thank you for registering with My Keyser. Your email is verified and you can now log in.

'. + '

We are glad to have you in the Keyser community.

'; + return [$subject, $text, emailShellHtml('Thanks For Registering', 'Your My Keyser account is verified.', $body, 'Log In', absoluteUrl('/login.php'))]; +} +function sendVerificationEmail(array $user, string $token): array { + $url = absoluteUrl('/verify-email.php?token='.rawurlencode($token)); + [$subject, $text, $html] = verificationEmailContent($user, $url); + return mailgunSend((string)$user['email'], $subject, $text, $html); +} +function sendRegistrationCompleteEmail(array $user): array { + [$subject, $text, $html] = registrationCompleteEmailContent($user); + return mailgunSend((string)$user['email'], $subject, $text, $html); +} diff --git a/index.php b/index.php index 42acfe4..9beeba4 100644 --- a/index.php +++ b/index.php @@ -30,8 +30,8 @@ include __DIR__.'/includes/header.php';
MINERAL COUNTY · WEST VIRGINIA
-

KEYSERAFTER DARK

-

A neon-lit guide to local businesses, upcoming events, mountain history, and the community pages that keep Keyser moving.

+

DISCOVERKEYSER

+

This place is new. Sign up to claim your business!

- + -
Used for account recovery and owner verification follow-up.
+ autocomplete="email" required> +
Used for email verification and account recovery.
diff --git a/scripts/keyser-admin.php b/scripts/keyser-admin.php new file mode 100644 index 0000000..234bb0e --- /dev/null +++ b/scripts/keyser-admin.php @@ -0,0 +1,366 @@ + [options]'); + out(''); + out('Business commands:'); + out(' business:list [--active=1|0|all] [--q=text] [--limit=50]'); + out(' business:show --id=ID|--slug=SLUG'); + out(' business:activate --id=ID|--slug=SLUG'); + out(' business:deactivate --id=ID|--slug=SLUG'); + out(' business:update --id=ID|--slug=SLUG [field options]'); + out(' business:set-video --id=ID|--slug=SLUG --video-url=URL'); + out(' business:clear-video --id=ID|--slug=SLUG'); + out(' business:delete --id=ID|--slug=SLUG --yes'); + out(''); + out('Business update field options:'); + out(' --name=TEXT --slug=TEXT --category-id=ID --subcategory=TEXT'); + out(' --description=TEXT --address=TEXT --phone=TEXT --email=TEXT'); + out(' --website=TEXT --video-url=URL --hours-json=JSON'); + out(' --lat=NUM --lng=NUM --active=1|0 --featured=1|0'); + out(''); + out('Menu commands:'); + out(' menu:wipe-all --yes'); + out(' menu:wipe-business --id=ID|--slug=SLUG --yes'); + out(' menu:show --id=ID|--slug=SLUG'); + out(''); + out('Other commands:'); + out(' categories:list'); + out(''); + out('Examples:'); + out(' php scripts/keyser-admin.php business:deactivate --slug=royal-restaurant'); + out(' php scripts/keyser-admin.php business:update --slug=queens-point-coffee --phone="304-555-0100" --featured=1'); + out(' php scripts/keyser-admin.php business:set-video --slug=queens-point-coffee --video-url="https://youtu.be/example"'); + out(' php scripts/keyser-admin.php business:clear-video --slug=queens-point-coffee'); + out(' php scripts/keyser-admin.php menu:wipe-business --slug=castiglia-italian --yes'); + exit(0); +} + +function parseArgs(array $argv): array { + $command = $argv[1] ?? 'help'; + $options = []; + + for ($i = 2; $i < count($argv); $i++) { + $arg = $argv[$i]; + if (!str_starts_with($arg, '--')) { + fail("Unexpected argument: $arg"); + } + + $arg = substr($arg, 2); + if ($arg === '') { + fail('Empty option name.'); + } + + if (str_contains($arg, '=')) { + [$key, $value] = explode('=', $arg, 2); + } else { + $key = $arg; + $value = '1'; + } + + $options[str_replace('-', '_', $key)] = $value; + } + + return [$command, $options]; +} + +function opt(array $options, string $key, mixed $default = null): mixed { + return $options[$key] ?? $default; +} + +function requireYes(array $options): void { + if (opt($options, 'yes') !== '1') { + fail('This is destructive. Re-run with --yes if you are sure.'); + } +} + +function boolish(mixed $value, string $label): int { + $value = strtolower(trim((string)$value)); + return match ($value) { + '1', 'yes', 'true', 'on', 'active', 'enabled' => 1, + '0', 'no', 'false', 'off', 'inactive', 'disabled' => 0, + default => fail("$label must be 1 or 0."), + }; +} + +function clipText(string $value, int $width): string { + if (function_exists('mb_strimwidth')) { + return mb_strimwidth($value, 0, $width, ''); + } + return strlen($value) > $width ? substr($value, 0, $width) : $value; +} + +function businessFromOptions(array $options): array { + $id = (int)opt($options, 'id', 0); + $slug = trim((string)opt($options, 'slug', '')); + + if ($id > 0 && $slug !== '') { + fail('Use either --id or --slug, not both.'); + } + if ($id <= 0 && $slug === '') { + fail('Provide --id=ID or --slug=SLUG.'); + } + + $business = $id > 0 + ? qone("SELECT * FROM businesses WHERE id=?", [$id]) + : qone("SELECT * FROM businesses WHERE slug=?", [$slug]); + + if (!$business) { + fail('Business not found.'); + } + + return $business; +} + +function printBusiness(array $business): void { + $category = qone("SELECT name, icon FROM categories WHERE id=?", [(int)$business['category_id']]); + out('ID: '.$business['id']); + out('Name: '.$business['name']); + out('Slug: '.$business['slug']); + out('Category: '.(($category['icon'] ?? '').' '.($category['name'] ?? 'Uncategorized'))); + out('Subcategory: '.($business['subcategory'] ?: '-')); + out('Address: '.($business['address'] ?: '-')); + out('Phone: '.($business['phone'] ?: '-')); + out('Email: '.($business['email'] ?: '-')); + out('Website: '.($business['website'] ?: '-')); + out('Video URL: '.(($business['video_url'] ?? '') ?: '-')); + out('Active: '.((int)$business['is_active'] === 1 ? 'yes' : 'no')); + out('Featured: '.((int)$business['is_featured'] === 1 ? 'yes' : 'no')); + out('Lat/Lng: '.$business['lat'].', '.$business['lng']); + out('Hours JSON: '.$business['hours']); + out('Description: '.$business['description']); +} + +function validateEmail(string $email): void { + if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + fail('Invalid email address.'); + } +} + +function validateHoursJson(string $json): void { + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + fail('--hours-json must be a JSON object.'); + } + foreach (array_keys($decoded) as $key) { + if (!is_string($key)) { + fail('--hours-json must be a JSON object with day names as keys.'); + } + } +} + +function setBusinessStatus(array $options, int $active): void { + $business = businessFromOptions($options); + qrun("UPDATE businesses SET is_active=? WHERE id=?", [$active, (int)$business['id']]); + out(($active ? 'Activated' : 'Deactivated').' business: '.$business['name'].' (#'.$business['id'].')'); +} + +function wipeBusinessMenu(int $businessId): array { + $sectionIds = array_column(qall("SELECT id FROM menu_sections WHERE business_id=?", [$businessId]), 'id'); + $itemCount = 0; + if ($sectionIds) { + $placeholders = implode(',', array_fill(0, count($sectionIds), '?')); + $itemCount = (int)qval("SELECT COUNT(*) FROM menu_items WHERE section_id IN ($placeholders)", $sectionIds); + } + $sectionCount = count($sectionIds); + qrun("DELETE FROM menu_sections WHERE business_id=?", [$businessId]); + return [$sectionCount, $itemCount]; +} + +[$command, $options] = parseArgs($argv); + +if (in_array($command, ['help', '--help', '-h'], true)) { + usage(); +} + +try { + match ($command) { + 'business:list' => (function () use ($options): void { + $where = []; + $params = []; + $active = strtolower((string)opt($options, 'active', 'all')); + if ($active !== 'all') { + $where[] = 'b.is_active=?'; + $params[] = boolish($active, '--active'); + } + $q = trim((string)opt($options, 'q', '')); + if ($q !== '') { + $where[] = '(b.name LIKE ? OR b.slug LIKE ? OR b.address LIKE ? OR b.subcategory LIKE ?)'; + $like = "%$q%"; + array_push($params, $like, $like, $like, $like); + } + $limit = max(1, min(500, (int)opt($options, 'limit', 50))); + $sqlWhere = $where ? 'WHERE '.implode(' AND ', $where) : ''; + $rows = qall(" + SELECT b.id,b.name,b.slug,b.is_active,b.is_featured,COALESCE(c.name,'Uncategorized') cat + FROM businesses b + LEFT JOIN categories c ON c.id=b.category_id + $sqlWhere + ORDER BY b.name + LIMIT $limit + ", $params); + + foreach ($rows as $row) { + out(sprintf( + '#%-4d %-38s %-26s active=%d featured=%d slug=%s', + $row['id'], + clipText((string)$row['name'], 38), + clipText((string)$row['cat'], 26), + $row['is_active'], + $row['is_featured'], + $row['slug'] + )); + } + out(count($rows).' business'.(count($rows) === 1 ? '' : 'es').' shown.'); + })(), + + 'business:show' => printBusiness(businessFromOptions($options)), + 'business:activate' => setBusinessStatus($options, 1), + 'business:deactivate' => setBusinessStatus($options, 0), + + 'business:set-video' => (function () use ($options): void { + $business = businessFromOptions($options); + $url = trim((string)opt($options, 'video_url', '')); + if ($url === '') { + fail('Provide --video-url=URL. Use --video-url="" with business:update to clear it.'); + } + if (!validBusinessVideoUrl($url)) { + fail('Video URL must be a YouTube or Vimeo URL.'); + } + qrun("UPDATE businesses SET video_url=? WHERE id=?", [$url, (int)$business['id']]); + out('Updated video URL for '.$business['name'].'.'); + })(), + + 'business:clear-video' => (function () use ($options): void { + $business = businessFromOptions($options); + qrun("UPDATE businesses SET video_url='' WHERE id=?", [(int)$business['id']]); + out('Cleared video URL for '.$business['name'].'.'); + })(), + + 'business:delete' => (function () use ($options): void { + requireYes($options); + $business = businessFromOptions($options); + qrun("DELETE FROM businesses WHERE id=?", [(int)$business['id']]); + out('Deleted business and related records: '.$business['name'].' (#'.$business['id'].')'); + })(), + + 'business:update' => (function () use ($options): void { + $business = businessFromOptions($options); + $map = [ + 'name' => 'name', + 'slug' => 'slug', + 'category_id' => 'category_id', + 'subcategory' => 'subcategory', + 'description' => 'description', + 'address' => 'address', + 'phone' => 'phone', + 'email' => 'email', + 'website' => 'website', + 'video_url' => 'video_url', + 'hours_json' => 'hours', + 'lat' => 'lat', + 'lng' => 'lng', + 'active' => 'is_active', + 'featured' => 'is_featured', + ]; + $set = []; + $params = []; + foreach ($map as $optionKey => $column) { + if (!array_key_exists($optionKey, $options)) continue; + $value = (string)$options[$optionKey]; + if ($optionKey === 'email') validateEmail($value); + if ($optionKey === 'video_url' && !validBusinessVideoUrl($value)) fail('Video URL must be a YouTube or Vimeo URL.'); + if ($optionKey === 'hours_json') validateHoursJson($value); + if ($optionKey === 'category_id') { + $value = (string)(int)$value; + if (!qval("SELECT id FROM categories WHERE id=?", [(int)$value])) fail('Category ID not found.'); + } + if (in_array($optionKey, ['active', 'featured'], true)) $value = (string)boolish($value, "--$optionKey"); + if (in_array($optionKey, ['lat', 'lng'], true) && !is_numeric($value)) fail("--$optionKey must be numeric."); + if ($optionKey === 'slug' && $value !== '') { + $value = makeSlug($value); + $clash = qval("SELECT id FROM businesses WHERE slug=? AND id!=?", [$value, (int)$business['id']]); + if ($clash) fail('That slug is already used by another business.'); + } + $set[] = "$column=?"; + $params[] = $value; + } + if (!$set) { + fail('No update fields provided. Run help to see supported field options.'); + } + $params[] = (int)$business['id']; + qrun("UPDATE businesses SET ".implode(',', $set)." WHERE id=?", $params); + out('Updated business: '.$business['name'].' (#'.$business['id'].')'); + })(), + + 'menu:show' => (function () use ($options): void { + $business = businessFromOptions($options); + $sections = qall("SELECT * FROM menu_sections WHERE business_id=? ORDER BY sort_order", [(int)$business['id']]); + out($business['name'].' menu'); + if (!$sections) { + out('No menu sections found.'); + return; + } + foreach ($sections as $section) { + out(''); + out('['.$section['id'].'] '.$section['name'].($section['description'] ? ' - '.$section['description'] : '')); + $items = qall("SELECT * FROM menu_items WHERE section_id=? ORDER BY sort_order", [(int)$section['id']]); + foreach ($items as $item) { + out(sprintf( + ' - [%d] %s%s%s', + $item['id'], + $item['name'], + (float)$item['price'] > 0 ? ' $'.number_format((float)$item['price'], 2) : '', + (int)$item['is_available'] === 1 ? '' : ' (unavailable)' + )); + } + } + })(), + + 'menu:wipe-business' => (function () use ($options): void { + requireYes($options); + $business = businessFromOptions($options); + [$sections, $items] = wipeBusinessMenu((int)$business['id']); + out("Deleted $sections menu section".($sections === 1 ? '' : 's')." and $items item".($items === 1 ? '' : 's')." for ".$business['name'].'.'); + })(), + + 'menu:wipe-all' => (function () use ($options): void { + requireYes($options); + $sectionCount = (int)qval("SELECT COUNT(*) FROM menu_sections"); + $itemCount = (int)qval("SELECT COUNT(*) FROM menu_items"); + qrun("DELETE FROM menu_sections"); + out("Deleted $sectionCount menu section".($sectionCount === 1 ? '' : 's')." and $itemCount item".($itemCount === 1 ? '' : 's').' across all businesses.'); + })(), + + 'categories:list' => (function (): void { + $rows = qall("SELECT id,name,icon,sort_order FROM categories ORDER BY sort_order,name"); + foreach ($rows as $row) { + out(sprintf('#%-3d %-2s %-32s sort=%d', $row['id'], $row['icon'], $row['name'], $row['sort_order'])); + } + })(), + + default => fail("Unknown command: $command. Run `php scripts/keyser-admin.php help`.", 2), + }; +} catch (PDOException $e) { + fail($e->getMessage()); +} diff --git a/verify-email.php b/verify-email.php new file mode 100644 index 0000000..e4bf86b --- /dev/null +++ b/verify-email.php @@ -0,0 +1,64 @@ + +
+
+
+

+

+

+ +
+
+