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()]); } } }