Files
Reservair/ARCHITECTURE.md
T
Martin Slachta 0d829845c4 initial
2026-06-11 19:03:29 +02:00

11 KiB

Architecture

This file contains description of the software architecture. Before starting any development, consult this file.

The plugin is made out of a Gutenberg widget that communicates with the backend using endpoints handled by PHP. The endpoints are defined in the controllers, that handle everything between the request and creating a application specific models to pass down to the application. It hides all the details about network communication.

Forms

The main component of the plugin are the forms. The administrator defines information that the user submits to them. There are two objects, the definition and submission. The administrator makes the definition: list of fields & elements the form is made out of. The user fills in the information and submits the form.

Submitting forms

Use POST endpoint forms/{form_id} to submit form. The RsvFormProcessor will validate the form and call handler for each element in the definition.

JavaScript

The JavaScript is mostly used for frontend components and slightly for the administration editor of the Wordpress widget. Each JS file and symbol in global scope must have the prefix Rsv or rsv. Each component should be encapsulated within it's own const object in separate file. The root directory is assets/js/components/.

The other JavaScript files are split between user-space and admin-space. Example of admin-space component is Wordpress-looking datagrid. Example of user-space component is calendar.

The JavaScript code should always use a resource object for communicating with the backend REST API. See Data Layer below.

Error handling

Data Layer

All REST API communication goes through the data layer in assets/js/datasource/.

RsvDataSource

RsvDataSource is a factory object with a single method:

RsvDataSource.create_rsv_resource(base_url, { nonce })

It returns a resource object that wraps fetch and provides a consistent CRUD interface:

Method Signature HTTP
get_page (skip = 0, limit = 20, params = {}) GET base_url?skip=…&limit=…
get (id) GET base_url/{id}
post (data) POST base_url
put (id, data) PUT base_url/{id}
delete (id) DELETE base_url/{id}

Every method returns a Promise. Non-2xx responses reject with an Error. A 204 No Content response resolves to null.

When a nonce is supplied, it is sent as the X-WP-Nonce header, which satisfies WordPress REST API authentication.

Resource files

Each REST endpoint has its own factory function in assets/js/datasource/. The factory calls RsvDataSource.create_rsv_resource and fills in the endpoint path using the ReservairServiceAPI.restUrl global (injected by PHP via wp_localize_script).

File Factory Endpoint
RsvReservationResource.js RsvReservationResource() /reservation
RsvFormDefinitionResource.js RsvFormDefinitionResource() /form-definition
RsvTimetableResource.js RsvTimetableResource() /timetable
RsvTimetableCapacityResource.js RsvTimetableCapacityResource(id) /timetable/{id}/capacity

To add a new endpoint, create a new file following the same pattern and register it in includes/RsvAssetsDefinition.php.

Admin REST Forms

Admin pages that write to the REST API follow a standard pattern to keep the code consistent and easy to extend.

HTML

Use a <form> element with three data attributes:

<form
    id="my_form"
    action="<?= esc_url(get_rest_url(null, 'reservations/v1/some-resource')) ?>"
    data-method="POST"
    data-success-msg="Record created.">

    <!-- inputs with name= attributes -->

    <button type="submit" class="button button-primary">Save</button>
</form>
  • action — the full REST endpoint URL (use get_rest_url + esc_url).
  • data-method — HTTP verb (POST, PATCH, PUT, DELETE). Standard HTML forms only support GET/POST; this attribute carries the real verb for the JS layer.
  • data-success-msg — human-readable message passed to show_notice on success.

Fields that should not appear as literal HTML inputs (e.g. a <select> populated asynchronously) still use name= so FormData picks them up automatically at submit time.

JavaScript

Attach a submit listener that reads everything it needs from the form element itself:

document.getElementById('my_form').addEventListener('submit', function(event) {
    event.preventDefault();
    const form = event.currentTarget;
    const fd   = new FormData(form);

    fetch(form.action, {
        method: form.dataset.method,
        credentials: 'same-origin',
        headers: {
            'Content-Type': 'application/json',
            'X-WP-Nonce': ReservairServiceAPI.nonce,
        },
        body: JSON.stringify({
            some_field: fd.get('some_field') || null,
            // ...
        }),
    })
    .then(response => {
        if (!response.ok) return response.json().then(err => { throw new Error(err.error || 'Request failed'); });
        show_notice(form, 'success', form.dataset.successMsg);
    })
    .catch(err => show_notice(form, 'error', err.message));
});

Rules:

  • Always call event.preventDefault() first.
  • Read the endpoint from form.action and the verb from form.dataset.method — never hard-code them in the handler.
  • Pass X-WP-Nonce: ReservairServiceAPI.nonce on every authenticated request.
  • Use show_notice(form, 'success'|'error', message) for user feedback — do not roll custom status spans.
  • Any async pre-population (e.g. fetching a list of calendars to fill a <select>) runs independently of the submit listener and writes its results into named <select> / hidden <input> elements so FormData picks them up transparently.

Forms

The plugin allows administrators to define & display forms on pages as a widget. The definitions is an ordered list of element definitions in a JSON format. The required attributes for each element are name, label and is_required. The optional are description.

To define new element, create a PHP class in includes/Services/Forms/ like RsvFormHelloElementHandler that derives from RsvFormElementHandler and implement all the abstract methods. The RsvFormHandler validates and commits the forms.

The form values are saved as JSON with keys for the input elements.

PHP

Controllers

Builds the REST API. Should not assume anything about the application and mainly just pass built domain object to the application layer. Each controller manages a single resource and defines operations that the user can do with the object.


Permissions & Capabilities

All administrative functionality — both the REST API and the admin pages — is gated by a single custom capability, and every REST route declares its intended audience through a small policy class. There are two source-of-truth symbols; nothing else should appear in authorization code:

Symbol File Purpose
RsvCapabilities::MANAGE ('manage_reservations') includes/RsvCapabilities.php The capability that authorises managing reservation data.
RsvRestPolicy::admin() / RsvRestPolicy::open() includes/Controllers/RsvRestPolicy.php The permission_callback tiers every route uses.

Rule: never write 'manage_options' or '__return_true' in this plugin. Routes go through RsvRestPolicy; menus and page-level checks use RsvCapabilities::MANAGE. This keeps the whole authorization surface greppable and consistent.

The capability

manage_reservations is a custom capability (not WordPress's built-in manage_options) so that reservation management can later be granted to a non-admin role without also handing over the whole site. By default it is granted only to the administrator role.

Lifecycle

WordPress only runs the activation hook on activate, never on a plugin update — so granting the cap solely on activation would silently lock admins out after an update. RsvCapabilities handles all three moments:

Moment Call Effect
Activation RsvInstaller::install()RsvCapabilities::ensure() Grants the cap to the default roles.
Every request rsv_bootstrap()RsvCapabilities::ensure() Self-heals after an update. No-op once the stored version matches.
Uninstall uninstall.phpRsvCapabilities::revoke() Removes the cap from every role and clears the marker.

ensure() is idempotent and guarded by the rsv_caps_version option compared against RsvCapabilities::VERSION. Bump VERSION whenever the capability set changes so existing installs re-grant on their next request. Because ensure() runs on every load, admins never need to manually reactivate the plugin.

Authorization tiers

Each route's permission_callback is one of:

Tier Callback Means
admin [RsvRestPolicy::class, 'admin'] Caller must have manage_reservations. Returns a WP_Error (401 logged-out / 403 under-privileged) otherwise.
open [RsvRestPolicy::class, 'open'] Either genuinely public (form submission, availability lookups) or a capability URL whose secret is validated inside the handler.

A capability URL is a public endpoint authorised by an unguessable secret in the request rather than by session auth — e.g. the maintainer accept/refuse links (/timetable-reservation/accept|refuse/{code}), the Google webhook (secret channel id in headers), and the OAuth callback. Each is marked open() with a comment naming where its secret is checked.

Rule: any open() route that is not fully public must authorise its caller from the request inside the handler. open() performs no check itself.

Using a capability

In a REST route (register_rest_route):

register_rest_route($this->namespace, '/' . $this->resource_name, [
    'methods'             => 'POST',
    'callback'            => [$this, 'create'],
    'permission_callback' => [RsvRestPolicy::class, 'admin'], // or 'open'
]);

In the admin menu (add_menu_page / add_submenu_page) — pass the constant as the capability argument:

add_menu_page('Reservations', 'Reservations', RsvCapabilities::MANAGE, 'reservations-settings', 'rsv_reservations_page');

In a page-level / inline check:

if (!current_user_can(RsvCapabilities::MANAGE)) {
    return;
}

Adding a new capability

The current design hardcodes the single MANAGE capability for the administrator role. To introduce a second (e.g. a read-only view_reservations for an editor):

  1. Declare it as a constant on RsvCapabilities (public const VIEW = 'view_reservations';).
  2. Grant it in RsvCapabilities::ensure() and remove it in revoke(). Once there is more than one capability, replace the single-cap logic with a capability => [roles] map and iterate it in both methods, so the two stay in sync.
  3. Bump RsvCapabilities::VERSION so ensure() re-applies the new grant on existing installs at their next request.
  4. Expose a tier for it: add a method to RsvRestPolicy (e.g. viewer() checking current_user_can(RsvCapabilities::VIEW)), following the admin() pattern (return true or a WP_Error with rest_authorization_required_code()).
  5. Reference it from the relevant routes, menu entries, and page checks — never the literal string.