initial
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user