Skip to content

feat: Auto-Cancel — email CSC one request for every pending cancellation - #86

Merged
pataniaeli merged 9 commits into
devfrom
feat/auto-cancel-csc-request
Sep 9, 2026
Merged

feat: Auto-Cancel — email CSC one request for every pending cancellation#86
pataniaeli merged 9 commits into
devfrom
feat/auto-cancel-csc-request

Conversation

@pataniaeli

Copy link
Copy Markdown
Collaborator

New feature, no issue number. Branched off dev. No migration needed.

An admin opens Cancellations → 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; each line carries date, time and reservation code.

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.

So a series marked Pending Cancellation has occurrences that are pending without any occurrence row saying so. Matching on the occurrence's own column would have silently skipped 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 (resolveOccurrence) precisely because it's the piece most likely to be wrong and the hardest to observe: no series is currently pending, so there's no live data to exercise it. It's unit-tested with synthetic rows instead.

Decisions I made — worth confirming

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 hasn't been told. The existing "Mark as Done" control stays the step that closes the loop. The success screen says this explicitly so nobody assumes otherwise.

Two guards, because this is the only mail Chambers sends outside SGA and it can't be recalled:

  • The admin approves the actual list, not a count.
  • 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 reviewed count travels with the request and a mismatch answers 409 rather than sending a list nobody agreed to.

The send is awaited, not deferred like the member-facing emails. It's the entire point of the request, and an admin told it went needs that to be true; a failure returns 502 and says nothing was changed.

Reservations with no code are listed, not dropped — flagged amber in the preview and marked in the email. CSC can find those by date and room; silently omitting them would lose a cancellation.

Recipient defaults to cscreservations@northeastern.edu, overridable via CSC_EMAIL so staging can point somewhere harmless. Replies go to OPS_EMAIL, not the no-reply sender.

Known gap for you to decide

Nothing records that a request was sent. Running Auto-Cancel twice re-sends the same reservations, since the bookings stay Pending Cancellation until CSC confirms. Options: a csc_requested_at column, or a small send-log table. I didn't invent schema for it — say which you'd prefer and I'll add it.

Verification

Run against the live database with the Resend client intercepted, so nothing was sent. Probes removed before committing.

Check Result
Collector vs. independent SQL exact match — same 6 rows, codes, rooms, bodies, ordered by date then time
Type filters tabling 0 · one-time 0 · weekly 6
Date bounds from 2 · to 4 · range 1
Malformed date rejected, falls back to no limit
Inheritance: all-NULL occurrence resolves to series' Pending Cancellationwould be caught
Inheritance: occurrence override beats the series; partial inherit works
Inheritance: no series safe fallbacks, no crash
Email correct recipient + reply-to, table fits the 600px frame, no cell overflow
Modal filters update the count live, send disabled on empty list, POST carries reviewed count
Auth GET and POST both 401 unauthenticated

Typecheck, lint and build clean. The one lint error in cancellations-tab.tsx is pre-existing — confirmed on the stashed baseline.

🤖 Generated with Claude Code

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>
@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 3:39pm UTC

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>
@pataniaeli

Copy link
Copy Markdown
Collaborator Author

Added 267f63f with all three changes.

1. Sending now cancels

The reservations in the request are marked Cancelled once CSC has been asked.

Order is deliberate: the email goes first, statuses move only after it is 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 this shows up in the Audit tab beside every other status change rather than looking like it happened by itself.

2. Codeless reservations dropped

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 that 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.

3. Ops copied

cc: OPS_EMAIL on every request, so the division holds the record rather than only the sender's mailbox. Reply-to unchanged.

This closes the duplicate-send gap

The open question from the original PR resolves itself: marked-Cancelled rows no longer match the query, so a second run cannot re-send them. No extra column or send-log table needed.

Verification — no mail sent, nothing written

Check Result
Code rule rejects null / undefined / "" / " "; accepts a real code, padded or not
Email headers to CSC, cc sgaOperations@northeastern.edu, reply-to set
Update targets exist 6/6 ids resolve to real occurrence rows
Audit parents correct 6/6 booking ids are the true parents
All still pending 6/6
Pending rows missed 0

That last block was run read-only against the database, so the UPDATE is confirmed to hit exactly the intended rows and nothing else — without executing it.

Through the real modal with skipped rows present: coded reservations listed, codeless ones in their own amber block marked not sent and not cancelled, footer naming both recipients, and the confirmation reporting what was cancelled and what was left alone.

…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>
@pataniaeli

Copy link
Copy Markdown
Collaborator Author

Added 773023f — rescoped from filter-driven to explicit selection.

Why this was the right call

Filters decided what got cancelled. Once sending also marks bookings Cancelled, a mis-set filter stopped being a bad email and became 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, since the send is both an email to an office that will act on it and a status change, neither reversible.

The filters are gone, not kept alongside

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 ever grows enough to need finding rather than reading, a filter that narrows only what is displayed can come back, with selection staying explicit. Say the word if you want that now.

The security property is preserved

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; the list reaching CSC is built from the server's own rows.

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

Verification — no mail sent, nothing written

Check Result
Keys unique + table-scoped source:uuid
Valid subset resolves exactly, 0 missing
Unknown key resolves to nothing, reported
Same uuid, wrong table prefix resolves to nothing — the reason the key carries its table
Mixed valid + stale reports the bad one → route refuses the batch
Auth GET and POST both 401

Through the real modal: 3 rows, none ticked, send disabled and recipient line hidden; ticking one enables it and names both recipients; select all / clear all move between 3 and 0; ticking the first and third posts exactly those two keys with the middle row excluded.

Worth noting the live pending list moved from 6 to 7 while I was working, which the list picked up — a good reminder of why the stale-key check refuses rather than proceeds.

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>
@pataniaeli

Copy link
Copy Markdown
Collaborator Author

Added c3adc12. You were right — this was a real correctness bug in what I built.

cancellation_requests.cancellation_type is Cancellation | Virtual (CHECK-constrained), and Virtual is already a status the rest of Chambers understands. Marking everything Cancelled was wrong for the second: a virtual meeting still happens, it just does not need the room.

Resolution rules

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, ordered so a still-Pending request beats a resolved one and the most recent beats an older one.

The fallback turned out to matter more than the mapping

Of the 7 reservations pending right now, 4 have no cancellation request behind them at all — a booking can be set to Pending Cancellation directly by an admin. So the default is the common path, not an edge case.

Those fall back to Cancelled: it is the plain reading of an admin marking something for cancellation, 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 are labelled in the list — "no request on file — defaulting" — because a row taking a default rather than a stated intent is the one worth a second look before approving.

Other bits this touched

  • Updates grouped by table and status, so a mixed batch writes each its own value.
  • Audit rows 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.
  • The send button stops claiming "mark cancelled" once a Virtual is selected; it reads "apply statuses" instead.
  • CSC's side is unchanged — either way the reservation is released, so the email asks for exactly that.

Verification — nothing written, no mail sent

outcomeOf unit results:

Input Status fromRequest
Virtual Virtual true
Cancellation Cancelled true
null / undefined / unrecognised Cancelled false

Resolved across the 7 live pending reservations: 6 Cancelled, 1 Virtual, 4 defaulted — matching an independent SQL join row for row, including the one Virtual (code 695614) and the two explicit Cancellation requests.

Through the real modal: each row carries its outcome badge, the defaulted row is labelled, the button reads "Send & mark cancelled (2)" for a Cancelled-only selection and flips to "Send & apply statuses (3)" once the Virtual is added, and the confirmation reads "2 now marked Cancelled, and 1 marked Virtual — those meetings still happen, without the room."

Updated cancellation request email content for clarity and consistency.
pataniaeli and others added 2 commits September 9, 2026 11:15
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>
@pataniaeli
pataniaeli merged commit e64d093 into dev Sep 9, 2026
4 checks passed
@pataniaeli
pataniaeli deleted the feat/auto-cancel-csc-request branch September 9, 2026 15:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant