initial
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
class RsvEmailSender {
|
||||
|
||||
private function headers(): array {
|
||||
return [
|
||||
'From: ' . get_bloginfo('name') . ' <' . get_option('admin_email') . '>',
|
||||
'Content-Type: text/html; charset=UTF-8',
|
||||
];
|
||||
}
|
||||
|
||||
private function do_send(string $to, string $subject, string $body): void {
|
||||
$success = wp_mail($to, $subject, $body, $this->headers());
|
||||
if (!$success) {
|
||||
throw new \RuntimeException('wp_mail failed for ' . $to);
|
||||
}
|
||||
}
|
||||
|
||||
public function send(string $to, string $subject, string $body): void {
|
||||
$this->do_send($to, $subject, $body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
class RsvEmailTemplater {
|
||||
public function render(string $template, array $data) : string {
|
||||
return preg_replace_callback('/{{\s*(\w+)\s*}}/', function($matches) use ($data) {
|
||||
return $data[$matches[1]] ?? '';
|
||||
}, $template);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
|
||||
class RsvButtonElementHandler implements RsvFormElementHandler {
|
||||
public function draw(RsvFormElementDefinition $element): void {
|
||||
?>
|
||||
<div class="rsv-form-input-group rsv-form-input-short">
|
||||
<button class="rsv-form-btn-primary"><?= $element->getLabel() ?></button>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
|
||||
// No side effects to undo.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
|
||||
interface RsvFormElementHandler {
|
||||
function draw(RsvFormElementDefinition $def) : void;
|
||||
|
||||
/**
|
||||
* Validate and execute the element. Records errors on $result and returns
|
||||
* false if it cannot complete.
|
||||
*
|
||||
* @param array<int,mixed> $data
|
||||
*/
|
||||
function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result) : bool;
|
||||
|
||||
/**
|
||||
* Undo a successful submit(); a no-op for elements without side effects.
|
||||
*
|
||||
* @param array<int,mixed> $data
|
||||
*/
|
||||
function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result) : void;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
class RsvFormReservationElementHandler implements RsvFormElementHandler {
|
||||
|
||||
private function end_from_start(DateTime $start, int $block_size_minutes): DateTime {
|
||||
return ($start)->modify("+{$block_size_minutes} minutes");
|
||||
}
|
||||
|
||||
public function draw(RsvFormElementDefinition $element): void {
|
||||
$timetable_id = (int) $element->getAttr('timetable_id');
|
||||
$price_per_block = (float) $element->getAttr('price_per_block', 0);
|
||||
$name = $element->getName();
|
||||
?>
|
||||
<div class="rsv-form-input-group">
|
||||
<label><?= esc_html($element->getLabel()) ?></label>
|
||||
<rsv-reservation-selector
|
||||
timetable-id="<?= $timetable_id ?>"
|
||||
name="<?= esc_attr($name) ?>"
|
||||
price-per-block="<?= $price_per_block ?>"
|
||||
class="rsv-form-field"
|
||||
></rsv-reservation-selector>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/** Validate the payload and create the reservation. */
|
||||
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
|
||||
$name = $def->getName();
|
||||
$payload = $data[$name] ?? null;
|
||||
|
||||
if ($def->isRequired() && is_null($payload)) {
|
||||
$result->addError($name, 'required', 'Reservation payload is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is_array($payload) || !array_key_exists('timetable_reservations', $payload)) {
|
||||
$result->addError($name, 'invalid', 'Reservation payload must be an object with timetable_reservations');
|
||||
return false;
|
||||
}
|
||||
|
||||
$timetable_id = intval($payload['timetable_id'] ?? null);
|
||||
$timetable = (new RsvTimetableService())->get($timetable_id);
|
||||
|
||||
if (!$timetable) {
|
||||
$result->addError($name, 'invalid', 'Invalid timetable ID');
|
||||
return false;
|
||||
}
|
||||
|
||||
$reservation = new RsvReservation(
|
||||
0,
|
||||
$submit_id,
|
||||
false,
|
||||
array_map(fn($t) => new RsvTimetableReservation(
|
||||
0,
|
||||
$timetable_id,
|
||||
new DateTime($t),
|
||||
$this->end_from_start(new DateTime($t), $timetable->block_size)
|
||||
), $payload['timetable_reservations'])
|
||||
);
|
||||
|
||||
try {
|
||||
$reservation_id = (new RsvReservationService())->create($reservation);
|
||||
} catch (\Throwable $e) {
|
||||
// The slot may have been taken since the user selected it.
|
||||
Logger::error($e);
|
||||
$result->addError($name, 'creation_failed', 'Could not create the reservation. The slot may no longer be available.');
|
||||
return false;
|
||||
}
|
||||
|
||||
$result->setValue($name . '_reservation_id', $reservation_id);
|
||||
|
||||
$price_per_block = (float) $def->getAttr('price_per_block', 0);
|
||||
$result->setValue($name . '_price', $price_per_block * count($payload['timetable_reservations']));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Delete the reservation created by submit(). */
|
||||
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
|
||||
$reservation_id = $result->getValue($def->getName() . '_reservation_id');
|
||||
if ($reservation_id) {
|
||||
(new RsvReservationService())->delete((int) $reservation_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
class RsvReservationSummaryElementHandler implements RsvFormElementHandler {
|
||||
|
||||
public function draw(RsvFormElementDefinition $def): void {
|
||||
?>
|
||||
<rsv-reservation-summary></rsv-reservation-summary>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
|
||||
// No side effects to undo.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
|
||||
class RsvTextFieldElementHandler implements RsvFormElementHandler {
|
||||
|
||||
/** @return array<string, array{0: callable, 1: string}> */
|
||||
private function rules(): array {
|
||||
return [
|
||||
'email' => ['is_email', 'Please enter a valid email address.'],
|
||||
'phone' => [$this->regexPredicate('\+?[0-9 ()\-]{6,20}'), 'Please enter a valid phone number.'],
|
||||
'digits' => [$this->regexPredicate('[0-9]+'), 'Please enter digits only.'],
|
||||
];
|
||||
}
|
||||
|
||||
private function regexPredicate(string $pattern): callable {
|
||||
return fn($value): bool => (bool) @preg_match('~^(?:' . $pattern . ')$~u', (string) $value);
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:callable,2:string}|null */
|
||||
private function resolveRule(RsvFormElementDefinition $def): ?array {
|
||||
$name = $def->getAttr('validation', '');
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
if ($name === 'pattern') {
|
||||
$pattern = $def->getAttr('pattern', '');
|
||||
if ($pattern === '') {
|
||||
return null;
|
||||
}
|
||||
return ['pattern', $this->regexPredicate($pattern), $def->getAttr('pattern_message', '') ?: 'Invalid format.'];
|
||||
}
|
||||
$rules = $this->rules();
|
||||
if (isset($rules[$name])) {
|
||||
return [$name, $rules[$name][0], $rules[$name][1]];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function draw(RsvFormElementDefinition $def): void {
|
||||
$validation = $def->getAttr('validation', '');
|
||||
$type = match ($validation) {
|
||||
'email' => 'email',
|
||||
'phone' => 'tel',
|
||||
'digits' => 'number',
|
||||
default => 'text',
|
||||
};
|
||||
$pattern = $validation === 'pattern' ? $def->getAttr('pattern', '') : '';
|
||||
?>
|
||||
<div class="rsv-form-input-group rsv-form-input-short">
|
||||
<label class="rsv-form-label"><?= $def->getLabel() ?>:</label>
|
||||
<input class="rsv-form-input rsv-form-field" type="<?= $type ?>" name="<?= $def->getName() ?>"<?= $pattern !== '' ? ' pattern="' . esc_attr($pattern) . '"' : '' ?> <?= $def->isRequired() ? "required" : "" ?>/>
|
||||
<small class="rsv-form-small"><?= $def->getDesc() ?></small>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
|
||||
$name = $def->getName();
|
||||
$value = $data[$name] ?? null;
|
||||
|
||||
if ($def->isRequired() && (is_null($value) || $value === "")) {
|
||||
$result->addError($name, 'required', 'Field is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
$rule = $this->resolveRule($def);
|
||||
if ($rule !== null && !is_null($value) && $value !== "") {
|
||||
[$code, $predicate, $message] = $rule;
|
||||
if (!$predicate($value)) {
|
||||
$result->addError($name, $code, $message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$result->setValue($name, sanitize_text_field($value));
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
|
||||
// No side effects to undo.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
class RsvFormData {
|
||||
private array $elements = [];
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $data
|
||||
*/
|
||||
public function __construct(array $data) {
|
||||
// Expecting raw post values as associative array
|
||||
$this->elements = $data['values'] ?? $data;
|
||||
}
|
||||
|
||||
public function getElements(): array {
|
||||
return $this->elements;
|
||||
}
|
||||
|
||||
public function getValue(string $name, $default = null) {
|
||||
return $this->elements[$name] ?? $default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
class RsvFormDefinition {
|
||||
public $_elements = [];
|
||||
|
||||
public string $_id = "";
|
||||
|
||||
public string $email_key = "";
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $definition Full definition array including 'elements' and 'email_key'.
|
||||
*/
|
||||
public function __construct(string $id, array $definition) {
|
||||
$this->_elements = [];
|
||||
|
||||
if (array_key_exists('elements', $definition)) {
|
||||
foreach ($definition['elements'] as $element) {
|
||||
array_push($this->_elements, RsvFormElementDefinition::fromArray($element));
|
||||
}
|
||||
}
|
||||
|
||||
$this->_id = $id;
|
||||
$this->email_key = $definition['email_key'] ?? '';
|
||||
}
|
||||
|
||||
public function getId(): string {
|
||||
return $this->_id;
|
||||
}
|
||||
|
||||
public function getEmailKey(): string {
|
||||
return $this->email_key;
|
||||
}
|
||||
|
||||
|
||||
public function hasElements() : bool {
|
||||
return count($this->_elements) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, RsvFormElementDefinition>
|
||||
*/
|
||||
public function getElements() : array {
|
||||
return $this->_elements;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Dynamic definition of the element to be rendered or handled.
|
||||
*/
|
||||
class RsvFormElementDefinition {
|
||||
public string $type;
|
||||
public string $name;
|
||||
public string $label;
|
||||
public string $desc;
|
||||
public bool $required;
|
||||
public array $attrs = [];
|
||||
|
||||
public function getType(): string {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getLabel(): string {
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function getDesc(): string {
|
||||
return $this->desc;
|
||||
}
|
||||
|
||||
public function isRequired(): bool {
|
||||
return $this->required;
|
||||
}
|
||||
|
||||
public function getAttr(string $key, $default = null) {
|
||||
return $this->attrs[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $array Array for initializing the form element definition
|
||||
* @return RsvFormElementDefinition Definition of the form element for rendering and handling.
|
||||
*/
|
||||
public static function fromArray(array $array): RsvFormElementDefinition {
|
||||
$known = ['type','name','label','desc','required'];
|
||||
$attrs = array_diff_key($array, array_flip($known));
|
||||
|
||||
$def = new self(
|
||||
$array['type'],
|
||||
$array['name'],
|
||||
$array['label'],
|
||||
$array['desc'] ?? "",
|
||||
$array['required'] ?? false
|
||||
);
|
||||
|
||||
$def->attrs = $attrs;
|
||||
return $def;
|
||||
}
|
||||
|
||||
public function __construct(string $type, string $name, string $label, string $desc = "", bool $required = false) {
|
||||
$this->type = $type;
|
||||
$this->name = $name;
|
||||
$this->label = $label;
|
||||
$this->desc = $desc;
|
||||
$this->required = $required;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
|
||||
class RsvFormElementRegistry {
|
||||
/** @var array<string, RsvFormElementHandler> */
|
||||
public array $handlers = [];
|
||||
|
||||
public function register(string $type, RsvFormElementHandler $handler): void {
|
||||
$this->handlers[$type] = $handler;
|
||||
}
|
||||
|
||||
public function get(string $type): ?RsvFormElementHandler {
|
||||
return $this->handlers[$type] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
|
||||
class RsvFormHtmlRenderer {
|
||||
public function draw(RsvFormDefinition $form): bool {
|
||||
if (!$form->hasElements()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$form_id = esc_attr($form->getId());
|
||||
?>
|
||||
<div>
|
||||
<form class="reservair-form confirmation"
|
||||
id="<?= $form_id ?>"
|
||||
onsubmit="RsvFormSender.send_form(event)"
|
||||
method="POST">
|
||||
|
||||
<?php foreach ($form->getElements() as $element): ?>
|
||||
<?php $this->draw_element($element); ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function draw_element(RsvFormElementDefinition $data): void {
|
||||
global $rsv_form_registry;
|
||||
|
||||
$handler = $rsv_form_registry->get($data->getType());
|
||||
if ($handler === null) {
|
||||
return;
|
||||
}
|
||||
$handler->draw($data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
class RsvFormProcessor {
|
||||
/** Submit every element. If one fails, undo the ones that already succeeded. */
|
||||
public function submit(RsvFormDefinition $definition, int $submit_id, RsvFormData $data): RsvFormSubmitResult {
|
||||
global $rsv_form_registry;
|
||||
|
||||
$result = new RsvFormSubmitResult();
|
||||
$committed = [];
|
||||
|
||||
foreach ($definition->getElements() as $element) {
|
||||
$handler = $rsv_form_registry->get($element->getType());
|
||||
if ($handler === null) {
|
||||
$result->addError($element->getName(), 'handler_missing', 'No handler registered for element type ' . $element->getType());
|
||||
$this->rollback_all($committed, $submit_id, $data, $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (!$handler->submit($element, $submit_id, $data->getElements(), $result)) {
|
||||
$this->rollback_all($committed, $submit_id, $data, $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
$committed[] = [$handler, $element];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{0:RsvFormElementHandler,1:RsvFormElementDefinition}> $committed
|
||||
*/
|
||||
private function rollback_all(array $committed, int $submit_id, RsvFormData $data, RsvFormSubmitResult $result): void {
|
||||
foreach (array_reverse($committed) as [$handler, $element]) {
|
||||
try {
|
||||
$handler->rollback($element, $submit_id, $data->getElements(), $result);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
/**
|
||||
* Handles submitting a form. If any element fails, nothing is persisted.
|
||||
*/
|
||||
class RsvFormSubmission {
|
||||
public function submit(string $formId, array $data) : array {
|
||||
$repo = new RsvFormDefinitionRepository();
|
||||
$row = $repo->get((int) $formId);
|
||||
|
||||
if ($row === null) {
|
||||
return ['success' => false, 'errors' => [['element' => '', 'code' => 'not_found', 'message' => 'Form not found']]];
|
||||
}
|
||||
|
||||
$definition = new RsvFormDefinition($formId, $row['definition']);
|
||||
$form_data = new RsvFormData($data);
|
||||
$processor = new RsvFormProcessor();
|
||||
|
||||
// Persist the submission first so element handlers can link to it.
|
||||
$submit_repo = new RsvFormSubmitRepository();
|
||||
$submit_id = $submit_repo->add((int) $definition->getId(), $data);
|
||||
|
||||
try {
|
||||
$result = $processor->submit($definition, $submit_id, $form_data);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error($e);
|
||||
$this->discard_submission($submit_repo, $submit_id);
|
||||
return ['success' => false, 'errors' => [['element' => '', 'code' => 'internal_error', 'message' => 'An unexpected error occurred.']]];
|
||||
}
|
||||
|
||||
if ($result->hasErrors()) {
|
||||
$this->discard_submission($submit_repo, $submit_id);
|
||||
return ['success' => false, 'errors' => $result->getErrors()];
|
||||
}
|
||||
|
||||
return ['success' => true, 'submit_id' => $submit_id, 'values' => $result->getValues()];
|
||||
}
|
||||
|
||||
/** Remove a submission whose run failed. */
|
||||
private function discard_submission(RsvFormSubmitRepository $repo, int $submit_id): void {
|
||||
try {
|
||||
$repo->delete($submit_id);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
class RsvFormSubmitResult {
|
||||
private array $errors = [];
|
||||
private array $values = [];
|
||||
|
||||
public function addError(string $elementName, string $code, string $message): void {
|
||||
$this->errors[] = [
|
||||
'element' => $elementName,
|
||||
'code' => $code,
|
||||
'message' => $message
|
||||
];
|
||||
}
|
||||
|
||||
public function hasErrors(): bool {
|
||||
return count($this->errors) > 0;
|
||||
}
|
||||
|
||||
public function getErrors(): array {
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
public function setValue(string $name, $value): void {
|
||||
$this->values[$name] = $value;
|
||||
}
|
||||
|
||||
public function getValue(string $name) {
|
||||
return $this->values[$name] ?? null;
|
||||
}
|
||||
|
||||
public function getValues(): array {
|
||||
return $this->values;
|
||||
}
|
||||
|
||||
public function toDto(): array {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
class RsvGoogleCalendarService {
|
||||
|
||||
public function is_google_connected(): bool {
|
||||
return (bool) get_option('rsv_google_access_token');
|
||||
}
|
||||
|
||||
private function get_client_id(): string {
|
||||
return (string) get_option('rsv_google_client_id', '');
|
||||
}
|
||||
|
||||
private function get_client_secret(): string {
|
||||
return (string) ($this->get_secret('rsv_google_client_secret') ?? '');
|
||||
}
|
||||
|
||||
public function get_calendar_id(): string {
|
||||
return (string) get_option('rsv_google_calendar_id', 'primary');
|
||||
}
|
||||
|
||||
public function is_webhook_registered(): bool {
|
||||
return (bool) get_option('rsv_google_webhook_channel_id');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Encrypted option storage for secrets (tokens, client secret)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Decrypt a secret stored in wp_options; null if unset or undecryptable. */
|
||||
private function get_secret(string $option): ?string {
|
||||
$stored = get_option($option);
|
||||
if (!is_string($stored) || $stored === '') {
|
||||
return null;
|
||||
}
|
||||
return RsvCrypto::decrypt($stored);
|
||||
}
|
||||
|
||||
/** Store a secret in wp_options, encrypted at rest. */
|
||||
private function set_secret(string $option, string $value): void {
|
||||
update_option($option, RsvCrypto::encrypt($value));
|
||||
}
|
||||
|
||||
/** Persist the OAuth client secret (encrypted). */
|
||||
public function set_client_secret(string $secret): void {
|
||||
$this->set_secret('rsv_google_client_secret', $secret);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// OAuth
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function get_oauth_url(): string {
|
||||
return 'https://accounts.google.com/o/oauth2/v2/auth?' . http_build_query([
|
||||
'client_id' => $this->get_client_id(),
|
||||
'redirect_uri' => site_url('/wp-json/reservations/v1/google-callback'),
|
||||
'response_type' => 'code',
|
||||
'scope' => 'https://www.googleapis.com/auth/calendar',
|
||||
'access_type' => 'offline',
|
||||
'prompt' => 'consent',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exchange_code(string $code): bool {
|
||||
$response = wp_remote_post('https://oauth2.googleapis.com/token', [
|
||||
'body' => [
|
||||
'code' => $code,
|
||||
'client_id' => $this->get_client_id(),
|
||||
'client_secret' => $this->get_client_secret(),
|
||||
'redirect_uri' => site_url('/wp-json/reservations/v1/google-callback'),
|
||||
'grant_type' => 'authorization_code',
|
||||
],
|
||||
]);
|
||||
|
||||
$data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
|
||||
if (!isset($data['access_token'])) {
|
||||
Logger::error('Google Calendar token exchange failed: ' . json_encode($data));
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->set_secret('rsv_google_access_token', $data['access_token']);
|
||||
$this->set_secret('rsv_google_refresh_token', $data['refresh_token']);
|
||||
update_option('rsv_google_token_expires', time() + $data['expires_in']);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function disconnect(): void {
|
||||
$this->stop_webhook();
|
||||
delete_option('rsv_google_access_token');
|
||||
delete_option('rsv_google_refresh_token');
|
||||
delete_option('rsv_google_token_expires');
|
||||
delete_option('rsv_google_sync_token');
|
||||
}
|
||||
|
||||
public function get_access_token(): ?string {
|
||||
$access_token = $this->get_secret('rsv_google_access_token');
|
||||
$refresh_token = $this->get_secret('rsv_google_refresh_token');
|
||||
$expires_at = (int) get_option('rsv_google_token_expires', 0);
|
||||
|
||||
if (!$access_token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (time() > $expires_at - 60) {
|
||||
$response = wp_remote_post('https://oauth2.googleapis.com/token', [
|
||||
'body' => [
|
||||
'client_id' => $this->get_client_id(),
|
||||
'client_secret' => $this->get_client_secret(),
|
||||
'refresh_token' => $refresh_token,
|
||||
'grant_type' => 'refresh_token',
|
||||
],
|
||||
]);
|
||||
|
||||
$data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
|
||||
if (isset($data['access_token'])) {
|
||||
$this->set_secret('rsv_google_access_token', $data['access_token']);
|
||||
update_option('rsv_google_token_expires', time() + $data['expires_in']);
|
||||
$access_token = $data['access_token'];
|
||||
} else {
|
||||
Logger::error('Google Calendar token refresh failed: ' . json_encode($data));
|
||||
// Refresh token is permanently invalid — clear credentials so the
|
||||
// admin UI shows "reconnect needed" instead of silently failing.
|
||||
delete_option('rsv_google_access_token');
|
||||
delete_option('rsv_google_refresh_token');
|
||||
delete_option('rsv_google_token_expires');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $access_token;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Calendar list
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function list_calendars(): array {
|
||||
$access_token = $this->get_access_token();
|
||||
if (!$access_token) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$response = wp_remote_get(
|
||||
'https://www.googleapis.com/calendar/v3/users/me/calendarList',
|
||||
['headers' => ['Authorization' => 'Bearer ' . $access_token]]
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
return array_map(fn($c) => ['id' => $c['id'], 'summary' => $c['summary']], $data['items'] ?? []);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Calendar events
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function add_event(
|
||||
string $calendar_id,
|
||||
string $summary,
|
||||
string $start_utc,
|
||||
string $end_utc,
|
||||
?string $attendee_email = null,
|
||||
?int $reservation_id = null,
|
||||
string $status = 'confirmed'
|
||||
): void {
|
||||
$access_token = $this->get_access_token();
|
||||
if (!$access_token) {
|
||||
throw new \RuntimeException('Not connected to Google Calendar.');
|
||||
}
|
||||
|
||||
$tz = wp_timezone()->getName();
|
||||
$start = (new \DateTime($start_utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone());
|
||||
$end = (new \DateTime($end_utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone());
|
||||
|
||||
$event = [
|
||||
'summary' => $summary,
|
||||
'status' => $status,
|
||||
'start' => ['dateTime' => $start->format(\DateTimeInterface::RFC3339), 'timeZone' => $tz],
|
||||
'end' => ['dateTime' => $end->format(\DateTimeInterface::RFC3339), 'timeZone' => $tz],
|
||||
];
|
||||
|
||||
if ($attendee_email) {
|
||||
$event['attendees'] = [['email' => $attendee_email]];
|
||||
}
|
||||
|
||||
if ($reservation_id !== null) {
|
||||
$event['extendedProperties'] = [
|
||||
'private' => ['rsv_reservation_id' => (string) $reservation_id],
|
||||
];
|
||||
}
|
||||
|
||||
$response = wp_remote_post(
|
||||
"https://www.googleapis.com/calendar/v3/calendars/{$calendar_id}/events?sendUpdates=all",
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $access_token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => json_encode($event),
|
||||
]
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
throw new \RuntimeException('Google Calendar request failed: ' . $response->get_error_message());
|
||||
}
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (!isset($body['id'])) {
|
||||
throw new \RuntimeException('Google Calendar event creation failed: ' . json_encode($body));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Push notification webhook
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function register_webhook(): array {
|
||||
$this->stop_webhook();
|
||||
|
||||
$access_token = $this->get_access_token();
|
||||
if (!$access_token) {
|
||||
return ['error' => 'Not connected to Google Calendar.'];
|
||||
}
|
||||
|
||||
// Get an initial sync token so the first process_changes() call works.
|
||||
$this->initialize_sync_token();
|
||||
|
||||
$channel_id = uniqid('rsv_gcal_', true);
|
||||
$response = wp_remote_post(
|
||||
"https://www.googleapis.com/calendar/v3/calendars/{$this->get_calendar_id()}/events/watch",
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $access_token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => json_encode([
|
||||
'id' => $channel_id,
|
||||
'type' => 'web_hook',
|
||||
'address' => site_url('/wp-json/reservations/v1/google-calendar-hook'),
|
||||
]),
|
||||
]
|
||||
);
|
||||
|
||||
$data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
|
||||
if (isset($data['id'])) {
|
||||
update_option('rsv_google_webhook_channel_id', $data['id']);
|
||||
update_option('rsv_google_webhook_resource_id', $data['resourceId']);
|
||||
update_option('rsv_google_webhook_expiration', $data['expiration'] / 1000); // ms → s
|
||||
} else {
|
||||
Logger::error('Google Calendar webhook registration failed: ' . json_encode($data));
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function stop_webhook(): void {
|
||||
$channel_id = get_option('rsv_google_webhook_channel_id');
|
||||
$resource_id = get_option('rsv_google_webhook_resource_id');
|
||||
|
||||
if (!$channel_id || !$resource_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$access_token = $this->get_access_token();
|
||||
if ($access_token) {
|
||||
wp_remote_post('https://www.googleapis.com/calendar/v3/channels/stop', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $access_token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => json_encode([
|
||||
'id' => $channel_id,
|
||||
'resourceId' => $resource_id,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
delete_option('rsv_google_webhook_channel_id');
|
||||
delete_option('rsv_google_webhook_resource_id');
|
||||
delete_option('rsv_google_webhook_expiration');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Change sync
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function initialize_sync_token(): void {
|
||||
$access_token = $this->get_access_token();
|
||||
if (!$access_token) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = wp_remote_get(
|
||||
'https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($this->get_calendar_id()) . '/events?' . http_build_query([
|
||||
'fields' => 'nextSyncToken',
|
||||
'showDeleted' => 'true',
|
||||
'singleEvents' => 'true',
|
||||
]),
|
||||
['headers' => ['Authorization' => 'Bearer ' . $access_token]]
|
||||
);
|
||||
|
||||
$data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (isset($data['nextSyncToken'])) {
|
||||
update_option('rsv_google_sync_token', $data['nextSyncToken']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events changed since the last sync and return actions to take.
|
||||
*
|
||||
* @return array<array{reservation_id: int, action: 'accept'|'refuse'}>
|
||||
*/
|
||||
public function process_changes(): array {
|
||||
$access_token = $this->get_access_token();
|
||||
if (!$access_token) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sync_token = get_option('rsv_google_sync_token');
|
||||
if (!$sync_token) {
|
||||
$this->initialize_sync_token();
|
||||
return [];
|
||||
}
|
||||
|
||||
$response = wp_remote_get(
|
||||
'https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($this->get_calendar_id()) . '/events?' . http_build_query([
|
||||
'syncToken' => $sync_token,
|
||||
'showDeleted' => 'true',
|
||||
'fields' => 'nextSyncToken,items(id,status,extendedProperties)',
|
||||
]),
|
||||
['headers' => ['Authorization' => 'Bearer ' . $access_token]]
|
||||
);
|
||||
|
||||
if (wp_remote_retrieve_response_code($response) === 410) {
|
||||
// Sync token expired — reinitialise and process next time.
|
||||
delete_option('rsv_google_sync_token');
|
||||
$this->initialize_sync_token();
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
|
||||
if (isset($data['nextSyncToken'])) {
|
||||
update_option('rsv_google_sync_token', $data['nextSyncToken']);
|
||||
}
|
||||
|
||||
$actions = [];
|
||||
foreach ($data['items'] ?? [] as $event) {
|
||||
$reservation_id = (int) ($event['extendedProperties']['private']['rsv_reservation_id'] ?? 0);
|
||||
if (!$reservation_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $event['status'] ?? '';
|
||||
if ($status === 'confirmed') {
|
||||
$actions[] = ['reservation_id' => $reservation_id, 'action' => 'accept'];
|
||||
} elseif ($status === 'cancelled') {
|
||||
$actions[] = ['reservation_id' => $reservation_id, 'action' => 'refuse'];
|
||||
}
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Database\Db;
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
class RsvReservationService {
|
||||
private RsvReservationRepository $repo;
|
||||
|
||||
public function __construct() {
|
||||
$this->repo = new RsvReservationRepository();
|
||||
}
|
||||
|
||||
public function get_all(?int $limit = null, int $skip = 0) {
|
||||
return $this->repo->get_all($limit, $skip);
|
||||
}
|
||||
|
||||
public function count_all(): int {
|
||||
return $this->repo->count_all();
|
||||
}
|
||||
|
||||
public function get(int $id) {
|
||||
return $this->repo->get($id);
|
||||
}
|
||||
|
||||
public function get_detail(int $id): ?array {
|
||||
return $this->repo->get_detail($id);
|
||||
}
|
||||
|
||||
public function create(RsvReservation $reservation) {
|
||||
// Serialise the availability-check + insert per timetable so two
|
||||
// concurrent bookings for the last free slot can't both pass the
|
||||
// capacity check and oversell. Locks are taken in a stable order to
|
||||
// avoid lock-ordering deadlocks and released only after the commit.
|
||||
$timetable_ids = array_values(array_unique(array_map(
|
||||
fn($tr) => $tr->timetable_id,
|
||||
$reservation->timetable_reservations
|
||||
)));
|
||||
sort($timetable_ids);
|
||||
|
||||
foreach ($timetable_ids as $tid) {
|
||||
if (!Db::acquire_lock($this->booking_lock_name($tid))) {
|
||||
throw new \RuntimeException('Could not acquire booking lock for timetable ' . $tid);
|
||||
}
|
||||
}
|
||||
|
||||
$timetable_reservation_service = new RsvTimetableReservationService();
|
||||
|
||||
try {
|
||||
Db::begin_transaction();
|
||||
|
||||
try {
|
||||
$reservation_id = $this->repo->insert($reservation->to_array());
|
||||
|
||||
foreach ($reservation->timetable_reservations as $timetable_reservation) {
|
||||
$timetable_reservation_service->create($reservation_id, $timetable_reservation);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (Exception $e) {
|
||||
Db::rollback();
|
||||
Logger::error($e);
|
||||
throw new \Exception($e);
|
||||
}
|
||||
|
||||
// Only now that the rows are durably committed do we let listeners
|
||||
// (maintainer emails, calendar sync) observe the new reservation.
|
||||
$timetable_reservation_service->flush_deferred_events();
|
||||
|
||||
$this->confirmation_state_changed($reservation_id);
|
||||
|
||||
return $reservation_id;
|
||||
} finally {
|
||||
foreach (array_reverse($timetable_ids) as $tid) {
|
||||
Db::release_lock($this->booking_lock_name($tid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function booking_lock_name(int $timetable_id): string {
|
||||
return Db::prefix() . 'rsv_booking_' . $timetable_id;
|
||||
}
|
||||
|
||||
/** Delete a reservation and its dependent timetable reservations and confirmations. */
|
||||
public function delete(int $id): void {
|
||||
$this->repo->delete($id);
|
||||
}
|
||||
|
||||
public function confirmation_state_changed(int $reservation_id): void {
|
||||
// Terminal state is persisted, so this is idempotent: once a reservation
|
||||
// is confirmed or refused, repeat calls (e.g. a Google Calendar re-sync)
|
||||
// short-circuit and never re-dispatch the closing events.
|
||||
if ($this->repo->is_resolved($reservation_id)) {
|
||||
Logger::info('Reservation already resolved: ' . $reservation_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// A reservation with no timetable items has nothing to confirm or refuse;
|
||||
// bailing out avoids mislabelling it as refused.
|
||||
if ($this->repo->count_subitems($reservation_id) === 0) {
|
||||
Logger::info('Reservation has not subitems: ' . $reservation_id);
|
||||
return;
|
||||
}
|
||||
|
||||
$timetable_service = new RsvTimetableReservationService();
|
||||
$all_confirmed = $this->repo->are_subitems_confirmed($reservation_id);
|
||||
$any_pending = $timetable_service->has_pending_confirmation($reservation_id);
|
||||
|
||||
if ($all_confirmed) {
|
||||
$this->repo->set_resolved_state($reservation_id, true);
|
||||
RsvEventDispatcher::dispatch(new RsvReservationConfirmedEvent($reservation_id));
|
||||
$form_submit_id = $this->repo->get_form_submit_id($reservation_id);
|
||||
if ($form_submit_id !== null) {
|
||||
RsvEventDispatcher::dispatch(new RsvFormSubmitClosedEvent($form_submit_id, $reservation_id, true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$any_pending) {
|
||||
// All items resolved but not all confirmed — at least one refused.
|
||||
$this->repo->set_resolved_state($reservation_id, false);
|
||||
RsvEventDispatcher::dispatch(new RsvReservationRefusedEvent($reservation_id));
|
||||
$form_submit_id = $this->repo->get_form_submit_id($reservation_id);
|
||||
if ($form_submit_id !== null) {
|
||||
RsvEventDispatcher::dispatch(new RsvFormSubmitClosedEvent($form_submit_id, $reservation_id, false));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Logger::info('Waiting for pending confirmations on reservation: ' . $reservation_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
class RsvTimetableReservationService {
|
||||
private RsvTimetableReservationRepository $repo;
|
||||
|
||||
/**
|
||||
* Events produced by create() that must not be dispatched until the
|
||||
* surrounding transaction has committed — otherwise a later rollback would
|
||||
* leave listeners (e.g. the maintainer notification email) acting on rows
|
||||
* that no longer exist.
|
||||
*
|
||||
* @var list<object>
|
||||
*/
|
||||
private array $deferred_events = [];
|
||||
|
||||
public function __construct() {
|
||||
$this->repo = new RsvTimetableReservationRepository();
|
||||
}
|
||||
|
||||
public function get_all(): array {
|
||||
return $this->repo->get_all();
|
||||
}
|
||||
|
||||
public function get(int $id) : RsvTimetableReservation {
|
||||
return $this->repo->get($id);
|
||||
}
|
||||
|
||||
private function time_of_day_minutes(DateTime $utc_dt): int {
|
||||
$dt = (clone $utc_dt)->setTimezone(wp_timezone());
|
||||
return (int) $dt->format('G') * 60 + (int) $dt->format('i');
|
||||
}
|
||||
|
||||
public function check_availability(int $timetable_id, DateTime $start_utc, DateTime $end_utc): bool {
|
||||
$overlapping_capacity = (new RsvTimetableCapacityRepository())
|
||||
->get_overlapping_capacity($timetable_id, $start_utc, $end_utc);
|
||||
|
||||
if (count($overlapping_capacity) === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$start_min = $this->time_of_day_minutes($start_utc);
|
||||
$end_min = $this->time_of_day_minutes($end_utc);
|
||||
|
||||
if ((int) $overlapping_capacity[0]->start_time > $start_min) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$max = (int) $overlapping_capacity[0]->end_time;
|
||||
foreach ($overlapping_capacity as $cap) {
|
||||
if ((int) $cap->start_time > $max) {
|
||||
return false;
|
||||
}
|
||||
$max = max($max, (int) $cap->end_time);
|
||||
}
|
||||
|
||||
if ($max < $end_min) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$capacity = (int) $overlapping_capacity[0]->capacity;
|
||||
$reservations = $this->repo->get_overlapping($timetable_id, $start_utc, $end_utc);
|
||||
|
||||
return count($reservations) < $capacity;
|
||||
}
|
||||
|
||||
public function is_reservation_valid(RsvTimetableReservation $reservation): bool {
|
||||
return $this->check_availability(
|
||||
$reservation->timetable_id,
|
||||
$reservation->start_utc,
|
||||
$reservation->end_utc,
|
||||
);
|
||||
}
|
||||
|
||||
private function create_confirmation(int $reservation_id, int $timetable_reservation_id): string {
|
||||
$code = wp_generate_password(32, false);
|
||||
$this->repo->insert_confirmation($reservation_id, $timetable_reservation_id, $code);
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function set_confirmed_state(string $code, bool $state): int {
|
||||
$confirmation = $this->repo->get_confirmation($code);
|
||||
if ($confirmation === null) {
|
||||
throw new InvalidArgumentException('Confirmation code not found.');
|
||||
}
|
||||
|
||||
$this->repo->set_confirmed((int) $confirmation['timetable_reservation_id'], $state);
|
||||
$this->repo->delete_confirmation($code);
|
||||
|
||||
$reservation_service = new RsvReservationService();
|
||||
$reservation_service->confirmation_state_changed($confirmation['reservation_id']);
|
||||
|
||||
return $confirmation['timetable_reservation_id'];
|
||||
}
|
||||
|
||||
public function accept(string $code): int {
|
||||
return $this->set_confirmed_state($code, true);
|
||||
}
|
||||
|
||||
public function refuse(string $code): int {
|
||||
return $this->set_confirmed_state($code, false);
|
||||
}
|
||||
|
||||
public function has_pending_confirmation(int $reservation_id): bool {
|
||||
return $this->repo->has_pending_confirmation($reservation_id);
|
||||
}
|
||||
|
||||
private function get_confirmation_code(int $reservation_id): string {
|
||||
$code = $this->repo->get_confirmation_code($reservation_id);
|
||||
|
||||
if ($code === null) {
|
||||
throw new InvalidArgumentException('No pending confirmation for this reservation.');
|
||||
}
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
public function accept_by_reservation_id(int $reservation_id): void {
|
||||
$this->set_confirmed_state($this->get_confirmation_code($reservation_id), true);
|
||||
}
|
||||
|
||||
public function refuse_by_reservation_id(int $reservation_id): void {
|
||||
$this->set_confirmed_state($this->get_confirmation_code($reservation_id), false);
|
||||
}
|
||||
|
||||
// TODO: Add requires_confirmation parameter
|
||||
public function create(int $reservation_id, RsvTimetableReservation $reservation): int {
|
||||
if (!$this->is_reservation_valid($reservation)) {
|
||||
throw new InvalidArgumentException();
|
||||
}
|
||||
|
||||
$capacity = (new RsvTimetableCapacityRepository())->get_overlapping_capacity(
|
||||
$reservation->timetable_id,
|
||||
$reservation->start_utc,
|
||||
$reservation->end_utc,
|
||||
);
|
||||
$needs_confirmation = array_filter($capacity, fn($c) => (bool) $c->requires_confirmation);
|
||||
|
||||
$insert_array = $reservation->to_array();
|
||||
$insert_array['reservation_id'] = $reservation_id;
|
||||
$insert_array['is_confirmed'] = empty($needs_confirmation);
|
||||
|
||||
$id = $this->repo->insert($insert_array);
|
||||
|
||||
if (!empty($needs_confirmation)) {
|
||||
$maintainer_email = (new RsvTimetableRepository())->get_maintainer_email($reservation->timetable_id);
|
||||
|
||||
if ($maintainer_email) {
|
||||
$code = $this->create_confirmation($reservation_id, $id);
|
||||
$this->deferred_events[] = new RsvTimetableReservationPendingEvent(
|
||||
$reservation_id,
|
||||
$reservation,
|
||||
$code,
|
||||
$maintainer_email
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$reservation_service = new RsvReservationService();
|
||||
$reservation_service->confirmation_state_changed($reservation_id);
|
||||
}
|
||||
|
||||
$this->deferred_events[] = new RsvTimetableReservationCreatedEvent($reservation);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch (and clear) the events buffered by create(). Callers must invoke
|
||||
* this *after* committing the transaction that wraps create().
|
||||
*/
|
||||
public function flush_deferred_events(): void {
|
||||
$events = $this->deferred_events;
|
||||
$this->deferred_events = [];
|
||||
foreach ($events as $event) {
|
||||
RsvEventDispatcher::dispatch($event);
|
||||
}
|
||||
}
|
||||
|
||||
public function get_by_timetable(int $timetable_id, ?int $limit = null, int $skip = 0): array {
|
||||
return $this->repo->get_by_timetable($timetable_id, $limit, $skip);
|
||||
}
|
||||
|
||||
public function count_by_timetable(int $timetable_id): int {
|
||||
return $this->repo->count_by_timetable($timetable_id);
|
||||
}
|
||||
|
||||
public function get_reservations_on_date(int $timetable_id, DateTime $date_utc): array {
|
||||
return $this->repo->get_reservations_on_date($timetable_id, $date_utc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
class RsvTimetableService {
|
||||
private RsvTimetableRepository $repo;
|
||||
|
||||
public function __construct() {
|
||||
$this->repo = new RsvTimetableRepository();
|
||||
}
|
||||
|
||||
public function get_all(?int $limit = null, int $skip = 0): array {
|
||||
return $this->repo->get_all($limit, $skip);
|
||||
}
|
||||
|
||||
public function count_all(): int {
|
||||
return $this->repo->count_all();
|
||||
}
|
||||
|
||||
public function get(int $id): ?RsvTimetable {
|
||||
return $this->repo->get($id);
|
||||
}
|
||||
|
||||
public function create(RsvTimetable $timetable): int {
|
||||
return $this->repo->create($timetable);
|
||||
}
|
||||
|
||||
public function update(int $id, RsvTimetable $timetable): int {
|
||||
return $this->repo->update($id, $timetable);
|
||||
}
|
||||
|
||||
public function get_all_maintainer_emails(): array {
|
||||
return $this->repo->get_all_maintainer_emails();
|
||||
}
|
||||
|
||||
public function set_google_calendar_id(int $id, ?string $calendar_id): void {
|
||||
$this->repo->set_google_calendar_id($id, $calendar_id);
|
||||
}
|
||||
|
||||
public function delete(int $id): int {
|
||||
Logger::info('Deleting timetable: ' . $id);
|
||||
return $this->repo->delete($id);
|
||||
}
|
||||
|
||||
private function datetime_to_minutes(DateTime $dt): int {
|
||||
$d = $dt->setTimezone(wp_timezone());
|
||||
return (int) $d->format('G') * 60 + (int) $d->format('i');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,RsvTimetableAvailability> Availability on date
|
||||
*/
|
||||
public function get_availability_on_date(int $timetable_id, int $block_length, DateTime $date) : array {
|
||||
/**
|
||||
* Get available seats for the given date
|
||||
* Keeps two stacks: capacities & reservations
|
||||
*
|
||||
* Goes from left to right in reservations and pushes to stack
|
||||
*/
|
||||
$capacities = (new RsvTimetableCapacityRepository())->get_capacities_for_date($timetable_id, $date);
|
||||
$reservations = (new RsvTimetableReservationService())->get_reservations_on_date($timetable_id, $date);
|
||||
|
||||
$capacity_stack = [];
|
||||
$reservation_stack = [];
|
||||
|
||||
$capacity_index = 0;
|
||||
$reservation_index = 0;
|
||||
|
||||
$blocks_count = 24 * 60 / $block_length;
|
||||
$blocks = array_fill(0, $blocks_count, 0);
|
||||
|
||||
$availabilities = [];
|
||||
$availability_idx = 0;
|
||||
|
||||
for($i = 0; $i < $blocks_count; $i++) {
|
||||
$reservation_stack = array_filter($reservation_stack, fn($reservation) =>
|
||||
$this->datetime_to_minutes($reservation->end_utc) > $i * $block_length
|
||||
);
|
||||
|
||||
$capacity_stack = array_filter($capacity_stack, fn($capacity) =>
|
||||
$capacity->end_time > $i * $block_length
|
||||
);
|
||||
|
||||
while($reservation_index < count($reservations) && $this->datetime_to_minutes($reservations[$reservation_index]->start_utc) <= $i * $block_length) {
|
||||
$reservation_stack[] = $reservations[$reservation_index];
|
||||
$reservation_index++;
|
||||
}
|
||||
|
||||
while($capacity_index < count($capacities) && $capacities[$capacity_index]->start_time <= $i * $block_length) {
|
||||
$capacity_stack[] = $capacities[$capacity_index];
|
||||
$capacity_index++;
|
||||
}
|
||||
|
||||
$total_capacity = array_sum(array_map(fn($x) => $x->capacity, $capacity_stack));
|
||||
|
||||
if ($total_capacity > 0) {
|
||||
if(count($availabilities) === $availability_idx) {
|
||||
$availabilities[] = new RsvTimetableAvailability($i * $block_length, ($i + 1) * $block_length, $block_length, []);
|
||||
}
|
||||
|
||||
$availabilities[$availability_idx]->push_block($total_capacity - count($reservation_stack));
|
||||
} else if($total_capacity === 0 && count($availabilities) !== $availability_idx) {
|
||||
$availability_idx++;
|
||||
}
|
||||
}
|
||||
|
||||
return $availabilities;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user