initial
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user