Files
MyKeyser/includes/db.php
T
Ty Clifford 354bb4255d - Rebuilt the CLI importer to use the official Facebook Graph API instead of HTML scraping.
Main changes:
Replaced 
[scripts/facebook-events-import.php](/Users/tyemeclifford/Documents/GH/MyKeyser/scripts/facebook-events-import.php) 
with a Graph API client.
It now supports --page-id, --pages-file, --event-id, --events-file, and 
optional area discovery via --discover-pages --center=LAT,LNG 
--query=....
Uses FACEBOOK_GRAPH_ACCESS_TOKEN or --access-token.
Pulls Graph fields for title, description, start/end time, 
place/location, ticket URI, and cover.source for the main photo.
Keeps the existing staged review/import workflow in 
/admin/event_imports.php.

- Implemented event detail pages and clickable event cards.
What changed:
Added [event.php](/Users/tyemeclifford/Documents/GH/MyKeyser/event.php) 
for individual event pages.
Made event cards clickable from 
[events.php](/Users/tyemeclifford/Documents/GH/MyKeyser/events.php), 
[index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/index.php), and 
legacy 
[2index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/2index.php).
Added large event photo display plus lightbox enlargement in 
[assets/css/style.css](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/css/style.css) 
and 
[assets/js/app.js](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/js/app.js).
Added eventUrl() and externalHref() helpers in 
[includes/functions.php](/Users/tyemeclifford/Documents/GH/MyKeyser/includes/functions.php).
Added copy-event-link support on event detail pages.
Updated README to list the new route.
2026-06-19 18:07:45 -04:00

783 lines
55 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Keyser WV Tourism Site — Database Layer
* SQLite via PDO; DB lives in /data/ (chmod 777)
*/
define('DATA_DIR', dirname(__DIR__).'/data');
define('DB_FILE', DATA_DIR.'/keyser.db');
function db(): PDO {
static $pdo = null;
if ($pdo !== null) return $pdo;
if (!is_dir(DATA_DIR)) mkdir(DATA_DIR, 0777, true);
@chmod(DATA_DIR, 0777);
$isNew = !file_exists(DB_FILE) || filesize(DB_FILE) === 0;
$pdo = new PDO('sqlite:'.DB_FILE, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=NORMAL;");
if ($isNew) { _schema($pdo); _seed($pdo); }
// Always ensure app upgrade tables/columns exist (for existing DBs)
_ensureAppUpgrades($pdo);
if (file_exists(DB_FILE)) @chmod(DB_FILE, 0666);
return $pdo;
}
/* ── Shorthand query helpers ────────────────────────────── */
function qr(string $sql, array $p=[]): PDOStatement { $s=db()->prepare($sql); $s->execute($p); return $s; }
function qall(string $sql, array $p=[]): array { return qr($sql,$p)->fetchAll(); }
function qone(string $sql, array $p=[]): ?array { $r=qr($sql,$p)->fetch(); return $r?:null; }
function qval(string $sql, array $p=[]): mixed { return qr($sql,$p)->fetchColumn(); }
function qrun(string $sql, array $p=[]): int { $s=db()->prepare($sql); $s->execute($p); return (int)db()->lastInsertId(); }
/* ── Ensure upgraded tables/columns exist on old DBs ─── */
function _hasColumn(PDO $db, string $table, string $column): bool {
$cols = $db->query("PRAGMA table_info($table)")->fetchAll(PDO::FETCH_ASSOC);
foreach ($cols as $col) {
if (($col['name'] ?? '') === $column) return true;
}
return false;
}
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, 'users', 'avatar_path')) {
$db->exec("ALTER TABLE users ADD COLUMN avatar_path TEXT NOT NULL DEFAULT ''");
}
if (!_hasColumn($db, 'businesses', 'video_url')) {
$db->exec("ALTER TABLE businesses ADD COLUMN video_url TEXT NOT NULL DEFAULT ''");
}
if (!_hasColumn($db, 'events', 'image_path')) {
$db->exec("ALTER TABLE events ADD COLUMN image_path TEXT NOT NULL DEFAULT ''");
}
if (!_hasColumn($db, 'menu_items', 'image_path')) {
$db->exec("ALTER TABLE menu_items ADD COLUMN image_path TEXT NOT NULL DEFAULT ''");
}
$db->exec("
CREATE TABLE IF NOT EXISTS business_edit_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
field TEXT NOT NULL,
old_value TEXT NOT NULL DEFAULT '',
new_value TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
admin_notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now')),
reviewed_at TEXT
);
CREATE TABLE IF NOT EXISTS business_claim_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
contact_phone TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
admin_notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now')),
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);
CREATE TABLE IF NOT EXISTS event_imports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL DEFAULT 'facebook',
source_area TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
venue TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
event_date TEXT NOT NULL DEFAULT '',
event_time TEXT NOT NULL DEFAULT '',
end_date TEXT,
end_time TEXT NOT NULL DEFAULT '',
cost TEXT NOT NULL DEFAULT 'Free',
website TEXT NOT NULL DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
image_path TEXT NOT NULL DEFAULT '',
raw_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'staged',
status_note TEXT NOT NULL DEFAULT '',
imported_event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_event_imports_status ON event_imports(status, created_at);
CREATE INDEX IF NOT EXISTS idx_event_imports_external ON event_imports(provider, external_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_imports_source ON event_imports(provider, source_url) WHERE source_url <> '';
CREATE TABLE IF NOT EXISTS forum_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL REFERENCES forum_categories(id) ON DELETE RESTRICT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
body TEXT NOT NULL DEFAULT '',
is_locked INTEGER NOT NULL DEFAULT 0,
is_pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic_id INTEGER NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic_id INTEGER REFERENCES forum_topics(id) ON DELETE CASCADE,
reply_id INTEGER REFERENCES forum_replies(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
original_name TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
CHECK (topic_id IS NOT NULL OR reply_id IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_forum_topics_category ON forum_topics(category_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_forum_replies_topic ON forum_replies(topic_id, created_at);
CREATE INDEX IF NOT EXISTS idx_forum_attachments_topic ON forum_attachments(topic_id);
CREATE INDEX IF NOT EXISTS idx_forum_attachments_reply ON forum_attachments(reply_id);
");
$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',
'event_upload_max_mb' => '5',
'menu_item_image_limit' => '1',
'menu_upload_max_mb' => '5',
'avatar_upload_max_mb' => '3',
'forum_enabled' => '1',
'forum_upload_max_mb' => '5',
'forum_allowed_filetypes' => 'jpg,jpeg,png,gif,webp,pdf,txt,doc,docx',
'forum_image_resize_threshold_mb' => '1',
'forum_image_max_width' => '1600',
'forum_image_quality' => '82',
];
$stmt = $db->prepare("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)");
foreach ($defaults as $k => $v) $stmt->execute([$k, $v]);
$catCount = (int)$db->query("SELECT COUNT(*) FROM forum_categories")->fetchColumn();
if ($catCount === 0) {
$stmt = $db->prepare("INSERT INTO forum_categories(name,slug,description,sort_order,is_active)VALUES(?,?,?,?,1)");
foreach ([
['General', 'general', 'Neighborhood conversation, questions, and introductions.', 1],
['Local Questions', 'local-questions', 'Ask for Keyser recommendations, resources, and local tips.', 2],
['Business Talk', 'business-talk', 'Business owner updates, ideas, and community feedback.', 3],
['Events', 'events', 'Discuss upcoming events and things happening around town.', 4],
] as $cat) $stmt->execute($cat);
}
}
/* ══════════════════════════════════════════════════════
SCHEMA
══════════════════════════════════════════════════════ */
function _schema(PDO $db): void {
$db->exec("
CREATE TABLE IF NOT EXISTS settings(
key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT,
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',
avatar_path TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now')),
last_login TEXT
);
CREATE TABLE IF NOT EXISTS categories(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
icon TEXT NOT NULL DEFAULT '🏢',
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS businesses(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
category_id INTEGER REFERENCES categories(id),
subcategory TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
website TEXT NOT NULL DEFAULT '',
video_url TEXT NOT NULL DEFAULT '',
hours TEXT NOT NULL DEFAULT '{}',
lat REAL NOT NULL DEFAULT 39.4364,
lng REAL NOT NULL DEFAULT -78.9762,
is_active INTEGER NOT NULL DEFAULT 1,
is_featured INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS business_owners(
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
PRIMARY KEY(user_id,business_id)
);
CREATE TABLE IF NOT EXISTS business_edit_requests(
id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
field TEXT NOT NULL,
old_value TEXT NOT NULL DEFAULT '',
new_value TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
admin_notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now')),
reviewed_at TEXT
);
CREATE TABLE IF NOT EXISTS business_claim_requests(
id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
contact_phone TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
admin_notes TEXT NOT NULL DEFAULT '',
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,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5),
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
is_approved INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS menu_sections(
id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS menu_items(
id INTEGER PRIMARY KEY AUTOINCREMENT,
section_id INTEGER NOT NULL REFERENCES menu_sections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
price REAL NOT NULL DEFAULT 0,
image_path TEXT NOT NULL DEFAULT '',
is_available INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS alerts(
id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'info',
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS attractions(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'Sightseeing',
description TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
website TEXT NOT NULL DEFAULT '',
hours TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS events(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
venue TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
event_date TEXT NOT NULL,
event_time TEXT NOT NULL DEFAULT '',
end_date TEXT,
end_time TEXT NOT NULL DEFAULT '',
cost TEXT NOT NULL DEFAULT 'Free',
website TEXT NOT NULL DEFAULT '',
contact_name TEXT NOT NULL DEFAULT '',
contact_email TEXT NOT NULL DEFAULT '',
contact_phone TEXT NOT NULL DEFAULT '',
image_path TEXT NOT NULL DEFAULT '',
submitted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending',
is_featured INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS event_imports(
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL DEFAULT 'facebook',
source_area TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
venue TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
event_date TEXT NOT NULL DEFAULT '',
event_time TEXT NOT NULL DEFAULT '',
end_date TEXT,
end_time TEXT NOT NULL DEFAULT '',
cost TEXT NOT NULL DEFAULT 'Free',
website TEXT NOT NULL DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
image_path TEXT NOT NULL DEFAULT '',
raw_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'staged',
status_note TEXT NOT NULL DEFAULT '',
imported_event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS reports(
id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'correction',
message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
admin_notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL REFERENCES forum_categories(id) ON DELETE RESTRICT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
body TEXT NOT NULL DEFAULT '',
is_locked INTEGER NOT NULL DEFAULT 0,
is_pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic_id INTEGER NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic_id INTEGER REFERENCES forum_topics(id) ON DELETE CASCADE,
reply_id INTEGER REFERENCES forum_replies(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
original_name TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
CHECK (topic_id IS NOT NULL OR reply_id IS NOT NULL)
);
");
}
/* ══════════════════════════════════════════════════════
SEED DATA
══════════════════════════════════════════════════════ */
function _seed(PDO $db): void {
/* Settings */
foreach (['registration_enabled'=>'1','site_name'=>'Discover Keyser WV',
'site_tagline'=>'The Friendliest City in the U.S.A.',
'contact_email'=>'info@discoverkeyser.com',
'events_require_approval'=>'1',
'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',
'event_upload_max_mb'=>'5',
'menu_item_image_limit'=>'1',
'menu_upload_max_mb'=>'5',
'avatar_upload_max_mb'=>'3',
'forum_enabled'=>'1',
'forum_upload_max_mb'=>'5',
'forum_allowed_filetypes'=>'jpg,jpeg,png,gif,webp,pdf,txt,doc,docx',
'forum_image_resize_threshold_mb'=>'1',
'forum_image_max_width'=>'1600',
'forum_image_quality'=>'82'] 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,email_verified_at,member_type)
VALUES('administrator','admin@discoverkeyser.com',?,1,datetime('now'),'member')",[$hash]);
/* Categories */
foreach ([
['Dining & Food','🍽️',1],['Shopping & Retail','🛍️',2],
['Lodging','🏨',3],['Healthcare','🏥',4],['Education','🎓',5],
['Financial Services','🏦',6],['Automotive','🔧',7],
['Professional Services','💼',8],['Recreation & Fitness','⛹️',9],
['Beauty & Personal Care','💇',10],['Government & Civic','🏛️',11],
['Legal Services','⚖️',12],['Churches & Faith','⛪',13],
] as $c) qrun("INSERT OR IGNORE INTO categories(name,icon,sort_order)VALUES(?,?,?)",$c);
foreach ([
['General', 'general', 'Neighborhood conversation, questions, and introductions.', 1],
['Local Questions', 'local-questions', 'Ask for Keyser recommendations, resources, and local tips.', 2],
['Business Talk', 'business-talk', 'Business owner updates, ideas, and community feedback.', 3],
['Events', 'events', 'Discuss upcoming events and things happening around town.', 4],
] as $fc) qrun("INSERT OR IGNORE INTO forum_categories(name,slug,description,sort_order,is_active)VALUES(?,?,?,?,1)", $fc);
$cid = fn(string $n): int => (int)qval("SELECT id FROM categories WHERE name=?",[$n]);
$h = fn(array $d): string => json_encode($d);
$std = $h(['Mon'=>'9am5pm','Tue'=>'9am5pm','Wed'=>'9am5pm','Thu'=>'9am5pm','Fri'=>'9am5pm','Sat'=>'Closed','Sun'=>'Closed']);
$always= $h(['Mon'=>'Open 24 hrs','Tue'=>'Open 24 hrs','Wed'=>'Open 24 hrs','Thu'=>'Open 24 hrs','Fri'=>'Open 24 hrs','Sat'=>'Open 24 hrs','Sun'=>'Open 24 hrs']);
$D=$cid('Dining & Food'); $SH=$cid('Shopping & Retail'); $LO=$cid('Lodging');
$HC=$cid('Healthcare'); $ED=$cid('Education'); $FI=$cid('Financial Services');
$AU=$cid('Automotive'); $PS=$cid('Professional Services'); $RF=$cid('Recreation & Fitness');
$BP=$cid('Beauty & Personal Care'); $GV=$cid('Government & Civic');
$LG=$cid('Legal Services'); $CH=$cid('Churches & Faith');
$bi = $db->prepare("INSERT INTO businesses(name,slug,category_id,subcategory,description,address,phone,email,website,hours,lat,lng,is_featured)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)");
$biz = [
["Castiglia's Italian Eatery",'castiglia-italian',$D,'Italian Restaurant',
"Keyser's most beloved restaurant. Family recipes passed down for generations: handmade pasta, wood-fired dishes, classic antipasti, and decadent Italian desserts. Known for huge servings and amazing food.",
'401 S. Mineral St., Keyser, WV 26726','304-788-1300','','',
$h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'11am9pm','Thu'=>'11am9pm','Fri'=>'11am9pm','Sat'=>'11am9pm','Sun'=>'12pm9pm']),39.4340,-78.9734,1],
['The Candlewyck Inn','candlewyck-inn',$D,'Fine Dining',
"Elegant fine dining in a beautifully restored historic setting. Upscale American cuisine with locally sourced seasonal ingredients. Award-winning wine list. Keyser's premier destination for special occasions.",
'65 S. Mineral St., Keyser, WV 26726','304-788-6594','','',
$h(['Mon'=>'Closed','Tue'=>'5pm9pm','Wed'=>'5pm9pm','Thu'=>'5pm9pm','Fri'=>'5pm10pm','Sat'=>'4pm10pm','Sun'=>'Closed']),39.4370,-78.9734,1],
["Clancy's Irish Pub",'clancys-irish-pub',$D,'Irish Pub & Bar',
"Keyser's favorite neighborhood pub! Cold craft beers on tap, hearty pub fare, sports on every screen, trivia nights on Wednesdays, and live music on weekends.",
'777 Armstrong St., Keyser, WV 26726','304-788-1133','','',
$h(['Mon'=>'4pm12am','Tue'=>'4pm12am','Wed'=>'4pm12am','Thu'=>'4pm12am','Fri'=>'4pm2am','Sat'=>'12pm2am','Sun'=>'12pm10pm']),39.4390,-78.9750,1],
['North Branch Craft Pub','north-branch-craft-pub',$D,'Craft Brewery & Pub',
"Keyser's craft beer destination. Local and regional craft beers on rotating taps, great pub fare, and stunning North Branch river views. Live entertainment regularly.",
'101 Armstrong St., Ste. 1, Keyser, WV 26726','304-790-7125','','',
$h(['Mon'=>'Closed','Tue'=>'4pm11pm','Wed'=>'4pm11pm','Thu'=>'4pm11pm','Fri'=>'3pm1am','Sat'=>'12pm1am','Sun'=>'12pm8pm']),39.4392,-78.9750,1],
['Queens Point Coffee','queens-point-coffee',$D,'Coffee Shop',
'"THE best coffee shop I\'ve ever been to." Named after the iconic Queens Point cliff. Iced matcha, lattes, specialty drinks, amazing food, and friendly staff.',
'101 Armstrong St., Keyser, WV 26726','304-790-7125','','',
$h(['Mon'=>'7am3pm','Tue'=>'7am5pm','Wed'=>'7am5pm','Thu'=>'7am5pm','Fri'=>'7am5pm','Sat'=>'8am5pm','Sun'=>'9am2pm']),39.4392,-78.9750,1],
['Royal Restaurant','royal-restaurant',$D,'American / Comfort Food',
"A Keyser classic for generations. Hearty American comfort food for breakfast, lunch, and dinner. Known for great breakfasts, homemade pies, and friendly small-town service.",
'88 N. Main St., Keyser, WV 26726','304-788-9825','','',
$h(['Mon'=>'6am8pm','Tue'=>'6am8pm','Wed'=>'6am8pm','Thu'=>'6am8pm','Fri'=>'6am9pm','Sat'=>'6am9pm','Sun'=>'7am3pm']),39.4378,-78.9734,0],
['Fat Bottom Grille','fat-bottom-grille',$D,'American Bar & Grill',
'"THE best place in town to get a burger or steak sub." Everything made fresh. The smash burger is juicy and flavored to perfection. Huge portions at excellent prices.',
'Keyser, WV 26726','304-788-0000','','',
$h(['Mon'=>'11am9pm','Tue'=>'11am9pm','Wed'=>'11am9pm','Thu'=>'11am9pm','Fri'=>'11am10pm','Sat'=>'11am10pm','Sun'=>'12pm9pm']),39.4360,-78.9740,1],
["Denny's",'dennys',$D,'American / Diner',
"Open 24/7. America's diner at Route 220 South. Grand Slams, hearty burgers, and all-day breakfast.",
'825 S. Mineral St. (US Rt. 220 S), Keyser, WV 26726','304-788-3090','','dennys.com',$always,39.4300,-78.9730,0],
["McDonald's",'mcdonalds',$D,'Fast Food',
"Burgers, fries, breakfast sandwiches, and McCafé beverages. Drive-through available.",
'700 S. Mineral St., Keyser, WV 26726','304-788-0604','','mcdonalds.com',
$h(['Mon'=>'6am11pm','Tue'=>'6am11pm','Wed'=>'6am11pm','Thu'=>'6am11pm','Fri'=>'6am12am','Sat'=>'6am12am','Sun'=>'7am11pm']),39.4310,-78.9730,0],
['Burger King','burger-king',$D,'Fast Food',
'Home of the Whopper! Flame-grilled burgers and crispy fries. Drive-through available.',
'RR#3 Box 3240, New Creek Hwy, Keyser, WV 26726','304-788-0000','','burgerking.com',
$h(['Mon'=>'6am12am','Tue'=>'6am12am','Wed'=>'6am12am','Thu'=>'6am12am','Fri'=>'6am1am','Sat'=>'6am1am','Sun'=>'7am12am']),39.4285,-78.9720,0],
["Domino's Pizza",'dominos',$D,'Pizza / Delivery',
'Hot fresh pizza fast. Carryout or delivered. Online ordering at dominos.com.',
'590 S. Mineral St., Keyser, WV 26726','304-788-6400','','dominos.com',
$h(['Mon'=>'10:30am12am','Tue'=>'10:30am12am','Wed'=>'10:30am12am','Thu'=>'10:30am12am','Fri'=>'10:30am1am','Sat'=>'10:30am1am','Sun'=>'10:30am12am']),39.4330,-78.9730,0],
['Dairy Queen Grill & Chill','dairy-queen',$D,'Ice Cream / Fast Food',
"Signature Blizzard® treats, soft-serve cones, and grill burgers. A Keyser summer tradition!",
'460 S. Mineral St., Keyser, WV 26726','304-788-1499','','dairyqueen.com',
$h(['Mon'=>'10am10pm','Tue'=>'10am10pm','Wed'=>'10am10pm','Thu'=>'10am10pm','Fri'=>'10am10:30pm','Sat'=>'10am10:30pm','Sun'=>'11am9pm']),39.4320,-78.9730,0],
["Fox's Pizza Den",'foxs-pizza',$D,'Pizza',
"Local pizza legend! Hand-tossed pizzas, subs, wings, and salads. A true Keyser staple.",
'567 S. Mineral St., Keyser, WV 26726','304-788-1149','','',
$h(['Mon'=>'11am10pm','Tue'=>'11am10pm','Wed'=>'11am10pm','Thu'=>'11am10pm','Fri'=>'11am11pm','Sat'=>'11am11pm','Sun'=>'12pm9pm']),39.4315,-78.9730,0],
['Little Caesars','little-caesars',$D,'Pizza / Fast Food',
'Hot-N-Ready pizzas at unbeatable prices. No wait. Crazy Bread and Italian favorites.',
'30 Armstrong St., Keyser, WV 26726','304-788-7738','','littlecaesars.com',
$h(['Mon'=>'11am9pm','Tue'=>'11am9pm','Wed'=>'11am9pm','Thu'=>'11am9pm','Fri'=>'11am10pm','Sat'=>'11am10pm','Sun'=>'12pm9pm']),39.4395,-78.9750,0],
['Subway','subway',$D,'Sandwiches / Fast Food',
'Fresh-built footlong subs, wraps, and salads.',
'2 Heskiet St. (Gulf), Keyser, WV 26726','304-788-3613','','subway.com',
$h(['Mon'=>'7am10pm','Tue'=>'7am10pm','Wed'=>'7am10pm','Thu'=>'7am10pm','Fri'=>'7am10pm','Sat'=>'8am10pm','Sun'=>'9am9pm']),39.4260,-78.9720,0],
['Taco Bell','taco-bell',$D,'Mexican Fast Food',
'Tacos, burritos, nachos, and Crunchwrap Supremes. Drive-through available.',
'41 Plaza Drive, Keyser, WV 26726','304-788-0000','','tacobell.com',
$h(['Mon'=>'7am12am','Tue'=>'7am12am','Wed'=>'7am12am','Thu'=>'7am12am','Fri'=>'7am2am','Sat'=>'7am2am','Sun'=>'8am12am']),39.4265,-78.9722,0],
["Ducky's Bar & Grill",'duckys-bar-grill',$D,'American Bar & Grill',
'"The ultimate combination of bar and grill." Famous Tennessee whiskey burger and chili cheese fries.',
'Keyser, WV 26726','304-788-0000','','',
$h(['Mon'=>'11am11pm','Tue'=>'11am11pm','Wed'=>'11am11pm','Thu'=>'11am11pm','Fri'=>'11am1am','Sat'=>'11am1am','Sun'=>'12pm9pm']),39.4358,-78.9738,0],
["Martie's Hot Dog Stand",'marties-hot-dogs',$D,'Hot Dogs / Street Food',
"A beloved Keyser institution. Classic hot dogs with all the fixings for generations.",
'96 N. Main St., Keyser, WV 26726','304-788-7690','','',
$h(['Mon'=>'10am6pm','Tue'=>'10am6pm','Wed'=>'10am6pm','Thu'=>'10am6pm','Fri'=>'10am7pm','Sat'=>'10am7pm','Sun'=>'Closed']),39.4375,-78.9734,0],
["Jin's Asian Cuisine 2",'jins-asian-cuisine',$D,'Asian / Chinese',
"Orange chicken, bourbon chicken, lo mein, fried rice, and crab rangoon made fresh to order.",
'196 N. Tornado Way #11, Keyser, WV 26726','304-788-2222','','',
$h(['Mon'=>'11am9pm','Tue'=>'11am9pm','Wed'=>'11am9pm','Thu'=>'11am9pm','Fri'=>'11am10pm','Sat'=>'11am10pm','Sun'=>'12pm9pm']),39.4355,-78.9730,0],
// Shopping
["Wayne's Country Meats",'waynes-country-meats',$SH,'Butcher / Specialty Meats',
'Premium quality meats from local farms. Custom cuts, specialty items, fresh jerky.',
'670 Armstrong St., Keyser, WV 26726','304-788-5956','','',
$h(['Mon'=>'8am6pm','Tue'=>'8am6pm','Wed'=>'8am6pm','Thu'=>'8am6pm','Fri'=>'8am6pm','Sat'=>'8am5pm','Sun'=>'Closed']),39.4395,-78.9750,1],
['Save-A-Lot','save-a-lot',$SH,'Grocery',
'Affordable grocery shopping. Fresh produce, quality meats, dairy, and pantry essentials.',
'S. Mineral St., Keyser, WV 26726','304-788-7570','','save-a-lot.com',
$h(['Mon'=>'8am9pm','Tue'=>'8am9pm','Wed'=>'8am9pm','Thu'=>'8am9pm','Fri'=>'8am9pm','Sat'=>'8am9pm','Sun'=>'9am7pm']),39.4298,-78.9728,0],
['AutoZone','autozone',$SH,'Auto Parts',
"America's leading auto parts retailer. Free battery testing, loaner tool program.",
'2 W. Piedmont St., Keyser, WV 26726','304-788-9058','','autozone.com',
$h(['Mon'=>'7:30am9pm','Tue'=>'7:30am9pm','Wed'=>'7:30am9pm','Thu'=>'7:30am9pm','Fri'=>'7:30am9pm','Sat'=>'7:30am9pm','Sun'=>'9am8pm']),39.4378,-78.9734,0],
['CVS Pharmacy','cvs',$SH,'Pharmacy / Health & Beauty',
'Prescriptions, health products, beauty supplies, and household essentials.',
'45 S. Mineral St., Keyser, WV 26726','304-788-3443','','cvs.com',
$h(['Mon'=>'8am9pm','Tue'=>'8am9pm','Wed'=>'8am9pm','Thu'=>'8am9pm','Fri'=>'8am9pm','Sat'=>'9am6pm','Sun'=>'10am6pm']),39.4368,-78.9734,0],
['AT&T Store','att-store',$SH,'Telecommunications',
'Latest smartphones, tablets, and wireless plans.',
'862 S. Mineral St., Keyser, WV 26726','304-788-9000','','att.com',
$h(['Mon'=>'10am7pm','Tue'=>'10am7pm','Wed'=>'10am7pm','Thu'=>'10am7pm','Fri'=>'10am7pm','Sat'=>'10am7pm','Sun'=>'12pm5pm']),39.4292,-78.9728,0],
["Christy's Florist",'christys-florist',$SH,'Florist',
'Fresh floral arrangements for weddings, funerals, anniversaries, and celebrations.',
'81 N. Main St., Keyser, WV 26726','304-788-3679','','',
$h(['Mon'=>'9am5pm','Tue'=>'9am5pm','Wed'=>'9am5pm','Thu'=>'9am5pm','Fri'=>'9am6pm','Sat'=>'9am3pm','Sun'=>'Closed']),39.4382,-78.9734,0],
['Southern States','southern-states',$SH,'Farm & Garden Supply',
'Agricultural supplies, pet food, hardware, seed, fertilizer, and home and garden products.',
'201 Patrick St., Keyser, WV 26726','304-788-2317','','southernstates.com',
$h(['Mon'=>'7:30am5:30pm','Tue'=>'7:30am5:30pm','Wed'=>'7:30am5:30pm','Thu'=>'7:30am5:30pm','Fri'=>'7:30am5:30pm','Sat'=>'8am3pm','Sun'=>'Closed']),39.4410,-78.9755,0],
['Back Alley Crafts & Tarts','back-alley-crafts',$SH,'Crafts & Gifts',
'Unique handmade crafts, locally made artisan goods, and specialty gifts.',
'226 S. Main St., Keyser, WV 26726','304-813-6256','','',
$h(['Mon'=>'10am5pm','Tue'=>'10am5pm','Wed'=>'10am5pm','Thu'=>'10am5pm','Fri'=>'10am6pm','Sat'=>'10am4pm','Sun'=>'Closed']),39.4357,-78.9734,0],
['Heaven Sent Creations','heaven-sent-creations',$SH,'Gifts & Crafts',
'Inspirational gifts, custom creations, home décor, and unique finds.',
'117 Armstrong St., Keyser, WV 26726','304-790-7402','','',
$h(['Mon'=>'10am5pm','Tue'=>'10am5pm','Wed'=>'10am5pm','Thu'=>'10am5pm','Fri'=>'10am5pm','Sat'=>'10am4pm','Sun'=>'Closed']),39.4393,-78.9750,0],
['Thunder Hill Outfitters','thunder-hill-outfitters',$SH,'Hunting & Fishing Supplies',
"Hunting and fishing supplies, gear, licenses, and local expertise.",
'105 Armstrong St., Keyser, WV 26726','304-790-7125','','',
$h(['Mon'=>'9am6pm','Tue'=>'9am6pm','Wed'=>'9am6pm','Thu'=>'9am6pm','Fri'=>'9am7pm','Sat'=>'8am7pm','Sun'=>'10am4pm']),39.4392,-78.9750,0],
// Lodging
['SureStay Plus by Best Western Keyser','surestay-best-western',$LO,'Hotel',
'Free hot breakfast daily, free WiFi, indoor pool, and fitness center. Pet-friendly.',
'New Creek Hwy, Keyser, WV 26726','304-788-0000','','bestwestern.com',$always,39.4298,-78.9705,1],
['Keyser Inn','keyser-inn',$LO,'Motel',
'Affordable, clean accommodations. Continental breakfast, free WiFi, pet-friendly.',
'Keyser, WV 26726','304-788-0000','','',$always,39.4360,-78.9734,0],
// Healthcare
['WVU Medicine Potomac Valley Hospital','pvh',$HC,'Hospital / Medical Center',
'Full-service community hospital. Emergency department, surgery, maternity care, cardiac services.',
'100 Pin Oak Ln., Keyser, WV 26726','304-597-3500','','wvumedicine.org',$always,39.4275,-78.9725,1],
['Med-a-Save Pharmacy','med-a-save',$HC,'Independent Pharmacy',
'Local independent pharmacy. Prescriptions, compounding, and immunizations.',
'818 S. Mineral St., Keyser, WV 26726','304-788-6010','','',
$h(['Mon'=>'9am6pm','Tue'=>'9am6pm','Wed'=>'9am6pm','Thu'=>'9am6pm','Fri'=>'9am6pm','Sat'=>'9am1pm','Sun'=>'Closed']),39.4287,-78.9728,0],
// Education
['Potomac State College of WVU','potomac-state-college',$ED,'Community College / University',
"WVU Potomac State College — founded 1901 on historic Fort Fuller. Associate and bachelor's degrees in 40+ programs. Home of the Catamounts.",
'101 Fort Ave., Keyser, WV 26726','304-788-6800','','potomacstatecollege.edu',$std,39.4368,-78.9715,1],
['Mineral County Public Library','mineral-county-library',$ED,'Public Library',
'Books, digital resources, internet access, printing, and community programs for all ages.',
'Keyser, WV 26726','304-788-3100','','',
$h(['Mon'=>'10am7pm','Tue'=>'10am7pm','Wed'=>'10am7pm','Thu'=>'10am7pm','Fri'=>'10am5pm','Sat'=>'10am4pm','Sun'=>'Closed']),39.4370,-78.9734,0],
// Financial
["First United Bank & Trust",'first-united-bank',$FI,'Community Bank',
'Community banking rooted in West Virginia. Personal checking, savings, mortgages.',
'29 West Southern Dr., Keyser, WV 26726','304-788-2552','','mybank.com',
$h(['Mon'=>'9am5pm','Tue'=>'9am5pm','Wed'=>'9am5pm','Thu'=>'9am5pm','Fri'=>'9am5:30pm','Sat'=>'9am12pm','Sun'=>'Closed']),39.4372,-78.9740,0],
['M&T Bank','m-t-bank',$FI,'Bank',
'Full-service personal, small business, and commercial banking. 24/7 ATM.',
'67 N. Main St., Keyser, WV 26726','304-788-6782','','mtb.com',
$h(['Mon'=>'9am5pm','Tue'=>'9am5pm','Wed'=>'9am5pm','Thu'=>'9am5pm','Fri'=>'9am6pm','Sat'=>'9am12pm','Sun'=>'Closed']),39.4380,-78.9734,0],
// Automotive
["Boddy's Automotive",'boddys-auto',$AU,'Full Service Auto Repair',
'Trusted local auto repair for all makes and models. Honest prices.',
'220 Armstrong St., Keyser, WV 26726','304-788-5511','','',
$h(['Mon'=>'8am5pm','Tue'=>'8am5pm','Wed'=>'8am5pm','Thu'=>'8am5pm','Fri'=>'8am5pm','Sat'=>'Closed','Sun'=>'Closed']),39.4392,-78.9750,0],
['Gimme A Brake Auto','gimme-a-brake',$AU,'Brakes / Tires / Oil Changes',
'Fast, friendly, and affordable auto care. No appointment needed for routine maintenance.',
'375 West Piedmont St., Keyser, WV 26726','304-788-7810','','',
$h(['Mon'=>'8am5:30pm','Tue'=>'8am5:30pm','Wed'=>'8am5:30pm','Thu'=>'8am5:30pm','Fri'=>'8am5:30pm','Sat'=>'8am12pm','Sun'=>'Closed']),39.4378,-78.9748,0],
// Professional Services
['Boggs Supply and Rental Center','boggs-supply',$PS,'Equipment Rental / Supply',
'Tools, equipment, and supplies for rent or purchase.',
'464 Harley O Staggers Sr. Dr., Keyser, WV 26726','304-788-1617','','',
$h(['Mon'=>'7:30am5pm','Tue'=>'7:30am5pm','Wed'=>'7:30am5pm','Thu'=>'7:30am5pm','Fri'=>'7:30am5pm','Sat'=>'8am12pm','Sun'=>'Closed']),39.4382,-78.9748,0],
['Legal Aid of WV','legal-aid',$LG,'Legal Aid',
'Free civil legal aid for eligible low-income West Virginians.',
'251 W. Piedmont St., Keyser, WV 26726','304-788-6770','','lawv.net',$std,39.4374,-78.9750,0],
// Recreation
['North Branch Ventures','north-branch-ventures',$RF,'Kayak Rental / Outdoor Recreation',
"Kayak and river supply rentals on the beautiful North Branch Potomac. Guided river experiences.",
'101 Armstrong St., Keyser, WV 26726','304-790-7125','','',
$h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'10am6pm','Thu'=>'10am6pm','Fri'=>'9am7pm','Sat'=>'8am7pm','Sun'=>'8am5pm']),39.4392,-78.9750,1],
['PSC Recreation Center','psc-rec-center',$RF,'Fitness Center / Pool',
"Full-service fitness center with indoor pool, hot tub, strength and cardio, group exercise. Open to the public.",
'J. Edward Kelley Complex, 101 Fort Ave., Keyser, WV','304-788-6800','','potomacstatecollege.edu',
$h(['Mon'=>'6am9pm','Tue'=>'6am9pm','Wed'=>'6am9pm','Thu'=>'6am9pm','Fri'=>'6am8pm','Sat'=>'8am6pm','Sun'=>'12pm6pm']),39.4368,-78.9715,0],
['Mineral County Parks & Recreation','mineral-county-parks',$RF,'Parks & Recreation',
"Larenim Park — 365 acres with pavilions, 600-seat amphitheater, fishing ponds, and trails.",
'Keyser, WV 26726','304-788-3066','','mineralwv.gov',
$h(['Mon'=>'Open Daily','Tue'=>'Open Daily','Wed'=>'Open Daily','Thu'=>'Open Daily','Fri'=>'Open Daily','Sat'=>'Open Daily','Sun'=>'Open Daily']),39.4350,-78.9720,0],
// Beauty
['Eclips Hair Salon','eclips',$BP,'Hair Salon',
'Full-service hair salon. Cuts, color, styling, and treatments for men and women.',
'153 S. Mineral St., Keyser, WV 26726','304-788-5578','','',
$h(['Mon'=>'Closed','Tue'=>'9am5pm','Wed'=>'9am5pm','Thu'=>'9am5pm','Fri'=>'9am6pm','Sat'=>'8am4pm','Sun'=>'Closed']),39.4352,-78.9733,0],
['In The Skin II','in-the-skin',$BP,'Tattoo & Body Art',
'Professional tattoo and body art studio. Custom designs, cover-ups, and piercings.',
'129 N. Main St., Keyser, WV 26726','304-597-2070','','',
$h(['Mon'=>'11am7pm','Tue'=>'11am7pm','Wed'=>'11am7pm','Thu'=>'11am7pm','Fri'=>'11am8pm','Sat'=>'10am6pm','Sun'=>'Closed']),39.4373,-78.9734,0],
// Government
['Mineral County Courthouse','mineral-county-courthouse',$GV,'County Government',
"Historic 1868 courthouse listed on the National Register of Historic Places — the architectural crown jewel of downtown Keyser.",
'Keyser, WV 26726','304-788-3003','','mineralwv.gov',
$h(['Mon'=>'8:30am4:30pm','Tue'=>'8:30am4:30pm','Wed'=>'8:30am4:30pm','Thu'=>'8:30am4:30pm','Fri'=>'8:30am4:30pm','Sat'=>'Closed','Sun'=>'Closed']),39.4385,-78.9738,0],
['City of Keyser','city-of-keyser',$GV,'City Government',
"Official offices of the City of Keyser, WV. City council, mayor's office, utilities, and municipal services.",
'Keyser, WV 26726','304-788-0222','','cityofkeyser.com',
$h(['Mon'=>'8:30am4:30pm','Tue'=>'8:30am4:30pm','Wed'=>'8:30am4:30pm','Thu'=>'8:30am4:30pm','Fri'=>'8:30am4:30pm','Sat'=>'Closed','Sun'=>'Closed']),39.4375,-78.9734,0],
['Mineral County Tourism Visitors Center','mineral-county-tourism',$GV,'Tourism / Visitors Center',
'167 S. Mineral Street — Your starting point for exploring Mineral County! Brochures, maps, and friendly staff.',
'167 S. Mineral St., Keyser, WV 26726','304-790-7081','mineralcocvb@gmail.com','govisitmineralwv.com',
$h(['Mon'=>'9am4pm','Tue'=>'9am4pm','Wed'=>'9am4pm','Thu'=>'9am4pm','Fri'=>'9am4pm','Sat'=>'Call Ahead','Sun'=>'Closed']),39.4358,-78.9734,1],
['Mineral County Historical Society Museum','mineral-county-historical',$GV,'Museum / Historical Society',
"Museum tracking the history of Mineral County and Keyser with engaging exhibits and artifacts. Open by appointment.",
'Keyser, WV 26726','304-788-3066','','',
$h(['Mon'=>'By Appt','Tue'=>'By Appt','Wed'=>'By Appt','Thu'=>'By Appt','Fri'=>'By Appt','Sat'=>'Special Events','Sun'=>'Special Events']),39.4378,-78.9734,0],
// Churches
['Keyser First United Methodist Church','keyser-umc',$CH,'Methodist Church',
'Welcoming United Methodist congregation serving Keyser since the 19th century.',
'32 N. Davis St., Keyser, WV 26726','304-788-2522','','',
$h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'Closed','Thu'=>'Closed','Fri'=>'Closed','Sat'=>'Closed','Sun'=>'Worship 11am']),39.4372,-78.9730,0],
['Living Faith Fellowship','living-faith',$CH,'Non-Denominational Church',
'Vibrant non-denominational Christian fellowship. Contemporary worship and community ministry.',
'1 N. Main St., Keyser, WV 26726','304-788-1910','','',
$h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'7pm','Thu'=>'Closed','Fri'=>'Closed','Sat'=>'Closed','Sun'=>'Worship 10:30am']),39.4376,-78.9734,0],
];
foreach ($biz as $r) $bi->execute($r);
/* ── Menus ── */
$si = $db->prepare("INSERT INTO menu_sections(business_id,name,description,sort_order)VALUES(?,?,?,?)");
$mi = $db->prepare("INSERT INTO menu_items(section_id,name,description,price,sort_order)VALUES(?,?,?,?,?)");
$castId = (int)qval("SELECT id FROM businesses WHERE slug='castiglia-italian'");
foreach ([['Antipasti & Salads','Fresh starters',1],['Pasta','Handmade pastas',2],['Pizza','Stone-baked Italian pizzas',3],['Entrees','Secondi piatti',4],['Desserts','Dolci',5]] as $s) $si->execute(array_merge([$castId],$s));
$cs=[]; foreach(qall("SELECT id,name FROM menu_sections WHERE business_id=$castId") as $r) $cs[$r['name']]=$r['id'];
foreach(['Antipasti & Salads'=>[['Bruschetta al Pomodoro','Toasted rustic bread with vine-ripened tomatoes, fresh basil & extra-virgin olive oil',8.95,1],['Calamari Fritti','Lightly breaded fresh calamari, marinara sauce & lemon aioli',13.95,2],['Caprese Salad','Fresh buffalo mozzarella, heirloom tomatoes, basil & balsamic glaze',11.95,3],['Minestrone Soup','Classic Italian vegetable soup with cannellini beans',7.50,4],['Caesar Salad','Romaine, house Caesar dressing, shaved Parmigiano, croutons',9.95,5]],
'Pasta'=>[['Spaghetti Carbonara','Classic Roman — guanciale, egg, Pecorino Romano, cracked black pepper',15.95,1],['Fettuccine Alfredo','House-made fettuccine in rich cream & Parmigiano sauce',14.95,2],['Lasagna al Forno','House-rolled pasta, Bolognese, bechamel & Parmigiano',16.95,3],['Baked Ziti','Ziti, house marinara, ricotta & melted mozzarella',13.95,4],['Penne Arrabbiata','Spicy tomato, garlic, Calabrian chiles & fresh parsley',12.95,5]],
'Pizza'=>[['Margherita','San Marzano tomatoes, fior di latte mozzarella & fresh basil',14.95,1],['Pepperoni','House marinara, mozzarella & premium pepperoni',15.95,2],['Quattro Stagioni','Ham, mushrooms, artichoke hearts & Kalamata olives',17.95,3],['Prosciutto & Arugula','Parma prosciutto, baby arugula & shaved Parmigiano',18.95,4]],
'Entrees'=>[['Chicken Parmigiana','Breaded chicken, house marinara & fresh mozzarella, served with pasta',18.95,1],['Osso Buco alla Milanese','Braised veal shank, saffron risotto & traditional gremolata',34.95,2],['Eggplant Parmigiana','Layered eggplant, San Marzano sauce & mozzarella',15.95,3],['Salmon Piccata','Pan-seared Atlantic salmon, lemon-caper butter & fresh herbs',24.95,4]],
'Desserts'=>[['Tiramisu','Espresso-soaked ladyfingers, mascarpone cream & cocoa',8.95,1],['Cannoli Siciliani','Crispy shells with sweet ricotta, chocolate chips & pistachios',7.95,2],['Panna Cotta','Silky vanilla panna cotta with seasonal berry coulis',7.50,3],['Gelato del Giorno','Two scoops house-made gelato — ask your server',6.95,4]]]
as $sec=>$items) foreach($items as $item) $mi->execute(array_merge([$cs[$sec]],$item));
// Queens Point Coffee menu
$qpId = (int)qval("SELECT id FROM businesses WHERE slug='queens-point-coffee'");
foreach ([['Espresso Drinks','Hot & cold espresso beverages',1],['Cold Drinks','Refreshing chilled options',2],['Food','Fresh baked goods & light bites',3]] as $s) $si->execute(array_merge([$qpId],$s));
$qs=[]; foreach(qall("SELECT id,name FROM menu_sections WHERE business_id=$qpId") as $r) $qs[$r['name']]=$r['id'];
foreach(['Espresso Drinks'=>[['Latte','Smooth espresso with steamed milk',4.50,1],['Cappuccino','Double espresso with foamed milk',4.25,2],['Americano','Espresso shots with hot water',3.50,3],['Mocha','Espresso, chocolate & steamed milk',5.00,4],['Iced Matcha Latte','Matcha with milk — our most raved drink!',5.25,5]],
'Cold Drinks'=>[['Cold Brew','Smooth 24-hour cold brew over ice',3.75,1],['Iced Latte','Chilled espresso with cold milk',4.75,2],['Blended Frappe','Blended ice coffee drink',5.50,3]],
'Food'=>[['Blueberry Muffin','Fresh-baked daily',3.00,1],['Avocado Toast','Multigrain with avocado & everything spice',7.50,2],['Breakfast Sandwich','Egg, cheese & choice of meat',6.50,3],['Cookie','Assorted fresh-baked cookies',2.50,4]]]
as $sec=>$items) foreach($items as $item) $mi->execute(array_merge([$qs[$sec]],$item));
/* ── Alerts ── */
foreach ([
['castiglia-italian','news','New Menu Items This Season!',
"Chef's new handmade gnocchi and wood-fired seasonal pizza are now available. Fresh locally-sourced summer ingredients throughout the menu."],
['queens-point-coffee','news','Try Our Famous Iced Matcha!',
'"THE best coffee shop I\'ve ever been to." Our iced matcha with coconut and pistachio is the most-talked-about drink in Keyser.'],
['clancys-irish-pub','event','Live Music Every Friday & Saturday!',
"Live music from 9pm-1am every Friday and Saturday night. No cover charge! Food and drinks served until close."],
['pvh','info','Urgent Care Now Open 7 Days',
'WVU Medicine Potomac Valley Hospital Urgent Care is open Mon-Fri 8am-8pm and weekends 9am-5pm. No appointment needed.'],
['north-branch-ventures','event','Guided Kayak Tours - Book Now!',
"Guided half-day kayak tours on the North Branch Potomac every Saturday and Sunday through October. All skill levels welcome."],
] as [$slug,$type,$title,$body])
qrun("INSERT INTO alerts(business_id,type,title,body,is_active)VALUES((SELECT id FROM businesses WHERE slug=?),?,?,?,1)",[$slug,$type,$title,$body]);
/* ── Attractions ── */
foreach ([
['Queens Point Overlook','Natural Landmark',"A spectacular Oriskany sandstone cliff rising ~400 feet above the North Branch Potomac River. Breathtaking panoramic views of Keyser and the river valley.",'McCoole, MD (viewable from Keyser riverfront)','','','Accessible year-round',1,1],
['North Branch Potomac River','Outdoor Recreation',"World-class smallmouth bass fishing, kayaking, canoeing, and bird watching. Contact North Branch Ventures for rentals and guided tours.",'Armstrong St. riverfront, Keyser, WV','304-790-7125','','Open year-round',1,2],
['Jennings Randolph Lake','Lake / Reservoir',"A stunning 952-acre reservoir on the North Branch Potomac. Boating, fishing, swimming, camping, and picnicking. Managed by U.S. Army Corps of Engineers.",'Elk Garden, WV 26717 (~20 min from Keyser)','304-355-2346','','Open year-round',1,3],
['Larenim Park','County Park',"Mineral County's premier park at 365 acres. Pavilions, 600-seat amphitheater, fishing ponds, and 5 miles of trails.",'Keyser, WV 26726','304-788-3066','','Open daily',1,4],
['Mineral County Courthouse','Historic Site',"The 1868 courthouse listed on the National Register of Historic Places — the architectural centerpiece of downtown Keyser.",'Keyser, WV 26726','304-788-3003','','Exterior always viewable',1,5],
['Potomac State College Campus / Fort Fuller','Historic Site',"Fort Hill — site of Civil War Fort Fuller — commanded by future president Benjamin Harrison and Lew Wallace (author of Ben-Hur). Now the beautiful PSC campus.",'101 Fort Ave., Keyser, WV 26726','304-788-6800','potomacstatecollege.edu','Campus open daily',1,6],
['Potomac Eagle Scenic Railroad','Train Excursion',"Scenic excursions through The Trough along the South Branch Potomac River — famous for bald eagle sightings. About 30 miles from Keyser.",'Romney, WV 26757 (~30 miles)','304-822-7400','potomaceagle.info','Seasonal spring through fall',1,7],
['Blackwater Falls State Park','State Park',"The amber-colored Blackwater Falls plunge 57 feet into the dramatic canyon. Hiking, skiing, camping, and a beautiful lodge. About 40 minutes from Keyser.",'Davis, WV 26260 (~40 min)','304-259-5216','wvstateparks.com','Open year-round',1,8],
['Seneca Rocks','Natural Landmark',"Massive quartzite outcroppings rising 900 feet from the valley floor. Rock climbing, hiking, and spectacular views. About 45 minutes from Keyser.",'Seneca Rocks, WV (~45 min)','304-567-2827','','Open year-round',1,9],
['Fort Ashby Blockhouse','Historic Site',"The only remaining French and Indian War fort in West Virginia. Ordered built by young George Washington in 1755. Just 15 miles from Keyser.",'Fort Ashby, WV 26719 (~15 miles)','','','Open seasonally',1,10],
] as $a) qrun("INSERT INTO attractions(name,category,description,address,phone,website,hours,is_active,sort_order)VALUES(?,?,?,?,?,?,?,?,?)",$a);
/* ── Events ── */
$adminId=(int)qval("SELECT id FROM users WHERE username='administrator'");
foreach ([
['Mineral County Fair','Annual county fair featuring livestock shows, carnival rides, food vendors, live entertainment, and the best of Mineral County agriculture.','Mineral County Fairgrounds','Fort Ashby, WV','2025-08-12','9:00 AM','2025-08-16','10:00 PM','Admission varies','mineralcountyfair.org','','','304-788-0000',$adminId,'approved',1],
['Keyser Christmas Parade & Tree Lighting','Annual holiday parade through downtown Keyser with floats, marching bands, Santa Claus, and tree lighting.','Downtown Main Street','Keyser, WV 26726','2025-12-06','5:00 PM','2025-12-06','7:00 PM','Free','cityofkeyser.com','','','304-788-0222',$adminId,'approved',1],
['PSC Catamount Athletics','Support the WVU Potomac State Catamounts at home athletic events throughout the semester.','Potomac State College','101 Fort Ave., Keyser','2025-09-01','Varies','2025-11-30','Varies','Free$5','potomacstatecollege.edu','','','304-788-6800',$adminId,'approved',0],
['North Branch Kayak Adventure Series','Guided and recreational kayaking on the North Branch Potomac every Saturday through October.','North Branch Ventures','101 Armstrong St., Keyser','2025-05-01','8:00 AM','2025-10-31','4:00 PM','$25$45','','','','304-790-7125',$adminId,'approved',0],
] as $ev) qrun("INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,submitted_by,status,is_featured)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",$ev);
}