Next.js 16 static export + PHP 8 API for cPanel/Apache hosting. - Location-aware campus finder (IP estimate, optional precise location, ZIP search) - Three-step Plan Your Visit form with separate email/SMS consent - PHP API: validation, anti-spam, rate limiting, SQLite lead store, Mailchimp and Sinch Engage adapters, idempotent retries via cron - Attribution capture (utm_*, gclid/gbraid/wbraid, fbclid, ttclid, twclid) and consent-aware analytics hooks - Vitest and PHP test suites, cPanel deployment script and docs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
134 lines
5.1 KiB
PHP
134 lines
5.1 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Lead persistence. SQLite by default (available on virtually every cPanel PHP
|
|
* build via pdo_sqlite), stored in the private data directory. Set
|
|
* DATABASE_DSN / DATABASE_USER / DATABASE_PASSWORD to use cPanel MySQL instead.
|
|
*
|
|
* Leads are ALWAYS written here before any third-party call is made.
|
|
*/
|
|
final class Db
|
|
{
|
|
private static ?PDO $pdo = null;
|
|
|
|
public static function pdo(): PDO
|
|
{
|
|
if (self::$pdo instanceof PDO) {
|
|
return self::$pdo;
|
|
}
|
|
$dsn = Config::get('DATABASE_DSN');
|
|
if ($dsn) {
|
|
$pdo = new PDO($dsn, Config::get('DATABASE_USER', ''), Config::get('DATABASE_PASSWORD', ''), [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
PDO::ATTR_TIMEOUT => 5,
|
|
]);
|
|
} else {
|
|
$file = Config::dataDir() . '/leads.sqlite';
|
|
$isNew = !file_exists($file);
|
|
$pdo = new PDO('sqlite:' . $file, null, null, [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
PDO::ATTR_TIMEOUT => 5,
|
|
]);
|
|
$pdo->exec('PRAGMA journal_mode=WAL');
|
|
$pdo->exec('PRAGMA busy_timeout=5000');
|
|
$pdo->exec('PRAGMA foreign_keys=ON');
|
|
if ($isNew) {
|
|
@chmod($file, 0640);
|
|
}
|
|
}
|
|
self::$pdo = $pdo;
|
|
self::migrate($pdo);
|
|
return $pdo;
|
|
}
|
|
|
|
public static function isSqlite(): bool
|
|
{
|
|
return self::pdo()->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
|
|
}
|
|
|
|
private static function migrate(PDO $pdo): void
|
|
{
|
|
$sqlite = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
|
|
$text = $sqlite ? 'TEXT' : 'TEXT';
|
|
$json = $sqlite ? 'TEXT' : 'JSON';
|
|
$pk = $sqlite ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'BIGINT AUTO_INCREMENT PRIMARY KEY';
|
|
$ts = $sqlite ? "TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))" : 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP';
|
|
|
|
$pdo->exec("CREATE TABLE IF NOT EXISTS leads (
|
|
id $pk,
|
|
submission_id VARCHAR(36) NOT NULL UNIQUE,
|
|
created_at $ts,
|
|
updated_at $ts,
|
|
first_name VARCHAR(80) NOT NULL,
|
|
last_name VARCHAR(80) NULL,
|
|
email VARCHAR(254) NOT NULL,
|
|
phone_e164 VARCHAR(20) NULL,
|
|
campus_id VARCHAR(40) NOT NULL,
|
|
visit_date VARCHAR(10) NULL,
|
|
service_time VARCHAR(80) NULL,
|
|
adults SMALLINT NULL,
|
|
children SMALLINT NULL,
|
|
child_age_ranges VARCHAR(80) NULL,
|
|
questions $text NULL,
|
|
email_consent TINYINT NOT NULL DEFAULT 0,
|
|
sms_consent TINYINT NOT NULL DEFAULT 0,
|
|
consent_version VARCHAR(40) NOT NULL,
|
|
consent_at VARCHAR(30) NOT NULL,
|
|
consent_ip_hash VARCHAR(64) NULL,
|
|
attribution $json NULL,
|
|
variant VARCHAR(40) NULL,
|
|
referrer $text NULL,
|
|
landing_url $text NULL,
|
|
user_agent VARCHAR(300) NULL,
|
|
mailchimp_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
|
mailchimp_detail $text NULL,
|
|
mailchimp_attempts SMALLINT NOT NULL DEFAULT 0,
|
|
sms_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
|
sms_detail $text NULL,
|
|
sms_message_id VARCHAR(80) NULL,
|
|
sms_attempts SMALLINT NOT NULL DEFAULT 0,
|
|
next_retry_at VARCHAR(30) NULL
|
|
)");
|
|
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_leads_retry ON leads (next_retry_at)");
|
|
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_leads_created ON leads (created_at)");
|
|
|
|
$pdo->exec("CREATE TABLE IF NOT EXISTS rate_limits (
|
|
bucket VARCHAR(120) NOT NULL PRIMARY KEY,
|
|
hits INT NOT NULL DEFAULT 0,
|
|
window_start INT NOT NULL
|
|
)");
|
|
|
|
$pdo->exec("CREATE TABLE IF NOT EXISTS cache (
|
|
cache_key VARCHAR(120) NOT NULL PRIMARY KEY,
|
|
value $text NOT NULL,
|
|
expires_at INT NOT NULL
|
|
)");
|
|
}
|
|
|
|
/** Simple cache helpers (used for geolocation lookups). */
|
|
public static function cacheGet(string $key): ?string
|
|
{
|
|
$st = self::pdo()->prepare('SELECT value FROM cache WHERE cache_key = ? AND expires_at > ?');
|
|
$st->execute([$key, time()]);
|
|
$v = $st->fetchColumn();
|
|
return $v === false ? null : (string)$v;
|
|
}
|
|
|
|
public static function cacheSet(string $key, string $value, int $ttlSeconds): void
|
|
{
|
|
$pdo = self::pdo();
|
|
if (self::isSqlite()) {
|
|
$st = $pdo->prepare('INSERT OR REPLACE INTO cache (cache_key, value, expires_at) VALUES (?, ?, ?)');
|
|
} else {
|
|
$st = $pdo->prepare('REPLACE INTO cache (cache_key, value, expires_at) VALUES (?, ?, ?)');
|
|
}
|
|
$st->execute([$key, $value, time() + $ttlSeconds]);
|
|
if (random_int(1, 50) === 1) {
|
|
$pdo->prepare('DELETE FROM cache WHERE expires_at < ?')->execute([time()]);
|
|
}
|
|
}
|
|
}
|