223 lines
11 KiB
Markdown
223 lines
11 KiB
Markdown
# 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:
|
|
|
|
```js
|
|
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:
|
|
|
|
```html
|
|
<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:
|
|
|
|
```javascript
|
|
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.php` → `RsvCapabilities::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`):
|
|
|
|
```php
|
|
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:
|
|
|
|
```php
|
|
add_menu_page('Reservations', 'Reservations', RsvCapabilities::MANAGE, 'reservations-settings', 'rsv_reservations_page');
|
|
```
|
|
|
|
**In a page-level / inline check:**
|
|
|
|
```php
|
|
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.
|