Plan Your Visit landing page for visit.waterschurch.org
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>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# /api — PHP endpoints. Everything under lib/, cron/, config/ and data/ is private.
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteRule ^(lib|cron|config|data)(/|$) - [F,L]
|
||||
</IfModule>
|
||||
|
||||
# Belt and braces for servers without mod_rewrite
|
||||
<FilesMatch "^(bootstrap|Config|Log|Db|Http|Campuses|Phone|Validator|RateLimit|Turnstile|GeoIp|Geocode|Mailchimp|Sinch|LeadProcessor)\.php$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
<FilesMatch "\.(json|sqlite|sqlite-wal|sqlite-shm|log|lock|env)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header set X-Content-Type-Options "nosniff"
|
||||
</IfModule>
|
||||
|
||||
Options -Indexes
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/**
|
||||
* CLI: export leads as CSV for staff follow-up.
|
||||
*
|
||||
* php api/cron/export-leads.php [--since=YYYY-MM-DD] > leads.csv
|
||||
*
|
||||
* Contains personal data — store the output securely and delete when done.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
require_once dirname(__DIR__) . '/lib/bootstrap.php';
|
||||
|
||||
$since = null;
|
||||
foreach ($argv as $arg) {
|
||||
if (str_starts_with($arg, '--since=')) {
|
||||
$since = substr($arg, 8);
|
||||
}
|
||||
}
|
||||
$sql = 'SELECT submission_id, created_at, first_name, last_name, email, phone_e164, campus_id, visit_date, service_time, adults, children, child_age_ranges, questions, email_consent, sms_consent, consent_version, consent_at, variant, attribution, mailchimp_status, sms_status FROM leads';
|
||||
$params = [];
|
||||
if ($since) {
|
||||
$sql .= ' WHERE created_at >= ?';
|
||||
$params[] = $since;
|
||||
}
|
||||
$sql .= ' ORDER BY created_at ASC';
|
||||
$st = Db::pdo()->prepare($sql);
|
||||
$st->execute($params);
|
||||
$out = fopen('php://stdout', 'w');
|
||||
$first = true;
|
||||
while ($row = $st->fetch()) {
|
||||
if ($first) {
|
||||
fputcsv($out, array_keys($row));
|
||||
$first = false;
|
||||
}
|
||||
fputcsv($out, $row);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
/**
|
||||
* Cron: retry failed Mailchimp / Sinch deliveries and purge expired leads.
|
||||
*
|
||||
* cPanel → Cron Jobs → every 5 minutes:
|
||||
* VISIT_PRIVATE_DIR=/home/USER/visit-private /usr/local/bin/php /home/USER/visit.waterschurch.org/api/cron/retry-integrations.php >> /home/USER/visit-private/logs/cron.log 2>&1
|
||||
*
|
||||
* CLI only — web requests are refused by the .htaccess in this folder AND by the guard below.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/lib/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/lib/LeadProcessor.php';
|
||||
|
||||
$lock = fopen(Config::dataDir() . '/retry.lock', 'c');
|
||||
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
fwrite(STDOUT, gmdate('c') . " retry: another run is in progress\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$result = LeadProcessor::retryDue(Config::int('RETRY_BATCH_SIZE', 25));
|
||||
$purged = LeadProcessor::purgeExpired();
|
||||
fwrite(STDOUT, sprintf("%s retry: checked=%d processed=%d purged=%d mode=%s\n", gmdate('c'), $result['checked'], $result['processed'], $purged, Config::mode()));
|
||||
flock($lock, LOCK_UN);
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/** GET /api/geocode.php?zip=02763 — US ZIP centroid lookup for the manual campus search. */
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/lib/bootstrap.php';
|
||||
require_once __DIR__ . '/lib/Validator.php';
|
||||
require_once __DIR__ . '/lib/RateLimit.php';
|
||||
require_once __DIR__ . '/lib/Geocode.php';
|
||||
|
||||
Api::requireMethod('GET');
|
||||
|
||||
$ip = Api::clientIp();
|
||||
$rl = RateLimit::hit(RateLimit::ipBucket('geocode', $ip), 40, 600);
|
||||
if (!$rl['allowed']) {
|
||||
Api::json(['ok' => false, 'error' => 'Too many searches. Please wait a moment.'], 429, ['Retry-After' => (string)$rl['retryAfter']]);
|
||||
}
|
||||
|
||||
$zip = isset($_GET['zip']) ? preg_replace('/\D/', '', (string)$_GET['zip']) : '';
|
||||
$zip = substr((string)$zip, 0, 5);
|
||||
if (strlen($zip) !== 5) {
|
||||
Api::json(['ok' => false, 'error' => 'Enter a 5-digit ZIP code.'], 400);
|
||||
}
|
||||
|
||||
$loc = Geocode::zip($zip);
|
||||
if (!$loc) {
|
||||
Api::json(['ok' => false, 'error' => "We couldn't find that ZIP. Try another or pick a campus below."], 200);
|
||||
}
|
||||
Api::json(['ok' => true, 'location' => $loc], 200, ['Cache-Control' => 'public, max-age=86400']);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /api/health.php — deployment self-check (no secrets or PII in the output).
|
||||
* Safe to leave enabled; reveals only booleans and version strings.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/lib/bootstrap.php';
|
||||
|
||||
Api::requireMethod('GET');
|
||||
|
||||
$checks = [];
|
||||
$checks['php'] = PHP_VERSION;
|
||||
$checks['extensions'] = [
|
||||
'curl' => extension_loaded('curl'),
|
||||
'pdo_sqlite' => extension_loaded('pdo_sqlite'),
|
||||
'pdo_mysql' => extension_loaded('pdo_mysql'),
|
||||
'json' => extension_loaded('json'),
|
||||
'mbstring' => extension_loaded('mbstring'),
|
||||
'openssl' => extension_loaded('openssl'),
|
||||
];
|
||||
$checks['private_dir_found'] = is_dir(Config::privateDir());
|
||||
$checks['env_file_found'] = is_readable(Config::privateDir() . '/.env');
|
||||
$checks['data_dir_writable'] = is_writable(Config::dataDir());
|
||||
$checks['logs_dir_writable'] = is_writable(Config::privateDir() . '/logs') || is_writable(Config::privateDir());
|
||||
$checks['integration_mode'] = Config::mode();
|
||||
$checks['mailchimp_configured'] = Config::get('MAILCHIMP_API_KEY') !== null && Config::get('MAILCHIMP_AUDIENCE_ID') !== null;
|
||||
$checks['sinch_configured'] = Config::get('SINCH_API_KEY') !== null && Config::get('SINCH_API_SECRET') !== null;
|
||||
$checks['geoip_provider'] = Config::get('GEOIP_PROVIDER', 'ipapi_co');
|
||||
$checks['geocode_provider'] = Config::get('GEOCODE_PROVIDER', 'zippopotam');
|
||||
$checks['turnstile_enabled'] = Config::get('TURNSTILE_SECRET_KEY') !== null;
|
||||
$checks['https'] = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
|
||||
try {
|
||||
Db::pdo();
|
||||
$checks['database'] = 'ok (' . Db::pdo()->getAttribute(PDO::ATTR_DRIVER_NAME) . ')';
|
||||
} catch (Throwable $e) {
|
||||
$checks['database'] = 'error';
|
||||
Log::error('Health: database failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
try {
|
||||
$checks['campuses_loaded'] = count(Campuses::all());
|
||||
} catch (Throwable $e) {
|
||||
$checks['campuses_loaded'] = 0;
|
||||
}
|
||||
|
||||
$ok = $checks['private_dir_found'] && $checks['data_dir_writable'] && $checks['database'] !== 'error' && $checks['campuses_loaded'] > 0 && $checks['extensions']['curl'];
|
||||
Api::json(['ok' => $ok, 'checks' => $checks], $ok ? 200 : 500);
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Reads the shared campus configuration (config/campuses.json). The build
|
||||
* copies it to api/config/campuses.json; in local development we fall back to
|
||||
* the repo copy.
|
||||
*/
|
||||
final class Campuses
|
||||
{
|
||||
/** @var array<string,array<string,mixed>>|null */
|
||||
private static ?array $byId = null;
|
||||
|
||||
/** @return array<string,array<string,mixed>> */
|
||||
public static function all(): array
|
||||
{
|
||||
if (self::$byId !== null) {
|
||||
return self::$byId;
|
||||
}
|
||||
$candidates = [
|
||||
dirname(__DIR__) . '/config/campuses.json',
|
||||
dirname(__DIR__, 3) . '/config/campuses.json',
|
||||
];
|
||||
foreach ($candidates as $file) {
|
||||
if (is_readable($file)) {
|
||||
$data = json_decode((string)file_get_contents($file), true);
|
||||
if (is_array($data) && isset($data['campuses'])) {
|
||||
$out = [];
|
||||
foreach ($data['campuses'] as $c) {
|
||||
$out[$c['id']] = $c;
|
||||
}
|
||||
return self::$byId = $out;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('campuses.json not found');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function get(string $id): ?array
|
||||
{
|
||||
return self::all()[$id] ?? null;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public static function openIds(): array
|
||||
{
|
||||
return array_keys(array_filter(self::all(), static fn($c) => ($c['status'] ?? 'open') === 'open'));
|
||||
}
|
||||
|
||||
/** "Sunday 9:30 AM" style summary of the first service day. */
|
||||
public static function timesSummary(array $campus): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($campus['serviceTimes'] ?? [] as $st) {
|
||||
$parts[] = $st['day'] . ' ' . implode(', ', $st['times']);
|
||||
}
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Configuration lives OUTSIDE the web root in a private directory containing a
|
||||
* `.env` file (see .env.example at the repo root for every key).
|
||||
*
|
||||
* The private directory is resolved in this order:
|
||||
* 1. VISIT_PRIVATE_DIR environment variable (set via `SetEnv` in .htaccess, or cPanel cron env)
|
||||
* 2. <parent of DOCUMENT_ROOT>/visit-private (e.g. /home/USER/visit-private)
|
||||
* 3. <repo>/private (local development only)
|
||||
*/
|
||||
final class Config
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
private static array $values = [];
|
||||
private static ?string $privateDir = null;
|
||||
private static bool $loaded = false;
|
||||
|
||||
public static function load(): void
|
||||
{
|
||||
if (self::$loaded) {
|
||||
return;
|
||||
}
|
||||
self::$loaded = true;
|
||||
$dir = self::privateDir();
|
||||
$env = $dir . '/.env';
|
||||
if (is_readable($env)) {
|
||||
foreach (file($env, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$k, $v] = explode('=', $line, 2);
|
||||
$k = trim($k);
|
||||
$v = trim($v);
|
||||
if (str_starts_with($k, 'export ')) {
|
||||
$k = trim(substr($k, 7));
|
||||
}
|
||||
if ((str_starts_with($v, '"') && str_ends_with($v, '"')) || (str_starts_with($v, "'") && str_ends_with($v, "'"))) {
|
||||
$v = substr($v, 1, -1);
|
||||
}
|
||||
self::$values[$k] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function privateDir(): string
|
||||
{
|
||||
if (self::$privateDir !== null) {
|
||||
return self::$privateDir;
|
||||
}
|
||||
$candidates = [];
|
||||
$fromEnv = getenv('VISIT_PRIVATE_DIR') ?: ($_SERVER['VISIT_PRIVATE_DIR'] ?? ($_SERVER['REDIRECT_VISIT_PRIVATE_DIR'] ?? null));
|
||||
if ($fromEnv) {
|
||||
$candidates[] = $fromEnv;
|
||||
}
|
||||
if (!empty($_SERVER['DOCUMENT_ROOT'])) {
|
||||
$candidates[] = dirname(rtrim($_SERVER['DOCUMENT_ROOT'], '/')) . '/visit-private';
|
||||
}
|
||||
$candidates[] = dirname(__DIR__, 3) . '/private'; // repo/private (dev)
|
||||
foreach ($candidates as $c) {
|
||||
if (is_dir($c)) {
|
||||
return self::$privateDir = rtrim($c, '/');
|
||||
}
|
||||
}
|
||||
// Fall back to the first candidate so error messages point somewhere useful.
|
||||
return self::$privateDir = rtrim($candidates[0], '/');
|
||||
}
|
||||
|
||||
public static function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
$env = getenv($key);
|
||||
if ($env !== false && $env !== '') {
|
||||
return $env;
|
||||
}
|
||||
return (isset(self::$values[$key]) && self::$values[$key] !== '') ? self::$values[$key] : $default;
|
||||
}
|
||||
|
||||
public static function require(string $key): string
|
||||
{
|
||||
$v = self::get($key);
|
||||
if ($v === null) {
|
||||
throw new RuntimeException("Missing required configuration: $key");
|
||||
}
|
||||
return $v;
|
||||
}
|
||||
|
||||
public static function bool(string $key, bool $default = false): bool
|
||||
{
|
||||
$v = self::get($key);
|
||||
if ($v === null) {
|
||||
return $default;
|
||||
}
|
||||
return in_array(strtolower($v), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
public static function int(string $key, int $default): int
|
||||
{
|
||||
$v = self::get($key);
|
||||
return $v === null ? $default : (int)$v;
|
||||
}
|
||||
|
||||
/** "mock" (default when unset), "live". */
|
||||
public static function mode(): string
|
||||
{
|
||||
return strtolower(self::get('INTEGRATION_MODE', 'mock') ?? 'mock') === 'live' ? 'live' : 'mock';
|
||||
}
|
||||
|
||||
public static function isMock(): bool
|
||||
{
|
||||
return self::mode() === 'mock';
|
||||
}
|
||||
|
||||
public static function dataDir(): string
|
||||
{
|
||||
$d = self::get('DATA_DIR') ?: self::privateDir() . '/data';
|
||||
if (!is_dir($d)) {
|
||||
@mkdir($d, 0750, true);
|
||||
}
|
||||
return $d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Privacy-conscious approximate IP geolocation for the "closest campus"
|
||||
* recommendation. Results are city-level, cached briefly by hashed IP, and
|
||||
* never stored with a lead.
|
||||
*
|
||||
* GEOIP_PROVIDER:
|
||||
* none – disabled (visitor always chooses manually)
|
||||
* header – trust a CDN header (Cloudflare: CF-IPCountry/CF-IPLatitude/CF-IPLongitude)
|
||||
* ipapi_co – https://ipapi.co (no key needed for low volume; GEOIP_API_KEY optional)
|
||||
* ipinfo – https://ipinfo.io (GEOIP_API_KEY required)
|
||||
* ip_api_com – http(s)://ip-api.com (GEOIP_API_KEY required for HTTPS/commercial use)
|
||||
*/
|
||||
final class GeoIp
|
||||
{
|
||||
/** @return array{lat:float,lng:float,city:?string,region:?string}|null */
|
||||
public static function locate(string $ip): ?array
|
||||
{
|
||||
$provider = strtolower(Config::get('GEOIP_PROVIDER', 'ipapi_co') ?? 'none');
|
||||
if ($provider === 'none' || self::isPrivate($ip)) {
|
||||
return null;
|
||||
}
|
||||
if ($provider === 'header') {
|
||||
return self::fromHeaders();
|
||||
}
|
||||
$cacheKey = 'geoip:' . substr(hash('sha256', $ip . (Config::get('HASH_SALT') ?? '')), 0, 40);
|
||||
$cached = Db::cacheGet($cacheKey);
|
||||
if ($cached !== null) {
|
||||
$c = json_decode($cached, true);
|
||||
return is_array($c) && $c ? $c : null;
|
||||
}
|
||||
$result = null;
|
||||
try {
|
||||
$result = match ($provider) {
|
||||
'ipapi_co' => self::ipapiCo($ip),
|
||||
'ipinfo' => self::ipinfo($ip),
|
||||
'ip_api_com' => self::ipApiCom($ip),
|
||||
default => null,
|
||||
};
|
||||
} catch (Throwable $e) {
|
||||
Log::warn('GeoIP provider failed', ['provider' => $provider, 'error' => $e->getMessage()]);
|
||||
}
|
||||
Db::cacheSet($cacheKey, json_encode($result ?? []), Config::int('GEOIP_CACHE_TTL', 3600));
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function fromHeaders(): ?array
|
||||
{
|
||||
$lat = $_SERVER['HTTP_CF_IPLATITUDE'] ?? null;
|
||||
$lng = $_SERVER['HTTP_CF_IPLONGITUDE'] ?? null;
|
||||
if ($lat === null || $lng === null) {
|
||||
return null;
|
||||
}
|
||||
return self::result((float)$lat, (float)$lng, $_SERVER['HTTP_CF_IPCITY'] ?? null, $_SERVER['HTTP_CF_REGION_CODE'] ?? null);
|
||||
}
|
||||
|
||||
private static function ipapiCo(string $ip): ?array
|
||||
{
|
||||
$key = Config::get('GEOIP_API_KEY');
|
||||
$base = rtrim(Config::get('GEOIP_BASE_URL_OVERRIDE') ?: 'https://ipapi.co', '/'); // override is for the test-suite only
|
||||
$url = "$base/" . rawurlencode($ip) . "/json/" . ($key ? "?key=" . rawurlencode($key) : '');
|
||||
$r = Http::request('GET', $url, null, ['Accept' => 'application/json'], 3);
|
||||
$j = $r['json'];
|
||||
if ($r['status'] !== 200 || !is_array($j) || !isset($j['latitude'], $j['longitude'])) {
|
||||
return null;
|
||||
}
|
||||
return self::result((float)$j['latitude'], (float)$j['longitude'], $j['city'] ?? null, $j['region_code'] ?? null);
|
||||
}
|
||||
|
||||
private static function ipinfo(string $ip): ?array
|
||||
{
|
||||
$key = Config::require('GEOIP_API_KEY');
|
||||
$r = Http::request('GET', "https://ipinfo.io/" . rawurlencode($ip) . "/json?token=" . rawurlencode($key), null, ['Accept' => 'application/json'], 3);
|
||||
$j = $r['json'];
|
||||
if ($r['status'] !== 200 || !is_array($j) || empty($j['loc'])) {
|
||||
return null;
|
||||
}
|
||||
[$lat, $lng] = array_map('floatval', explode(',', (string)$j['loc']) + [0, 0]);
|
||||
return self::result($lat, $lng, $j['city'] ?? null, $j['region'] ?? null);
|
||||
}
|
||||
|
||||
private static function ipApiCom(string $ip): ?array
|
||||
{
|
||||
$key = Config::get('GEOIP_API_KEY');
|
||||
$base = $key ? "https://pro.ip-api.com/json/" : "http://ip-api.com/json/";
|
||||
$r = Http::request('GET', $base . rawurlencode($ip) . "?fields=status,lat,lon,city,region" . ($key ? "&key=" . rawurlencode($key) : ''), null, [], 3);
|
||||
$j = $r['json'];
|
||||
if ($r['status'] !== 200 || !is_array($j) || ($j['status'] ?? '') !== 'success') {
|
||||
return null;
|
||||
}
|
||||
return self::result((float)$j['lat'], (float)$j['lon'], $j['city'] ?? null, $j['region'] ?? null);
|
||||
}
|
||||
|
||||
private static function result(float $lat, float $lng, ?string $city, ?string $region): ?array
|
||||
{
|
||||
if ($lat === 0.0 && $lng === 0.0 || abs($lat) > 90 || abs($lng) > 180) {
|
||||
return null;
|
||||
}
|
||||
// Round to ~1 km so we never hold anything close to a precise location.
|
||||
return ['lat' => round($lat, 2), 'lng' => round($lng, 2), 'city' => $city ? Validator::clean($city, 60) : null, 'region' => $region ? Validator::clean($region, 20) : null];
|
||||
}
|
||||
|
||||
public static function isPrivate(string $ip): bool
|
||||
{
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* US ZIP → coordinates for the manual campus search.
|
||||
*
|
||||
* GEOCODE_PROVIDER:
|
||||
* zippopotam – https://api.zippopotam.us (free, no key) [default]
|
||||
* nominatim – OpenStreetMap Nominatim (free, 1 req/s, requires identifying UA)
|
||||
* google – Google Geocoding API (GEOCODE_API_KEY required)
|
||||
*/
|
||||
final class Geocode
|
||||
{
|
||||
/** @return array{lat:float,lng:float,city:?string,state:?string}|null */
|
||||
public static function zip(string $zip): ?array
|
||||
{
|
||||
if (!preg_match('/^\d{5}$/', $zip)) {
|
||||
return null;
|
||||
}
|
||||
$cacheKey = 'zip:' . $zip;
|
||||
$cached = Db::cacheGet($cacheKey);
|
||||
if ($cached !== null) {
|
||||
$c = json_decode($cached, true);
|
||||
return is_array($c) && $c ? $c : null;
|
||||
}
|
||||
$provider = strtolower(Config::get('GEOCODE_PROVIDER', 'zippopotam') ?? 'zippopotam');
|
||||
$result = null;
|
||||
try {
|
||||
$result = match ($provider) {
|
||||
'nominatim' => self::nominatim($zip),
|
||||
'google' => self::google($zip),
|
||||
default => self::zippopotam($zip),
|
||||
};
|
||||
} catch (Throwable $e) {
|
||||
Log::warn('Geocode provider failed', ['provider' => $provider, 'error' => $e->getMessage()]);
|
||||
}
|
||||
// ZIP centroids do not change: cache for 30 days (failures for 10 minutes).
|
||||
Db::cacheSet($cacheKey, json_encode($result ?? []), $result ? 30 * 86400 : 600);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function zippopotam(string $zip): ?array
|
||||
{
|
||||
$base = rtrim(Config::get('GEOCODE_BASE_URL_OVERRIDE') ?: 'https://api.zippopotam.us', '/'); // override is for the test-suite only
|
||||
$r = Http::request('GET', "$base/us/$zip", null, ['Accept' => 'application/json'], 6);
|
||||
$j = $r['json'];
|
||||
if ($r['status'] !== 200 || !is_array($j) || empty($j['places'][0])) {
|
||||
return null;
|
||||
}
|
||||
$p = $j['places'][0];
|
||||
return self::result((float)$p['latitude'], (float)$p['longitude'], $p['place name'] ?? null, $p['state abbreviation'] ?? null);
|
||||
}
|
||||
|
||||
private static function nominatim(string $zip): ?array
|
||||
{
|
||||
$r = Http::request('GET', "https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&countrycodes=us&postalcode=$zip", null, ['Accept' => 'application/json'], 6);
|
||||
$j = $r['json'];
|
||||
if ($r['status'] !== 200 || !is_array($j) || empty($j[0]['lat'])) {
|
||||
return null;
|
||||
}
|
||||
return self::result((float)$j[0]['lat'], (float)$j[0]['lon'], null, null);
|
||||
}
|
||||
|
||||
private static function google(string $zip): ?array
|
||||
{
|
||||
$key = Config::require('GEOCODE_API_KEY');
|
||||
$r = Http::request('GET', "https://maps.googleapis.com/maps/api/geocode/json?components=postal_code:$zip|country:US&key=" . rawurlencode($key), null, [], 6);
|
||||
$j = $r['json'];
|
||||
if ($r['status'] !== 200 || !is_array($j) || ($j['status'] ?? '') !== 'OK') {
|
||||
return null;
|
||||
}
|
||||
$loc = $j['results'][0]['geometry']['location'] ?? null;
|
||||
if (!$loc) {
|
||||
return null;
|
||||
}
|
||||
$city = null;
|
||||
$state = null;
|
||||
foreach ($j['results'][0]['address_components'] ?? [] as $c) {
|
||||
if (in_array('locality', $c['types'] ?? [], true)) {
|
||||
$city = $c['long_name'];
|
||||
}
|
||||
if (in_array('administrative_area_level_1', $c['types'] ?? [], true)) {
|
||||
$state = $c['short_name'];
|
||||
}
|
||||
}
|
||||
return self::result((float)$loc['lat'], (float)$loc['lng'], $city, $state);
|
||||
}
|
||||
|
||||
private static function result(float $lat, float $lng, ?string $city, ?string $state): ?array
|
||||
{
|
||||
if (($lat === 0.0 && $lng === 0.0) || abs($lat) > 90 || abs($lng) > 180) {
|
||||
return null;
|
||||
}
|
||||
return ['lat' => round($lat, 4), 'lng' => round($lng, 4), 'city' => $city ? Validator::clean($city, 60) : null, 'state' => $state ? Validator::clean($state, 20) : null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Small cURL wrapper with hard timeouts, used by every provider adapter. */
|
||||
final class Http
|
||||
{
|
||||
/**
|
||||
* @param array<string,string> $headers
|
||||
* @return array{status:int, body:string, json:mixed, error:?string}
|
||||
*/
|
||||
public static function request(string $method, string $url, ?string $body = null, array $headers = [], int $timeout = 8): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$hdrs = [];
|
||||
foreach ($headers as $k => $v) {
|
||||
$hdrs[] = "$k: $v";
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_HTTPHEADER => $hdrs,
|
||||
CURLOPT_USERAGENT => 'visit.waterschurch.org/1.0 (+https://visit.waterschurch.org)',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$out = curl_exec($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$err = curl_error($ch) ?: null;
|
||||
$text = is_string($out) ? $out : '';
|
||||
$json = null;
|
||||
if ($text !== '') {
|
||||
$decoded = json_decode($text, true);
|
||||
$json = json_last_error() === JSON_ERROR_NONE ? $decoded : null;
|
||||
}
|
||||
return ['status' => $status, 'body' => $text, 'json' => $json, 'error' => $err];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<string,string> $headers
|
||||
* @return array{status:int, body:string, json:mixed, error:?string}
|
||||
*/
|
||||
public static function json(string $method, string $url, ?array $payload, array $headers = [], int $timeout = 8): array
|
||||
{
|
||||
$headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json'] + $headers;
|
||||
return self::request($method, $url, $payload === null ? null : json_encode($payload, JSON_UNESCAPED_SLASHES), $headers, $timeout);
|
||||
}
|
||||
|
||||
/** True for statuses/transport errors that are worth retrying later. */
|
||||
public static function isTransient(int $status, ?string $error): bool
|
||||
{
|
||||
return $error !== null || $status === 0 || $status === 408 || $status === 429 || $status >= 500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/Mailchimp.php';
|
||||
require_once __DIR__ . '/Sinch.php';
|
||||
|
||||
/**
|
||||
* Persists a lead FIRST, then runs integrations, recording each outcome so a
|
||||
* partial failure never loses the lead and can be retried by cron.
|
||||
*/
|
||||
final class LeadProcessor
|
||||
{
|
||||
public const MAX_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $d Validated data from Validator::planVisit
|
||||
* @return array{lead:array<string,mixed>, duplicate:bool}
|
||||
*/
|
||||
public static function persist(array $d, string $ip, string $userAgent): array
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$existing = self::find($d['submission_id']);
|
||||
if ($existing) {
|
||||
return ['lead' => $existing, 'duplicate' => true];
|
||||
}
|
||||
$now = gmdate('Y-m-d\TH:i:s\Z');
|
||||
$st = $pdo->prepare('INSERT INTO leads (
|
||||
submission_id, created_at, updated_at, first_name, last_name, email, phone_e164, campus_id, visit_date, service_time,
|
||||
adults, children, child_age_ranges, questions, email_consent, sms_consent, consent_version, consent_at, consent_ip_hash,
|
||||
attribution, variant, referrer, landing_url, user_agent, mailchimp_status, sms_status
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)');
|
||||
$attr = $d['attribution'];
|
||||
try {
|
||||
$st->execute([
|
||||
$d['submission_id'], $now, $now, $d['first_name'], $d['last_name'], $d['email'], $d['phone_e164'], $d['campus_id'], $d['visit_date'], $d['service_time'],
|
||||
$d['adults'], $d['children'], $d['child_age_ranges'], $d['questions'], (int)$d['email_consent'], (int)$d['sms_consent'], $d['consent_version'], $now,
|
||||
hash('sha256', $ip . '|' . (Config::get('HASH_SALT') ?? 'visit')),
|
||||
json_encode($attr, JSON_UNESCAPED_SLASHES), $attr['variant'] ?? null, $attr['referrer'] ?? null, $attr['landing_url'] ?? null, mb_substr($userAgent, 0, 300),
|
||||
$d['email_consent'] ? 'pending' : 'skipped', $d['sms_consent'] ? 'pending' : 'skipped',
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
// Unique constraint race: another request with the same id got there first.
|
||||
if (str_contains($e->getMessage(), 'UNIQUE') || str_contains($e->getMessage(), 'Duplicate')) {
|
||||
$row = self::find($d['submission_id']);
|
||||
if ($row) {
|
||||
return ['lead' => $row, 'duplicate' => true];
|
||||
}
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
$lead = self::find($d['submission_id']);
|
||||
if (!$lead) {
|
||||
throw new RuntimeException('Lead insert failed');
|
||||
}
|
||||
return ['lead' => $lead, 'duplicate' => false];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function find(string $submissionId): ?array
|
||||
{
|
||||
$st = Db::pdo()->prepare('SELECT * FROM leads WHERE submission_id = ?');
|
||||
$st->execute([$submissionId]);
|
||||
$row = $st->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs Mailchimp then Sinch for a stored lead. Each step is guarded by a
|
||||
* status transition so concurrent requests/cron cannot double-send.
|
||||
*
|
||||
* @param array<string,mixed> $lead
|
||||
* @return array{email:string, sms:string}
|
||||
*/
|
||||
public static function runIntegrations(array $lead): array
|
||||
{
|
||||
$campus = Campuses::get($lead['campus_id']) ?? ['id' => $lead['campus_id'], 'name' => $lead['campus_id'], 'address' => ['line1' => '', 'city' => ''], 'serviceTimes' => []];
|
||||
$out = ['email' => $lead['mailchimp_status'], 'sms' => $lead['sms_status']];
|
||||
|
||||
if (self::claim($lead['id'], 'mailchimp')) {
|
||||
try {
|
||||
$r = Mailchimp::upsertVisitor($lead, $campus);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Mailchimp adapter threw', ['error' => $e->getMessage()]);
|
||||
$r = ['status' => 'failed', 'detail' => 'exception: ' . $e->getMessage(), 'transient' => true];
|
||||
}
|
||||
$out['email'] = self::record($lead['id'], 'mailchimp', $r['status'], $r['detail'] ?? null, $r['transient'] ?? false);
|
||||
}
|
||||
|
||||
if (self::claim($lead['id'], 'sms')) {
|
||||
try {
|
||||
$r = Sinch::sendVisitConfirmation($lead, $campus);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Sinch adapter threw', ['error' => $e->getMessage()]);
|
||||
$r = ['status' => 'failed', 'detail' => 'exception: ' . $e->getMessage(), 'message_id' => null, 'transient' => true];
|
||||
}
|
||||
$out['sms'] = self::record($lead['id'], 'sms', $r['status'], $r['detail'] ?? null, $r['transient'] ?? false, $r['message_id'] ?? null);
|
||||
if (in_array($r['status'], ['sent', 'mock'], true)) {
|
||||
try {
|
||||
Sinch::sendInternalLeadNotification($lead, $campus);
|
||||
} catch (Throwable $e) {
|
||||
Log::warn('Internal notify failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Atomically move pending/retry → sending. Returns false if someone else owns it. */
|
||||
private static function claim(int $id, string $which): bool
|
||||
{
|
||||
$col = $which === 'sms' ? 'sms_status' : 'mailchimp_status';
|
||||
$att = $which === 'sms' ? 'sms_attempts' : 'mailchimp_attempts';
|
||||
$st = Db::pdo()->prepare("UPDATE leads SET $col = 'sending', $att = $att + 1, updated_at = ? WHERE id = ? AND $col IN ('pending','retry')");
|
||||
$st->execute([gmdate('Y-m-d\TH:i:s\Z'), $id]);
|
||||
return $st->rowCount() === 1;
|
||||
}
|
||||
|
||||
/** Persists the outcome and returns the final stored status ('sent', 'mock', 'skipped', 'failed' or 'retry'). */
|
||||
private static function record(int $id, string $which, string $status, ?string $detail, bool $transient, ?string $messageId = null): string
|
||||
{
|
||||
$col = $which === 'sms' ? 'sms_status' : 'mailchimp_status';
|
||||
$det = $which === 'sms' ? 'sms_detail' : 'mailchimp_detail';
|
||||
$att = $which === 'sms' ? 'sms_attempts' : 'mailchimp_attempts';
|
||||
$pdo = Db::pdo();
|
||||
$attempts = (int)$pdo->query("SELECT $att FROM leads WHERE id = $id")->fetchColumn();
|
||||
$final = $status;
|
||||
$nextRetry = null;
|
||||
if ($status === 'failed' && $transient && $attempts < self::MAX_ATTEMPTS) {
|
||||
$final = 'retry';
|
||||
$nextRetry = gmdate('Y-m-d\TH:i:s\Z', time() + self::backoffSeconds($attempts));
|
||||
}
|
||||
$sql = "UPDATE leads SET $col = ?, $det = ?, updated_at = ?" . ($which === 'sms' ? ', sms_message_id = COALESCE(?, sms_message_id)' : '') . ', next_retry_at = ? WHERE id = ?';
|
||||
$params = [$final, $detail ? mb_substr($detail, 0, 500) : null, gmdate('Y-m-d\TH:i:s\Z')];
|
||||
if ($which === 'sms') {
|
||||
$params[] = $messageId;
|
||||
}
|
||||
$params[] = $nextRetry;
|
||||
$params[] = $id;
|
||||
$pdo->prepare($sql)->execute($params);
|
||||
// Keep the earliest pending retry across both integrations
|
||||
$pdo->prepare("UPDATE leads SET next_retry_at = (SELECT MIN(x) FROM (SELECT next_retry_at AS x FROM leads WHERE id = ?)) WHERE id = ?")->execute([$id, $id]);
|
||||
Log::info("Integration $which → $final", ['lead' => $id, 'attempts' => $attempts]);
|
||||
return $final;
|
||||
}
|
||||
|
||||
public static function backoffSeconds(int $attempt): int
|
||||
{
|
||||
return min(6 * 3600, (int)(60 * (2 ** max(0, $attempt - 1)))); // 1m, 2m, 4m, 8m … capped at 6h
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron entry point: retry rows whose integrations are in 'retry' (or stuck in 'sending' for >15 min).
|
||||
* @return array{checked:int, processed:int}
|
||||
*/
|
||||
public static function retryDue(int $limit = 25): array
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$now = gmdate('Y-m-d\TH:i:s\Z');
|
||||
$stale = gmdate('Y-m-d\TH:i:s\Z', time() - 900);
|
||||
// Unstick crashed "sending" rows
|
||||
$pdo->prepare("UPDATE leads SET mailchimp_status = 'retry' WHERE mailchimp_status = 'sending' AND updated_at < ?")->execute([$stale]);
|
||||
$pdo->prepare("UPDATE leads SET sms_status = 'retry' WHERE sms_status = 'sending' AND updated_at < ?")->execute([$stale]);
|
||||
$st = $pdo->prepare("SELECT * FROM leads WHERE (mailchimp_status = 'retry' OR sms_status = 'retry') AND (next_retry_at IS NULL OR next_retry_at <= ?) ORDER BY created_at ASC LIMIT $limit");
|
||||
$st->execute([$now]);
|
||||
$rows = $st->fetchAll();
|
||||
$processed = 0;
|
||||
foreach ($rows as $row) {
|
||||
self::runIntegrations($row);
|
||||
$processed++;
|
||||
}
|
||||
return ['checked' => count($rows), 'processed' => $processed];
|
||||
}
|
||||
|
||||
/** Data minimisation: delete leads older than LEAD_RETENTION_DAYS (0 disables). */
|
||||
public static function purgeExpired(): int
|
||||
{
|
||||
$days = Config::int('LEAD_RETENTION_DAYS', 0);
|
||||
if ($days <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$cutoff = gmdate('Y-m-d\TH:i:s\Z', time() - $days * 86400);
|
||||
$st = Db::pdo()->prepare("DELETE FROM leads WHERE created_at < ? AND mailchimp_status NOT IN ('pending','retry','sending') AND sms_status NOT IN ('pending','retry','sending')");
|
||||
$st->execute([$cutoff]);
|
||||
return $st->rowCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Minimal structured logger. Writes JSON lines to a private log file and
|
||||
* redacts anything that looks like personal data or a secret.
|
||||
*/
|
||||
final class Log
|
||||
{
|
||||
private static string $file = '';
|
||||
private static int $level = 1;
|
||||
private const LEVELS = ['debug' => 0, 'info' => 1, 'warn' => 2, 'error' => 3];
|
||||
private const REDACT_KEYS = ['email', 'phone', 'firstname', 'first_name', 'lastname', 'last_name', 'authorization', 'api_key', 'apikey', 'secret', 'token', 'password', 'questions', 'destination_number', 'email_address'];
|
||||
|
||||
public static function init(string $file, ?string $level = 'info'): void
|
||||
{
|
||||
self::$file = $file;
|
||||
self::$level = self::LEVELS[strtolower($level ?? 'info')] ?? 1;
|
||||
$dir = dirname($file);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0750, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $ctx */
|
||||
public static function debug(string $msg, array $ctx = []): void { self::write('debug', $msg, $ctx); }
|
||||
/** @param array<string,mixed> $ctx */
|
||||
public static function info(string $msg, array $ctx = []): void { self::write('info', $msg, $ctx); }
|
||||
/** @param array<string,mixed> $ctx */
|
||||
public static function warn(string $msg, array $ctx = []): void { self::write('warn', $msg, $ctx); }
|
||||
/** @param array<string,mixed> $ctx */
|
||||
public static function error(string $msg, array $ctx = []): void { self::write('error', $msg, $ctx); }
|
||||
|
||||
/** @param array<string,mixed> $ctx */
|
||||
private static function write(string $level, string $msg, array $ctx): void
|
||||
{
|
||||
if ((self::LEVELS[$level] ?? 1) < self::$level) {
|
||||
return;
|
||||
}
|
||||
$line = json_encode([
|
||||
'ts' => gmdate('c'),
|
||||
'level' => $level,
|
||||
'msg' => $msg,
|
||||
'ctx' => self::redact($ctx),
|
||||
], JSON_UNESCAPED_SLASHES) . "\n";
|
||||
if (self::$file !== '' && (is_writable(dirname(self::$file)) || is_writable(self::$file))) {
|
||||
@file_put_contents(self::$file, $line, FILE_APPEND | LOCK_EX);
|
||||
} else {
|
||||
error_log(rtrim($line));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param mixed $value */
|
||||
public static function redact($value, string $key = ''): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$out = [];
|
||||
foreach ($value as $k => $v) {
|
||||
$out[$k] = self::redact($v, (string)$k);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
if (is_string($value)) {
|
||||
if (in_array(strtolower($key), self::REDACT_KEYS, true)) {
|
||||
return self::mask($value);
|
||||
}
|
||||
// Inline redaction of emails, phone numbers and bearer-like tokens inside free text
|
||||
$value = preg_replace('/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', '[email]', $value) ?? $value;
|
||||
$value = preg_replace('/\+?\d[\d\s().-]{8,}\d/', '[phone]', $value) ?? $value;
|
||||
return preg_replace('/(Basic|Bearer)\s+[A-Za-z0-9+\/=._-]{8,}/', '$1 [redacted]', $value) ?? $value;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function mask(string $v): string
|
||||
{
|
||||
$len = strlen($v);
|
||||
if ($len === 0) {
|
||||
return '';
|
||||
}
|
||||
if ($len <= 4) {
|
||||
return str_repeat('*', $len);
|
||||
}
|
||||
return substr($v, 0, 2) . str_repeat('*', max(1, $len - 4)) . substr($v, -2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Mailchimp Marketing API v3 adapter (server-only).
|
||||
*
|
||||
* Required config: MAILCHIMP_API_KEY, MAILCHIMP_SERVER_PREFIX (e.g. us21), MAILCHIMP_AUDIENCE_ID
|
||||
* Optional: MAILCHIMP_JOURNEY_ID + MAILCHIMP_JOURNEY_STEP_ID (Customer Journey API trigger),
|
||||
* MAILCHIMP_TAG_PREFIX (default "Visit:" → tags like "Visit:Norwood", matching the
|
||||
* church's existing convention), MAILCHIMP_DOUBLE_OPTIN (default false)
|
||||
*
|
||||
* Idempotency: contacts are keyed by the MD5 of the lower-cased email (Mailchimp's
|
||||
* subscriber hash) and upserted with PUT, so retries never create duplicates.
|
||||
*/
|
||||
final class Mailchimp
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $lead Row from the leads table
|
||||
* @param array<string,mixed> $campus
|
||||
* @return array{status:string, detail:?string, transient:bool}
|
||||
*/
|
||||
public static function upsertVisitor(array $lead, array $campus): array
|
||||
{
|
||||
if (!(int)$lead['email_consent']) {
|
||||
return ['status' => 'skipped', 'detail' => 'no email consent', 'transient' => false];
|
||||
}
|
||||
$payload = self::buildMemberPayload($lead, $campus);
|
||||
$tags = self::buildTags($lead, $campus);
|
||||
|
||||
if (Config::isMock()) {
|
||||
Log::info('[mock] Mailchimp upsert', ['tags' => $tags, 'merge_fields' => array_keys($payload['merge_fields'])]);
|
||||
return ['status' => 'mock', 'detail' => 'mock mode: ' . implode(',', $tags), 'transient' => false];
|
||||
}
|
||||
|
||||
$apiKey = Config::require('MAILCHIMP_API_KEY');
|
||||
$dc = Config::get('MAILCHIMP_SERVER_PREFIX') ?: (str_contains($apiKey, '-') ? substr($apiKey, strrpos($apiKey, '-') + 1) : null);
|
||||
if (!$dc) {
|
||||
throw new RuntimeException('MAILCHIMP_SERVER_PREFIX missing');
|
||||
}
|
||||
$list = Config::require('MAILCHIMP_AUDIENCE_ID');
|
||||
$hash = md5(strtolower($lead['email']));
|
||||
$base = Config::get('MAILCHIMP_BASE_URL_OVERRIDE') ?: "https://$dc.api.mailchimp.com/3.0"; // override is for the test-suite only
|
||||
$auth = ['Authorization' => 'Basic ' . base64_encode("anystring:$apiKey")];
|
||||
|
||||
// 1) Upsert the member (idempotent)
|
||||
$r = Http::json('PUT', "$base/lists/$list/members/$hash?skip_merge_validation=true", $payload, $auth, 10);
|
||||
if ($r['status'] < 200 || $r['status'] >= 300) {
|
||||
$detail = is_array($r['json']) ? ($r['json']['title'] ?? '') . ': ' . ($r['json']['detail'] ?? '') : ($r['error'] ?? "HTTP {$r['status']}");
|
||||
$transient = Http::isTransient($r['status'], $r['error']);
|
||||
Log::warn('Mailchimp upsert failed', ['status' => $r['status'], 'detail' => $detail]);
|
||||
return ['status' => 'failed', 'detail' => substr($detail, 0, 500), 'transient' => $transient];
|
||||
}
|
||||
|
||||
// 2) Apply tags (separate endpoint; PUT on members accepts tags only on create)
|
||||
$tr = Http::json('POST', "$base/lists/$list/members/$hash/tags", [
|
||||
'tags' => array_map(static fn($t) => ['name' => $t, 'status' => 'active'], $tags),
|
||||
], $auth, 10);
|
||||
if ($tr['status'] < 200 || $tr['status'] >= 300) {
|
||||
Log::warn('Mailchimp tag update failed (member saved)', ['status' => $tr['status']]);
|
||||
}
|
||||
|
||||
// 3) Optionally trigger a Customer Journey step (API-triggered journeys)
|
||||
$journey = Config::get('MAILCHIMP_JOURNEY_ID');
|
||||
$step = Config::get('MAILCHIMP_JOURNEY_STEP_ID');
|
||||
$journeyNote = '';
|
||||
if ($journey && $step) {
|
||||
$jr = Http::json('POST', "$base/customer-journeys/journeys/$journey/steps/$step/actions/trigger", ['email_address' => $lead['email']], $auth, 10);
|
||||
$journeyNote = ($jr['status'] >= 200 && $jr['status'] < 300) ? '; journey triggered' : "; journey trigger HTTP {$jr['status']}";
|
||||
if ($jr['status'] < 200 || $jr['status'] >= 300) {
|
||||
Log::warn('Mailchimp journey trigger failed', ['status' => $jr['status'], 'body' => substr($jr['body'], 0, 300)]);
|
||||
}
|
||||
}
|
||||
|
||||
$status = is_array($r['json']) ? ($r['json']['status'] ?? 'unknown') : 'unknown';
|
||||
return ['status' => 'sent', 'detail' => "member status: $status" . $journeyNote, 'transient' => false];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $lead
|
||||
* @param array<string,mixed> $campus
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function buildMemberPayload(array $lead, array $campus): array
|
||||
{
|
||||
$attr = is_string($lead['attribution'] ?? null) ? (json_decode($lead['attribution'], true) ?: []) : ($lead['attribution'] ?? []);
|
||||
$doubleOptIn = Config::bool('MAILCHIMP_DOUBLE_OPTIN', false);
|
||||
$merge = [
|
||||
'FNAME' => $lead['first_name'],
|
||||
'LNAME' => $lead['last_name'] ?? '',
|
||||
'CAMPUS' => $campus['name'],
|
||||
'VISITDATE' => $lead['visit_date'] ?? '',
|
||||
'SERVICE' => $lead['service_time'] ?? '',
|
||||
'ADULTS' => $lead['adults'] ?? '',
|
||||
'KIDS' => $lead['children'] ?? '',
|
||||
'KIDAGES' => $lead['child_age_ranges'] ?? '',
|
||||
'QUESTIONS' => mb_substr((string)($lead['questions'] ?? ''), 0, 255),
|
||||
'UTMSOURCE' => $attr['utm_source'] ?? '',
|
||||
'UTMCAMP' => $attr['utm_campaign'] ?? '',
|
||||
'LPVARIANT' => $attr['variant'] ?? '',
|
||||
'SUBID' => $lead['submission_id'],
|
||||
];
|
||||
if (!empty($lead['phone_e164'])) {
|
||||
$merge['PHONE'] = $lead['phone_e164'];
|
||||
}
|
||||
// Remove empties so we never overwrite existing values with blanks.
|
||||
$merge = array_filter($merge, static fn($v) => $v !== '' && $v !== null);
|
||||
|
||||
return [
|
||||
'email_address' => $lead['email'],
|
||||
'status_if_new' => $doubleOptIn ? 'pending' : 'subscribed',
|
||||
'email_type' => 'html',
|
||||
'merge_fields' => $merge,
|
||||
'marketing_permissions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $lead
|
||||
* @param array<string,mixed> $campus
|
||||
* @return string[]
|
||||
*/
|
||||
public static function buildTags(array $lead, array $campus): array
|
||||
{
|
||||
$prefix = Config::get('MAILCHIMP_TAG_PREFIX', 'Visit:') ?? 'Visit:';
|
||||
$attr = is_string($lead['attribution'] ?? null) ? (json_decode($lead['attribution'], true) ?: []) : ($lead['attribution'] ?? []);
|
||||
// Campus tag first: "Visit:Norwood" is what the church's Mailchimp journeys trigger on.
|
||||
$tags = [
|
||||
$prefix . $campus['name'],
|
||||
$prefix . 'First-Time Visitor',
|
||||
$prefix . 'Source visit.waterschurch.org',
|
||||
];
|
||||
$platform = self::platform($attr);
|
||||
$tags[] = $prefix . 'Platform ' . $platform;
|
||||
if (!empty($attr['utm_campaign'])) {
|
||||
$tags[] = $prefix . 'Campaign ' . mb_substr($attr['utm_campaign'], 0, 60);
|
||||
}
|
||||
if (!empty($lead['visit_date'])) {
|
||||
$tags[] = $prefix . 'Visit ' . $lead['visit_date'];
|
||||
} elseif (!empty($lead['service_time'])) {
|
||||
$tags[] = $prefix . 'Service ' . mb_substr($lead['service_time'], 0, 40);
|
||||
}
|
||||
if ((int)($lead['children'] ?? 0) > 0) {
|
||||
$tags[] = $prefix . 'Has Kids';
|
||||
}
|
||||
if ((int)$lead['sms_consent']) {
|
||||
$tags[] = $prefix . 'SMS Opt-In';
|
||||
}
|
||||
return array_values(array_unique(array_map(static fn($t) => mb_substr($t, 0, 100), $tags)));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $attr */
|
||||
public static function platform(array $attr): string
|
||||
{
|
||||
$src = strtolower((string)($attr['utm_source'] ?? ''));
|
||||
if (!empty($attr['gclid']) || !empty($attr['gbraid']) || !empty($attr['wbraid']) || preg_match('/google|adwords/', $src)) {
|
||||
return 'Google';
|
||||
}
|
||||
if (preg_match('/instagram|^ig$/', $src)) {
|
||||
return 'Instagram';
|
||||
}
|
||||
if (!empty($attr['fbclid']) || preg_match('/facebook|^fb$|meta/', $src)) {
|
||||
return 'Facebook';
|
||||
}
|
||||
if (!empty($attr['ttclid']) || str_contains($src, 'tiktok')) {
|
||||
return 'TikTok';
|
||||
}
|
||||
if (!empty($attr['twclid']) || preg_match('/twitter|^x$|x\.com/', $src)) {
|
||||
return 'X';
|
||||
}
|
||||
return $src !== '' ? ucfirst($src) : 'Direct';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** North American Numbering Plan normalization to E.164 (+1NXXNXXXXXX). */
|
||||
final class Phone
|
||||
{
|
||||
public static function normalizeNanp(string $input): ?string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $input) ?? '';
|
||||
if (strlen($digits) === 11 && $digits[0] === '1') {
|
||||
$digits = substr($digits, 1);
|
||||
}
|
||||
if (strlen($digits) !== 10) {
|
||||
return null;
|
||||
}
|
||||
$area = substr($digits, 0, 3);
|
||||
$exchange = substr($digits, 3, 3);
|
||||
if (in_array($area[0], ['0', '1'], true) || in_array($exchange[0], ['0', '1'], true)) {
|
||||
return null;
|
||||
}
|
||||
if ($area[1] === '1' && $area[2] === '1') {
|
||||
return null; // N11 service codes
|
||||
}
|
||||
return '+1' . $digits;
|
||||
}
|
||||
|
||||
public static function isE164(string $v): bool
|
||||
{
|
||||
return (bool)preg_match('/^\+[1-9]\d{6,14}$/', $v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Fixed-window rate limiter backed by the database (works on shared hosting without Redis). */
|
||||
final class RateLimit
|
||||
{
|
||||
/**
|
||||
* @return array{allowed:bool, retryAfter:int}
|
||||
*/
|
||||
public static function hit(string $bucket, int $limit, int $windowSeconds): array
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$now = time();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$st = $pdo->prepare('SELECT hits, window_start FROM rate_limits WHERE bucket = ?');
|
||||
$st->execute([$bucket]);
|
||||
$row = $st->fetch();
|
||||
if (!$row || ($now - (int)$row['window_start']) >= $windowSeconds) {
|
||||
if (Db::isSqlite()) {
|
||||
$pdo->prepare('INSERT OR REPLACE INTO rate_limits (bucket, hits, window_start) VALUES (?, 1, ?)')->execute([$bucket, $now]);
|
||||
} else {
|
||||
$pdo->prepare('REPLACE INTO rate_limits (bucket, hits, window_start) VALUES (?, 1, ?)')->execute([$bucket, $now]);
|
||||
}
|
||||
$pdo->commit();
|
||||
return ['allowed' => true, 'retryAfter' => 0];
|
||||
}
|
||||
$hits = (int)$row['hits'] + 1;
|
||||
$pdo->prepare('UPDATE rate_limits SET hits = ? WHERE bucket = ?')->execute([$hits, $bucket]);
|
||||
$pdo->commit();
|
||||
if ($hits > $limit) {
|
||||
return ['allowed' => false, 'retryAfter' => max(1, $windowSeconds - ($now - (int)$row['window_start']))];
|
||||
}
|
||||
return ['allowed' => true, 'retryAfter' => 0];
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
Log::warn('Rate limit storage failed; allowing request', ['error' => $e->getMessage()]);
|
||||
return ['allowed' => true, 'retryAfter' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable, non-reversible bucket key for an IP. */
|
||||
public static function ipBucket(string $prefix, string $ip): string
|
||||
{
|
||||
return $prefix . ':' . substr(hash('sha256', $ip . '|' . (Config::get('HASH_SALT') ?? 'visit')), 0, 32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Sinch Engage (formerly MessageMedia) Messages API adapter (server-only).
|
||||
*
|
||||
* Verified against the Sinch Engage OpenAPI spec (github.com/messagemedia/sinchengage-developer):
|
||||
* POST {SINCH_API_BASE}/v1/messages
|
||||
* Authorization: Basic base64(api_key:api_secret)
|
||||
* Account: <sub-account id> (optional; lets a parent key send as a campus sub-account)
|
||||
* { "messages": [ { "content", "destination_number", "source_number", "format": "SMS",
|
||||
* "delivery_report": true, "metadata": {...}, "message_expiry_timestamp" } ] }
|
||||
*
|
||||
* Regional hosts: https://us.app.api.sinch.com (US), https://eu.app.api.sinch.com, https://au.app.api.sinch.com
|
||||
*
|
||||
* Config: SINCH_API_BASE, SINCH_API_KEY, SINCH_API_SECRET, SINCH_DEFAULT_SOURCE_NUMBER,
|
||||
* SINCH_USE_CAMPUS_SUBACCOUNTS (default true), SINCH_INTERNAL_NOTIFY_NUMBER (optional)
|
||||
*
|
||||
* Idempotency: a lead is only sent once. The DB row is marked "sending" before the
|
||||
* request and updated with the provider message_id after; retries skip rows that are
|
||||
* not in a retryable state.
|
||||
*/
|
||||
final class Sinch
|
||||
{
|
||||
public const TEMPLATE_DEFAULT = "Hi {{first_name}} — we're excited that you're considering Waters Church {{campus_name}}. Your visit details: {{service_or_date}}. Have a question? Reply to this message. Reply STOP to opt out.";
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $lead
|
||||
* @param array<string,mixed> $campus
|
||||
* @return array{status:string, detail:?string, message_id:?string, transient:bool}
|
||||
*/
|
||||
public static function sendVisitConfirmation(array $lead, array $campus): array
|
||||
{
|
||||
if (!(int)$lead['sms_consent']) {
|
||||
return ['status' => 'skipped', 'detail' => 'no sms consent', 'message_id' => null, 'transient' => false];
|
||||
}
|
||||
$to = $lead['phone_e164'] ?? null;
|
||||
if (!$to || !Phone::isE164($to)) {
|
||||
return ['status' => 'failed', 'detail' => 'no valid phone', 'message_id' => null, 'transient' => false];
|
||||
}
|
||||
$content = self::buildConfirmation($lead, $campus);
|
||||
$from = self::sourceNumberFor($campus);
|
||||
$account = self::accountFor($campus);
|
||||
|
||||
if (Config::isMock()) {
|
||||
Log::info('[mock] Sinch send', ['account' => $account, 'source' => $from, 'chars' => mb_strlen($content)]);
|
||||
return ['status' => 'mock', 'detail' => 'mock mode; would send from ' . ($from ?? 'default') . ($account ? " as $account" : ''), 'message_id' => 'mock-' . substr($lead['submission_id'], 0, 8), 'transient' => false];
|
||||
}
|
||||
|
||||
$message = [
|
||||
'content' => $content,
|
||||
'destination_number' => $to,
|
||||
'format' => 'SMS',
|
||||
'delivery_report' => true,
|
||||
'metadata' => ['submission_id' => $lead['submission_id'], 'campus' => $campus['id'], 'source' => 'visit.waterschurch.org'],
|
||||
'message_expiry_timestamp' => gmdate('Y-m-d\TH:i:s\Z', time() + 6 * 3600),
|
||||
];
|
||||
if ($from) {
|
||||
$message['source_number'] = $from;
|
||||
}
|
||||
$r = self::post('/v1/messages', ['messages' => [$message]], $account);
|
||||
if ($r['status'] === 202 || $r['status'] === 200) {
|
||||
$msg = $r['json']['messages'][0] ?? [];
|
||||
$id = is_array($msg) ? ($msg['message_id'] ?? null) : null;
|
||||
$status = is_array($msg) ? ($msg['status'] ?? 'queued') : 'queued';
|
||||
// Only claim success when the provider actually accepted the message.
|
||||
if (!$id) {
|
||||
return ['status' => 'failed', 'detail' => 'provider response missing message_id', 'message_id' => null, 'transient' => true];
|
||||
}
|
||||
return ['status' => 'sent', 'detail' => "provider status: $status", 'message_id' => $id, 'transient' => false];
|
||||
}
|
||||
$detail = is_array($r['json']) ? json_encode($r['json']) : ($r['error'] ?? "HTTP {$r['status']}");
|
||||
Log::warn('Sinch send failed', ['status' => $r['status'], 'detail' => substr((string)$detail, 0, 300)]);
|
||||
return ['status' => 'failed', 'detail' => substr((string)$detail, 0, 500), 'message_id' => null, 'transient' => Http::isTransient($r['status'], $r['error'])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional internal heads-up to campus staff. Enabled only when
|
||||
* SINCH_INTERNAL_NOTIFY_NUMBER is set. Contains no visitor PII beyond first name.
|
||||
*
|
||||
* @param array<string,mixed> $lead
|
||||
* @param array<string,mixed> $campus
|
||||
*/
|
||||
public static function sendInternalLeadNotification(array $lead, array $campus): ?array
|
||||
{
|
||||
$to = Config::get('SINCH_INTERNAL_NOTIFY_NUMBER');
|
||||
if (!$to) {
|
||||
return null;
|
||||
}
|
||||
$content = sprintf('New first-time visitor plan: %s → %s%s. View in the leads export.', $lead['first_name'], $campus['name'], $lead['visit_date'] ? ' on ' . $lead['visit_date'] : '');
|
||||
if (Config::isMock()) {
|
||||
Log::info('[mock] Sinch internal notify', ['to' => $to]);
|
||||
return ['status' => 'mock'];
|
||||
}
|
||||
$r = self::post('/v1/messages', ['messages' => [['content' => $content, 'destination_number' => $to, 'format' => 'SMS', 'source_number' => Config::get('SINCH_DEFAULT_SOURCE_NUMBER')]]], null);
|
||||
return ['status' => ($r['status'] === 202 || $r['status'] === 200) ? 'sent' : 'failed'];
|
||||
}
|
||||
|
||||
public static function normalizePhoneNumber(string $raw): ?string
|
||||
{
|
||||
return Phone::normalizeNanp($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $lead
|
||||
* @param array<string,mixed> $campus
|
||||
*/
|
||||
public static function buildConfirmation(array $lead, array $campus): string
|
||||
{
|
||||
$template = Config::get('SINCH_CONFIRMATION_TEMPLATE') ?: self::TEMPLATE_DEFAULT;
|
||||
$when = self::serviceOrDate($lead, $campus);
|
||||
$vars = [
|
||||
'{{first_name}}' => $lead['first_name'],
|
||||
'{{campus_name}}' => $campus['name'],
|
||||
'{{service_or_date}}' => $when,
|
||||
'{{address}}' => $campus['address']['line1'] . ', ' . $campus['address']['city'],
|
||||
];
|
||||
$text = strtr($template, $vars);
|
||||
$text = preg_replace('/\s+/', ' ', trim($text)) ?? $text;
|
||||
// Keep to a sane length; carriers concatenate but cost rises. 320 chars = 2 segments (GSM) or ~4 (UCS-2).
|
||||
return mb_substr($text, 0, 320);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $lead
|
||||
* @param array<string,mixed> $campus
|
||||
*/
|
||||
public static function serviceOrDate(array $lead, array $campus): string
|
||||
{
|
||||
$parts = [];
|
||||
if (!empty($lead['visit_date'])) {
|
||||
$ts = strtotime($lead['visit_date']);
|
||||
$parts[] = $ts ? date('D, M j', $ts) : $lead['visit_date'];
|
||||
}
|
||||
if (!empty($lead['service_time'])) {
|
||||
$parts[] = $lead['service_time'];
|
||||
}
|
||||
if (!$parts) {
|
||||
$parts[] = Campuses::timesSummary($campus);
|
||||
}
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $campus */
|
||||
public static function sourceNumberFor(array $campus): ?string
|
||||
{
|
||||
if (Config::bool('SINCH_USE_CAMPUS_NUMBERS', true) && !empty($campus['smsNumber'])) {
|
||||
return $campus['smsNumber'];
|
||||
}
|
||||
return Config::get('SINCH_DEFAULT_SOURCE_NUMBER');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $campus */
|
||||
public static function accountFor(array $campus): ?string
|
||||
{
|
||||
if (Config::bool('SINCH_USE_CAMPUS_SUBACCOUNTS', true) && !empty($campus['sinchAccountId'])) {
|
||||
return $campus['sinchAccountId'];
|
||||
}
|
||||
return Config::get('SINCH_ACCOUNT_ID');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array{status:int, body:string, json:mixed, error:?string}
|
||||
*/
|
||||
private static function post(string $path, array $payload, ?string $account): array
|
||||
{
|
||||
$base = rtrim(Config::get('SINCH_API_BASE', 'https://us.app.api.sinch.com') ?? '', '/');
|
||||
$key = Config::require('SINCH_API_KEY');
|
||||
$secret = Config::require('SINCH_API_SECRET');
|
||||
$headers = ['Authorization' => 'Basic ' . base64_encode("$key:$secret")];
|
||||
if ($account) {
|
||||
$headers['Account'] = $account;
|
||||
}
|
||||
return Http::json('POST', $base . $path, $payload, $headers, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Optional Cloudflare Turnstile verification. Only enforced when TURNSTILE_SECRET_KEY is set. */
|
||||
final class Turnstile
|
||||
{
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return Config::get('TURNSTILE_SECRET_KEY') !== null;
|
||||
}
|
||||
|
||||
public static function verify(?string $token, string $ip): bool
|
||||
{
|
||||
if (!self::enabled()) {
|
||||
return true;
|
||||
}
|
||||
if (!$token) {
|
||||
return false;
|
||||
}
|
||||
$res = Http::request('POST', 'https://challenges.cloudflare.com/turnstile/v0/siteverify', http_build_query([
|
||||
'secret' => Config::require('TURNSTILE_SECRET_KEY'),
|
||||
'response' => $token,
|
||||
'remoteip' => $ip,
|
||||
]), ['Content-Type' => 'application/x-www-form-urlencoded'], 6);
|
||||
if ($res['error'] !== null || $res['status'] !== 200) {
|
||||
// Fail open on provider outage so real visitors are never blocked; honeypot + rate limit still apply.
|
||||
Log::warn('Turnstile verification unavailable', ['status' => $res['status'], 'error' => $res['error']]);
|
||||
return true;
|
||||
}
|
||||
return (bool)($res['json']['success'] ?? false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Server-side validation and sanitization of the Plan Your Visit payload.
|
||||
* Mirrors src/lib/schema.ts. The server is authoritative.
|
||||
*/
|
||||
final class Validator
|
||||
{
|
||||
public const CHILD_AGE_RANGES = ['0-2', '3-5', '6-10', '11-13', '14-18'];
|
||||
private const ATTR_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'gclid', 'gbraid', 'wbraid', 'fbclid', 'ttclid', 'twclid', 'variant', 'landing_url', 'referrer', 'first_seen_at'];
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $in
|
||||
* @return array{data:array<string,mixed>, errors:array<string,string>}
|
||||
*/
|
||||
public static function planVisit(array $in): array
|
||||
{
|
||||
$errors = [];
|
||||
$d = [];
|
||||
|
||||
$d['submission_id'] = self::str($in, 'submissionId', 36);
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $d['submission_id'])) {
|
||||
$errors['submissionId'] = 'Invalid submission id.';
|
||||
}
|
||||
|
||||
$d['first_name'] = self::str($in, 'firstName', 60);
|
||||
if ($d['first_name'] === '') {
|
||||
$errors['firstName'] = 'Please tell us your first name.';
|
||||
}
|
||||
$d['last_name'] = self::str($in, 'lastName', 60) ?: null;
|
||||
|
||||
$email = strtolower(self::str($in, 'email', 254));
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !str_contains($email, '.')) {
|
||||
$errors['email'] = "That email doesn't look right.";
|
||||
}
|
||||
$d['email'] = $email;
|
||||
|
||||
$d['email_consent'] = (bool)($in['emailConsent'] ?? false);
|
||||
$d['sms_consent'] = (bool)($in['smsConsent'] ?? false);
|
||||
|
||||
$phoneRaw = self::str($in, 'phone', 30);
|
||||
$d['phone_e164'] = null;
|
||||
if ($phoneRaw !== '') {
|
||||
$e164 = Phone::normalizeNanp($phoneRaw);
|
||||
if ($e164 === null) {
|
||||
$errors['phone'] = "That phone number doesn't look right.";
|
||||
} else {
|
||||
$d['phone_e164'] = $e164;
|
||||
}
|
||||
}
|
||||
if ($d['sms_consent'] && $d['phone_e164'] === null && !isset($errors['phone'])) {
|
||||
$errors['phone'] = 'Add a mobile number so we can text you, or uncheck texting.';
|
||||
}
|
||||
|
||||
$d['campus_id'] = self::str($in, 'campusId', 40);
|
||||
if (!in_array($d['campus_id'], Campuses::openIds(), true)) {
|
||||
$errors['campusId'] = 'Please choose a campus.';
|
||||
}
|
||||
|
||||
$visitDate = self::str($in, 'visitDate', 10);
|
||||
$d['visit_date'] = null;
|
||||
if ($visitDate !== '') {
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $visitDate) || !strtotime($visitDate)) {
|
||||
$errors['visitDate'] = 'Use the date picker.';
|
||||
} elseif ($visitDate < gmdate('Y-m-d', time() - 86400)) { // allow one day of timezone slack
|
||||
$errors['visitDate'] = 'Pick today or a future date.';
|
||||
} else {
|
||||
$d['visit_date'] = $visitDate;
|
||||
}
|
||||
}
|
||||
|
||||
$d['service_time'] = self::str($in, 'serviceTime', 80) ?: null;
|
||||
|
||||
$d['adults'] = self::intOrNull($in, 'adults', 1, 20);
|
||||
$d['children'] = self::intOrNull($in, 'children', 0, 10);
|
||||
|
||||
$ranges = $in['childAgeRanges'] ?? [];
|
||||
$clean = [];
|
||||
if (is_array($ranges)) {
|
||||
foreach ($ranges as $r) {
|
||||
if (is_string($r) && in_array($r, self::CHILD_AGE_RANGES, true) && !in_array($r, $clean, true)) {
|
||||
$clean[] = $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
$d['child_age_ranges'] = $clean ? implode(',', $clean) : null;
|
||||
|
||||
$d['questions'] = self::str($in, 'questions', 1000) ?: null;
|
||||
|
||||
$d['consent_version'] = self::str($in, 'consentVersion', 40);
|
||||
if ($d['consent_version'] === '') {
|
||||
$errors['consentVersion'] = 'Missing consent version.';
|
||||
}
|
||||
|
||||
// Anti-spam: honeypot + minimum time on form
|
||||
$d['honeypot'] = self::str($in, 'website', 200);
|
||||
$startedAt = (int)($in['startedAt'] ?? 0);
|
||||
$d['elapsed_ms'] = $startedAt > 0 ? max(0, (int)(microtime(true) * 1000) - $startedAt) : 0;
|
||||
|
||||
// Attribution: whitelist keys, cap lengths, strip control chars
|
||||
$attr = [];
|
||||
if (isset($in['attribution']) && is_array($in['attribution'])) {
|
||||
foreach ($in['attribution'] as $k => $v) {
|
||||
if (is_string($k) && is_string($v) && in_array($k, self::ATTR_KEYS, true)) {
|
||||
$max = in_array($k, ['landing_url', 'referrer'], true) ? 2000 : 200;
|
||||
$attr[$k] = self::clean($v, $max);
|
||||
}
|
||||
}
|
||||
}
|
||||
$d['attribution'] = $attr;
|
||||
$d['turnstile_token'] = is_string($in['turnstileToken'] ?? null) ? substr($in['turnstileToken'], 0, 4000) : null;
|
||||
|
||||
return ['data' => $d, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $in */
|
||||
private static function str(array $in, string $key, int $max): string
|
||||
{
|
||||
$v = $in[$key] ?? '';
|
||||
if (!is_string($v)) {
|
||||
return '';
|
||||
}
|
||||
return self::clean($v, $max);
|
||||
}
|
||||
|
||||
public static function clean(string $v, int $max): string
|
||||
{
|
||||
$v = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $v) ?? '';
|
||||
$v = trim($v);
|
||||
if (!mb_check_encoding($v, 'UTF-8')) {
|
||||
$v = mb_convert_encoding($v, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
return mb_substr($v, 0, $max);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $in */
|
||||
private static function intOrNull(array $in, string $key, int $min, int $max): ?int
|
||||
{
|
||||
if (!isset($in[$key]) || $in[$key] === '' || $in[$key] === null) {
|
||||
return null;
|
||||
}
|
||||
if (!is_numeric($in[$key])) {
|
||||
return null;
|
||||
}
|
||||
$n = (int)$in[$key];
|
||||
return ($n < $min || $n > $max) ? null : $n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared bootstrap for every API endpoint.
|
||||
*
|
||||
* - Loads configuration from a private .env OUTSIDE the web root (see Config.php)
|
||||
* - Hardens error output (never leak stack traces or secrets to the browser)
|
||||
* - Provides JSON response helpers and same-origin protection
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_VERSION_ID < 80100) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['ok' => false, 'error' => 'Server misconfiguration (PHP 8.1+ required).']);
|
||||
exit;
|
||||
}
|
||||
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
date_default_timezone_set('UTC');
|
||||
|
||||
require_once __DIR__ . '/Config.php';
|
||||
require_once __DIR__ . '/Log.php';
|
||||
require_once __DIR__ . '/Db.php';
|
||||
require_once __DIR__ . '/Http.php';
|
||||
require_once __DIR__ . '/Campuses.php';
|
||||
require_once __DIR__ . '/Phone.php';
|
||||
|
||||
Config::load();
|
||||
Log::init(Config::privateDir() . '/logs/app.log', Config::get('LOG_LEVEL', 'info'));
|
||||
|
||||
set_exception_handler(static function (Throwable $e): void {
|
||||
Log::error('Unhandled exception', ['type' => get_class($e), 'message' => $e->getMessage(), 'file' => basename($e->getFile()), 'line' => $e->getLine()]);
|
||||
Api::json(['ok' => false, 'error' => 'Something went wrong on our end. Please try again in a moment.'], 500);
|
||||
});
|
||||
|
||||
set_error_handler(static function (int $no, string $str, string $file, int $line): bool {
|
||||
Log::warn('PHP notice', ['no' => $no, 'message' => $str, 'file' => basename($file), 'line' => $line]);
|
||||
return true;
|
||||
});
|
||||
|
||||
final class Api
|
||||
{
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function json(array $payload, int $status = 200, array $headers = []): never
|
||||
{
|
||||
if (!headers_sent()) {
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Referrer-Policy: same-origin');
|
||||
foreach ($headers as $k => $v) {
|
||||
header("$k: $v");
|
||||
}
|
||||
}
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function requireMethod(string ...$methods): void
|
||||
{
|
||||
$m = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
if ($m === 'OPTIONS') {
|
||||
self::corsHeaders();
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
if (!in_array($m, $methods, true)) {
|
||||
self::json(['ok' => false, 'error' => 'Method not allowed'], 405, ['Allow' => implode(', ', $methods)]);
|
||||
}
|
||||
self::corsHeaders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-origin protection for a static site: the browser always sends Origin
|
||||
* (or at least Referer) on cross-site fetches. Allowed origins are the
|
||||
* production URL plus anything in ALLOWED_ORIGINS (comma separated, for
|
||||
* local development).
|
||||
*/
|
||||
public static function requireSameOrigin(): void
|
||||
{
|
||||
$allowed = self::allowedOrigins();
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? null;
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? null;
|
||||
$candidate = $origin ?: ($referer ? self::originOf($referer) : null);
|
||||
$custom = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
|
||||
|
||||
if ($candidate === null) {
|
||||
// No Origin/Referer at all: only accept if our custom header is present (fetch sets it; simple forms cannot).
|
||||
if ($custom !== 'visit-page') {
|
||||
self::json(['ok' => false, 'error' => 'Request blocked.'], 403);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!in_array(rtrim($candidate, '/'), $allowed, true)) {
|
||||
Log::warn('Cross-origin request rejected', ['origin' => $candidate]);
|
||||
self::json(['ok' => false, 'error' => 'Request blocked.'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public static function allowedOrigins(): array
|
||||
{
|
||||
$list = [rtrim(Config::get('PUBLIC_SITE_URL', 'https://visit.waterschurch.org'), '/')];
|
||||
foreach (explode(',', Config::get('ALLOWED_ORIGINS', '')) as $o) {
|
||||
$o = rtrim(trim($o), '/');
|
||||
if ($o !== '') {
|
||||
$list[] = $o;
|
||||
}
|
||||
}
|
||||
// Same host as the request (covers preview subdomains on the same server).
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if ($host !== '') {
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$list[] = "$scheme://$host";
|
||||
}
|
||||
return array_values(array_unique($list));
|
||||
}
|
||||
|
||||
private static function corsHeaders(): void
|
||||
{
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if ($origin !== '' && in_array(rtrim($origin, '/'), self::allowedOrigins(), true)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Vary: Origin');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, X-Requested-With');
|
||||
header('Access-Control-Max-Age: 600');
|
||||
}
|
||||
}
|
||||
|
||||
private static function originOf(string $url): ?string
|
||||
{
|
||||
$p = parse_url($url);
|
||||
if (!$p || empty($p['host']) || empty($p['scheme'])) {
|
||||
return null;
|
||||
}
|
||||
$port = isset($p['port']) ? ':' . $p['port'] : '';
|
||||
return strtolower($p['scheme'] . '://' . $p['host'] . $port);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function readJsonBody(int $maxBytes = 32768): array
|
||||
{
|
||||
$len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||
if ($len > $maxBytes) {
|
||||
self::json(['ok' => false, 'error' => 'Request too large.'], 413);
|
||||
}
|
||||
$raw = file_get_contents('php://input', false, null, 0, $maxBytes + 1) ?: '';
|
||||
if (strlen($raw) > $maxBytes) {
|
||||
self::json(['ok' => false, 'error' => 'Request too large.'], 413);
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
self::json(['ok' => false, 'error' => 'Invalid request body.'], 400);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** Best-effort client IP (respects cPanel/Cloudflare proxies only when configured). */
|
||||
public static function clientIp(): string
|
||||
{
|
||||
$trust = Config::bool('TRUST_PROXY_HEADERS', false);
|
||||
if ($trust) {
|
||||
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR'] as $h) {
|
||||
if (!empty($_SERVER[$h])) {
|
||||
$ip = trim(explode(',', (string)$_SERVER[$h])[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /api/locate.php — approximate, non-invasive location for the campus recommendation.
|
||||
* Returns city-level coordinates only. Nothing is stored beyond a short hashed cache.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/lib/bootstrap.php';
|
||||
require_once __DIR__ . '/lib/Validator.php';
|
||||
require_once __DIR__ . '/lib/RateLimit.php';
|
||||
require_once __DIR__ . '/lib/GeoIp.php';
|
||||
|
||||
Api::requireMethod('GET');
|
||||
|
||||
$ip = Api::clientIp();
|
||||
$rl = RateLimit::hit(RateLimit::ipBucket('locate', $ip), 30, 600);
|
||||
if (!$rl['allowed']) {
|
||||
Api::json(['ok' => false, 'reason' => 'rate_limited'], 429, ['Retry-After' => (string)$rl['retryAfter']]);
|
||||
}
|
||||
|
||||
// Local development helper: ?debug_ip=8.8.8.8 (only when DEBUG_GEO=true)
|
||||
if (Config::bool('DEBUG_GEO', false) && !empty($_GET['debug_ip']) && filter_var($_GET['debug_ip'], FILTER_VALIDATE_IP)) {
|
||||
$ip = (string)$_GET['debug_ip'];
|
||||
}
|
||||
if (Config::bool('DEBUG_GEO', false) && isset($_GET['debug_lat'], $_GET['debug_lng'])) {
|
||||
Api::json(['ok' => true, 'approximate' => ['lat' => (float)$_GET['debug_lat'], 'lng' => (float)$_GET['debug_lng'], 'city' => 'Debug', 'confidence' => 'low']]);
|
||||
}
|
||||
|
||||
$loc = GeoIp::locate($ip);
|
||||
if (!$loc) {
|
||||
Api::json(['ok' => false, 'reason' => 'unavailable'], 200, ['Cache-Control' => 'private, max-age=300']);
|
||||
}
|
||||
Api::json([
|
||||
'ok' => true,
|
||||
'approximate' => ['lat' => $loc['lat'], 'lng' => $loc['lng'], 'city' => $loc['city'], 'region' => $loc['region'], 'confidence' => 'low'],
|
||||
], 200, ['Cache-Control' => 'private, max-age=3600']);
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/plan-visit.php
|
||||
*
|
||||
* 1. Same-origin + method checks
|
||||
* 2. Rate limit (per IP) and bot checks (honeypot, minimum time, optional Turnstile)
|
||||
* 3. Validate + normalize
|
||||
* 4. Persist the lead (idempotent on submissionId)
|
||||
* 5. Run Mailchimp / Sinch, recording each outcome for retry
|
||||
* 6. Return a friendly summary — never provider internals
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/lib/bootstrap.php';
|
||||
require_once __DIR__ . '/lib/Validator.php';
|
||||
require_once __DIR__ . '/lib/RateLimit.php';
|
||||
require_once __DIR__ . '/lib/Turnstile.php';
|
||||
require_once __DIR__ . '/lib/LeadProcessor.php';
|
||||
|
||||
Api::requireMethod('POST');
|
||||
Api::requireSameOrigin();
|
||||
|
||||
$ip = Api::clientIp();
|
||||
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||
|
||||
// --- Rate limiting: 5 submissions / 10 minutes per IP, 60 / day per IP
|
||||
$rl = RateLimit::hit(RateLimit::ipBucket('submit:10m', $ip), Config::int('RATE_LIMIT_SUBMIT_PER_10MIN', 5), 600);
|
||||
if (!$rl['allowed']) {
|
||||
Api::json(['ok' => false, 'error' => 'Too many attempts. Please wait a moment and try again.', 'retryAfter' => $rl['retryAfter']], 429, ['Retry-After' => (string)$rl['retryAfter']]);
|
||||
}
|
||||
$rlDay = RateLimit::hit(RateLimit::ipBucket('submit:1d', $ip), Config::int('RATE_LIMIT_SUBMIT_PER_DAY', 60), 86400);
|
||||
if (!$rlDay['allowed']) {
|
||||
Api::json(['ok' => false, 'error' => 'Too many attempts today. Text us and we will help directly.', 'retryAfter' => $rlDay['retryAfter']], 429, ['Retry-After' => (string)$rlDay['retryAfter']]);
|
||||
}
|
||||
|
||||
$input = Api::readJsonBody();
|
||||
['data' => $d, 'errors' => $errors] = Validator::planVisit($input);
|
||||
|
||||
// --- Bot checks. Respond with a fake success so bots learn nothing.
|
||||
if ($d['honeypot'] !== '' || ($d['elapsed_ms'] > 0 && $d['elapsed_ms'] < Config::int('MIN_FORM_SECONDS', 3) * 1000)) {
|
||||
Log::warn('Bot signal on submit', ['honeypot' => $d['honeypot'] !== '', 'elapsed_ms' => $d['elapsed_ms']]);
|
||||
Api::json(['ok' => true, 'submissionId' => $d['submission_id'], 'email' => ['status' => 'skipped'], 'sms' => ['status' => 'skipped']]);
|
||||
}
|
||||
if (!Turnstile::verify($d['turnstile_token'], $ip)) {
|
||||
Api::json(['ok' => false, 'error' => 'Security check failed. Please try again.'], 400);
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
Api::json(['ok' => false, 'error' => 'Please check the highlighted fields.', 'fieldErrors' => $errors], 422);
|
||||
}
|
||||
|
||||
// --- Persist first
|
||||
['lead' => $lead, 'duplicate' => $duplicate] = LeadProcessor::persist($d, $ip, $ua);
|
||||
Log::info($duplicate ? 'Duplicate submission (idempotent replay)' : 'Lead stored', ['lead' => $lead['id'], 'campus' => $lead['campus_id'], 'email_consent' => (bool)$lead['email_consent'], 'sms_consent' => (bool)$lead['sms_consent']]);
|
||||
|
||||
// --- Integrations (each guarded; failures are recorded for cron retry)
|
||||
$statuses = LeadProcessor::runIntegrations($lead);
|
||||
|
||||
$public = static function (string $s): string {
|
||||
return match ($s) {
|
||||
'sent' => 'sent',
|
||||
'mock' => 'mock',
|
||||
'skipped' => 'skipped',
|
||||
'failed' => 'failed',
|
||||
default => 'queued', // pending / retry / sending
|
||||
};
|
||||
};
|
||||
|
||||
Api::json([
|
||||
'ok' => true,
|
||||
'submissionId' => $lead['submission_id'],
|
||||
'duplicate' => $duplicate,
|
||||
'email' => ['status' => $public($statuses['email'])],
|
||||
'sms' => ['status' => $public($statuses['sms'])],
|
||||
]);
|
||||
Reference in New Issue
Block a user