Close the notification loop: interest list, resumable announcements, live funnel - #318
Conversation
Four things the product collected data for and then never used.
**The interest list is finally told.** It exists for exactly one moment —
registration opening — and nothing sent it, so the runbook told organisers to
hand-compose an announcement instead. Registration Controls now offers "Email N
interested" the moment an edition is open, 500 at a time, stamping
hackathon_interest.registration_open_email_sent_at as each one goes out. A
closed tab resumes rather than re-sending.
**Announcements survive a closed tab.** The send loop runs in an organiser's
browser and walked the audience by offset across separate requests, re-resolving
it each time — so any row that moved between batches shifted the window, and a
half-finished send could only be repeated from the start. Composing now freezes
the message and its audience into rows (hackathon_announcement and
hackathon_announcement_recipient), and each recipient is marked as their message
leaves. Unfinished sends are listed and resumable, and "exactly once" is a
unique constraint rather than arithmetic in a browser.
**/hacklytics survives registration opening.** getUpcoming filtered to
`announced` only, so the single public entrance to the hackathon — the 2027
site's only CTA and the navbar both land there — went blank with "Nothing
announced yet" at the exact moment it mattered. It now covers announced, open
and in_progress, and swaps the interest form for a register CTA.
**/submit says whether it is open.** The page never queried the window it was
gated on, so an attendee could write a full description and learn it was refused
only on submit. Both now share computeSubmissionWindow.
Also: one email template layer. Every message is {subject, heading,
paragraphs[], cta} with the text always escaped and a plain-text alternative
built from the same content; the acceptance email was folded onto it from its
own hand-written HTML and its own escaping.
Database: packages/db/ddl/2026-08-08-notifications.sql. Additive only — safe to
apply before this deploys.
Verified: typecheck, 391 tests, lint --max-warnings 0, build.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2569669. Configure here.
| }); | ||
|
|
||
| // Housekeeping rides along with the write that created the need for it. | ||
| maybePruneAuditLogs(db); |
There was a problem hiding this comment.
Security audits never pruned
Medium Severity
Weekly audit cleanup was removed and retention now runs only from recordAdminAction. Security middleware still bulk-inserts rate-limit and auth events into audit_logs without calling maybePruneAuditLogs, so those rows may never expire unless an admin action triggers a prune.
Reviewed by Cursor Bugbot for commit 2569669. Configure here.
|
| Filename | Overview |
|---|---|
| packages/api/src/routers/hackathon/announce.ts | Adds persisted announcements and atomic recipient claims, but fixed-duration claims can expire while a live sequential batch is still sending. |
| packages/api/src/routers/hackathon/interest.ts | Adds registration-open notification claiming and delivery state, including the same active-claim expiry exposure as announcement sending. |
| packages/db/src/schemas/hackathons.ts | Adds the announcement, recipient, and interest-delivery fields required by the resumable notification flows. |
| packages/auth/src/email.ts | Consolidates email rendering around escaped structured content and supplies the new notification templates. |
| sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx | Adds composition, progress, and resume controls for persisted announcement jobs. |
| sites/mainweb/components/admin/hackathons/RegistrationControls.tsx | Adds status and trigger controls for notifying pending interest-list recipients. |
| sites/mainweb/app/(portal)/hacklytics/page.tsx | Keeps the public funnel visible after registration opens and switches its primary action accordingly. |
| sites/mainweb/app/(portal)/submit/page.tsx | Presents submission-window state before attendees invest effort in the form. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Admin starts or resumes send] --> B[Claim pending recipients]
B --> C[Send recipients sequentially]
C --> D[Stamp sent or failed per recipient]
C --> E{Claim older than 15 minutes?}
E -- No --> C
E -- Yes --> F[Another request may reclaim unsent rows]
F --> G[Both requests may deliver the same recipient]
Reviews (2): Last reviewed commit: "fix(notifications): claim recipients bef..." | Re-trigger Greptile
…hook logs Greptile on #318, plus the CodeQL alerts on main. **Overlapping batches could mail the same person twice.** Both send loops selected rows with no sent marker and wrote the marker afterwards, so two requests — two organisers, or one impatient double-click — read the same rows and both sent to them. Each loop now claims its batch with a single atomic UPDATE and works only from what it won; the loser gets a smaller batch instead of a duplicate delivery. A claim older than fifteen minutes is reclaimable, so a request killed by a deploy or a timeout does not strand its recipients and make the send permanently unfinishable. **The interest send reported itself finished while recipients were pending.** `done` was inferred from the batch size, so a batch where some addresses failed came back done with those people never mailed. Failures are now marked — left pending, a permanently bad address is retried on every batch and the send can never finish — and `done` is counted from the table rather than inferred. **Externally-controlled format string / log injection** (CodeQL, 2 high + 2 medium on main). The Stripe webhook interpolated ids from the request body into `console.error` messages. The mock branch parses that body without verifying a signature, so those values are not always Stripe's: a newline forges log entries and a `%s` is read as a format directive. Ids now go through `safeLogId` and are passed as arguments, never as the message. Verified: typecheck, 391 tests, lint --max-warnings 0, build. The claim guard was mutation-tested.
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
|
Both findings were real. Fixed in 24bcccb, along with the CodeQL alerts that landed on Concurrent duplicate delivery — correct for both loops. Each now claims its batch with one atomic Interest loop reporting Also fixed here, from CodeQL on Schema: Gate re-run: typecheck · 391 tests · lint |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_a61c5b8f-8db5-46ab-a539-be1dbef67b41) |
|
Visit the preview URL for this PR (updated for commit 24bcccb): https://hacklytics2027--pr-318-v23d332i.web.app (expires Sun, 16 Aug 2026 00:02:08 GMT) 🔥 via Firebase Hosting GitHub Action 🌎 Sign: c48ba34db61581e25fe2978355160b5eefe0e83f |
| isNull(hackathonAnnouncementRecipients.failedAt), | ||
| or( | ||
| isNull(hackathonAnnouncementRecipients.claimedAt), | ||
| lt(hackathonAnnouncementRecipients.claimedAt, claimCutoff), |
There was a problem hiding this comment.
If a 500-recipient batch remains active for more than the fixed 15-minute claim window because SMTP delivery is slow or stalled, another resume request reclaims recipients that the first request is still processing. Because each sent marker is written only after its sequential SMTP call returns, both requests deliver the same announcement; the registration-open flow uses the same lease behavior.


Second in the stack. Based on
mainnow that #316 has landed.Four things the product collected data for and never used
The interest list is finally told. It exists for exactly one moment — registration opening — and nothing sent it, so the runbook told organisers to hand-compose an announcement. Registration Controls now offers "Email N interested" as soon as an edition is open. 500 at a time, stamping
hackathon_interest.registration_open_email_sent_atper recipient before the next one is attempted, so a closed tab, a timeout or an impatient second click resumes instead of re-sending.Announcements survive a closed tab. The old loop ran in the organiser's browser, re-resolved the audience on every request and sliced it by offset — so any row that moved between batches shifted the window (some people mailed twice, others never), and a half-finished send could only be repeated from the start. Composing now freezes the message and its audience into rows;
sendBatchwalks the un-sent ones and marks each as it goes. Unfinished sends are listed with a Resume button, and "exactly once" is a unique constraint rather than arithmetic in a browser./hacklyticssurvives registration opening.getUpcomingfiltered toannouncedonly. The moment an organiser opened registration, the single public entrance to the hackathon — the 2027 site's only CTA and the navbar both land there — said "Nothing announced yet". It now coversannounced/open/in_progressand swaps the interest form for a register CTA./submitsays whether it is open. The page never queried the window it was gated on, so an attendee could write a full description and learn it was refused only on submit. Page and mutation now sharecomputeSubmissionWindow.Also
One email template layer: every message is
{subject, heading, paragraphs[], cta}, text always escaped, plain-text alternative built from the same content. The acceptance email was folded onto it from its own hand-written HTML and its own copy of the escaping.Database
packages/db/ddl/2026-08-08-notifications.sql— two tables and one column, additive only, safe to apply before this deploys.Verification
typecheck · 391 tests · lint
--max-warnings 0· build — green on this commit.Note
Medium Risk
Bulk email paths touch production SMTP with new claim/resume semantics and new DB tables—high operational impact if mis-deployed without DDL, but auth stays on
isAdminand failures are isolated per recipient.Overview
Closes gaps where the product collected interest and composed messages but never delivered them reliably, and where public pages broke at the moment registration opened.
Resumable mass announcements split compose from send:
createAnnouncementfreezes the message and audience intohackathon_announcement/ recipient rows (no offset slicing, no re-resolving the audience each batch).sendBatchclaims up to 500 rows atomically, emails one-by-one, and stampssentAtorfailedAtper recipient so a closed tab or double-click resumes instead of duplicating mail. Admin UI lists unfinished sends with Resume.Interest list → registration open adds per-row
registration_open_email_*markers and adminnotifyRegistrationOpen(same claim/batch pattern), plus Registration Controls “Email N interested” when the edition is open.Public funnel:
getUpcomingincludesopen/in_progressand exposesregistrationOpenso/hacklyticskeeps working and swaps interest form for a register CTA./submitsurfaces the submission window before submit (viasubmissionWindowquery).Email layer: shared
sendTransactionalEmailwith escaped paragraphs and plain-text alt; new templates (registration open, judge approved, initiative decision, results) and acceptance folded onto the shared path.Other: DDL for new tables/columns;
MAX_ARRAY_LENGTH2500 so large roster batches work; targeted cache invalidation mappings; removal of misleadingadminProcedure/judgeProcedurestubs;safeLogIdon Stripe webhook errors; unique hackathon name in schema.Reviewed by Cursor Bugbot for commit 24bcccb. Bugbot is set up for automated code reviews on this repo. Configure here.