- Email verification (regular, business owner), CLI administration

This commit is contained in:
Ty Clifford
2026-06-18 14:50:48 -04:00
parent 66f93757b6
commit cfd1fba5ab
9 changed files with 778 additions and 28 deletions
+366
View File
@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This script must be run from the command line.\n");
exit(1);
}
require_once dirname(__DIR__).'/includes/boot.php';
function out(string $message = ''): void {
fwrite(STDOUT, $message.PHP_EOL);
}
function fail(string $message, int $code = 1): never {
fwrite(STDERR, "Error: $message".PHP_EOL);
exit($code);
}
function usage(): never {
out('Keyser Admin CLI');
out('');
out('Usage:');
out(' php scripts/keyser-admin.php <command> [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());
}