This commit is contained in:
Martin Slachta
2026-06-11 19:03:29 +02:00
commit 0d829845c4
150 changed files with 38582 additions and 0 deletions
@@ -0,0 +1,3 @@
<?php
const RSV_REST_API_BASE = 'reservations/';
@@ -0,0 +1,32 @@
<?php
class RsvFormController {
private $namespace;
private $resource_name;
public function __construct() {
$this->namespace = 'reservations/v1';
$this->resource_name = 'form';
}
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>[^/]+)', [
'methods' => 'POST',
'callback' => [$this, 'handle'],
// Public: site visitors submit reservation forms. The handler validates
// the form definition and payload before persisting anything.
'permission_callback' => [RsvRestPolicy::class, 'open']
]);
}
function handle(WP_REST_Request $request) {
$submitter = new RsvFormSubmission();
$submit_result = $submitter->submit($request->get_param("id"), $request->get_json_params());
if(isset($submit_result['success']) && $submit_result['success'] === true) {
return new WP_REST_Response($submit_result, 200);
}
return new WP_REST_Response($submit_result, 400);
}
}
@@ -0,0 +1,119 @@
<?php
use Reservair\Logger\Logger;
class RsvFormDefinitionController {
use RsvPagedResponseTrait;
private string $namespace = 'reservations/v1';
private string $resource_name = 'form-definition';
private static function schema(): array {
return [
'type' => 'object',
'properties' => [
'form_id' => ['type' => 'integer', 'readonly' => true],
'name' => ['type' => 'string', 'required' => true, 'minLength' => 1],
'definition' => [
'type' => 'object',
'required' => false,
'properties' => [
'email_key' => ['type' => 'string', 'required' => false],
'elements' => ['type' => 'array', 'default' => []],
],
],
],
];
}
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
[
'methods' => 'GET',
'callback' => [$this, 'index'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(self::schema()),
],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [
[
'methods' => 'GET',
'callback' => [$this, 'show'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'PUT',
'callback' => [$this, 'update'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(self::schema()),
],
[
'methods' => 'DELETE',
'callback' => [$this, 'destroy'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
]);
}
function index(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$repo = new RsvFormDefinitionRepository();
return $this->paged_response($repo->get_all($limit, $skip), $repo->count_all());
}
function show(WP_REST_Request $request): WP_REST_Response {
$row = (new RsvFormDefinitionRepository())->get((int) $request->get_param('id'));
if ($row === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
return new WP_REST_Response($row, 200);
}
function create(WP_REST_Request $request): WP_REST_Response {
try {
$id = (new RsvFormDefinitionRepository())->add(
$request->get_param('name'),
$request->get_param('definition') ?? []
);
} catch(Throwable $e) {
Logger::error($e);
return new WP_REST_Response(['error' => 'An error occurred.'], 500);
}
return new WP_REST_Response(['id' => $id], 201);
}
function destroy(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$repo = new RsvFormDefinitionRepository();
if ($repo->get($id) === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
$repo->delete($id);
return new WP_REST_Response(null, 204);
}
function update(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$repo = new RsvFormDefinitionRepository();
if ($repo->get($id) === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
$repo->update($id, $request->get_param('name'), $request->get_param('definition'));
return new WP_REST_Response(null, 204);
}
}
@@ -0,0 +1,31 @@
<?php
trait RsvPagedResponseTrait {
private function paged_response(array $data, ?int $total = null): WP_REST_Response {
return new WP_REST_Response([
'total' => $total ?? count($data),
'data' => $data,
], 200);
}
/**
* Read pagination from the request as [skip, limit]. limit is clamped to
* 1..100 and defaults to 20; skip defaults to 0.
*
* @return array{0:int,1:int}
*/
private static function paging(WP_REST_Request $request): array {
$skip = max(0, (int) $request->get_param('skip'));
$limit = (int) $request->get_param('limit');
$limit = $limit > 0 ? min($limit, 100) : 20;
return [$skip, $limit];
}
/** Extract writable (non-readonly) properties from a schema for use as route args. */
private static function input_args(array $schema): array {
return array_filter(
$schema['properties'],
fn(array $prop): bool => empty($prop['readonly'])
);
}
}
@@ -0,0 +1,87 @@
<?php
class RsvReservationController {
use RsvPagedResponseTrait;
private $namespace;
private $resource_name;
public function __construct() {
$this->namespace = 'reservations/v1';
$this->resource_name = 'reservation';
}
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
'methods' => 'GET',
'callback' => [$this, 'get_all'],
'permission_callback' => [RsvRestPolicy::class, 'admin']
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [$this, 'get'],
'permission_callback' => [RsvRestPolicy::class, 'admin']
]);
register_rest_route($this->namespace, '/' . $this->resource_name, [
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin']
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)/accept', [
'methods' => 'POST',
'callback' => [$this, 'accept_by_id'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)/refuse', [
'methods' => 'POST',
'callback' => [$this, 'refuse_by_id'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
}
function get_all(WP_REST_Request $request) {
[$skip, $limit] = self::paging($request);
$service = new RsvReservationService();
return $this->paged_response((array) $service->get_all($limit, $skip), $service->count_all());
}
function get(WP_REST_Request $request): WP_REST_Response {
$service = new RsvReservationService();
$detail = $service->get_detail((int) $request->get_param('id'));
if ($detail === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
return new WP_REST_Response($detail, 200);
}
function create(WP_REST_Request $request) {
$service = new RsvReservationService();
$body = $request->get_json_params();
return $service->create(RsvReservation::from_array($body));
}
function accept_by_id(WP_REST_Request $request): WP_REST_Response {
try {
(new RsvTimetableReservationService())->accept_by_reservation_id((int) $request->get_param('id'));
return new WP_REST_Response(['status' => 'accepted'], 200);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => $e->getMessage()], 404);
}
}
function refuse_by_id(WP_REST_Request $request): WP_REST_Response {
try {
(new RsvTimetableReservationService())->refuse_by_reservation_id((int) $request->get_param('id'));
return new WP_REST_Response(['status' => 'refused'], 200);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => $e->getMessage()], 404);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Authorization policy for the reservations/v1 REST API.
*
* Every route's `permission_callback` references one of these tiers so the
* intended audience is visible at the route definition:
*
* - admin(): requires the manage_reservations capability (see RsvCapabilities).
* - open(): genuinely public, OR a capability URL whose secret is validated
* inside the handler itself (confirmation codes, the Google webhook,
* the OAuth callback). Any `open()` route that is not fully public
* MUST authorise its caller from the request.
*/
final class RsvRestPolicy {
/** Administrative endpoints: managing timetables, capacities, forms, reservations. */
public static function admin(): bool|WP_Error {
if ( current_user_can( RsvCapabilities::MANAGE ) ) {
return true;
}
return new WP_Error(
'rsv_forbidden',
__( 'Sorry, you are not allowed to do that.', 'reservair' ),
// 401 when logged out, 403 when logged in but under-privileged.
[ 'status' => rest_authorization_required_code() ]
);
}
/** Public endpoints, and capability URLs validated inside the handler. */
public static function open(): bool {
return true;
}
}
@@ -0,0 +1,38 @@
<?php
use Reservair\Logger\Logger;
class RsvTimetableAvailabilityController {
private string $namespace = 'reservations/v1';
public function register_routes(): void {
register_rest_route($this->namespace, '/timetable/(?P<id>\d+)/availability', [
'methods' => 'GET',
'callback' => [$this, 'show'],
// Public: the booking widget reads availability for anonymous visitors.
'permission_callback' => [RsvRestPolicy::class, 'open'],
'args' => [
'date' => ['type' => 'string', 'required' => true, 'format' => 'date'],
],
]);
}
public function show(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$service = new RsvTimetableService();
$timetable = $service->get($id);
if ($timetable === null || $timetable->id === null) {
return new WP_REST_Response(['error' => 'Timetable not found'], 404);
}
try {
$availability = $service->get_availability_on_date($id, $timetable->block_size, new DateTime($request->get_param('date')));
} catch (Throwable $e) {
Logger::error($e);
return new WP_REST_Response(['error' => $e->getMessage()], 400);
}
return new WP_REST_Response($availability, 200);
}
}
@@ -0,0 +1,114 @@
<?php
class RsvTimetableCapacityController {
use RsvPagedResponseTrait;
private string $namespace = 'reservations/v1';
private string $resource_name = 'timetable/(?P<id>\d+)/capacity';
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
[
'methods' => 'GET',
'callback' => [$this, 'get_all'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
// 'args' => self::input_args(RsvTimetableCapacity::schema()),
],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<capacity_id>\d+)', [
[
'methods' => 'GET',
'callback' => [$this, 'get'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'PUT',
'callback' => [$this, 'update'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(RsvTimetableCapacity::schema()),
],
[
'methods' => 'DELETE',
'callback' => [$this, 'delete'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
]);
}
public function get_all(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$timetable_id = (int) $request->get_param('id');
$service = new RsvTimetableCapacityRepository();
return $this->paged_response(
$service->get_all($timetable_id, $limit, $skip),
$service->count_all($timetable_id)
);
}
public function get(WP_REST_Request $request): WP_REST_Response {
return new WP_REST_Response(
(new RsvTimetableCapacityRepository())->get((int) $request->get_param('capacity_id')),
200
);
}
public function create(WP_REST_Request $request): WP_REST_Response {
$items = $request->get_json_params();
$timetable_id = (int) $request->get_param('id');
$ids = [];
foreach($items as $item) {
$capacity = new RsvTimetableCapacity(
null,
$timetable_id,
(int) $item['capacity'],
(int) $item['min_lead_time_minutes'],
new DateTime($item['date']),
(int) $item['start_time'],
(int) $item['end_time'],
(int) $item['repeat_period_in_days'],
(int) $item['repeat_times'],
(bool) $item['requires_confirmation'],
);
$ids[] = (new RsvTimetableCapacityRepository())->create($capacity);
}
return new WP_REST_Response(
['ids' => $ids],
201
);
}
public function update(WP_REST_Request $request): WP_REST_Response {
$capacity = new RsvTimetableCapacity(
(int) $request->get_param('capacity_id'),
(int) $request->get_param('id'),
(int) $request->get_param('capacity'),
(int) $request->get_param('min_lead_time_minutes'),
new DateTime($request->get_param('date')),
(int)$request->get_param('start_time'),
(int)$request->get_param('end_time'),
(int) $request->get_param('repeat_period_in_days'),
(int) $request->get_param('repeat_times'),
(bool) $request->get_param('requires_confirmation'),
);
$capacity_id = (int) $request->get_param('capacity_id');
(new RsvTimetableCapacityRepository())->update($capacity_id, $capacity);
return new WP_REST_Response(['id' => $capacity_id], 200);
}
public function delete(WP_REST_Request $request): WP_REST_Response {
(new RsvTimetableCapacityRepository())->delete((int) $request->get_param('capacity_id'));
return new WP_REST_Response(null, 204);
}
}
@@ -0,0 +1,88 @@
<?php
class RsvTimetableDefinitionController {
use RsvPagedResponseTrait;
private string $namespace = 'reservations/v1';
private string $resource_name = 'timetable';
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
[
'methods' => 'GET',
'callback' => [$this, 'index'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(RsvTimetable::schema()),
],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [
[
'methods' => 'PATCH',
'callback' => [$this, 'update'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(RsvTimetable::schema()),
],
[
'methods' => 'DELETE',
'callback' => [$this, 'destroy'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
]);
}
public function index(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$service = new RsvTimetableService();
return $this->paged_response($service->get_all($limit, $skip), $service->count_all());
}
public function create(WP_REST_Request $request): WP_REST_Response {
$service = new RsvTimetableService();
$id = $service->create(new RsvTimetable([
'name' => $request->get_param('name'),
'block_size' => (int) $request->get_param('block_size'),
'maintainer_email' => $request->get_param('maintainer_email'),
]));
return new WP_REST_Response(['id' => $id], 201);
}
public function update(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$service = new RsvTimetableService();
$body = $request->get_json_params();
$timetable = $service->get($id);
if ($timetable === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
if (array_key_exists('name', $body)) $timetable->name = $body['name'];
if (array_key_exists('block_size', $body)) $timetable->block_size = (int) $body['block_size'];
if (array_key_exists('maintainer_email', $body)) $timetable->maintainer_email = $body['maintainer_email'] ?: null;
if (array_key_exists('google_calendar_id', $body)) $timetable->google_calendar_id = $body['google_calendar_id'] ?: null;
$service->update($id, $timetable);
return new WP_REST_Response(['id' => $id], 200);
}
public function destroy(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$service = new RsvTimetableService();
if ($service->get($id) === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
$service->delete($id);
return new WP_REST_Response(null, 204);
}
}
@@ -0,0 +1,62 @@
<?php
class RsvTimetableReservationController {
use RsvPagedResponseTrait;
private $namespace = 'reservations/v1';
private $resource_name = '/timetable/(?P<id>\d+)/reservation';
public function register_routes(): void {
register_rest_route($this->namespace, $this->resource_name, [
'methods' => 'GET',
'callback' => [$this, 'by_timetable'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route($this->namespace, '/timetable-reservation/accept/(?P<code>[a-zA-Z0-9]+)', [
'methods' => 'GET',
'callback' => [$this, 'accept'],
// Capability URL: authorised by the secret confirmation code, which
// accept() validates against the database before changing state.
'permission_callback' => [RsvRestPolicy::class, 'open'],
]);
register_rest_route($this->namespace, '/timetable-reservation/refuse/(?P<code>[a-zA-Z0-9]+)', [
'methods' => 'GET',
'callback' => [$this, 'refuse'],
// Capability URL: authorised by the secret confirmation code, which
// refuse() validates against the database before changing state.
'permission_callback' => [RsvRestPolicy::class, 'open'],
]);
}
public function by_timetable(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$timetable_id = (int) $request->get_param('id');
$service = new RsvTimetableReservationService();
return $this->paged_response(
$service->get_by_timetable($timetable_id, $limit, $skip),
$service->count_by_timetable($timetable_id)
);
}
function accept(WP_REST_Request $request) {
try {
$service = new RsvTimetableReservationService();
$service->accept($request->get_param('code'));
return new WP_REST_Response(['status' => 'accepted'], 200);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404);
}
}
function refuse(WP_REST_Request $request) {
try {
$service = new RsvTimetableReservationService();
$service->refuse($request->get_param('code'));
return new WP_REST_Response(['status' => 'refused'], 200);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404);
}
}
}