Files
zbatteandClaude Fable 5 5d2e9cb7e5 fix(instanda): hygiene pass + v1.1.0 (uninstall, notices, cron, resubmit, theme)
- 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>
2026-07-02 16:20:10 -06:00

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;
}
}