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,3 @@
<?php
const RSV_REST_API_BASE = 'reservations/';
@@ -0,0 +1,32 @@
<?php
class RsvFormController {
private $namespace;
private $resource_name;
public function __construct() {
$this->namespace = 'reservations/v1';
$this->resource_name = 'form';
}
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>[^/]+)', [
'methods' => 'POST',
'callback' => [$this, 'handle'],
// Public: site visitors submit reservation forms. The handler validates
// the form definition and payload before persisting anything.
'permission_callback' => [RsvRestPolicy::class, 'open']
]);
}
function handle(WP_REST_Request $request) {
$submitter = new RsvFormSubmission();
$submit_result = $submitter->submit($request->get_param("id"), $request->get_json_params());
if(isset($submit_result['success']) && $submit_result['success'] === true) {
return new WP_REST_Response($submit_result, 200);
}
return new WP_REST_Response($submit_result, 400);
}
}
@@ -0,0 +1,119 @@
<?php
use Reservair\Logger\Logger;
class RsvFormDefinitionController {
use RsvPagedResponseTrait;
private string $namespace = 'reservations/v1';
private string $resource_name = 'form-definition';
private static function schema(): array {
return [
'type' => 'object',
'properties' => [
'form_id' => ['type' => 'integer', 'readonly' => true],
'name' => ['type' => 'string', 'required' => true, 'minLength' => 1],
'definition' => [
'type' => 'object',
'required' => false,
'properties' => [
'email_key' => ['type' => 'string', 'required' => false],
'elements' => ['type' => 'array', 'default' => []],
],
],
],
];
}
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
[
'methods' => 'GET',
'callback' => [$this, 'index'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(self::schema()),
],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [
[
'methods' => 'GET',
'callback' => [$this, 'show'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'PUT',
'callback' => [$this, 'update'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(self::schema()),
],
[
'methods' => 'DELETE',
'callback' => [$this, 'destroy'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
]);
}
function index(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$repo = new RsvFormDefinitionRepository();
return $this->paged_response($repo->get_all($limit, $skip), $repo->count_all());
}
function show(WP_REST_Request $request): WP_REST_Response {
$row = (new RsvFormDefinitionRepository())->get((int) $request->get_param('id'));
if ($row === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
return new WP_REST_Response($row, 200);
}
function create(WP_REST_Request $request): WP_REST_Response {
try {
$id = (new RsvFormDefinitionRepository())->add(
$request->get_param('name'),
$request->get_param('definition') ?? []
);
} catch(Throwable $e) {
Logger::error($e);
return new WP_REST_Response(['error' => 'An error occurred.'], 500);
}
return new WP_REST_Response(['id' => $id], 201);
}
function destroy(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$repo = new RsvFormDefinitionRepository();
if ($repo->get($id) === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
$repo->delete($id);
return new WP_REST_Response(null, 204);
}
function update(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$repo = new RsvFormDefinitionRepository();
if ($repo->get($id) === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
$repo->update($id, $request->get_param('name'), $request->get_param('definition'));
return new WP_REST_Response(null, 204);
}
}
@@ -0,0 +1,31 @@
<?php
trait RsvPagedResponseTrait {
private function paged_response(array $data, ?int $total = null): WP_REST_Response {
return new WP_REST_Response([
'total' => $total ?? count($data),
'data' => $data,
], 200);
}
/**
* Read pagination from the request as [skip, limit]. limit is clamped to
* 1..100 and defaults to 20; skip defaults to 0.
*
* @return array{0:int,1:int}
*/
private static function paging(WP_REST_Request $request): array {
$skip = max(0, (int) $request->get_param('skip'));
$limit = (int) $request->get_param('limit');
$limit = $limit > 0 ? min($limit, 100) : 20;
return [$skip, $limit];
}
/** Extract writable (non-readonly) properties from a schema for use as route args. */
private static function input_args(array $schema): array {
return array_filter(
$schema['properties'],
fn(array $prop): bool => empty($prop['readonly'])
);
}
}
@@ -0,0 +1,87 @@
<?php
class RsvReservationController {
use RsvPagedResponseTrait;
private $namespace;
private $resource_name;
public function __construct() {
$this->namespace = 'reservations/v1';
$this->resource_name = 'reservation';
}
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
'methods' => 'GET',
'callback' => [$this, 'get_all'],
'permission_callback' => [RsvRestPolicy::class, 'admin']
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [$this, 'get'],
'permission_callback' => [RsvRestPolicy::class, 'admin']
]);
register_rest_route($this->namespace, '/' . $this->resource_name, [
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin']
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)/accept', [
'methods' => 'POST',
'callback' => [$this, 'accept_by_id'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)/refuse', [
'methods' => 'POST',
'callback' => [$this, 'refuse_by_id'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
}
function get_all(WP_REST_Request $request) {
[$skip, $limit] = self::paging($request);
$service = new RsvReservationService();
return $this->paged_response((array) $service->get_all($limit, $skip), $service->count_all());
}
function get(WP_REST_Request $request): WP_REST_Response {
$service = new RsvReservationService();
$detail = $service->get_detail((int) $request->get_param('id'));
if ($detail === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
return new WP_REST_Response($detail, 200);
}
function create(WP_REST_Request $request) {
$service = new RsvReservationService();
$body = $request->get_json_params();
return $service->create(RsvReservation::from_array($body));
}
function accept_by_id(WP_REST_Request $request): WP_REST_Response {
try {
(new RsvTimetableReservationService())->accept_by_reservation_id((int) $request->get_param('id'));
return new WP_REST_Response(['status' => 'accepted'], 200);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => $e->getMessage()], 404);
}
}
function refuse_by_id(WP_REST_Request $request): WP_REST_Response {
try {
(new RsvTimetableReservationService())->refuse_by_reservation_id((int) $request->get_param('id'));
return new WP_REST_Response(['status' => 'refused'], 200);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => $e->getMessage()], 404);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Authorization policy for the reservations/v1 REST API.
*
* Every route's `permission_callback` references one of these tiers so the
* intended audience is visible at the route definition:
*
* - admin(): requires the manage_reservations capability (see RsvCapabilities).
* - open(): genuinely public, OR a capability URL whose secret is validated
* inside the handler itself (confirmation codes, the Google webhook,
* the OAuth callback). Any `open()` route that is not fully public
* MUST authorise its caller from the request.
*/
final class RsvRestPolicy {
/** Administrative endpoints: managing timetables, capacities, forms, reservations. */
public static function admin(): bool|WP_Error {
if ( current_user_can( RsvCapabilities::MANAGE ) ) {
return true;
}
return new WP_Error(
'rsv_forbidden',
__( 'Sorry, you are not allowed to do that.', 'reservair' ),
// 401 when logged out, 403 when logged in but under-privileged.
[ 'status' => rest_authorization_required_code() ]
);
}
/** Public endpoints, and capability URLs validated inside the handler. */
public static function open(): bool {
return true;
}
}
@@ -0,0 +1,38 @@
<?php
use Reservair\Logger\Logger;
class RsvTimetableAvailabilityController {
private string $namespace = 'reservations/v1';
public function register_routes(): void {
register_rest_route($this->namespace, '/timetable/(?P<id>\d+)/availability', [
'methods' => 'GET',
'callback' => [$this, 'show'],
// Public: the booking widget reads availability for anonymous visitors.
'permission_callback' => [RsvRestPolicy::class, 'open'],
'args' => [
'date' => ['type' => 'string', 'required' => true, 'format' => 'date'],
],
]);
}
public function show(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$service = new RsvTimetableService();
$timetable = $service->get($id);
if ($timetable === null || $timetable->id === null) {
return new WP_REST_Response(['error' => 'Timetable not found'], 404);
}
try {
$availability = $service->get_availability_on_date($id, $timetable->block_size, new DateTime($request->get_param('date')));
} catch (Throwable $e) {
Logger::error($e);
return new WP_REST_Response(['error' => $e->getMessage()], 400);
}
return new WP_REST_Response($availability, 200);
}
}
@@ -0,0 +1,114 @@
<?php
class RsvTimetableCapacityController {
use RsvPagedResponseTrait;
private string $namespace = 'reservations/v1';
private string $resource_name = 'timetable/(?P<id>\d+)/capacity';
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
[
'methods' => 'GET',
'callback' => [$this, 'get_all'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
// 'args' => self::input_args(RsvTimetableCapacity::schema()),
],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<capacity_id>\d+)', [
[
'methods' => 'GET',
'callback' => [$this, 'get'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'PUT',
'callback' => [$this, 'update'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(RsvTimetableCapacity::schema()),
],
[
'methods' => 'DELETE',
'callback' => [$this, 'delete'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
]);
}
public function get_all(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$timetable_id = (int) $request->get_param('id');
$service = new RsvTimetableCapacityRepository();
return $this->paged_response(
$service->get_all($timetable_id, $limit, $skip),
$service->count_all($timetable_id)
);
}
public function get(WP_REST_Request $request): WP_REST_Response {
return new WP_REST_Response(
(new RsvTimetableCapacityRepository())->get((int) $request->get_param('capacity_id')),
200
);
}
public function create(WP_REST_Request $request): WP_REST_Response {
$items = $request->get_json_params();
$timetable_id = (int) $request->get_param('id');
$ids = [];
foreach($items as $item) {
$capacity = new RsvTimetableCapacity(
null,
$timetable_id,
(int) $item['capacity'],
(int) $item['min_lead_time_minutes'],
new DateTime($item['date']),
(int) $item['start_time'],
(int) $item['end_time'],
(int) $item['repeat_period_in_days'],
(int) $item['repeat_times'],
(bool) $item['requires_confirmation'],
);
$ids[] = (new RsvTimetableCapacityRepository())->create($capacity);
}
return new WP_REST_Response(
['ids' => $ids],
201
);
}
public function update(WP_REST_Request $request): WP_REST_Response {
$capacity = new RsvTimetableCapacity(
(int) $request->get_param('capacity_id'),
(int) $request->get_param('id'),
(int) $request->get_param('capacity'),
(int) $request->get_param('min_lead_time_minutes'),
new DateTime($request->get_param('date')),
(int)$request->get_param('start_time'),
(int)$request->get_param('end_time'),
(int) $request->get_param('repeat_period_in_days'),
(int) $request->get_param('repeat_times'),
(bool) $request->get_param('requires_confirmation'),
);
$capacity_id = (int) $request->get_param('capacity_id');
(new RsvTimetableCapacityRepository())->update($capacity_id, $capacity);
return new WP_REST_Response(['id' => $capacity_id], 200);
}
public function delete(WP_REST_Request $request): WP_REST_Response {
(new RsvTimetableCapacityRepository())->delete((int) $request->get_param('capacity_id'));
return new WP_REST_Response(null, 204);
}
}
@@ -0,0 +1,88 @@
<?php
class RsvTimetableDefinitionController {
use RsvPagedResponseTrait;
private string $namespace = 'reservations/v1';
private string $resource_name = 'timetable';
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
[
'methods' => 'GET',
'callback' => [$this, 'index'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(RsvTimetable::schema()),
],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [
[
'methods' => 'PATCH',
'callback' => [$this, 'update'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(RsvTimetable::schema()),
],
[
'methods' => 'DELETE',
'callback' => [$this, 'destroy'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
]);
}
public function index(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$service = new RsvTimetableService();
return $this->paged_response($service->get_all($limit, $skip), $service->count_all());
}
public function create(WP_REST_Request $request): WP_REST_Response {
$service = new RsvTimetableService();
$id = $service->create(new RsvTimetable([
'name' => $request->get_param('name'),
'block_size' => (int) $request->get_param('block_size'),
'maintainer_email' => $request->get_param('maintainer_email'),
]));
return new WP_REST_Response(['id' => $id], 201);
}
public function update(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$service = new RsvTimetableService();
$body = $request->get_json_params();
$timetable = $service->get($id);
if ($timetable === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
if (array_key_exists('name', $body)) $timetable->name = $body['name'];
if (array_key_exists('block_size', $body)) $timetable->block_size = (int) $body['block_size'];
if (array_key_exists('maintainer_email', $body)) $timetable->maintainer_email = $body['maintainer_email'] ?: null;
if (array_key_exists('google_calendar_id', $body)) $timetable->google_calendar_id = $body['google_calendar_id'] ?: null;
$service->update($id, $timetable);
return new WP_REST_Response(['id' => $id], 200);
}
public function destroy(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$service = new RsvTimetableService();
if ($service->get($id) === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
$service->delete($id);
return new WP_REST_Response(null, 204);
}
}
@@ -0,0 +1,62 @@
<?php
class RsvTimetableReservationController {
use RsvPagedResponseTrait;
private $namespace = 'reservations/v1';
private $resource_name = '/timetable/(?P<id>\d+)/reservation';
public function register_routes(): void {
register_rest_route($this->namespace, $this->resource_name, [
'methods' => 'GET',
'callback' => [$this, 'by_timetable'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route($this->namespace, '/timetable-reservation/accept/(?P<code>[a-zA-Z0-9]+)', [
'methods' => 'GET',
'callback' => [$this, 'accept'],
// Capability URL: authorised by the secret confirmation code, which
// accept() validates against the database before changing state.
'permission_callback' => [RsvRestPolicy::class, 'open'],
]);
register_rest_route($this->namespace, '/timetable-reservation/refuse/(?P<code>[a-zA-Z0-9]+)', [
'methods' => 'GET',
'callback' => [$this, 'refuse'],
// Capability URL: authorised by the secret confirmation code, which
// refuse() validates against the database before changing state.
'permission_callback' => [RsvRestPolicy::class, 'open'],
]);
}
public function by_timetable(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$timetable_id = (int) $request->get_param('id');
$service = new RsvTimetableReservationService();
return $this->paged_response(
$service->get_by_timetable($timetable_id, $limit, $skip),
$service->count_by_timetable($timetable_id)
);
}
function accept(WP_REST_Request $request) {
try {
$service = new RsvTimetableReservationService();
$service->accept($request->get_param('code'));
return new WP_REST_Response(['status' => 'accepted'], 200);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404);
}
}
function refuse(WP_REST_Request $request) {
try {
$service = new RsvTimetableReservationService();
$service->refuse($request->get_param('code'));
return new WP_REST_Response(['status' => 'refused'], 200);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
interface RsvEventBusInterface {
public function dispatch(object $event): void;
public function listen(string $event_class, callable $listener): void;
}
class RsvWordPressEventBus implements RsvEventBusInterface {
public function dispatch(object $event): void {
do_action(get_class($event), $event);
}
public function listen(string $event_class, callable $listener): void {
add_action($event_class, $listener);
}
}
class RsvEventDispatcher {
private static ?RsvEventBusInterface $bus = null;
public static function init(RsvEventBusInterface $bus): void {
self::$bus = $bus;
}
private static function bus(): RsvEventBusInterface {
// Fall back to the WordPress bus if init() was never called, so a stray
// dispatch/listen during early bootstrap can't fatal on an uninitialised
// typed property.
return self::$bus ??= new RsvWordPressEventBus();
}
public static function dispatch(object $event): void {
self::bus()->dispatch($event);
}
public static function listen(string $event_class, callable $listener): void {
self::bus()->listen($event_class, $listener);
}
}
@@ -0,0 +1,17 @@
<?php
/**
* Dispatched when every element of a form submission reaches its terminal
* state — i.e. the form transitions from open to closed.
*
* $accepted is true when every timetable item was confirmed, false when at
* least one was refused. It is resolved at dispatch time so listeners never
* need a second database round-trip.
*/
final class RsvFormSubmitClosedEvent {
public function __construct(
public int $form_submit_id,
public int $reservation_id,
public bool $accepted
) {}
}
@@ -0,0 +1,11 @@
<?php
/**
* Dispatched when every timetable item of a reservation is confirmed, i.e. the
* whole reservation has been accepted.
*/
class RsvReservationConfirmedEvent {
public function __construct(
public int $reservation_id
) {}
}
@@ -0,0 +1,11 @@
<?php
/**
* Dispatched when a reservation is refused (at least one of its timetable items
* was rejected), so the whole reservation can no longer be confirmed.
*/
class RsvReservationRefusedEvent {
public function __construct(
public int $reservation_id
) {}
}
@@ -0,0 +1,7 @@
<?php
class RsvTimetableReservationAcceptedEvent {
public function __construct(
public int $reservation_id
) {}
}
@@ -0,0 +1,7 @@
<?php
class RsvTimetableReservationCreatedEvent {
public function __construct(
public RsvTimetableReservation $reservation
) {}
}
@@ -0,0 +1,15 @@
<?php
/**
* Dispatched when a single timetable reservation item requires maintainer
* confirmation. Carries everything the email module needs so it never has to
* reach back into the reservation services.
*/
class RsvTimetableReservationPendingEvent {
public function __construct(
public int $reservation_id,
public RsvTimetableReservation $reservation,
public string $code,
public string $maintainer_email
) {}
}
@@ -0,0 +1,7 @@
<?php
class RsvTimetableReservationRefusedEvent {
public function __construct(
public int $reservation_id
) {}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
use Reservair\Logger\Logger;
/**
* Handles all outgoing email for the reservation module.
*
* Two responsibilities:
* 1. Maintainer approval request — fires per timetable item that needs
* confirmation; template is hardcoded (not admin-configurable).
* 2. User notification on form close — fires once per form submission when
* every reservation item reaches a terminal state. Subject/body come from
* the form definition's reservation element `email_templates` attr.
*
* Exceptions are swallowed so a mail failure never rolls back a transaction.
*/
class RsvEmailListener {
private const string DEFAULT_PENDING_SUBJECT = 'Nová rezervace čeká na schválení';
private const string DEFAULT_PENDING_BODY = "
<h1>Nový požadavek o rezervaci</h1>
<p>Rezervace č. {{reservation_id}} čeká na vaše schválení.</p>
<p><strong>Datum:</strong> {{date}}</p>
<p><strong>Čas:</strong> {{start}} {{end}}</p>
<p>
<a href='{{accept_url}}'>Přijmout</a>
&nbsp;|&nbsp;
<a href='{{refuse_url}}'>Odmítnout</a>
</p>
";
private const string DEFAULT_ACCEPTED_SUBJECT = 'Rezervace přijata';
private const string DEFAULT_ACCEPTED_BODY = "
<h1>Vaše rezervace byla přijata</h1>
<p>Vaše rezervace byla schválena. Těšíme se na vás!</p>
";
private const string DEFAULT_REFUSED_SUBJECT = 'Rezervace zamítnuta';
private const string DEFAULT_REFUSED_BODY = "
<h1>Vaše rezervace byla zamítnuta</h1>
<p>Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.</p>
";
public static function register(): void {
RsvEventDispatcher::listen(RsvTimetableReservationPendingEvent::class, [self::class, 'on_pending']);
RsvEventDispatcher::listen(RsvFormSubmitClosedEvent::class, [self::class, 'on_form_submit_closed']);
}
public static function on_pending(RsvTimetableReservationPendingEvent $event): void {
try {
$tz = wp_timezone();
$start_dt = (clone $event->reservation->start_utc)->setTimezone($tz);
$end_dt = (clone $event->reservation->end_utc)->setTimezone($tz);
$body = (new RsvEmailTemplater())->render(self::DEFAULT_PENDING_BODY, [
'reservation_id' => (string) $event->reservation_id,
'date' => esc_html($start_dt->format('Y-m-d')),
'start' => esc_html($start_dt->format('H:i')),
'end' => esc_html($end_dt->format('H:i')),
'accept_url' => esc_url(get_rest_url(null, 'reservations/v1/timetable-reservation/accept/' . $event->code)),
'refuse_url' => esc_url(get_rest_url(null, 'reservations/v1/timetable-reservation/refuse/' . $event->code)),
]);
(new RsvEmailSender())->send($event->maintainer_email, self::DEFAULT_PENDING_SUBJECT, $body);
} catch (\Throwable $e) {
Logger::error($e);
}
}
/**
* HTML-escape scalar form values for safe interpolation into an HTML email
* body. Non-scalar values (e.g. nested arrays) are dropped to an empty
* string since the templater only substitutes plain placeholders.
*
* @param array<string,mixed> $values
* @return array<string,string>
*/
private static function escape_values(array $values): array {
$escaped = [];
foreach ($values as $key => $value) {
$escaped[$key] = is_scalar($value) ? esc_html((string) $value) : '';
}
return $escaped;
}
public static function on_form_submit_closed(RsvFormSubmitClosedEvent $event): void {
try {
$form_submit = (new RsvFormSubmitRepository())->get($event->form_submit_id);
if ($form_submit === null) {
Logger::warning("on_form_submit_closed: form_submit {$event->form_submit_id} not found");
return;
}
$definition = (new RsvFormDefinitionRepository())->get((int) $form_submit['form_id']);
if ($definition === null) {
Logger::warning("on_form_submit_closed: form definition for submit {$event->form_submit_id} not found");
return;
}
$elements = $definition['definition']['elements'] ?? [];
$reservation_element = null;
foreach ($elements as $el) {
if (($el['type'] ?? '') === 'reservation') {
$reservation_element = $el;
break;
}
}
if ($reservation_element === null) {
return;
}
$template_key = $event->accepted ? 'on_accepted' : 'on_refused';
$templates = $reservation_element['email_templates'] ?? [];
$tpl = $templates[$template_key] ?? [];
$tpl_enabled = $tpl['enabled'] ?? null;
$enabled = is_bool($tpl_enabled) ? $tpl_enabled : ($template_key === 'on_accepted');
if (!$enabled) {
return;
}
$email_key = $definition['definition']['email_key'] ?? null;
$form_values = $form_submit['values'] ?? [];
$user_email = is_string($email_key) && $email_key !== '' ? ($form_values[$email_key] ?? null) : null;
if ($user_email === null || $user_email === '') {
Logger::warning("on_form_submit_closed: no user email for submit {$event->form_submit_id}");
return;
}
$default_subject = $event->accepted ? self::DEFAULT_ACCEPTED_SUBJECT : self::DEFAULT_REFUSED_SUBJECT;
$default_body = $event->accepted ? self::DEFAULT_ACCEPTED_BODY : self::DEFAULT_REFUSED_BODY;
// Treat blank (admin cleared the field) the same as "use the default".
$subject_tpl = trim((string) ($tpl['subject'] ?? '')) !== '' ? $tpl['subject'] : $default_subject;
$body_tpl = trim((string) ($tpl['body'] ?? '')) !== '' ? $tpl['body'] : $default_body;
$templater = new RsvEmailTemplater();
// Subject is plain text: render with raw values, then strip any tags
// or newlines to avoid header issues.
$subject = sanitize_text_field($templater->render($subject_tpl, $form_values));
// Body is HTML: escape the user-submitted values before interpolation
// so they can't inject markup into the message.
$body = $templater->render($body_tpl, self::escape_values($form_values));
(new RsvEmailSender())->send($user_email, $subject, $body);
} catch (\Throwable $e) {
Logger::error($e);
}
}
}
@@ -0,0 +1,59 @@
<?php
class RsvGoogleCalendarListener {
public static function register(): void {
RsvEventDispatcher::listen(RsvTimetableReservationCreatedEvent::class, [self::class, 'on_created']);
RsvEventDispatcher::listen(RsvTimetableReservationAcceptedEvent::class, [self::class, 'on_accepted']);
RsvEventDispatcher::listen(RsvTimetableReservationRefusedEvent::class, [self::class, 'on_refused']);
}
public static function on_created(RsvTimetableReservationCreatedEvent $event): void {
try {
$gcal = new RsvGoogleCalendarService();
if (!$gcal->is_google_connected()) {
return;
}
$calendar_id = self::resolve_calendar_id($gcal, $event->timetable_id);
if (!$calendar_id) {
return;
}
$status = $event->reservation->requires_confirmation ? 'tentative' : 'confirmed';
$gcal->add_event(
$calendar_id,
"Reservation #{$event->reservation->id}",
$event->reservation->start,
$event->reservation->end,
$event->reservation->user_email,
$event->reservation->id,
$status,
);
} catch (\Throwable $e) {
error_log('RsvGoogleCalendarListener::on_created: ' . $e->getMessage());
}
}
public static function on_accepted(RsvTimetableReservationAcceptedEvent $event): void {
// Future: update the calendar event status to 'confirmed'.
}
public static function on_refused(RsvTimetableReservationRefusedEvent $event): void {
// Future: cancel the calendar event.
}
// -------------------------------------------------------------------------
private static function resolve_calendar_id(RsvGoogleCalendarService $gcal, int $timetable_id): ?string {
$timetable_service = new RsvTimetableService();
$timetable = $timetable_service->get($timetable_id);
if ($timetable && !empty($timetable->google_calendar_id)) {
return $timetable->google_calendar_id;
}
$global = $gcal->get_calendar_id();
return $global ?: null;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
class RsvReservation {
public ?int $id;
public int $form_submit_id;
public bool|null $is_confirmed;
public array $timetable_reservations;
public static function schema(): array {
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'readonly' => true],
'form_submit_id' => ['type' => 'integer', 'readonly' => true],
'is_confirmed' => ['type' => 'boolean', 'readonly' => true],
'timetable_reservations' => [
'type' => 'array',
'required' => true,
'items' => RsvTimetableReservation::schema(),
],
],
];
}
public static function from_array(array $data) : self {
return new self(
intval($data['id'] ?? null),
intval($data['form_submit_id'] ?? null),
$data['is_confirmed'] ?? null,
array_map(fn($t) =>
new RsvTimetableReservation(null, $data['timetable_id'], $t['start'], $t['end']),
$data['timetable_reservations'] ?? [])
);
}
public function __construct(?int $id, int $form_submit_id, bool|null $is_confirmed, array $timetable_reservations)
{
$this->id = $id;
$this->form_submit_id = $form_submit_id;
$this->is_confirmed = $is_confirmed;
$this->timetable_reservations = $timetable_reservations;
}
public function to_array() {
return [
'id' => $this->id,
'form_submit_id' => $this->form_submit_id,
'is_confirmed' => $this->is_confirmed,
// 'timetable_reservations' => $this->timetable_reservations,
];
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
class RsvReservationTypeConfigurationStep {
public int $index;
public string $type;
public array|null $configuration;
public function __construct(array $data) {
$this->index = $data['index'];
$this->type = $data['type'];
$this->configuration = $data['configuration'] ?? null;
}
}
class RsvReservationTypeConfiguration {
public array $steps = [];
public function __construct(array $data) {
$this->steps = [];
foreach( $data['steps'] as $step ) {
array_push( $this->steps, new RsvReservationTypeConfigurationStep($step) );
}
}
}
class RsvReservationType {
public int $id;
public string $name;
public string $description;
public $configuration;
public function __construct(array $data) {
$this->id = $data['id'];
$this->name = $data['name'];
$this->description = $data['description'];
$this->configuration = json_decode($data['configuration'], true);
}
public function to_array() {
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'configuration' => json_encode($this->configuration)
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
class RsvTimetable {
public int|null $id;
public string $name;
public int $block_size;
public ?string $google_calendar_id;
public ?string $maintainer_email;
public static function schema(): array {
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'readonly' => true],
'name' => ['type' => 'string', 'required' => true, 'minLength' => 1],
'block_size' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
'maintainer_email' => ['type' => ['string', 'null'], 'format' => 'email'],
'google_calendar_id' => ['type' => ['string', 'null']],
],
];
}
public function __construct(array $data) {
$this->id = $data['id'] ?? null;
$this->name = $data['name'];
$this->block_size = $data['block_size'] ?? 0;
$this->google_calendar_id = $data['google_calendar_id'] ?? null;
$this->maintainer_email = $data['maintainer_email'] ?? null;
}
}
@@ -0,0 +1,21 @@
<?php
/**
* Number of available seats for each time block
*/
class RsvTimetableAvailability {
/**
* @param array<int,int> $occupancy Number of available seats for each time block
*/
public function __construct(
public int $from_minutes,
public int $to_minutes,
public int $block_size_in_minutes,
public array $occupancy
) { }
public function push_block(int $capacity) {
$this->occupancy[] = $capacity;
$this->to_minutes += $this->block_size_in_minutes;
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
class RsvTimetableCapacity {
public int|null $id;
public int $timetable_id;
public int $capacity;
public int $min_lead_time_minutes;
public DateTime $date;
public int $start_time;
public int $end_time;
public int $repeat_period_in_days;
public int $repeat_times;
public bool $requires_confirmation;
public static function schema(): array {
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'readonly' => true],
'timetable_id' => ['type' => 'integer', 'readonly' => true],
'capacity' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
'min_lead_time_minutes' => ['type' => 'integer', 'required' => true, 'minimum' => 0],
'date' => ['type' => 'string', 'required' => true, 'format' => 'date'],
'start_time' => ['type' => 'integer', 'required' => true, 'minimum' => 0],
'end_time' => ['type' => 'integer', 'required' => true, 'minimum' => 0],
'repeat_period_in_days' => ['type' => 'integer', 'required' => true, 'minimum' => 0],
'repeat_times' => ['type' => 'integer', 'required' => true, 'minimum' => 0],
'requires_confirmation' => ['type' => 'boolean'],
],
];
}
public static function from_array(array $data): self {
return new self(
$data['id'],
$data['timetable_id'],
$data['capacity'],
$data['min_lead_time_minutes'],
new DateTime($data['date']),
$data['start_time'],
$data['end_time'],
$data['repeat_period_in_days'],
$data['repeat_times'],
$data['requires_confirmation'] ?? false
);
}
public function __construct(
int|null $id,
int $timetable_id,
int $capacity,
int $min_lead_time_minutes,
DateTime $date,
int $start_time,
int $end_time,
int|null $repeat_period_in_days,
int|null $repeat_times,
bool $requires_confirmation
) {
$this->id = $id;
$this->timetable_id = $timetable_id;
$this->capacity = $capacity;
$this->min_lead_time_minutes = $min_lead_time_minutes;
$this->date = $date;
$this->start_time = $start_time;
$this->end_time = $end_time;
$this->repeat_period_in_days = $repeat_period_in_days;
$this->repeat_times = $repeat_times;
$this->requires_confirmation = $requires_confirmation;
// $this->repeat_times = $data['repeat_times'];
// $this->requires_confirmation = $data['requires_confirmation'] ?? false;
}
public function to_array(): array {
return [
'id' => $this->id,
'timetable_id' => $this->timetable_id,
'capacity' => $this->capacity,
'min_lead_time_minutes' => $this->min_lead_time_minutes,
'date' => $this->date->format('Y-m-d'),
'start_time' => $this->start_time,
'end_time' => $this->end_time,
'repeat_period_in_days' => $this->repeat_period_in_days,
'repeat_times' => $this->repeat_times,
'requires_confirmation' => $this->requires_confirmation,
];
}
}
@@ -0,0 +1,46 @@
<?php
class RsvTimetableReservation {
public int|null $id;
public int $timetable_id;
public DateTime $start_utc; // UTC, 'Y-m-d H:i:s'
public DateTime $end_utc; // UTC, 'Y-m-d H:i:s'
public static function schema(): array {
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'readonly' => true],
'timetable_id' => ['type' => 'integer', 'required' => true],
'start_utc' => ['type' => 'string', 'required' => true, 'format' => 'date-time'],
'end_utc' => ['type' => 'string', 'required' => true, 'format' => 'date-time'],
],
];
}
public function __construct(int|null $id, int $timetable_id, DateTime $start_utc, DateTime $end_utc) {
$this->id = $id;
$this->timetable_id = $timetable_id;
$this->start_utc = $start_utc;
$this->end_utc = $end_utc;
}
public static function from_array(array $data): self {
$utc = new DateTimeZone('UTC');
return new self(
$data['id'] ?? null,
(int) $data['timetable_id'],
new DateTime($data['start_utc'], $utc),
new DateTime($data['end_utc'], $utc),
);
}
public function to_array(): array {
return [
'id' => $this->id,
'timetable_id' => $this->timetable_id,
'start_utc' => $this->start_utc->format('Y-m-d H:i:s'),
'end_utc' => $this->end_utc->format('Y-m-d H:i:s'),
];
}
}
+15
View File
@@ -0,0 +1,15 @@
# PHP Code Structure
The PHP side combines processing and drawing forms.
## Views
Output side of the web-interface layer to the application.
## Services
Application-layer classes.
## Controllers
API classes.
@@ -0,0 +1,65 @@
<?php
use Reservair\Database\Db;
use Reservair\Logger\Logger;
class RsvFormDefinitionRepository {
private string $table;
public function __construct() {
$this->table = Db::prefix() . 'rsv_form_definition';
}
public function add(string $name, array $definition): int {
return Db::insert($this->table, [
'name' => $name,
'definition' => json_encode($definition),
]);
}
public function get_all(?int $limit = null, int $skip = 0): array {
if ($limit === null) {
return Db::get_results(
"SELECT form_id, name FROM {$this->table} ORDER BY form_id ASC",
[],
ARRAY_A
);
}
return Db::get_results(
"SELECT form_id, name FROM {$this->table} ORDER BY form_id ASC LIMIT %d OFFSET %d",
[$limit, $skip],
ARRAY_A
);
}
public function count_all(): int {
return (int) Db::get_var("SELECT COUNT(*) FROM {$this->table}");
}
public function update(int $id, string $name, array $definition): void {
Db::update(
$this->table,
['name' => $name, 'definition' => json_encode($definition)],
['form_id' => $id]
);
}
public function delete(int $id): void {
Db::delete($this->table, ['form_id' => $id]);
}
public function get(int $id): ?array {
$row = Db::get_row(
"SELECT * FROM {$this->table} WHERE form_id = %d",
[$id],
ARRAY_A
);
if ($row === null) {
return null;
}
$row['definition'] = json_decode($row['definition'], true);
return $row;
}
}
@@ -0,0 +1,37 @@
<?php
use Reservair\Database\Db;
class RsvFormSubmitRepository {
private string $table;
public function __construct() {
$this->table = Db::prefix() . 'rsv_form_submit';
}
public function add(int $form_id, array $values): int {
return Db::insert($this->table, [
'form_id' => $form_id,
'values' => json_encode($values),
]);
}
public function delete(int $id): void {
Db::delete($this->table, ['form_submit_id' => $id]);
}
public function get(int $id): ?array {
$row = Db::get_row(
"SELECT * FROM {$this->table} WHERE form_submit_id = %d",
[$id],
ARRAY_A
);
if ($row === null) {
return null;
}
$row['values'] = json_decode($row['values'], true);
return $row;
}
}
@@ -0,0 +1,125 @@
<?php
use Reservair\Database\Db;
class RsvReservationRepository {
private string $table;
private string $timetable_reservations_table;
public function __construct() {
$this->table = Db::prefix() . 'rsv_reservation';
$this->timetable_reservations_table = Db::prefix() . 'rsv_timetable_reservation';
}
public function get_all(?int $limit = null, int $skip = 0) {
if ($limit === null) {
return Db::get_results("SELECT * FROM {$this->table} ORDER BY id DESC");
}
return Db::get_results(
"SELECT * FROM {$this->table} ORDER BY id DESC LIMIT %d OFFSET %d",
[$limit, $skip]
);
}
public function count_all(): int {
return (int) Db::get_var("SELECT COUNT(*) FROM {$this->table}");
}
public function get(int $id) {
return Db::get_row(
"SELECT * FROM {$this->table} WHERE id = %d",
[$id]
);
}
public function get_detail(int $id): ?array {
$reservation = Db::get_row(
"SELECT * FROM {$this->table} WHERE id = %d",
[$id],
ARRAY_A
);
if ($reservation === null) {
return null;
}
$form_submit_table = Db::prefix() . 'rsv_form_submit';
$form_submit = Db::get_row(
"SELECT `values` FROM {$form_submit_table} WHERE form_submit_id = %d",
[(int) $reservation['form_submit_id']],
ARRAY_A
);
$reservation['form_values'] = $form_submit ? json_decode($form_submit['values'], true) : null;
$reservation['timetable_reservations'] = Db::get_results(
"SELECT id, timetable_id, `start_utc`, `end_utc` FROM {$this->timetable_reservations_table} WHERE reservation_id = %d",
[$id],
ARRAY_A
);
$confirmation_table = Db::prefix() . 'rsv_timetable_reservation_confirmation';
$reservation['pending_confirmation'] = (int) Db::get_var(
"SELECT COUNT(*) FROM {$confirmation_table} c
JOIN {$this->timetable_reservations_table} tr ON tr.id = c.timetable_reservation_id
WHERE tr.reservation_id = %d",
[$id]
) > 0;
return $reservation;
}
public function insert(array $data): int {
return Db::insert($this->table, $data);
}
public function delete(int $id): void {
Db::delete($this->table, ['id' => $id]);
}
public function count_subitems(int $reservation_id): int {
return (int) Db::get_var(
"SELECT COUNT(*) FROM {$this->timetable_reservations_table} WHERE reservation_id = %d",
[$reservation_id]
);
}
public function are_subitems_confirmed(int $reservation_id): bool {
$result = Db::get_row(
"SELECT MIN(rtr.is_confirmed) AS is_confirmed
FROM {$this->table} AS rr
LEFT JOIN {$this->timetable_reservations_table} AS rtr ON rr.id = rtr.reservation_id
WHERE rr.id = %d",
[$reservation_id]
);
return $result !== null && $result->is_confirmed == 1;
}
/**
* Whether the reservation has reached a terminal state. `is_confirmed` is
* NULL while pending, 1 once confirmed and 0 once refused, so any non-NULL
* value means the outcome is settled.
*/
public function is_resolved(int $reservation_id): bool {
$result = Db::get_row(
"SELECT is_confirmed FROM {$this->table} WHERE id = %d",
[$reservation_id]
);
return $result !== null && $result->is_confirmed !== null && $result->is_confirmed;
}
public function set_resolved_state(int $reservation_id, bool $confirmed): void {
Db::update(
$this->table,
['is_confirmed' => $confirmed ? 1 : 0],
['id' => $reservation_id]
);
}
public function get_form_submit_id(int $reservation_id): ?int {
$result = Db::get_var(
"SELECT form_submit_id FROM {$this->table} WHERE id = %d",
[$reservation_id]
);
return $result !== null ? (int) $result : null;
}
}
@@ -0,0 +1,104 @@
<?php
use Reservair\Database\Db;
class RsvTimetableCapacityRepository {
private string $table;
public function __construct() {
$this->table = Db::prefix() . 'rsv_timetable_capacity';
}
public function get_all($timetable_id, ?int $limit = null, int $skip = 0): array {
if ($limit === null) {
return Db::get_results(
"SELECT * FROM {$this->table} WHERE timetable_id = %d ORDER BY id",
[$timetable_id]
);
}
return Db::get_results(
"SELECT * FROM {$this->table} WHERE timetable_id = %d ORDER BY id LIMIT %d OFFSET %d",
[$timetable_id, $limit, $skip]
);
}
public function count_all($timetable_id): int {
return (int) Db::get_var(
"SELECT COUNT(*) FROM {$this->table} WHERE timetable_id = %d",
[$timetable_id]
);
}
public function get(int $id): ?RsvTimetableCapacity {
$row = Db::get_row(
"SELECT * FROM {$this->table} WHERE id = %d",
[$id],
ARRAY_A
);
return $row === null ? null : RsvTimetableCapacity::from_array($row);
}
public function create(RsvTimetableCapacity $capacity): int {
return Db::insert($this->table, $capacity->to_array());
}
public function delete(int $id): void {
Db::delete($this->table, ['id' => $id]);
}
public function update(int $id, RsvTimetableCapacity $capacity): int {
$capacity->id = $id;
return Db::update($this->table, $capacity->to_array(), ['id' => $id]);
}
public function get_overlapping_capacity(int $timetable_id, DateTime $start, DateTime $end): array {
$start_str = (clone $start)->setTimezone(wp_timezone())->format('Y-m-d H:i:s');
$end_str = (clone $end)->setTimezone(wp_timezone())->format('Y-m-d H:i:s');
return Db::get_results(
"SELECT * FROM {$this->table}
WHERE timetable_id = %d
AND (
date = DATE(%s)
OR (
repeat_period_in_days > 0
AND DATEDIFF(DATE(%s), date) >= 0
AND MOD(DATEDIFF(DATE(%s), date), repeat_period_in_days) = 0
)
)
AND DATE_ADD(DATE(%s), INTERVAL start_time MINUTE) < %s
AND DATE_ADD(DATE(%s), INTERVAL end_time MINUTE) > %s
ORDER BY start_time",
[$timetable_id, $start_str, $start_str, $start_str, $start_str, $end_str, $start_str, $start_str]
);
}
public function get_capacities_for_date(int $timetable_id, DateTime $date) : array {
$row = Db::get_results(
"SELECT *
FROM {$this->table}
WHERE timetable_id = %d
AND (date = DATE(%s) OR (repeat_period_in_days > 0 AND MOD(DATEDIFF(date, %s), repeat_period_in_days) = 0))
ORDER BY start_time ASC",
[$timetable_id, $date->format('Y-m-d'), $date->format('Y-m-d')],
ARRAY_A
);
return array_map(fn($x) => RsvTimetableCapacity::from_array($x), $row);
}
public function get_available_range_for_date(int $timetable_id, DateTime $date) {
if ($date === null) {
throw new InvalidArgumentException('Invalid date');
}
return Db::get_row(
"SELECT MAX(start_time) AS `from`, MIN(end_time) AS `to`
FROM {$this->table}
WHERE timetable_id = %d
AND (date = DATE(%s) OR (repeat_period_in_days > 0 AND MOD(DATEDIFF(date, %s), repeat_period_in_days) = 0))",
[$timetable_id, $date->format('Y-m-d'), $date->format('Y-m-d')]
);
}
}
@@ -0,0 +1,83 @@
<?php
use Reservair\Database\Db;
class RsvTimetableRepository {
private string $table;
public function __construct() {
$this->table = Db::prefix() . 'rsv_timetable';
}
public function get_all(?int $limit = null, int $skip = 0): array {
if ($limit === null) {
return Db::get_results("SELECT * FROM {$this->table} ORDER BY id");
}
return Db::get_results(
"SELECT * FROM {$this->table} ORDER BY id LIMIT %d OFFSET %d",
[$limit, $skip]
);
}
public function count_all(): int {
return (int) Db::get_var("SELECT COUNT(*) FROM {$this->table}");
}
public function get(int $id): ?RsvTimetable {
$row = Db::get_row(
"SELECT * FROM {$this->table} WHERE id = %d",
[$id],
ARRAY_A
);
if ($row === null) {
return null;
}
return new RsvTimetable($row);
}
public function create(RsvTimetable $timetable): int {
return Db::insert($this->table, [
'name' => $timetable->name,
'block_size' => $timetable->block_size,
'maintainer_email' => $timetable->maintainer_email,
]);
}
public function update(int $id, RsvTimetable $timetable): int {
return Db::update(
$this->table,
[
'name' => $timetable->name,
'block_size' => $timetable->block_size,
'maintainer_email' => $timetable->maintainer_email,
],
['id' => $id]
);
}
public function get_all_maintainer_emails(): array {
return Db::get_col(
"SELECT DISTINCT maintainer_email FROM {$this->table}
WHERE maintainer_email IS NOT NULL AND maintainer_email != ''"
);
}
public function get_maintainer_email(int $id): ?string {
return Db::get_var(
"SELECT maintainer_email FROM {$this->table} WHERE id = %d",
[$id]
) ?: null;
}
public function set_google_calendar_id(int $id, ?string $calendar_id): void {
Db::update(
$this->table,
['google_calendar_id' => $calendar_id],
['id' => $id]
);
}
public function delete(int $id): int {
return Db::delete($this->table, ['id' => $id]);
}
}
@@ -0,0 +1,129 @@
<?php
use Reservair\Database\Db;
class RsvTimetableReservationRepository {
private string $table;
private string $confirmation_table;
public function __construct() {
$this->table = Db::prefix() . 'rsv_timetable_reservation';
$this->confirmation_table = Db::prefix() . 'rsv_timetable_reservation_confirmation';
}
public function get_all(): array {
return array_map(
'RsvTimetableReservation::from_array',
Db::get_results("SELECT * FROM {$this->table}")
);
}
public function get(int $id): RsvTimetableReservation {
return RsvTimetableReservation::from_array(Db::get_row(
"SELECT * FROM {$this->table} WHERE id = %d",
[$id],
ARRAY_A
));
}
public function get_overlapping(int $timetable_id, DateTime $start_utc, DateTime $end_utc): array {
return array_map(
'RsvTimetableReservation::from_array',
Db::get_results(
"SELECT * FROM {$this->table}
WHERE timetable_id = %d
AND `start_utc` < %s
AND `end_utc` > %s",
[$timetable_id, $end_utc, $start_utc]
)
);
}
public function insert(array $data): int {
return Db::insert($this->table, $data);
}
public function insert_confirmation(int $reservation_id, int $timetable_reservation_id, string $code): void {
Db::insert($this->confirmation_table, [
'reservation_id' => $reservation_id,
'timetable_reservation_id' => $timetable_reservation_id,
'code' => $code,
]);
}
public function get_confirmation(string $code): ?array {
return Db::get_row(
"SELECT c.*
FROM {$this->confirmation_table} c
JOIN {$this->table} tr ON tr.id = c.timetable_reservation_id
WHERE c.code = %s
LIMIT 1",
[$code],
ARRAY_A
);
}
public function set_confirmed(int $timetable_reservation_id, bool $state): void {
Db::update(
$this->table,
['is_confirmed' => $state ? 1 : 0],
['id' => $timetable_reservation_id]
);
}
public function delete_confirmation(string $code): void {
Db::delete($this->confirmation_table, ['code' => $code]);
}
public function has_pending_confirmation(int $reservation_id): bool {
return (int) Db::get_var(
"SELECT COUNT(*) FROM {$this->confirmation_table} c
JOIN {$this->table} tr ON tr.id = c.timetable_reservation_id
WHERE tr.reservation_id = %d",
[$reservation_id]
) > 0;
}
public function get_confirmation_code(int $reservation_id): ?string {
return Db::get_var(
"SELECT c.code FROM {$this->confirmation_table} c
JOIN {$this->table} tr ON tr.id = c.timetable_reservation_id
WHERE tr.reservation_id = %d
LIMIT 1",
[$reservation_id]
);
}
public function get_by_timetable(int $timetable_id, ?int $limit = null, int $skip = 0): array {
$sql = "SELECT tr.*, c.timetable_reservation_id AS pending_confirmation_id
FROM {$this->table} tr
LEFT JOIN {$this->confirmation_table} c ON c.timetable_reservation_id = tr.id
WHERE tr.timetable_id = %d
ORDER BY tr.start_utc DESC";
if ($limit === null) {
return Db::get_results($sql, [$timetable_id], ARRAY_A);
}
return Db::get_results($sql . " LIMIT %d OFFSET %d", [$timetable_id, $limit, $skip], ARRAY_A);
}
public function count_by_timetable(int $timetable_id): int {
return (int) Db::get_var(
"SELECT COUNT(*) FROM {$this->table} WHERE timetable_id = %d",
[$timetable_id]
);
}
public function get_reservations_on_date(int $timetable_id, DateTime $date_utc): array {
return array_map(
'RsvTimetableReservation::from_array',
Db::get_results(
"SELECT * FROM {$this->table}
WHERE timetable_id = %d AND DATE(`start_utc`) = DATE(%s)
ORDER BY `start_utc`",
[$timetable_id, $date_utc->format('Y-m-d')],
ARRAY_A
)
);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
/**
* Contains definitions of the admin menus
*/
function rsv_admin_menu_definition() {
add_menu_page(
'Reservations Settings', // Page title
'Reservations', // Menu title
RsvCapabilities::MANAGE, // Capability
'reservations-settings', // Menu slug
'rsv_reservations_page', // Callback
'dashicons-calendar', // Icon
20 // Position
);
add_submenu_page(
'reservations-settings',
'Forms',
'Forms',
RsvCapabilities::MANAGE,
'forms-settings',
'rsv_forms_page'
);
add_submenu_page(
'reservations-settings',
'Timetables',
'Timetables',
RsvCapabilities::MANAGE,
'timetable-settings',
'rsv_timetable_page'
);
add_submenu_page(
'reservations-settings',
'Google Calendar',
'Google Calendar',
RsvCapabilities::MANAGE,
'rsv-google-calendar',
'rsv_google_calendar_settings_page'
);
}
+97
View File
@@ -0,0 +1,97 @@
<?php
/**
* Defines assets that must be included to the client. Separated into assets for
* admin and the default user.
*/
function rsv_asset_url(string $relative): string {
return plugin_dir_url(__FILE__) . '../assets/' . $relative;
}
function rsv_asset_file(string $relative): string {
return plugin_dir_path(__FILE__) . '../assets/' . $relative;
}
function rsv_js(string $handle, string $relative, array $deps = []): void {
wp_enqueue_script($handle, rsv_asset_url($relative), $deps, filemtime(rsv_asset_file($relative)));
}
function rsv_css(string $handle, string $relative): void {
wp_enqueue_style($handle, rsv_asset_url($relative), [], filemtime(rsv_asset_file($relative)));
}
// --- Shared between frontend and admin ---
function rsv_enqueue_shared_assets(): void {
rsv_js('rsv_calendar', 'js/elements/RsvCalendar.js');
rsv_js('rsv_timeline', 'js/elements/RsvTimeline.js');
rsv_js('rsv_api', 'js/RsvApi.js');
rsv_js('reservation_selector', 'js/elements/RsvReservationSelector.js');
rsv_js('rsv_reservation_summary', 'js/elements/RsvReservationSummary.js');
rsv_js('rsv_data_source', 'js/datasource/RsvDataSource.js');
rsv_js('rsv_reservation_resource', 'js/datasource/RsvReservationResource.js');
rsv_js('rsv_form_definition_resource', 'js/datasource/RsvFormDefinitionResource.js');
rsv_js('rsv_timetable_resource', 'js/datasource/RsvTimetableResource.js');
rsv_js('rsv_timetable_capacity_resource', 'js/datasource/RsvTimetableCapacityResource.js');
rsv_js('rsv_timetable_reservation_resource', 'js/datasource/RsvTimetableReservationResource.js');
rsv_js('rsv_reservation_client', 'js/datasource/RsvReservationClient.js');
wp_localize_script('rsv_api', 'ReservairServiceAPI', [
'restUrl' => rest_url('reservations/v1'),
'nonce' => wp_create_nonce('wp_rest'),
]);
wp_localize_script('rsv_api', 'ReservairStrings', [
'timeline' => [
'not_reservable' => 'Tento objekt nelze rezervovat.',
'no_blocks' => 'Tento den není dostupný žádný blok. Vyberte jiné datum.',
'seats' => 'míst',
],
'summary' => [
'title' => 'Vybrané termíny',
'clear_all' => 'Smazat vše',
'count_one' => '1 termín',
'count_few' => '%d termíny',
'count_many' => '%d termínů',
'currency' => 'Kč',
],
'form' => [
'success_title' => 'Rezervace potvrzena!',
'success_subtitle' => 'Potvrzení jsme zaslali na váš email.',
'new_reservation' => 'Nová rezervace',
'error_generic' => 'Něco se pokazilo. Zkuste to prosím znovu.',
],
]);
rsv_css('reservations-styles', 'css/RsvMainStyle.css');
rsv_css('rsv-form-summary-styles', 'css/components/RsvFormSummaryStyles.css');
rsv_css('rsv-calendar-styles', 'css/components/RsvCalendarStyles.css');
rsv_css('rsv-form-styles', 'css/components/RsvFormStyles.css');
rsv_css('rsv-time-slot-styles', 'css/components/RsvTimeSlotsStyles.css');
}
// --- Public hooks ---
function rsv_enqueue_assets(): void {
rsv_enqueue_shared_assets();
rsv_js('rsv_timetable_service', 'js/services/RsvTimetableService.js');
rsv_js('rsv_form_sender', 'js/forms/RsvFormSender.js');
rsv_js('rsv_form_encoder', 'js/forms/RsvFormEncoder.js');
}
function rsv_enqueue_admin_assets(): void {
rsv_enqueue_shared_assets();
$admin_js = plugin_dir_path(__FILE__) . '../src/components/admin.js';
wp_enqueue_script('admin', plugin_dir_url(__FILE__) . '../src/components/admin.js', [], filemtime($admin_js));
rsv_js('rsv_inline_form_builder', 'js/forms/RsvInlineFormBuilder.js');
rsv_js('datagrid', 'js/elements/RsvDatagrid.js');
rsv_js('rsv_form_encoder', 'js/forms/RsvFormEncoder.js');
// RsvAdminForm needs the encoder, the localized nonce (rsv_api), and
// show_notice() (admin) — declare them as deps so load order is correct.
rsv_js('rsv_admin_form', 'js/forms/RsvAdminForm.js', ['rsv_form_encoder', 'rsv_api', 'admin']);
rsv_css('rsv-admin-style', 'css/RsvAdminStyle.css');
}
+60
View File
@@ -0,0 +1,60 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Central definition and lifecycle for the plugin's custom capability.
*
* `manage_reservations` gates every administrative REST endpoint. It is granted to the
* roles in DEFAULT_ROLES on activation.
*
* Because WordPress only runs the activation hook on *activate* (never on a
* plugin update), ensure() re-grants the capability when the stored version
* lags behind, so an update can never silently lock admins out of the API.
*/
final class RsvCapabilities {
/** The capability that authorises managing reservation data. */
public const MANAGE = 'manage_reservations';
/** Bumped whenever the capability set changes, to drive re-grants on update. */
public const VERSION = '1';
/** Option that records which capability VERSION has been applied. */
private const VERSION_OPTION = 'rsv_caps_version';
/** Roles that receive the capability by default. */
private const DEFAULT_ROLES = [ 'administrator' ];
/**
* Grant the capability to the default roles, then record the version.
* Idempotent and safe to call on activation and on every bootstrap.
*/
public static function ensure(): void {
if ( get_option( self::VERSION_OPTION ) === self::VERSION ) {
return;
}
foreach ( self::DEFAULT_ROLES as $role_name ) {
$role = get_role( $role_name );
if ( $role && ! $role->has_cap( self::MANAGE ) ) {
$role->add_cap( self::MANAGE );
}
}
update_option( self::VERSION_OPTION, self::VERSION );
}
/** Remove the capability from every role and clear the version marker. */
public static function revoke(): void {
foreach ( array_keys( wp_roles()->roles ) as $role_name ) {
$role = get_role( $role_name );
if ( $role ) {
$role->remove_cap( self::MANAGE );
}
}
delete_option( self::VERSION_OPTION );
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Symmetric encryption for secrets kept in wp_options (Google OAuth access /
* refresh tokens and the client secret).
*
* Uses libsodium's secretbox with a 32-byte key derived from the site's
* AUTH_KEY / AUTH_SALT. Ciphertext is prefixed with "rsvenc:" so decrypt() can
* transparently pass through values that were written as plaintext before this
* was introduced — existing installs migrate to encrypted storage on the next
* write (e.g. the next token refresh).
*/
final class RsvCrypto {
private const PREFIX = 'rsvenc:';
public static function encrypt(string $plaintext): string {
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = sodium_crypto_secretbox($plaintext, $nonce, self::key());
return self::PREFIX . base64_encode($nonce . $cipher);
}
/** Returns the plaintext, or null if a ciphertext can't be decrypted. */
public static function decrypt(string $stored): ?string {
if (!str_starts_with($stored, self::PREFIX)) {
return $stored; // legacy plaintext — pass through for migration
}
$raw = base64_decode(substr($stored, strlen(self::PREFIX)), true);
if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
return null;
}
$nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plain = sodium_crypto_secretbox_open($cipher, $nonce, self::key());
return $plain === false ? null : $plain;
}
/** 32-byte key derived from the site's auth salts. */
private static function key(): string {
$material = (defined('AUTH_KEY') ? AUTH_KEY : '') . '|' . (defined('AUTH_SALT') ? AUTH_SALT : '');
return hash('sha256', $material, true);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
use Reservair\Database\Db;
use Reservair\Logger\Logger;
class RsvInstaller {
public static function install() : void {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_form_definition (
form_id bigint unsigned NOT NULL AUTO_INCREMENT,
name TINYTEXT NOT NULL,
definition JSON NOT NULL,
PRIMARY KEY (form_id)
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_form_submit (
form_submit_id bigint unsigned NOT NULL AUTO_INCREMENT,
form_id bigint unsigned NOT NULL,
submitted_on_utc TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`values` JSON NOT NULL,
PRIMARY KEY (form_submit_id),
CONSTRAINT fk_form_submit_definition
FOREIGN KEY (form_id) REFERENCES {$wpdb->prefix}rsv_form_definition (form_id)
ON DELETE CASCADE
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_timetable (
id bigint unsigned NOT NULL AUTO_INCREMENT,
name TINYTEXT NOT NULL,
block_size int unsigned NOT NULL DEFAULT 0,
maintainer_email TINYTEXT NULL DEFAULT NULL,
google_calendar_id TINYTEXT NULL DEFAULT NULL,
PRIMARY KEY (id)
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_reservation (
id bigint unsigned NOT NULL AUTO_INCREMENT,
form_submit_id bigint unsigned NOT NULL,
is_confirmed tinyint(1) NULL DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT fk_reservation_form_submit
FOREIGN KEY (form_submit_id) REFERENCES {$wpdb->prefix}rsv_form_submit (form_submit_id)
ON DELETE CASCADE
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_timetable_capacity (
id bigint unsigned NOT NULL AUTO_INCREMENT,
timetable_id bigint unsigned NOT NULL,
capacity int unsigned NOT NULL DEFAULT 1,
min_lead_time_minutes int unsigned NOT NULL DEFAULT 0,
date DATE NOT NULL,
start_time smallint unsigned NOT NULL,
end_time smallint unsigned NOT NULL,
repeat_period_in_days int unsigned NOT NULL DEFAULT 0,
repeat_times int unsigned NOT NULL DEFAULT 0,
requires_confirmation tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_cap_timetable_date (timetable_id, date),
CONSTRAINT fk_capacity_timetable
FOREIGN KEY (timetable_id) REFERENCES {$wpdb->prefix}rsv_timetable (id)
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_timetable_reservation (
id bigint unsigned NOT NULL AUTO_INCREMENT,
timetable_id bigint unsigned NOT NULL,
reservation_id bigint unsigned NOT NULL,
start_utc DATETIME NOT NULL,
end_utc DATETIME NOT NULL,
is_confirmed tinyint(1) NULL DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_ttr_timetable_time (timetable_id, start_utc, end_utc),
CONSTRAINT fk_timetable_reservation_timetable
FOREIGN KEY (timetable_id) REFERENCES {$wpdb->prefix}rsv_timetable (id),
CONSTRAINT fk_timetable_reservation_reservation
FOREIGN KEY (reservation_id) REFERENCES {$wpdb->prefix}rsv_reservation (id)
ON DELETE CASCADE
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_timetable_reservation_confirmation (
reservation_id bigint unsigned NOT NULL,
timetable_reservation_id bigint unsigned NOT NULL,
code VARCHAR(32) NOT NULL,
PRIMARY KEY (timetable_reservation_id),
CONSTRAINT fk_trc_timetable_reservation
FOREIGN KEY (timetable_reservation_id) REFERENCES {$wpdb->prefix}rsv_timetable_reservation (id)
ON DELETE CASCADE,
CONSTRAINT fk_trc_reservation
FOREIGN KEY (reservation_id) REFERENCES {$wpdb->prefix}rsv_reservation (id)
ON DELETE CASCADE
) $charset_collate;");
// Grant the custom capability that gates the admin REST endpoints.
RsvCapabilities::ensure();
}
private static function run(string $sql) : void {
if (Db::query($sql) === false) {
Logger::error('RsvInstaller error');
}
}
public static function uninstall() : void {
RsvCapabilities::revoke();
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
use Reservair\Database\DbException;
use Reservair\Logger\Logger;
function rsv_define_rest_api(): void {
// Exception handler for all reservations/v1 endpoints.
// Runs before WordPress calls the callback, so no try/catch is needed
// in individual controller methods.
add_filter('rest_dispatch_request', function ($result, WP_REST_Request $request, string $route, array $handler) {
if ($result !== null) {
return $result;
}
if (!str_starts_with($route, '/reservations/v1/') && $route !== '/reservations/v1') {
return $result;
}
try {
return call_user_func($handler['callback'], $request);
} catch (DbException $e) {
Logger::error($e);
return new WP_REST_Response(['error' => 'A database error occurred.'], 500);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => $e->getMessage()], 400);
} catch (\Throwable $e) {
Logger::error($e);
return new WP_REST_Response(['error' => 'An unexpected error occurred.'], 500);
}
}, 10, 4);
// -------------------------------------------------------------------------
// Routes
// -------------------------------------------------------------------------
register_rest_route('reservations/v1', '/google-callback', [
'methods' => 'GET',
'callback' => 'rsv_google_oauth_callback',
// Public: OAuth redirect from Google; the handler validates the returned code.
'permission_callback' => [RsvRestPolicy::class, 'open'],
]);
register_rest_route('reservations/v1', '/google-calendar-hook', [
'methods' => 'POST',
'callback' => 'rsv_google_calendar_webhook',
// Public webhook: authorised by matching the secret channel id carried in
// the request headers (validated inside rsv_google_calendar_webhook).
'permission_callback' => [RsvRestPolicy::class, 'open'],
]);
register_rest_route('reservations/v1', '/google-calendars', [
'methods' => 'GET',
'callback' => function () {
$service = new RsvGoogleCalendarService();
if (!$service->is_google_connected()) {
return new WP_REST_Response(['error' => 'Not connected to Google Calendar'], 403);
}
return new WP_REST_Response($service->list_calendars(), 200);
},
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route('reservations/v1', '/timetable/(?P<id>\d+)/google-calendar', [
'methods' => 'PUT',
'callback' => function (WP_REST_Request $request) {
$id = (int) $request->get_param('id');
$calendar_id = $request->get_json_params()['calendar_id'] ?? null;
$service = new RsvTimetableService();
if (!$service->get($id)) {
return new WP_REST_Response(['error' => 'Timetable not found'], 404);
}
$service->set_google_calendar_id($id, $calendar_id ?: null);
return new WP_REST_Response(['ok' => true], 200);
},
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
(new RsvReservationController())->register_routes();
(new RsvTimetableDefinitionController())->register_routes();
(new RsvTimetableAvailabilityController())->register_routes();
(new RsvTimetableCapacityController())->register_routes();
(new RsvTimetableReservationController())->register_routes();
(new RsvFormController())->register_routes();
(new RsvFormDefinitionController())->register_routes();
}
@@ -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;
}
}
+397
View File
@@ -0,0 +1,397 @@
<?php
use Reservair\Forms\RsvFormBuilder;
// Shared inline script for the elements data grid.
// $elements_with_ids: array of element objects already carrying an 'id' key.
// $next_id: the first integer not yet used as an id.
function rsv_elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = []): void {
$elements_json = json_encode($elements_with_ids);
$types_json = json_encode(array_values($element_types));
$timetables_json = json_encode(array_values($timetables));
?>
<script>
const rsv_element_types = <?= $types_json ?>;
const rsv_timetables = <?= $timetables_json ?>;
const RSV_EMAIL_DEFAULTS = {
accepted_subject: <?= json_encode('Rezervace přijata') ?>,
accepted_body: <?= json_encode("<h1>Vaše rezervace byla přijata</h1>\n<p>Vaše rezervace byla schválena. Těšíme se na vás!</p>") ?>,
refused_subject: <?= json_encode('Rezervace zamítnuta') ?>,
refused_body: <?= json_encode("<h1>Vaše rezervace byla zamítnuta</h1>\n<p>Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.</p>") ?>,
};
const rsv_elements_source = (function(initial_items, next_id_start) {
const items = initial_items;
let next_id = next_id_start;
return {
get_page(skip, limit) {
skip = skip ?? 0;
limit = limit ?? 20;
return Promise.resolve({ total: items.length, data: items.slice(skip, skip + limit) });
},
put(id, data) {
const idx = items.findIndex(e => e.id === id);
if (idx === -1) return Promise.reject(new Error('Element not found'));
// Destructure reservation-specific fields so they don't bleed into extra_attrs.
const { id: _id, name: _n, label: _l, type: _t, desc: _d, required: _r,
price_per_block: _p, email_templates: _et, timetable_id: _ti, ...extra_attrs } = items[idx];
items[idx] = {
...extra_attrs,
id,
name: data.name ?? '',
label: data.label ?? '',
type: data.type ?? 'text',
desc: data.desc ?? '',
required: data.required === 'on',
...(data.type === 'input-text' ? {
validation: data.validation ?? '',
pattern: data.pattern ?? '',
pattern_message: data.pattern_message ?? '',
} : {}),
...(data.type === 'reservation' ? {
timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null,
price_per_block: parseFloat(data.price_per_block ?? '0') || 0,
email_templates: {
on_accepted: {
enabled: !!data.email_accepted_enabled,
subject: data.email_accepted_subject ?? RSV_EMAIL_DEFAULTS.accepted_subject,
body: data.email_accepted_body ?? RSV_EMAIL_DEFAULTS.accepted_body,
},
on_refused: {
enabled: !!data.email_refused_enabled,
subject: data.email_refused_subject ?? RSV_EMAIL_DEFAULTS.refused_subject,
body: data.email_refused_body ?? RSV_EMAIL_DEFAULTS.refused_body,
},
},
} : {}),
};
return Promise.resolve(items[idx]);
},
add() {
const item = { id: next_id++, name: '', label: '', type: 'text', desc: '', required: false };
items.push(item);
return item;
},
move_up(id) {
const idx = items.findIndex(e => e.id === id);
if (idx > 0) [items[idx - 1], items[idx]] = [items[idx], items[idx - 1]];
},
move_down(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1 && idx < items.length - 1) [items[idx], items[idx + 1]] = [items[idx + 1], items[idx]];
},
remove(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1) items.splice(idx, 1);
},
get_all() {
return items.map(({ id, ...rest }) => rest);
},
};
})(<?= $elements_json ?>, <?= $next_id ?>);
function rsv_render_element_inline_form(dt, row, data) {
const builder = RsvInlineFormBuilder.create(rsv_elements_source)
.fieldset('Element', '50%')
.input_text('name', 'Slug', data?.name ?? '')
.input_text('label', 'Label', data?.label ?? '')
.input_select('type', 'Type', rsv_element_types, data?.type ?? rsv_element_types[0])
.fieldset('Options', '50%')
.input_text('desc', 'Description', data?.desc ?? '')
.input_checkbox('required', 'Required', data?.required ?? false);
if ((data?.type ?? rsv_element_types[0]) === 'reservation') {
const accepted = data?.email_templates?.on_accepted ?? {};
const refused = data?.email_templates?.on_refused ?? {};
const timetable_options = [
{ value: '', label: '— none —' },
...rsv_timetables.map(t => ({ value: t.id, label: t.name })),
];
builder
.input_select('timetable_id', 'Timetable', timetable_options, data?.timetable_id ?? '')
.input_number('price_per_block', 'Price per block', data?.price_per_block ?? 0)
.fieldset('Email — accepted', '100%')
.input_checkbox('email_accepted_enabled', 'Send email when accepted', accepted.enabled ?? true)
.input_text('email_accepted_subject', 'Subject', accepted.subject ?? RSV_EMAIL_DEFAULTS.accepted_subject)
.input_textarea('email_accepted_body', 'Body', accepted.body ?? RSV_EMAIL_DEFAULTS.accepted_body)
.fieldset('Email — refused', '100%')
.input_checkbox('email_refused_enabled', 'Send email when refused', refused.enabled ?? true)
.input_text('email_refused_subject', 'Subject', refused.subject ?? RSV_EMAIL_DEFAULTS.refused_subject)
.input_textarea('email_refused_body', 'Body', refused.body ?? RSV_EMAIL_DEFAULTS.refused_body);
}
if ((data?.type ?? rsv_element_types[0]) === 'input-text') {
builder
.input_select('validation', 'Validation', [
{ value: '', label: '— none —' },
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'digits', label: 'Digits only' },
{ value: 'pattern', label: 'Custom pattern' },
], data?.validation ?? '')
.input_text('pattern', 'Custom pattern (regex)', data?.pattern ?? '')
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'))
.input_text('pattern_message', 'Pattern error message', data?.pattern_message ?? '')
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'));
}
const node = builder.build({
id: data?.id,
colspan: 5,
save_label: 'Save',
on_success: () => elements_dt.refresh(),
on_cancel: () => elements_dt.refresh(),
});
// Type swaps whole fieldsets, so re-render the inline form on change.
// Common fields are carried over; type-specific fields reset to stored data.
const type_select = node.querySelector('select[name="type"]');
if (type_select) {
type_select.addEventListener('change', () => {
const current = Object.fromEntries(new FormData(node.querySelector('form')));
const merged = {
...data,
name: current.name ?? data?.name,
label: current.label ?? data?.label,
desc: current.desc ?? data?.desc,
required: 'required' in current,
type: type_select.value,
};
row.replaceChildren(rsv_render_element_inline_form(dt, row, merged));
});
}
return node;
}
// The elements data grid only exists on the edit page. Guard it so a missing
// container can't abort the script and strip the form's submit handler.
const elements_table_el = document.getElementById('form_elements_table');
if (elements_table_el) {
var elements_dt = RsvDataGrid.create_data_grid(
elements_table_el,
rsv_elements_source,
{
'name': RsvDataGrid.action_column('Name', false, {
'Edit': RsvDataGrid.edit_action(function(dt, row, data) {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_element_inline_form(dt, row, data));
}),
'Move Up': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_up(data.id);
dt.refresh();
}),
'Move Down': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_down(data.id);
dt.refresh();
}),
'Remove': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.remove(data.id);
dt.refresh();
}),
}),
'label': RsvDataGrid.column('Label', false),
'type': RsvDataGrid.column('Type', false),
'desc': RsvDataGrid.column('Description', false),
'required': RsvDataGrid.column('Required', false),
'details': RsvDataGrid.column('Details', false),
}
);
elements_dt.map_column('details', (dt, row, data) => {
const td = document.createElement('td');
if (data.type === 'reservation') {
const parts = [];
if (data.timetable_id) {
const t = rsv_timetables.find(t => t.id === data.timetable_id);
parts.push(`Timetable: ${t ? t.name : data.timetable_id}`);
}
if (data.price_per_block != null) parts.push(`Price/block: ${data.price_per_block}`);
const et = data.email_templates ?? {};
const emails = [];
if (et.on_accepted?.enabled) emails.push('accepted');
if (et.on_refused?.enabled) emails.push('refused');
if (emails.length) parts.push(`Emails: ${emails.join(', ')}`);
td.innerText = parts.join(' · ');
}
return td;
});
elements_dt.refresh();
document.getElementById('rsv_add_element_btn').onclick = function() {
rsv_elements_source.add();
elements_dt.refresh();
};
}
RsvAdminForm.bind(document.getElementById('<?= $form_id ?>'), {
transform: (body) => ({
name: body.name,
definition: {
email_key: body.definition?.email_key ?? '',
elements: rsv_elements_source.get_all(),
},
}),
refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); },
});
</script>
<?php
}
function rsv_form_info_page(): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$elements_with_ids = [];
$next_id = 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Formuláře</h1>
<hr>
<div id="col-container" class="wp-clearfix">
<div id="col-left">
<div class="col-wrap">
<div class="form-wrap">
<h2>Přidat formulář</h2>
<form id="add_form_definition"
method="post"
data-method="POST"
data-success-msg="Form definition created."
action="<?= get_rest_url(null, 'reservations/v1/form-definition'); ?>">
<?php wp_nonce_field('my_action', 'my_nonce'); ?>
<?php echo RsvFormBuilder::create('add_form_definition')->text('name', 'Název')->render(); ?>
</form>
</div>
<hr>
<p class="submit">
<button type="submit" form="add_form_definition" class="button button-primary">Add Form Definition</button>
</p>
</div>
</div>
<div id="col-right">
<div class="col-wrap">
<div id="forms_table"></div>
<script>
var forms_dt = RsvDataGrid.create_data_grid(forms_table,
RsvFormDefinitionResource(), {
'form_id': RsvDataGrid.column('ID', false, 30),
'name': RsvDataGrid.action_column('Název', false, {
'Edit': RsvDataGrid.link_action((data) =>
`<?= menu_page_url('forms-settings', false) ?>&id=${data.form_id}&action=edit`
),
'Trash': RsvDataGrid.func_action(function(dt, row, data) {
dt.resource.delete(data.form_id).then(() => forms_dt.refresh()).catch(err => alert(err.message));
}),
'Clone': RsvDataGrid.func_action(function(dt, row, data) {
const base_url = `<?= get_rest_url(null, 'reservations/v1/form-definition') ?>`;
fetch(`${base_url}/${data.form_id}`, {
headers: { 'Accept': 'application/json' },
})
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Fetch failed'); });
return r.json();
})
.then(form_def => fetch(base_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
name: 'Copy of ' + form_def.name,
definition: form_def.definition,
}),
}))
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Clone failed'); });
forms_dt.refresh();
})
.catch(err => alert(err.message));
}),
}),
});
forms_dt.refresh();
</script>
</div>
</div>
</div>
<?php rsv_elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?>
<?php
}
function rsv_form_definition_edit_page(int $id): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$repo = new RsvFormDefinitionRepository();
$form_def = $repo->get($id);
if ($form_def === null) {
echo '<div class="notice notice-error"><p>Form definition not found.</p></div>';
return;
}
$definition = $form_def['definition'] ?? [];
$raw_elements = array_values($definition['elements'] ?? []);
$elements_with_ids = array_map(function (array $el, int $idx): array {
return array_merge($el, ['id' => $idx + 1]);
}, $raw_elements, array_keys($raw_elements));
$next_id = count($elements_with_ids) + 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Edit Form: <?= esc_html($form_def['name']) ?></h1>
<a href="<?= menu_page_url('forms-settings', false) ?>">← Back to Forms</a>
<hr>
<form id="edit_form_definition"
method="post"
data-method="PUT"
data-success-msg="Form definition updated."
action="<?= get_rest_url(null, 'reservations/v1/form-definition/' . $id); ?>">
<?php
echo RsvFormBuilder::create('edit_form_definition')
->text('name', 'Name', '', true, $form_def['name'])
->text('definition.email_key', 'Email Key', "Name of the form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
->render();
?>
</form>
<hr>
<h2>Form Elements</h2>
<p>Define the fields that will appear in this form.</p>
<div id="form_elements_table"></div>
<p>
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
</p>
<p class="submit">
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
</p>
<?php rsv_elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?>
<?php
}
function rsv_forms_page(): void {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'edit' && isset($_GET['id'])) {
rsv_form_definition_edit_page(intval($_GET['id']));
return;
}
}
rsv_form_info_page();
}
@@ -0,0 +1,132 @@
<?php
function rsv_google_calendar_settings_page(): void {
if (!current_user_can(RsvCapabilities::MANAGE)) {
return;
}
$service = new RsvGoogleCalendarService();
$notice = null;
if (isset($_GET['connected'])) {
$notice = ['type' => 'success', 'message' => 'Google Calendar connected successfully.'];
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['rsv_google_settings_nonce'])) {
if (!wp_verify_nonce($_POST['rsv_google_settings_nonce'], 'rsv_google_settings')) {
wp_die('Security check failed.');
}
if (isset($_POST['rsv_disconnect'])) {
$service->disconnect();
$notice = ['type' => 'success', 'message' => 'Disconnected from Google Calendar.'];
} elseif (isset($_POST['rsv_register_webhook'])) {
$result = $service->register_webhook();
$notice = isset($result['id'])
? ['type' => 'success', 'message' => 'Webhook registered.']
: ['type' => 'error', 'message' => 'Webhook registration failed: ' . ($result['error'] ?? json_encode($result))];
} elseif (isset($_POST['rsv_stop_webhook'])) {
$service->stop_webhook();
$notice = ['type' => 'success', 'message' => 'Webhook stopped.'];
} else {
update_option('rsv_google_client_id', sanitize_text_field($_POST['rsv_google_client_id'] ?? ''));
update_option('rsv_google_calendar_id', sanitize_text_field($_POST['rsv_google_calendar_id'] ?? 'primary'));
// Only overwrite the (encrypted) client secret when a new value is
// supplied — the field renders blank, so otherwise saving any other
// setting would wipe the stored secret.
$client_secret = sanitize_text_field($_POST['rsv_google_client_secret'] ?? '');
if ($client_secret !== '') {
$service->set_client_secret($client_secret);
}
$notice = ['type' => 'success', 'message' => 'Settings saved.'];
}
}
$connected = $service->is_google_connected();
$webhook_registered = $service->is_webhook_registered();
$webhook_expiry = (int) get_option('rsv_google_webhook_expiration', 0);
$client_id = esc_attr(get_option('rsv_google_client_id', ''));
$cal_id = esc_attr(get_option('rsv_google_calendar_id', 'primary'));
$oauth_url = esc_url($service->get_oauth_url());
?>
<div class="wrap">
<h1>Google Calendar</h1>
<?php if ($notice): ?>
<div class="notice notice-<?= esc_attr($notice['type']) ?> is-dismissible">
<p><?= esc_html($notice['message']) ?></p>
</div>
<?php endif; ?>
<form method="post">
<?php wp_nonce_field('rsv_google_settings', 'rsv_google_settings_nonce'); ?>
<h2>OAuth Credentials</h2>
<p>Create a project in <a href="https://console.cloud.google.com/" target="_blank">Google Cloud Console</a>, enable the <strong>Google Calendar API</strong>, and create OAuth 2.0 credentials. Set the authorised redirect URI to <code><?= esc_html(site_url('/wp-json/reservations/v1/google-callback')) ?></code>.</p>
<table class="form-table">
<tr>
<th><label for="rsv_google_client_id">Client ID</label></th>
<td><input class="regular-text" type="text" id="rsv_google_client_id" name="rsv_google_client_id" value="<?= $client_id ?>"></td>
</tr>
<tr>
<th><label for="rsv_google_client_secret">Client Secret</label></th>
<td><input class="regular-text" type="password" id="rsv_google_client_secret" name="rsv_google_client_secret" placeholder="<?= $connected ? '(saved)' : '' ?>"></td>
</tr>
<tr>
<th><label for="rsv_google_calendar_id">Calendar ID</label></th>
<td>
<input class="regular-text" type="text" id="rsv_google_calendar_id" name="rsv_google_calendar_id" value="<?= $cal_id ?>">
<p class="description">Use <code>primary</code> for the account's main calendar, or paste a specific calendar ID from Google Calendar settings.</p>
</td>
</tr>
</table>
<?php submit_button('Save Settings'); ?>
<hr>
<h2>Connection</h2>
<?php if ($connected): ?>
<p><span style="color:#46b450; font-weight:600;">&#10004; Connected to Google Calendar.</span></p>
<button type="submit" name="rsv_disconnect" value="1" class="button button-secondary" style="color:#b32d2e;">
Disconnect
</button>
<?php else: ?>
<p><span style="color:#dc3232; font-weight:600;">&#10008; Not connected.</span></p>
<a href="<?= $oauth_url ?>" class="button button-primary">Connect with Google</a>
<p class="description" style="margin-top:.5rem;">You must save the Client ID and Secret first.</p>
<?php endif; ?>
<?php if ($connected): ?>
<hr>
<h2>Webhook</h2>
<p>The webhook lets Google Calendar notify this site when you confirm or cancel a reservation event, so the reservation state is updated automatically.</p>
<?php $webhook_url = site_url('/wp-json/reservations/v1/google-calendar-hook'); ?>
<p>Webhook URL: <code><?= esc_html($webhook_url) ?></code></p>
<?php if (!str_starts_with($webhook_url, 'https://')): ?>
<div class="notice notice-warning inline">
<p><strong>HTTPS required.</strong> Google Calendar only accepts webhook URLs served over HTTPS. This site is currently on HTTP, so webhook registration will be rejected by Google. Use a publicly accessible HTTPS address in production.</p>
</div>
<?php endif; ?>
<?php if ($webhook_registered): ?>
<p><span style="color:#46b450; font-weight:600;">&#10004; Webhook active.</span>
<?php if ($webhook_expiry): ?>
Expires <?= esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $webhook_expiry)) ?>.
<?php endif; ?>
</p>
<button type="submit" name="rsv_register_webhook" value="1" class="button">Re-register</button>
<button type="submit" name="rsv_stop_webhook" value="1" class="button button-secondary" style="color:#b32d2e;">Stop</button>
<?php else: ?>
<p><span style="color:#dc3232; font-weight:600;">&#10008; Webhook not registered.</span></p>
<button type="submit" name="rsv_register_webhook" value="1" class="button button-primary">Register Webhook</button>
<?php endif; ?>
<?php endif; ?>
</form>
</div>
<?php
}
+219
View File
@@ -0,0 +1,219 @@
<?php
function rsv_reservations_page(): void {
?>
<h1>Form Submissions</h1>
<hr>
<div id="reservations_table"></div>
<script>
function rsv_fmt_utc(utc_str) {
if (!utc_str) return '';
return new Date(utc_str.replace(' ', 'T') + 'Z').toLocaleString();
}
function rsv_cell_value(value) {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function rsv_make_table(head_labels, rows_data, cell_fn) {
const table = document.createElement('table');
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
const thead = document.createElement('thead');
const header_row = document.createElement('tr');
for (const label of head_labels) {
const th = document.createElement('th');
th.textContent = label;
header_row.appendChild(th);
}
thead.appendChild(header_row);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const row_data of rows_data) {
const tr = document.createElement('tr');
for (const cell of cell_fn(row_data)) {
const td = document.createElement('td');
td.textContent = cell;
tr.appendChild(td);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
return table;
}
function rsv_flatten_form_entries(obj, depth) {
const rows = [];
for (const [key, val] of Object.entries(obj)) {
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
rows.push({ key, value: null, depth });
for (const child of rsv_flatten_form_entries(val, depth + 1)) rows.push(child);
} else if (Array.isArray(val)) {
rows.push({ key, value: null, depth });
val.forEach((item, i) => {
if (item !== null && typeof item === 'object') {
rows.push({ key: `[${i}]`, value: null, depth: depth + 1 });
for (const child of rsv_flatten_form_entries(item, depth + 2)) rows.push(child);
} else {
rows.push({ key: `[${i}]`, value: rsv_cell_value(item), depth: depth + 1 });
}
});
} else {
rows.push({ key, value: rsv_cell_value(val), depth });
}
}
return rows;
}
function rsv_make_form_table(form_values) {
const table = document.createElement('table');
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
const thead = document.createElement('thead');
const header_row = document.createElement('tr');
for (const label of ['Field', 'Value']) {
const th = document.createElement('th');
th.textContent = label;
header_row.appendChild(th);
}
thead.appendChild(header_row);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const { key, value, depth } of rsv_flatten_form_entries(form_values, 0)) {
const tr = document.createElement('tr');
const td_key = document.createElement('td');
td_key.textContent = key;
td_key.classList.add('rsv-form-key');
td_key.style.setProperty('--rsv-depth', depth);
if (value === null) td_key.classList.add('rsv-form-key--group');
const td_val = document.createElement('td');
td_val.textContent = value ?? '';
if (value === null) td_val.classList.add('rsv-form-val--null');
tr.appendChild(td_key);
tr.appendChild(td_val);
tbody.appendChild(tr);
}
table.appendChild(tbody);
return table;
}
function rsv_render_reservation_detail(dt, row, data, detail) {
const td = document.createElement('td');
td.setAttribute('colspan', 3);
td.classList.add('rsv-detail-expand');
const form_heading = document.createElement('h4');
form_heading.textContent = 'Form Submission';
form_heading.classList.add('rsv-detail-heading');
const form_values = detail.form_values ?? {};
let form_content;
if (Object.keys(form_values).length === 0) {
form_content = document.createElement('p');
form_content.textContent = 'No form values recorded.';
form_content.classList.add('rsv-detail-empty');
} else {
form_content = rsv_make_form_table(form_values);
}
const timetable_heading = document.createElement('h4');
timetable_heading.textContent = 'Timetable Reservations';
timetable_heading.classList.add('rsv-detail-heading');
const timetable_rows = detail.timetable_reservations ?? [];
let timetable_content;
if (timetable_rows.length === 0) {
timetable_content = document.createElement('p');
timetable_content.textContent = 'No timetable reservations.';
timetable_content.classList.add('rsv-detail-empty');
} else {
timetable_content = rsv_make_table(
['ID', 'Timetable', 'Start', 'End'],
timetable_rows,
r => [r.id, r.timetable_id, rsv_fmt_utc(r.start), rsv_fmt_utc(r.end)]
);
}
const actions = document.createElement('div');
actions.classList.add('rsv-detail-actions');
const close_btn = document.createElement('button');
close_btn.classList.add('button');
close_btn.textContent = 'Close';
close_btn.onclick = () => dt.refresh_row(row, data);
actions.appendChild(close_btn);
if (detail.pending_confirmation) {
const base_url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
const accept_btn = document.createElement('button');
accept_btn.classList.add('button', 'button-primary');
accept_btn.textContent = 'Accept';
accept_btn.onclick = () => {
accept_btn.disabled = true;
refuse_btn.disabled = true;
fetch(base_url + '/accept', { method: 'POST', credentials: 'same-origin' })
.then(() => dt.refresh())
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
};
const refuse_btn = document.createElement('button');
refuse_btn.classList.add('button', 'button-secondary', 'rsv-btn-refuse');
refuse_btn.textContent = 'Refuse';
refuse_btn.onclick = () => {
accept_btn.disabled = true;
refuse_btn.disabled = true;
fetch(base_url + '/refuse', { method: 'POST', credentials: 'same-origin' })
.then(() => dt.refresh())
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
};
actions.appendChild(accept_btn);
actions.appendChild(refuse_btn);
}
td.replaceChildren(form_heading, form_content, timetable_heading, timetable_content, actions);
return td;
}
var reservations_dt = RsvDataGrid.create_data_grid(
document.getElementById('reservations_table'),
RsvReservationResource(),
{
'id': RsvDataGrid.action_column('ID', false, {
'View': RsvDataGrid.func_action(function(dt, row, data) {
const url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
fetch(url, {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
})
.then(r => r.json())
.then(detail => {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_reservation_detail(dt, row, data, detail));
});
}),
}),
'form_submit_id': RsvDataGrid.column('Form Submit', false),
'is_confirmed': RsvDataGrid.column('Confirmed', false),
}
);
reservations_dt.refresh();
</script>
<?php
}
+434
View File
@@ -0,0 +1,434 @@
<?php
use Reservair\Forms\RsvFormBuilder;
function rsv_timetable_list_page() {
?>
<style>
/*#col-left {
width: 30%;
}*/
/*#col-right {
width: 70%;
}*/
</style>
<h1>Timetables</h1>
<hr>
<div id="col-container" class="wp-clearfix">
<div id="col-left">
<div class="col-wrap">
<div class="form-wrap">
<h2>Add timetable</h2>
<?php
$timetable_service = new RsvTimetableService();
$existing_emails = $timetable_service->get_all_maintainer_emails();
$existing_emails_json = json_encode($existing_emails);
?>
<datalist id="maintainer_email_suggestions">
<?php foreach ($existing_emails as $email): ?>
<option value="<?= esc_attr($email) ?>">
<?php endforeach; ?>
</datalist>
<form id="add_timetable_form"
method="post"
data-method="POST"
data-success-msg="Timetable created."
action="<?= get_rest_url(null, 'reservations/v1/timetable'); ?>">
<?php
echo RsvFormBuilder::create('add_timetable_form')
->text('name', 'Name', 'Name of the timetable that can be reserved.', true)
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, '', 1)
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, '', 'maintainer_email_suggestions')
->submit('Add Timetable')
->render();
?>
</form>
<script>
RsvAdminForm.bind(add_timetable_form, {
transform: (body) => ({
name: body.name,
block_size: parseInt(body.block_size),
maintainer_email: body.maintainer_email || null,
}),
refresh: () => availability_dt.refresh(),
});
</script>
</div>
</div>
</div>
<div id="col-right">
<div class="col-wrap">
<div id="availability_table">
</div>
<script>
var availability_dt = RsvDataGrid.create_data_grid(availability_table,
RsvTimetableResource(), {
'id': RsvDataGrid.column('ID', false, 20),
'name': RsvDataGrid.action_column('Název', false, {
'View': RsvDataGrid.link_action((data) => `<?= menu_page_url('timetable-settings', false) ?>&id=${data.id}&action=view`),
'Edit': RsvDataGrid.link_action((data) => `<?= menu_page_url('timetable-settings', false) ?>&id=${data.id}&action=edit`),
'Trash': RsvDataGrid.func_action((dt, row, data) => {
if (confirm('Delete this timetable? This cannot be undone.')) {
dt.resource.delete(data.id).then(() => dt.refresh());
}
}),
}),
'block_size': RsvDataGrid.column('Velikost bloku', false),
});
availability_dt.refresh();
</script>
</div>
</div>
</div>
<?php
}
function rsv_create_capacity_form($timetable_id) {
$form = RsvFormBuilder::create("create_capacity_form", get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'));
$form->date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d'));
$form->group('Availability Range', fn($g) => $g
->time('start_time', 'Start')
->time('end_time', 'End')
);
$form->number('capacity', 'Capacity', 'How many reservations can overlap on the same time.', true, 1, 1);
$form->number('min_lead_time_minutes', 'Minimum lead time (minutes)', 'How many minutes in advance must be the reservation created. This is useful if it takes some time to prepare the reservation.', true);
$form->checkbox('requires_confirmation', 'Requires Confirmation?', 'If checked, all the reservations that overlap this capacity will require confirmation from maintainer. The maintainer will receive an email asking for the confirmation.', true);
$form->custom('Is Repeating Event', function() {
return '
<input id="is_repeating" class="regular-text" type="checkbox" name="is_repeating" checked="true">
<p>If the capacity is available repeatingly. For example: repeat each monday every week.</p>
';
});
$form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true);
$form->custom('Apply to Days', function() {
return '
<table class="option-table">
<tbody>
<tr class="form-day-names">
<td>Monday</td>
<td>Tuesday</td>
<td>Wednesday</td>
<td>Thursday</td>
<td>Friday</td>
<td>Saturday</td>
<td>Sunday</td>
</tr>
<tr class="form-days">
<td><input class="is-repeating-input" type="checkbox" name="monday"></td>
<td><input class="is-repeating-input" type="checkbox" name="tuesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="wednesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="thursday"></td>
<td><input class="is-repeating-input" type="checkbox" name="friday"></td>
<td><input class="is-repeating-input" type="checkbox" name="saturday"></td>
<td><input class="is-repeating-input" type="checkbox" name="sunday"></td>
</tr>
</tbody>
</table>';
});
$form->submit('Create Capacity', 'button-primary', 'submit');
?>
<form
id="create_capacity_form"
data-method="POST"
data-success-msg="Capacity created."
action="<?= get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'); ?>" >
<?php
$form->output();
?>
</form>
<?php
}
function rsv_timetable_capacity_view($id) {
$timetable_service = new RsvTimetableService();
$timetable = $timetable_service->get($id);
$gcal_service = new RsvGoogleCalendarService();
$gcal_connected = $gcal_service->is_google_connected();
$current_calendar_id = $timetable->google_calendar_id ?? null;
$existing_emails = $timetable_service->get_all_maintainer_emails();
?>
<h2>Settings</h2>
<datalist id="maintainer_email_list">
<?php foreach ($existing_emails as $email): ?>
<option value="<?= esc_attr($email) ?>">
<?php endforeach; ?>
</datalist>
<form id="timetable_settings_form"
action="<?= esc_url(get_rest_url(null, 'reservations/v1/timetable/' . $id)) ?>"
data-method="PATCH"
data-success-msg="Settings saved.">
<?php
RsvFormBuilder::create("timetable_settings_form")
->text('name', 'Name', 'Name of the timetable that can be reserved.', true, $timetable->name)
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, $timetable->block_size, 1)
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, $timetable->maintainer_email ?? '', 'maintainer_email_suggestions')
->custom('Google Calendar', function() use ($gcal_connected) {
if (!$gcal_connected) {
return'<p>Not connected to Google Calendar.
<a href="' . esc_url(admin_url('admin.php?page=rsv-google-calendar')) . '">Connect in settings &rarr;</a>
</p>';
} else {
return '<select id="gcal_select" name="google_calendar_id" style="min-width:260px;">
<option value="">— None —</option>
</select>
<p class="description">Sync reservations to this calendar.</p>';
}
})
->submit('Save Settings', 'button-primary', 'submit')
->output();
?>
</form>
<script>
(function() {
const gcalSelect = document.getElementById('gcal_select');
const currentCalId = <?= json_encode($current_calendar_id) ?>;
if (gcalSelect) {
fetch('<?= esc_js(get_rest_url(null, 'reservations/v1/google-calendars')) ?>', {
credentials: 'same-origin',
headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce },
})
.then(r => r.json())
.then(calendars => {
for (const cal of calendars) {
const opt = document.createElement('option');
opt.value = cal.id;
opt.textContent = cal.summary;
if (cal.id === currentCalId) opt.selected = true;
gcalSelect.appendChild(opt);
}
})
.catch(() => {
gcalSelect.disabled = true;
gcalSelect.options[0].textContent = 'Failed to load calendars';
});
}
RsvAdminForm.bind(timetable_settings_form);
})();
</script>
<h2>Capacity</h2>
<p>Define capacities for timetable.</p>
<?php
rsv_create_capacity_form($id);
?>
<script>
(function() {
const DAY_DOW = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 };
function nearest_weekday(base_str, dow) {
const d = new Date(base_str + 'T00:00:00');
const diff = (dow - d.getDay() + 7) % 7;
d.setDate(d.getDate() + diff);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
RsvAdminForm.bind(document.getElementById('create_capacity_form'), {
// Expand one form submission into one capacity row per selected weekday.
transform: (body, form) => {
const fd = new FormData(form);
const is_repeating = fd.get('is_repeating') !== null;
const repeat_period_in_days = is_repeating
? parseInt(fd.get('repeat_period_in_days')) * parseInt(fd.get('repeat_period_multiplier'))
: 1;
const repeat_times = is_repeating ? parseInt(fd.get('repeat_times')) : 0;
const common = {
start_time: time_to_minutes(fd.get('start_time')),
end_time: time_to_minutes(fd.get('end_time')),
capacity: parseInt(fd.get('capacity')),
min_lead_time_minutes: parseInt(fd.get('min_lead_time_minutes')),
requires_confirmation: fd.get('requires_confirmation') !== null,
repeat_period_in_days,
repeat_times,
};
const base_date = fd.get('date');
const selected_days = Object.keys(DAY_DOW).filter(day => fd.get(day) !== null);
return selected_days.length > 0
? selected_days.map(day => ({ ...common, date: nearest_weekday(base_date, DAY_DOW[day]) }))
: [{ ...common, date: base_date }];
},
refresh: () => capacity_dt.refresh(),
});
})();
</script>
<div id="capacity_table"></div>
<script>
function minutes_to_time(minutes) {
const h = Math.floor(minutes / 60).toString().padStart(2, '0');
const m = (minutes % 60).toString().padStart(2, '0');
return `${h}:${m}`;
}
function time_to_minutes(time_str) {
const [h, m] = time_str.split(':').map(Number);
return h * 60 + m;
}
function rsv_render_capacity_inline_form(dt, row, data) {
const resource_with_time_conversion = {
...dt.resource,
put(id, form_data) {
return dt.resource.put(id, {
...form_data,
start_time: time_to_minutes(form_data.start_time),
end_time: time_to_minutes(form_data.end_time),
});
},
};
return RsvInlineFormBuilder.create(resource_with_time_conversion)
.input_hidden('min_lead_time_minutes', 0)
.fieldset('Datum a čas', '50%')
.input_date('date', 'Datum', data?.date ?? '')
.input_time('start_time', 'Začátek', minutes_to_time(data?.start_time ?? 0))
.input_time('end_time', 'Konec', minutes_to_time(data?.end_time ?? 0))
.fieldset('Kapacita a opakování', '50%')
.input_number('capacity', 'Kapacita', data?.capacity ?? '')
.input_number('repeat_period_in_days', 'Dnů mezi opakováním', data?.repeat_period_in_days ?? '')
.input_number('repeat_times', 'Počet opakování', data?.repeat_times ?? '')
.input_checkbox('requires_confirmation', 'Vyžaduje potvrzení', data?.requires_confirmation == 1)
.build({
id: data?.id,
colspan: 8,
save_label: 'Aktualizovat',
on_success: () => capacity_dt.refresh(),
on_cancel: () => capacity_dt.refresh(),
});
}
var capacity_dt = RsvDataGrid.create_data_grid(capacity_table,
RsvTimetableCapacityResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'date': RsvDataGrid.action_column('Datum', false, {
'Edit': RsvDataGrid.edit_action((dt, row, data) => {
row.classList.add('inline-edit-row', 'inline-edit-row-post', 'quick-edit-row', 'quick-edit-row-post', 'inline-edit-post', 'inline-editor');
row.replaceChildren(rsv_render_capacity_inline_form(dt, row, data));
}),
'Trash': RsvDataGrid.func_action((dt, row, data) => {
dt.resource.delete(data.id).then(() => dt.refresh());
}),
}),
'start_time': RsvDataGrid.column('Začátek', false),
'end_time': RsvDataGrid.column('Konec', false),
'capacity': RsvDataGrid.column('Kapacita', false),
'repeat_period_in_days': RsvDataGrid.column('Dnů mezi opakováním', false),
'repeat_times': RsvDataGrid.column('Počet opakování', false),
'requires_confirmation': RsvDataGrid.column('Vyžaduje potvrzení', false),
}, );
capacity_dt.map_column('start_time', (dt, row, data) => {
const td = document.createElement('td');
td.innerText = minutes_to_time(data.start_time);
return td;
});
capacity_dt.map_column('end_time', (dt, row, data) => {
const td = document.createElement('td');
td.innerText = minutes_to_time(data.end_time);
return td;
});
capacity_dt.refresh();
</script>
<?php
}
function rsv_timetable_view_page(int $id): void {
$timetable = (new RsvTimetableService())->get($id);
if ($timetable === null) {
echo '<div class="notice notice-error"><p>Timetable not found.</p></div>';
return;
}
?>
<h1><?= esc_html($timetable->name) ?></h1>
<a href="<?= esc_url(menu_page_url('timetable-settings', false)) ?>">← Back to Timetables</a>
<hr>
<table class="form-table">
<tr><th>Name</th><td><?= esc_html($timetable->name) ?></td></tr>
<tr><th>Block size</th><td><?= esc_html($timetable->block_size) ?> minutes</td></tr>
<?php if ($timetable->maintainer_email): ?>
<tr><th>Maintainer email</th><td><?= esc_html($timetable->maintainer_email) ?></td></tr>
<?php endif; ?>
</table>
<h2>Reservations</h2>
<div id="timetable_reservations_table"></div>
<script>
RsvDataGrid.create_data_grid(
timetable_reservations_table,
RsvTimetableReservationResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'reservation_id': RsvDataGrid.action_column('Reservation', false, {
'Accept': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.accept(data.reservation_id).then(() => dt.refresh()),
(item) => item.pending_confirmation_id !== null
),
'Refuse': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.refuse(data.reservation_id).then(() => dt.refresh()),
(item) => item.pending_confirmation_id !== null
),
}),
'start_utc': RsvDataGrid.column('Start', false),
'end_utc': RsvDataGrid.column('End', false),
'is_confirmed': RsvDataGrid.column('Status', false),
}
)
.map_column('is_confirmed', (dt, row, data) => {
const td = document.createElement('td');
td.textContent = data.pending_confirmation_id !== null ? 'Pending'
: data.is_confirmed == 1 ? 'Confirmed'
: 'Refused';
return td;
})
.refresh();
</script>
<?php
}
function rsv_timetable_edit_page($id) {
rsv_timetable_capacity_view($id);
}
function rsv_timetable_page() {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'view' && isset($_GET['id'])) {
rsv_timetable_view_page(intval($_GET['id']));
return;
} else if($_GET['action'] === 'edit' && isset($_GET['id'])) {
rsv_timetable_edit_page(intval($_GET['id']));
return;
}
// Deletion is intentionally not handled here: a state-changing GET is
// CSRF-prone. Timetables are deleted via the nonce-authenticated REST
// DELETE /timetable/{id} (see the "Trash" action in the list grid).
}
rsv_timetable_list_page();
}