This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,168 @@
<?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;
}
/** 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;
}
}