$in * @return array{data:array, errors:array} */ 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 $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 $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; } }