Files
visit.waterschurch.org/public/api/plan-visit.php
T
Waters ChurchandClaude Fable 5.1 78cd0cc550 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>
2026-09-22 13:22:26 -04:00

76 lines
3.2 KiB
PHP

<?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'])],
]);