Skip to content

Close the notification loop: interest list, resumable announcements, live funnel - #318

Merged
aamoghS merged 2 commits into
mainfrom
stack/2-notifications
Aug 9, 2026
Merged

Close the notification loop: interest list, resumable announcements, live funnel#318
aamoghS merged 2 commits into
mainfrom
stack/2-notifications

Conversation

@aamoghS

@aamoghS aamoghS commented Aug 8, 2026

Copy link
Copy Markdown
Member

Second in the stack. Based on main now 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_at per 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; sendBatch walks 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.

/hacklytics survives registration opening. getUpcoming filtered to announced only. 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 covers announced/open/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. Page and mutation now share computeSubmissionWindow.

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 isAdmin and 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: createAnnouncement freezes the message and audience into hackathon_announcement / recipient rows (no offset slicing, no re-resolving the audience each batch). sendBatch claims up to 500 rows atomically, emails one-by-one, and stamps sentAt or failedAt per 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 admin notifyRegistrationOpen (same claim/batch pattern), plus Registration Controls “Email N interested” when the edition is open.

Public funnel: getUpcoming includes open / in_progress and exposes registrationOpen so /hacklytics keeps working and swaps interest form for a register CTA. /submit surfaces the submission window before submit (via submissionWindow query).

Email layer: shared sendTransactionalEmail with 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_LENGTH 2500 so large roster batches work; targeted cache invalidation mappings; removal of misleading adminProcedure / judgeProcedure stubs; safeLogId on 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.

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.
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Aug 8, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Fix All in Cursor

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

Comment thread packages/api/src/routers/hackathon/interest.ts
});

// Housekeeping rides along with the write that created the need for it.
maybePruneAuditLogs(db);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2569669. Configure here.

Comment thread packages/api/src/routers/hackathon/announce.ts
Comment thread packages/api/src/routers/hackathon/interest.ts
@greptile-apps

greptile-apps Bot commented Aug 8, 2026

Copy link
Copy Markdown

Greptile Summary

The PR persists announcement audiences and per-recipient delivery state so mass-email jobs can resume, adds registration-open notifications, and keeps the public hackathon and submission funnels aligned with their active states.

  • Freezes announcement content and recipients at composition time, then sends claimed recipients in resumable batches.
  • Adds batched registration-open notifications for the interest list.
  • Extends the public hackathon landing page through open and in-progress editions.
  • Shares submission-window and email-template behavior across their respective entry points.

Confidence Score: 4/5

The PR is not yet safe to merge because a live email batch can lose its claim and duplicate deliveries when processing exceeds fifteen minutes.

The new claim mechanism closes the immediate concurrent-selection race, but its fixed lease can expire while the original request is still sequentially sending, allowing another resume request to reclaim and deliver the same unstamped recipients.

Files Needing Attention: packages/api/src/routers/hackathon/announce.ts and packages/api/src/routers/hackathon/interest.ts

Important Files Changed

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]
Loading

Reviews (2): Last reviewed commit: "fix(notifications): claim recipients bef..." | Re-trigger Greptile

Comment thread packages/api/src/routers/hackathon/announce.ts
Comment thread packages/api/src/routers/hackathon/interest.ts
…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.
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@aamoghS

aamoghS commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Both findings were real. Fixed in 24bcccb, along with the CodeQL alerts that landed on main.

Concurrent duplicate delivery — correct for both loops. Each now claims its batch with one atomic UPDATE … WHERE sent_at IS NULL AND failed_at IS NULL AND (claimed_at IS NULL OR claimed_at < now() - 15m) RETURNING … and sends only to what it won, so two overlapping requests split the work instead of both mailing the same people. The stale-claim window is what keeps it resumable: without it, a request killed by a deploy would hold its rows claimed forever and the send could never finish. Fifteen minutes is deliberately generous — a batch is 500 SMTP round trips, and re-mailing somebody is worse than making an organiser wait.

Interest loop reporting done after failures — also correct, and there were two halves to it. Failures were left pending and done was inferred from pending.length < MAX, so a batch with rejected addresses came back done with those people unmailed. Failures are now marked (registration_open_email_failed_at) — left pending, a permanently bad address is retried on every batch and the send never finishes — and done is counted from the table rather than inferred. The admin screen's pending count excludes failed and claimed rows for the same reason.

Also fixed here, from CodeQL on main (2 high, 2 medium): the webhook interpolated request-derived Stripe ids into console.error messages. The mock branch parses the body without verifying a signature, so those values are not always Stripe's — a newline forges log entries, a %s is read as a format directive. Ids now go through safeLogId and are passed as arguments rather than as the message.

Schema: claimed_at on the recipient rows, registration_open_email_claimed_at / _failed_at on hackathon_interest. DDL updated in the same file.

Gate re-run: typecheck · 391 tests · lint --max-warnings 0 · build. The claim guard is mutation-tested — the first version of that assertion passed with the claim broken, so it was tightened until breaking the claim fails it.

@aamoghS
aamoghS merged commit 86e55d7 into main Aug 9, 2026
19 checks passed
@cursor

cursor Bot commented Aug 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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)

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Live claims expire mid-send

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant