This commit is contained in:
Martin Slachta
2026-06-11 19:03:29 +02:00
commit 0d829845c4
150 changed files with 38582 additions and 0 deletions
@@ -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.
}
}
+21
View File
@@ -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;
}
}
+131
View File
@@ -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);
}
}
+109
View File
@@ -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;
}
}