Skip to content

Fourteen medium findings: a stale admin bcc list, an unheld slot, and two half-run write sequences - #93

Merged
MattA-Official merged 10 commits into
mainfrom
fix/review-mediums
Aug 25, 2026
Merged

MattA-Official merged 10 commits into
mainfrom
fix/review-mediums

Conversation

@MattA-Official

@MattA-Official MattA-Official commented Aug 25, 2026 •

Copy link
Copy Markdown
Member

Fourteen medium findings, in ten commits grouped by cause. Gates: lint, typecheck, comments, build. No test suite here, so each change was reasoned against the code and the affected paths driven by hand.

The outgoing committee kept receiving booking requests forever. users.is_rooms_admin is refreshed from the session, but only while that person keeps using this app. rooms:ADMIN carries a committee-year expiry, so the whole committee loses the role on one day and has no reason to sign in here again: their mirror rows kept the flag set for good, and every new request went on being bcc'd to them with the requester's name, the event title, the date, the attendee count and their free-text notes. Nothing in either app could have cleared it, since role changes are not pushed to consumers and a committee-year lapse has no revocation event to push. The fan-out now resolves recipients from stage-door's role-holders endpoint, intersecting against the local mirror in chunks so it still honours notification preferences, cached ten minutes per isolate and falling back to the stale flag with a warning if stage-door is unreachable, because sending nothing means a booking request nobody sees. proscenium already consumes that endpoint the same way. The lookup existed twice, in notifications.ts and inline in POST /api/bookings; there is one now.

A slot was decided by a SELECT and applied by an UPDATE that bound only the booking id, so nothing held it in between. Two admins triaging the Monday queue could each confirm a different request into Rehearsal Room 3 for the same evening: both checks saw it free, both writes succeeded, both casts were emailed a confirmation, and nothing in either row recorded the clash, so it surfaced when they both turned up. The UPDATE now carries the occupancy rule as a NOT EXISTS predicate built by the same planner every write path goes through, binding a fixed number of parameters. ADR-0008 records what a mid-batch clash still leaves behind under ?scope=series.

Two write sequences could half-run and leave a row holding a room nobody knew about: deleting one occurrence of a series ran three promotions and a delete as four statements after telling the member it was cancelled, and a recurring create wrote the head, then the pattern, then the occurrences, so a failed occurrence batch left the head committed, unannounced, and holding its slot. Both land in one db.batch now.

An owner could edit a confirmed booking by cancelling it. The route checked only the status on the cancellation branch, and the owner schema accepts startTime, endTime, eventTitle and the rest alongside it, so a member could send CANCELLED with a new window and have the lot written. The admin alert telling the committee to ring an external venue was then built from the rewritten row. The rule now lives in the schema, and the route narrows the patch to the status alone so the write cannot widen again if the schema is relaxed.

Two things members were told that were not true. The bulk Reject button sent the literal string "Bulk rejection" as the reason, which was emailed to every member in the selection, written to rejection_reason, shown on /requests and returned by a subject access export; the mandatory-reason rule exists because the reason is read by the requester, and a placeholder satisfied the rule while defeating it. And rejecting a twelve-week recurring request sends ?scope=series but built its notification from the single booking in the URL, so the member was told one date had been rejected and believed they still held the other eleven.

A failed availability check read as "every room is free". The composable emptied both lists on error; the forms read empty as "not run yet" and fell back to the plain room list, whose entries carry no conflicts, so every room computed available and unbadged, and totalUnavailable was zero so the "Show unavailable" button vanished too. The usual trigger is the session crossing the fifteen-minute staleness line. The server still refuses a clashing write, so this was a lying picker rather than a double booking.

Erased rows could refill. A sealed session stays readable for up to thirty days after erasure, so an erased member could open /settings/notifications, flick a switch and rewrite the two columns that keep the fan-out away from a scrubbed row.

Performance: SQLite creates no index for a foreign key, so bookings.user_id and external_venue_id had none, and loading the member home page was five full scans. Two indexes added; D1 bills by rows read and bookings are only ever added, so this got worse every term.

An uptime monitor that cache-busts its request, GET /api/health?cb=123, was
answered 401 by the session guard instead of the documented 200 or 503.
event.path carries the query string, and the allowlist entry for the health
route is anchored at the end, so the pattern never matched. A monitor
configured to alarm only on 5xx read that 401 as healthy, which would have
hidden the 503 that a schema-behind-code deploy returns.

The middleware now strips the query once, before testing any pattern, which is
the idiom the other three repos already use, and the health entry also accepts
the trailing-slash form that routes to the same handler. Recorded in the API
reference so whoever sets the monitor up next knows the parameter is safe.
Erasure empties the two notification columns, and that is the only thing
keeping the fan-out away from a scrubbed row. A sealed session stays readable
for up to thirty days after erasure, so an erased member could open
/settings/notifications, flick a switch, and refill both columns. The row then
no longer reflected the erasure, and shouldNotify started answering true for it
again. Nothing was delivered, because the scrubbed address ends in .invalid and
sendEmail drops those, but the estate rule is that an anonymised row is never
written back over, whichever caller asks.

The update now carries the same anonymised_at IS NULL predicate the mirror
upsert has, and answers 409 "Account erased" when it matches nothing, matching
POST /api/users. The data model and the API reference say so.
An owner may cancel a confirmed booking but not edit one. The route checked
only the status when it saw a cancellation, and the owner schema accepts
eventTitle, numberOfAttendees, notes, startTime and endTime alongside it, so a
member could send status CANCELLED with a new window and have the lot written:
the PENDING-only guard is on the other branch, and a cancelled booking holds no
slot so no occupancy check runs. The admin alert that tells the committee to
ring an external venue and release the slot was then built from the rewritten
row, so it named times the member had just invented and there was no untampered
record left to check it against.

The rule now lives in the schema, where the machine-readable contract is: a body
carrying status alongside any other key is a 400. The route also narrows the
patch to the status alone, so the write cannot widen again if the schema is
relaxed. API reference updated.
Rejecting a twelve-week recurring request from the admin page sends
?scope=series, which moves every open occurrence, but the notification was
built from the single booking in the URL. The member was told "Your booking
(Mon, 15 Jan 2026 at 2:00 pm - 4:00 pm) has been rejected", and believed they
still held the other eleven.

bookingStatusMessage now takes the number of occurrences the change covered and
names it, so all five statuses gain the same series wording rather than one
route growing a string of its own. The count is the server's resolved target
list, so a series whose other occurrences are already closed still reads as a
single date. Recorded in the API reference and struck from the README's known
gaps.
Two write sequences could half-run and leave a row holding a room that nobody
knew about.

Deleting one occurrence of a series ran three promotion updates and then the
delete, as four separate statements, and told the member it was cancelled
before any of them. A failure part-way left the booking alive and still holding
its slot while the member had already been told to stop turning up, and a
promotion that died between detaching the successor and repointing its siblings
split the series in two, so no later scope=series delete could clear both
halves. The promotion and the delete now go in one db.batch, and the member is
told afterwards, which is the order the bulk route already used.

Creating a recurring booking wrote the head, then the pattern, then the
occurrences, and only the last of those was atomic. A failed occurrence batch
left the head committed, unannounced and holding its slot, so the admin's retry
came back as "1 of 6 occurrence(s) clash" against a booking they had never
seen. The pattern and the occurrences now go in one batch, and a failure
deletes the head again rather than leaving it behind.

Data model and API reference updated.
Availability was decided by a SELECT and applied by a separate UPDATE whose
WHERE bound the booking id and nothing else, so nothing held the slot in
between. Two admins triaging the Monday queue could each confirm a different
pending request into Rehearsal Room 3 for the same evening: both checks saw the
room free, both writes succeeded, and both casts were emailed a confirmation.
Nothing in either row recorded the clash, so it surfaced when they both turned
up. D1 has no interactive transaction, so there was no way to close it by
wrapping the pair.

The UPDATE now carries the occupancy rule as a NOT EXISTS predicate, built by
the same planner every write path already goes through, so a route cannot skip
it. It is attached only when the resolved status actually holds a slot, is
skipped for the allowConflicts override, matches whichever of room or external
venue was assigned, and binds a fixed number of parameters. A write that
matches nothing is separated into a 404 for a booking that has gone and the
ordinary 409, with its list of clashing bookings, for one that lost the race.

ADR-0008 records the reasoning, including what a mid-batch clash leaves behind
under scope=series, and the data model and API reference point at it.
SQLite creates no index for a foreign key, so bookings.user_id and
bookings.external_venue_id had none. Every member-facing read filters on
user_id: the stats endpoint counts over it four times, the list endpoint scopes
a non-admin to their own rows, /requests walks every page of that, and four of
the GDPR hooks do the same. Loading the member home page was five full scans of
bookings. checkVenueAvailability was worse in kind, since it is the occupancy
gate for every external-venue assignment and had nothing to lean on, unlike the
room check which the (room_id, start_time, end_time) index already covers.
Bookings are only ever added and D1 bills by rows read, so this got worse every
term and never better.

Adds (user_id, start_time) and (external_venue_id, start_time, end_time). The
generated migration is two CREATE INDEX statements, no table rebuild, and the
data model's index section now lists all five and says why the foreign keys
needed their own.
users.is_rooms_admin is refreshed from the session, but only while that person
keeps using this app. rooms:ADMIN carries a committee-year expiry, so the whole
outgoing committee loses the role on the same day and has no reason to sign in
here again. Their mirror rows kept the flag set for good, and every new booking
request went on being bcc'd to them, carrying the requester's name, the event
title, the date, the attendee count and their free-text notes. Nothing in the
app or in stage-door could have cleared it: role changes are not pushed to
consumer apps, and a committee-year lapse has no revocation event to push.

The fan-out now resolves its recipients from stage-door's role-holders
endpoint, which excludes an expired grant, and intersects the ids against the
local mirror in chunks so it still has their notification preferences to
respect. The answer is cached for ten minutes per isolate; if stage-door cannot
be reached the cached flag is used and a warning logged, because sending
nothing means a booking request nobody sees. proscenium already consumes the
same endpoint the same way.

The lookup and the preference filter existed twice, in notifications.ts and
inline in POST /api/bookings. There is now one, so the next change to it cannot
miss half. ADR-0009 records the reasoning; the data model no longer claims the
column is refreshed on each request.
…take

The bulk Reject button sent the literal "Bulk rejection" as the reason, so every
member in the selection was emailed "Your booking ... has been rejected: Bulk
rejection". That string was also written to bookings.rejection_reason, so it is
what they saw on /requests afterwards and what a subject access export returned.
The mandatory-reason rule in the schema exists because the reason is shown to
the requester, and a placeholder satisfied the rule while defeating the point.
The single-booking path has always prompted for one.

Adds a bulk variant of the reject modal, taking the number of bookings and
emitting only the reason, with the same non-empty guard the single modal has.
The reason is then interpolated per booking exactly as the single path does, so
no server change was needed.

The button beside it disagreed with the action as well: its condition and its
count both looked only at PENDING while the handler also takes
AWAITING_EXTERNAL, so a selection of purely awaiting-external bookings hid the
button and a mixed selection rejected more than the badge said. Both now read
the same list.
useRoomAvailability caught every error, logged it and emptied both lists. The
booking forms read empty lists as "the check has not run yet" and fell back to
the plain room list, whose entries carry no conflicts, so every room computed as
available and was offered unbadged. totalUnavailable was zero, so the "Show
unavailable" button disappeared too, and the admin had no way to tell "nothing
clashes" from "we could not check". The usual trigger is the session crossing
the fifteen-minute staleness line, where the endpoint answers 401.

The composable now keeps an explicit error and a hasAvailability flag, so a
caller can tell a failed check from one that has not run. Both forms surface
the error through the shared toast and offer no rooms while it stands, with a
line in the picker saying why. The server still refuses a clashing write, so
this was a misleading picker rather than a double booking, but a picker that
quietly lies is worse than one that says it does not know.

Recorded in the README that a 401 carrying stale:true from a fetch is surfaced
rather than refreshed: only route navigation refreshes the session, and
deciding what an interceptor should do to a half-filled form is its own change.
@MattA-Official
MattA-Official merged commit 4de9fe3 into main Aug 25, 2026
1 of 2 checks passed
@MattA-Official
MattA-Official deleted the fix/review-mediums branch August 25, 2026 18:59
@MattA-Official MattA-Official changed the title Fourteen medium findings: partial bulk writes, three erasure gaps, and a DST-drifting recurrence Fourteen medium findings: a stale admin bcc list, an unheld slot, and two half-run write sequences Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant