- F-15: uninstall now removes ALL espinstanda_* options and transients (incl. the encrypted password and its self-provisioned key). - F-16: register the "Gravity Forms required" admin notice unconditionally so it is reachable when GF is inactive (gform_loaded never fires then). - F-22: self-heal the daily retention cron on admin_init (matches the schema self-heal). - F-19: re-validate the stored payload via Field_Mapper::validate_payload() before a resubmit (DB-tamper trust boundary). - F-20: increment the retry count on resubmit and dispatch the previously-dead espinstanda_resubmit_success notification event. - F-23: emit the theme's Form-5 date-range JS only on pages that embed a Gravity Form and disconnect the MutationObserver (was observing the DOM subtree forever). - F-24: bump plugin to 1.1.0; add readme.txt changelog documenting the audit fixes and the required ops actions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
200 lines
8.3 KiB
PHP
200 lines
8.3 KiB
PHP
<?php
|
|
/**
|
|
* Field_Mapper - pure transform from the Gravity Forms "Start a Quote" values to
|
|
* the INSTANDA StartQuote wire body, plus server-side re-validation.
|
|
*
|
|
* Pure by design: a flat array in, an array out. No WordPress, Gravity Forms,
|
|
* database, or HTTP access - the caller extracts GF field values (via GF field-value
|
|
* APIs, never raw $_POST) and hands them here. This keeps every transform rule
|
|
* (D1-D11 in the INSTANDA workbook) unit-testable with no bootstrap.
|
|
*
|
|
* @package ESP\Instanda
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace ESP\Instanda;
|
|
|
|
final class Field_Mapper {
|
|
|
|
/** Role catalogue values (workbook "Role" sheet) - byte-exact, drive every rule. */
|
|
public const ROLE_EVENT_HOST = 'Event Host or Organizer';
|
|
public const ROLE_VENDOR = 'Vendor, Exhibitor or Subcontractor';
|
|
public const ROLE_PERFORMER = 'Performer, Entertainer or DJ';
|
|
|
|
/**
|
|
* Build the INSTANDA StartQuote body. Assumes the input has already passed
|
|
* validate(); third-party choice values are transmitted verbatim (no normalization).
|
|
*
|
|
* @param array<string,mixed> $values Keyed: role, vendor_type, performer_type,
|
|
* activity_type, location_state, max_attendance, event_start, event_end, customer_email.
|
|
* @return array<string,mixed> INSTANDA wire variables (VendorType omitted per D1).
|
|
*/
|
|
public function to_payload(array $values, string $timezone): array {
|
|
$tz = new \DateTimeZone($timezone);
|
|
$start = $this->parse_date((string) ($values['event_start'] ?? ''), $tz);
|
|
$end = $this->parse_date((string) ($values['event_end'] ?? ''), $tz);
|
|
|
|
$payload = [
|
|
'Role' => (string) ($values['role'] ?? ''),
|
|
'ActivityType' => (string) ($values['activity_type'] ?? ''),
|
|
'PQ_LocationState_CHOICE' => (string) ($values['location_state'] ?? ''),
|
|
'PQ_MaximumDailyAttendance_NUM' => (int) ($values['max_attendance'] ?? 0),
|
|
'PQ_EventStart_DATE' => $start ? $start->format('Y-m-d') : '',
|
|
'PQ_EventEnd_DATE' => $end ? $end->format('Y-m-d') : '',
|
|
'PQ_EventConsecutiveDays_NUM' => ($start && $end) ? $this->consecutive_days($start, $end) : 0,
|
|
'PQ_CustomerEmailAddress_TXT' => trim((string) ($values['customer_email'] ?? '')),
|
|
];
|
|
|
|
// D1/D4 - VendorType is conditional and never sent empty.
|
|
$vendor_type = $this->resolve_vendor_type($values);
|
|
if ($vendor_type !== null) {
|
|
$payload['VendorType'] = $vendor_type;
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
/**
|
|
* Server-side re-validation. Returns a list of stable error codes (empty = valid)
|
|
* that the submission layer maps to friendly inline messages.
|
|
*
|
|
* @param array<string,mixed> $values See to_payload().
|
|
* @param \DateTimeImmutable|null $now Injectable clock for the "start in the past"
|
|
* check; defaults to now in $timezone.
|
|
* @return list<string>
|
|
*/
|
|
public function validate(array $values, string $timezone, ?\DateTimeImmutable $now = null): array {
|
|
$errors = [];
|
|
$tz = new \DateTimeZone($timezone);
|
|
$role = (string) ($values['role'] ?? '');
|
|
|
|
if ($role === '') {
|
|
$errors[] = 'role_required';
|
|
}
|
|
// D2/D3 - the role's sub-type dropdown is required when that role is selected.
|
|
if ($role === self::ROLE_VENDOR && (string) ($values['vendor_type'] ?? '') === '') {
|
|
$errors[] = 'vendor_type_required';
|
|
}
|
|
if ($role === self::ROLE_PERFORMER && (string) ($values['performer_type'] ?? '') === '') {
|
|
$errors[] = 'performer_type_required';
|
|
}
|
|
if ((string) ($values['activity_type'] ?? '') === '') {
|
|
$errors[] = 'activity_type_required';
|
|
}
|
|
if ((string) ($values['location_state'] ?? '') === '') {
|
|
$errors[] = 'location_required';
|
|
}
|
|
// D10 - attendance is a positive integer.
|
|
if (!$this->is_positive_integer($values['max_attendance'] ?? null)) {
|
|
$errors[] = 'attendance_not_positive_integer';
|
|
}
|
|
// D9 - a well-formed email.
|
|
$email = trim((string) ($values['customer_email'] ?? ''));
|
|
if ($email === '') {
|
|
$errors[] = 'email_required';
|
|
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
$errors[] = 'email_invalid';
|
|
}
|
|
|
|
// D6/D7 - both dates required, YYYY-MM-DD, end >= start, start today-or-later (site tz).
|
|
$start = $this->parse_date((string) ($values['event_start'] ?? ''), $tz);
|
|
$end = $this->parse_date((string) ($values['event_end'] ?? ''), $tz);
|
|
if ($start === null) {
|
|
$errors[] = 'start_required';
|
|
}
|
|
if ($end === null) {
|
|
$errors[] = 'end_required';
|
|
}
|
|
if ($start !== null && $end !== null) {
|
|
if ($end < $start) {
|
|
$errors[] = 'end_before_start';
|
|
}
|
|
$today_midnight = ($now ?? new \DateTimeImmutable('now', $tz))
|
|
->setTimezone($tz)
|
|
->setTime(0, 0, 0);
|
|
if ($start < $today_midnight) {
|
|
$errors[] = 'start_in_past';
|
|
}
|
|
}
|
|
|
|
return $errors;
|
|
}
|
|
|
|
/**
|
|
* Sanity-check a stored StartQuote wire body before re-sending it (resubmit trust
|
|
* boundary): the DB is treated as untrusted input. Returns error codes (empty = ok).
|
|
* Pure - uses a fixed UTC zone since only Y-m-d well-formedness is being checked.
|
|
*
|
|
* @param array<string,mixed> $body A body previously built by to_payload().
|
|
* @return list<string>
|
|
*/
|
|
public function validate_payload(array $body): array {
|
|
$errors = [];
|
|
foreach (['Role', 'ActivityType', 'PQ_LocationState_CHOICE'] as $key) {
|
|
if (trim((string) ($body[$key] ?? '')) === '') {
|
|
$errors[] = $key . '_missing';
|
|
}
|
|
}
|
|
if (!$this->is_positive_integer($body['PQ_MaximumDailyAttendance_NUM'] ?? null)) {
|
|
$errors[] = 'attendance_invalid';
|
|
}
|
|
$email = trim((string) ($body['PQ_CustomerEmailAddress_TXT'] ?? ''));
|
|
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
$errors[] = 'email_invalid';
|
|
}
|
|
$tz = new \DateTimeZone('UTC');
|
|
foreach (['PQ_EventStart_DATE', 'PQ_EventEnd_DATE'] as $key) {
|
|
if ($this->parse_date((string) ($body[$key] ?? ''), $tz) === null) {
|
|
$errors[] = $key . '_invalid';
|
|
}
|
|
}
|
|
return $errors;
|
|
}
|
|
|
|
/** D8 - inclusive consecutive-day count: (end - start) + 1; a single-day event is 1. */
|
|
private function consecutive_days(\DateTimeImmutable $start, \DateTimeImmutable $end): int {
|
|
return (int) $start->diff($end)->days + 1;
|
|
}
|
|
|
|
/** D1/D4 - resolve the one VendorType value, or null to omit the key entirely. */
|
|
private function resolve_vendor_type(array $values): ?string {
|
|
$role = (string) ($values['role'] ?? '');
|
|
if ($role === self::ROLE_EVENT_HOST) {
|
|
return null; // D1 - omit, never empty string.
|
|
}
|
|
// D4 - trust the role, then read whichever sub-type field that role populates.
|
|
$value = $role === self::ROLE_PERFORMER
|
|
? (string) ($values['performer_type'] ?? '')
|
|
: (string) ($values['vendor_type'] ?? '');
|
|
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
|
|
/** Parse a strict YYYY-MM-DD date at midnight in $tz; null if missing or malformed. */
|
|
private function parse_date(string $value, \DateTimeZone $tz): ?\DateTimeImmutable {
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
return null;
|
|
}
|
|
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value, $tz);
|
|
// createFromFormat is lenient on overflow (e.g. 2026-13-40); reject anything
|
|
// that does not round-trip to the exact input.
|
|
if ($date === false || $date->format('Y-m-d') !== $value) {
|
|
return null;
|
|
}
|
|
return $date;
|
|
}
|
|
|
|
private function is_positive_integer(mixed $value): bool {
|
|
if (is_int($value)) {
|
|
return $value > 0;
|
|
}
|
|
if (is_string($value)) {
|
|
$value = trim($value);
|
|
return $value !== '' && ctype_digit($value) && (int) $value > 0;
|
|
}
|
|
return false;
|
|
}
|
|
}
|