Scheduling — the reference plugin
A worked example of the plugin contract. Copy this folder into
plugins/ (it keeps the id and mount path scheduling) and point it at your own backend — the folder
name is the plugin id and mount path, so rename it only if you want a different one.
What it demonstrates:
- A list page that fetches upstream data —
GET /scheduling/shiftscalls the upstream REST service and renders the rows with the core building blocks (shifts.ejs→ app shell, filter-bar, data-table). Search round-trips the URL; zero-JS. (It fetches all rows for brevity — for a large list, parsepage/pageSizefromparseListQuery, forward them upstream as a?limit/?offset, and renderpagination.ejswithpaginate(), exactly as the admin example plugin does.) - A form that forwards a write upstream —
GET /scheduling/shifts/newrenders the form,POST /scheduling/shiftsCSRF-verifies it (ctx.verifyCsrf) and forwards the create upstream, then POST-redirect-GET. The form body lives in the plugin's ownviews/partials/shift-form.ejs, reusing the corefieldpartial. - Permission-gated nav — the "Shifts" nav leaf and routes are gated on
scheduling:read/scheduling:write; the whole "Scheduling" section is invisible to anyone without the grant.
The plugin holds no state — data lives upstream (README → Stateless). Handlers are thin and
fetch is injectable, so they unit-test as pure functions (shifts.test.ts).
Upstream
Set SCHEDULING_UPSTREAM to your backend's base URL. The dev compose points it at a tiny in-memory
mock (examples/shifts-upstream/) so docker compose up shows the plugin working out of the box.
A malformed/non-http URL fails the boot loudly (the plugin's onBoot hook).
Upstream contract
Your backend must expose two routes; the plugin treats any non-2xx as a recoverable failure (the list degrades to a "try again" alert, the create re-renders the form keeping the input).
| Route | Request | Success | Response body |
|---|---|---|---|
GET /shifts |
Accept: application/json |
200 |
JSON array of { id, title, assignee, start, end } (all strings; missing fields coerce to "") |
POST /shifts |
JSON body { title, assignee, start, end } |
2xx |
ignored (the plugin POST-redirect-GETs back to the list) |
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
form re-renders. The plugin only validates that title and assignee are non-empty.
start/end come from the form's datetime-local inputs as YYYY-MM-DDTHH:mm and are stored and
shown verbatim (the dev mock seeds a space-separated style, so created vs seeded rows differ only
cosmetically) — normalise to your backend's format there if it matters.
Granting access
A user sees Scheduling once they hold the scheduling:read role in Keto (and scheduling:write
to create). The one-command bootstrap grants both to the demo admin, so the seeded
admin@plainpages.local can use it immediately.