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