Skip to content

v1.14.0 - #90

Merged
pataniaeli merged 13 commits into
mainfrom
dev
Sep 9, 2026
Merged

v1.14.0#90
pataniaeli merged 13 commits into
mainfrom
dev

Conversation

@pataniaeli

Copy link
Copy Markdown
Collaborator

No description provided.

pataniaeli and others added 13 commits September 8, 2026 20:50
An admin opens Cancellations, presses Auto-Cancel, optionally narrows by booking
type and date range, reviews the list, and sends CSC a single email asking for
each reservation to be released. Every booking marked Pending Cancellation is
collected, and each line carries the date, the time and the reservation code.

The weekly half is the part worth reading twice. weekly_room_occurrences.status
is an override where NULL means "inherit from the series" -- 340 of the 385
occurrence rows carry a NULL status. A series marked Pending Cancellation
therefore has occurrences that are pending without any occurrence row saying so,
and matching on the occurrence's own column would silently skip every
series-level cancellation: the case with the most dates behind it. Room, times
and reservation code inherit the same way, so each is resolved against the series
before it reaches the email. That resolution is a pure exported function because
it is the piece most likely to be wrong and the hardest to observe -- no series
is currently pending, so there is no live data to exercise it.

Nothing changes status. The email asks CSC to release the rooms; CSC is the
authority on whether that happened, and marking anything 'Cancelled' here would
assert an outcome Chambers has not been told. The existing "Mark as Done" control
stays the step that closes the loop.

Two guards, because this is the only mail Chambers sends outside SGA and it
cannot be recalled. The admin approves the actual list rather than a count, and
the send re-runs the query server-side rather than trusting the posted body --
otherwise a client could mail any set of dates and codes it liked to an external
address. The approved count travels with the request, and a mismatch answers 409
rather than sending a list nobody agreed to.

The send is awaited rather than deferred like the member-facing emails: it is the
entire point of the request, and an admin told it went needs that to be true.

Reservations with no code on file are still listed, flagged in the preview and
marked in the email, rather than dropped -- CSC can find those by date and room,
and silently omitting them would lose a cancellation.

The recipient defaults to cscreservations@northeastern.edu and is overridable via
CSC_EMAIL so a staging deployment can point somewhere harmless. Replies go to
OPS_EMAIL rather than the no-reply sender.

Verified against the live database with Resend intercepted, so nothing was sent:
the collector returns exactly the 6 rows an independent SQL query finds, with the
same codes, rooms and bodies, ordered by date then time. Type filters give
0/0/6 for tabling/one-time/weekly, date bounds give 2, 4 and 1 rows, and a
malformed date is rejected rather than passed through. The inheritance helper
resolves an all-NULL occurrence to the series' pending status, lets an occurrence
override beat it, and falls back safely with no series at all. The email addresses
cscreservations@northeastern.edu with the reply-to set, and its table fits the
600px frame with no cell overflow. Through the real modal: filters update the
count live, the send button disables on an empty list, and the POST carries the
reviewed count with the filters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes to the behaviour agreed on review.

Sending now cancels. The reservations included in the request are marked
'Cancelled' once CSC has been asked, rather than being left Pending Cancellation
for a human to close out. Order matters and is deliberate: the email goes first,
and statuses move only after it has been accepted. Marking first and then failing
to send would leave a booking cancelled in Chambers that CSC still holds a room
for -- the one outcome worth designing against, because nobody would go looking
for it. If the mail fails, nothing is touched and the response says so.

An occurrence whose status was inherited gets 'Cancelled' written onto the
occurrence itself, so only the dates actually sent stop being pending and the
rest of the series is untouched.

Each affected booking also gets an audit_logs row, so the change appears in the
Audit tab beside every other status change rather than looking like it happened
by itself. Best effort: the mail is out and the statuses have moved, so failing
the request over a missing log would only invite a resend.

Codeless reservations are dropped rather than listed. A reservation code is the
only handle CSC has on a booking, so one without it can be neither requested nor
-- now that sending cancels -- cancelled, since doing so would put Chambers and
CSC out of step over a request CSC could not act on. They are set aside and shown
in the preview and on the confirmation, because they are still outstanding and
someone has to chase them by hand. Whitespace counts as absent: a code column
holding " " is a blank someone tabbed through.

Ops is copied on every request, from OPS_EMAIL, so the division holds the record
rather than only the sender's mailbox.

This also closes the duplicate-send gap raised on the PR. Marked-Cancelled rows
no longer match the query, so a second run cannot re-send them, and no extra
column or send log is needed.

Verified without sending mail or writing to the database. The code rule rejects
null, undefined, empty and whitespace and accepts a real code padded or not. The
send addresses CSC with OPS_EMAIL on cc and reply-to. The rows the updates would
target were checked against the database read-only: all six ids exist as
occurrences, all six parent booking ids are correct for the audit rows, all six
are currently pending, and no pending row is missed -- so the statement would hit
exactly the intended rows and nothing else. Through the real modal with skipped
rows present: the coded two are listed, the two without codes appear in their own
block marked not sent and not cancelled, the footer names both recipients, and
the confirmation reports what was cancelled and what was left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…atched

Auto-Cancel took a booking type and a date range and acted on everything that
matched. That made the filter -- something you set to look around with -- decide
what got cancelled, and after the change that sending also marks bookings
Cancelled, a mis-set filter was no longer a bad email but a batch of bookings
cancelled without anyone choosing them.

The button now returns every reservation currently marked Pending Cancellation,
and the admin ticks the ones to include. Nothing is selected when the modal
opens: including a reservation is an act, not a default, because the send is both
an email to a university office that will act on it and a status change in
Chambers, and neither can be taken back.

The filters are gone rather than kept alongside the checkboxes. Two mechanisms
for choosing what gets cancelled, one of which quietly hides rows, is how this
went wrong in the first place. If the list grows enough to need finding rather
than reading, a filter that narrows only what is displayed can come back --
selection would stay explicit.

The selection is a choice among what the server finds, never the source of truth.
Each row is named by a key scoped to its table, and every submitted key is
matched back against a freshly collected set before anything is sent: a client
cannot introduce a date, a code, or a booking that is not currently pending with
a code on file, and the list that reaches CSC is built from the server's own rows
rather than from anything posted.

A key that no longer resolves fails the whole request rather than the row.
Sending the remainder would cancel a different set from the one the admin
reviewed, and they would have no way to tell which.

Verified without sending mail or writing to the database. Keys are unique and
table-scoped; a valid subset resolves exactly; an unknown key resolves to nothing
and is reported; the same uuid under the wrong table prefix resolves to nothing,
which is the reason the key carries its table; a mixed selection reports the bad
one, so the route refuses the batch. Both handlers answer 401 unauthenticated.
Through the real modal: three rows, none ticked, send disabled and the recipient
line hidden; ticking one enables it and names both recipients; select all and
clear all move between 3 and 0; and ticking the first and third posts exactly
those two keys with the middle row excluded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A cancellation request records a cancellation_type: 'Cancellation' when the
meeting is off, 'Virtual' when it is moving online. Auto-Cancel was marking
everything 'Cancelled', which is wrong for the second -- a virtual meeting still
happens, it just does not need the room -- and 'Virtual' is already a status the
rest of Chambers understands.

Each pending reservation now resolves its own outcome. A request naming the exact
week wins over one covering the series; one-time and tabling rows match on their
booking. Requests are read once and indexed rather than looked up per row, and
ordered so a still-Pending request beats a resolved one and the most recent beats
an older one.

Where nothing says which, it falls back to 'Cancelled'. That is the common case
rather than an edge: of the seven reservations pending right now, four have no
cancellation request behind them at all, because a booking can be set to Pending
Cancellation directly by an admin. 'Cancelled' is the plain reading of that, and
going virtual is a specific thing a requester asks for -- defaulting the other way
would quietly leave rooms marked as still meeting.

Those rows say so in the list. A reservation taking a default rather than a
stated intent is labelled, because that is the one an admin might want to look at
before approving it.

Updates are grouped by table and by status, so a mixed batch writes each its own
value in one statement per pair. Audit rows are keyed the same way: one booking
can contribute both a cancelled week and a virtual one, and a single row saying
'Cancelled' would misreport the other.

CSC's side is unchanged. Either way the reservation is released, so the email
still asks for exactly that.

The send button stops claiming to mark things cancelled once a Virtual is in the
selection, and the confirmation reports the split.

Verified against the live database with nothing written and no mail sent.
outcomeOf maps 'Virtual' and 'Cancellation' to their statuses as stated, and
null, undefined and an unrecognised value to Cancelled as a default. Resolved
across the seven pending reservations it gives six Cancelled and one Virtual with
four defaulted, matching an independent SQL join row for row. Through the real
modal: each row carries its outcome, the defaulted one is labelled, the button
reads "mark cancelled" for a Cancelled-only selection and "apply statuses" once
the Virtual is added, and the confirmation reads "2 now marked Cancelled, and 1
marked Virtual".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Updated cancellation request email content for clarity and consistency.
package.json, the lockfile's two root entries, and the sidebar label.

Only lines 3 and 9 of package-lock.json belong to the project; a find-and-replace
across the file would bump a dependency to a version that does not exist and
break npm ci.

A minor rather than a patch: this release adds things rather than only fixing
them. Since 1.13.8 it carries the calendar filling the page (#70), booking cards
that lead with the purpose and open their details (#78), deniable revision
requests (#77), expected attendance on room requests (#76), creation emails and
update emails that say what changed (#79), and Auto-Cancel.

The FAQ's roadmap had v1.14.0 as the next unknown release. That entry now
describes what actually shipped, and the placeholder moves to v1.15.0 so the
roadmap keeps pointing forward. Written for members rather than for this repo:
what changed for someone using Chambers, with the administrator-only parts named
as such.

Also escapes an apostrophe in the v2.0.0 paragraph that has been failing
react/no-unescaped-entities since it was written. It is in the file either way,
and leaving a known lint error in a file being edited invites the next person to
assume it is meant to be there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bfb4b34 rewrote the plain-text body of the CSC request but left the HTML one
saying the old thing, so the two halves of the same email disagreed -- and since
almost every mail client renders the HTML part, the revision was invisible to
nearly everyone who received it.

The HTML now carries the same words as the text: the shorter opening, "Requested
by <name>." without the trailing division, the reply-all sentence, and the
sign-off, which the HTML had never had at all.

Wording is mirrored rather than reinterpreted -- these are the sentences from
bfb4b34, only marked up. The <strong> on the count and on the requester's name is
kept from the existing markup.

Verified by rendering both bodies from the real template with Resend intercepted:
they now read the same, line for line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A test run went to CSC. The recipient fell back to the real address whenever
CSC_EMAIL was unset, so a safe test depended on an environment variable being
set, in the right Vercel scope, on a deployment created after it was added --
with no sign when any of that was untrue. Auto-Cancel exists only on this branch,
so the run could only have been a preview deployment or a local server, and both
took the default.

Outside production the default is now refused rather than used. A preview or
local deployment must name its recipient explicitly, and if it has not the
request fails with an explanation instead of reaching a university office. The
failure mode becomes "your test did not send", which costs a minute, rather than
"CSC received a real cancellation request", which costs a retraction.

Production is untouched: VERCEL_ENV is 'production' there and the default
applies, so nothing needs configuring for the feature to work in earnest.

Refused before anything happens, so a misconfigured environment can neither send
nor cancel. The preview reports the block too, so the modal says sending is
disabled and why, rather than letting the button be pressed and fail.

Also: the destination was a grey footnote beside the button, and a real send
went out with the real address on screen the whole time. It is now a bordered
block above the button, red and reading "This goes to CSC for real" when it does,
and stating the substitute when redirected. A footnote earned what it got.

Carries the cancellation-request closing that prompted this session: sending now
marks the requests it fully covered as Done, so nobody closes by hand what
Auto-Cancel did. Only when every pending reservation a request covers went out --
an occurrence-scoped request covers one row and closes when selected, but sending
three weeks of a five-week series request does not finish it, and closing it
would drop the remaining two off the Cancellations tab with nothing done.

Verified with nothing sent. The guard refuses local-dev with no override, preview
with no override, preview with the variable empty or whitespace, and accepts an
explicit override in preview or production; production with no override still
resolves to CSC. The coverage rule is a pure exported function because no
series-scoped request exists in the data to exercise it. All three modal states
render: blocked disables the button and explains why, the real address shows red
as "This goes to CSC for real", and a redirect shows the substitute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The template existed but was swallowed by the `.env*` rule, so it lived on one
machine and nobody else could see it. It also did not mention CSC_EMAIL at all.

Between them that meant the only record of the correct spelling was the source,
which is how the variable came to be misnamed in Vercel's preview environment.
A misnamed variable is indistinguishable from an unset one, the recipient fell
back to the real address, and CSC received a test cancellation request.

So the template is tracked, and it now lists every variable the code reads.
CSC_EMAIL carries a note on what an empty value means in each environment --
production falls back to CSC on purpose, everywhere else refuses -- and on
spelling it exactly.

Safe to commit: every value is empty. It is a list of names and comments, which
is precisely what was missing.

CRON_SECRET was absent too, and UPSTASH_REDIS_REST_URL/TOKEN stay even though
they never appear as process.env references -- Redis.fromEnv() in lib/rate-limit
reads them implicitly, so a checklist that dropped them would be wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat: Auto-Cancel — email CSC one request for every pending cancellation
The advance-notice requirement rejected bookings that were far enough out. With
it set to two hours it behaved like six in summer and seven in winter, which is
why it is currently set to 0 in app_settings -- the only way to stop it blocking
legitimate bookings.

SGA Spaces stores times as Boston wall-clock digits with a Z on the end: a 6 PM
booking is T18:00:00.000Z whatever the offset is that month. slotToIso sets UTC
hours straight from the slot index, the modal reads them back with getUTCHours,
and the confirmation email depends on it too -- there is a comment in
space-booking-confirmed.ts saying UTC fields give the right local-time digits to
pair with TZID=America/New_York.

That is self-consistent until something compares one of those values against a
real instant. The check did exactly that: start_time against Date.now(). Every
booking therefore looked one UTC offset earlier than it really was, and the
requirement rejected anything inside minHours + 4, or + 5 once the clocks go
back.

Which is why the calendar disagreed with the server. The shading uses
wallClockNow, already in the stored domain, so it correctly showed the slot as
available -- and then submitting it failed.

"Now" is therefore taken as Boston wall clock, in the same domain as the value it
is compared against. Pinned to America/New_York rather than the server's clock,
because Vercel runs in UTC and reading local fields there is the same bug with an
extra step. Intl resolves the offset for the instant given, so EDT and EST both
work without a table.

Also fixes the same root cause in remaining-hours, which derived its Sun-Sat
window from real UTC. UTC is already Sunday by 8 PM on a Saturday in EDT, so for
the last four hours of the week a user's hours were counted against the next one
and the remaining figure jumped. Half a domain bug is worse than none.

Verified by running the check both ways against fixed instants in each season.
With a two-hour requirement in EDT, the old comparison rejected every booking up
to five hours out and the new one rejects only those under two; in EST the old
rejected up to six. Wall clock resolves to 11:46 from 15:46Z in September and to
11:00 from 16:00Z in January, so a hardcoded offset would have been wrong for
half the year.

min_hours_advance_spaces can go back to a real value once this ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The kiosk displays themselves were already right, and deliberately so: the page
compares local minutes-from-midnight against the stored value's UTC hours and
minutes, which are the wall-clock digits, so both sides are in the same domain.
There is a comment saying as much, and the date-flip trap is handled in the page
as well -- local date components rather than toISOString, which would roll over
at 8 PM EDT.

What was still armed is the server's fallback for a missing `date` parameter,
which took the UTC date. After 8 PM EDT that is already tomorrow, so a caller
without the parameter would be served the wrong day's bookings. The display page
always sends it, so nothing reaches this today -- but it is the same trap as the
booking buffer, and leaving it set for the next caller is how the buffer bug got
written in the first place.

Verified at 9:30 PM EDT: the kiosk still shows a 9-10 PM booking as in use, and
the two fallbacks disagree by a day exactly as expected -- UTC says the 9th,
Boston says the 8th.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: measure the SGA Spaces buffer in the timezone it is stored in (#87)
@pataniaeli pataniaeli self-assigned this Sep 9, 2026
@pataniaeli pataniaeli added bug Something isn't working enhancement New feature or request labels Sep 9, 2026
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
chambers Ready Ready Preview Sep 9, 2026 4:01pm UTC

@pataniaeli pataniaeli linked an issue Sep 9, 2026 that may be closed by this pull request
@pataniaeli
pataniaeli merged commit 6a409ec into main Sep 9, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SGA Spaces buffer doesn't block correctly

1 participant