367 lines
15 KiB
PHP
367 lines
15 KiB
PHP
<?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());
|
|
}
|