Scope My shifts at the upstream, and give Route and NavNode one gate declaration
CI / full-gate (push) Successful in 2m56s

This commit is contained in:
2026-09-02 08:15:08 +02:00
parent 8da75b4ca7
commit c7e6d66750
9 changed files with 51 additions and 32 deletions
+5 -1
View File
@@ -26,6 +26,10 @@ What it demonstrates:
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`).
The shifts list and "My shifts" repeat a little view-model and markup rather than sharing a
parameterised one: an example is read far more often than it is changed, and each page is meant to be
followed top to bottom on its own.
## Upstream
Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
@@ -39,7 +43,7 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
| 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 `""`) |
| `GET /shifts` | `Accept: application/json`, optional `?assignee=<who>` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`). With `assignee`, only that person's rows — "My shifts" asks for them rather than filtering everyone's here, because ownership is the backend's rule to enforce |
| `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
+8 -4
View File
@@ -174,15 +174,19 @@ test("buildFormModel marks title/assignee required and attaches field errors", (
// ---- the session-gated page: the visitor's own rows ----
test("my shifts renders only the signed-in visitor's own rows, and names them in the empty state", async () => {
test("my shifts asks the upstream for the visitor's own rows, and names them in the empty state", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const mine: Shift = { assignee: "blair.mora@example.test", end: "22:00", id: "3", start: "17:00", title: "Evening on-call" };
const listed = [...SHIFTS, mine]; // SHIFTS are assigned to other people
let asked: { assignee?: string } | undefined;
const r = asView(await myShifts(fakeUpstream({ list: async () => listed }))(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
const upstream = fakeUpstream({ list: async (opts) => { asked = opts; return [mine]; } });
const r = asView(await myShifts(upstream)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.equal(r.view, "mine");
// The ownership rule is the upstream's: the page asks for one person's rows rather than filtering
// everyone's here, so a real backend never hands this handler another visitor's shifts.
assert.deepEqual(asked, { assignee: "Blair.Mora@example.test" });
const table = r.data["table"] as { emptyText: string; rows: { name: string }[] };
assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]); // matched case-insensitively
assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]);
assert.match(table.emptyText, /Blair\.Mora@example\.test/); // an empty page still says whose it is
// The route carries `session: true`, but the handler asserts the session itself rather than trusting it.
+14 -11
View File
@@ -47,7 +47,9 @@ export class UpstreamError extends Error {
export interface ShiftsUpstream {
create(input: ShiftInput): Promise<void>;
list(): Promise<Shift[]>;
// `assignee` scopes the read at the source, which is where an ownership rule belongs (README →
// Three tiers of "may I?"); without it the caller would hold everyone's rows to render one page.
list(opts?: { assignee?: string }): Promise<Shift[]>;
}
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
@@ -66,8 +68,9 @@ export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch =
});
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
},
async list() {
const res = await fetchImpl(`${base()}/shifts`, { headers: { accept: "application/json" } });
async list(opts = {}) {
const query = opts.assignee == null ? "" : `?${new URLSearchParams({ assignee: opts.assignee })}`;
const res = await fetchImpl(`${base()}/shifts${query}`, { headers: { accept: "application/json" } });
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
const data: unknown = await res.json();
return Array.isArray(data) ? data.map(toShift) : [];
@@ -187,25 +190,21 @@ export function newShiftForm(): RouteHandler {
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
}
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
// else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O).
// The `session: true` archetype: the rows are the visitor's own, so there is no distinction a
// permission could name — anyone signed in sees theirs and only theirs.
// permission could name — anyone signed in sees theirs and only theirs. The scoping is the
// upstream's, never a filter here: it owns the data and answers for one person's rows.
export function myShifts(upstream: ShiftsUpstream): RouteHandler {
return async (ctx) => {
const user = requireSession(ctx);
let shifts: Shift[] = [];
let error: string | undefined;
try {
shifts = await upstream.list();
shifts = await upstream.list({ assignee: user.email });
} catch (err) {
ctx.log.warn("scheduling upstream unreachable", { error: String(err) });
error = ctx.t("scheduling.upstream.list");
}
const mine = shifts.filter((s) => s.assignee.toLowerCase() === user.email.toLowerCase());
return { data: buildMineModel({ chrome: ctx.chrome, email: user.email, ...(error ? { error } : {}), shifts: mine, t: ctx.t }), view: "mine" };
return { data: buildMineModel({ chrome: ctx.chrome, email: user.email, ...(error ? { error } : {}), shifts, t: ctx.t }), view: "mine" };
};
}
@@ -226,6 +225,10 @@ export function buildMineModel(opts: { chrome: PageChrome; email: string; error?
};
}
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
// else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O).
export function overview(): RouteHandler {
return (ctx) => ({
data: {