Decouple club membership from the hackathon edition - #316
Conversation
A club membership was welded to a hackathon edition: `member` was `unique(userId, hackathonId)` NOT NULL and every read resolved through `resolveCurrentHackathonId`. The day next year's edition flipped to `open`, every paying member silently became a non-member — the portal showed the pay button, club check-in threw at the door, initiatives locked out, and re-paying discarded whatever months were left. Membership is now annual and belongs to a person: `unique(userId)`, and the edition clause is gone from every read site (registration, member, events, initiative, portal-context, verify-email). `membership_history` is finally written on join and renewal, because `hackathonId` had been doing accidental duty as the only record of which year somebody was a member. Also here: - The Stripe payment row commits before any membership work. The two shared a transaction, so a grant that threw rolled back the payment record as well — the customer was charged and nothing anywhere recorded it, and none of the recovery paths could help because they look for a row that was never written. - Mock mode grants a real membership through the production confirm path, so the club half can be developed without a Stripe key. The old test asserted a returned shape, which is how "Access Granted with nothing written" survived. - verify-email calls the shared membership service instead of its own third copy, which restarted the term from today and wrote no history. The database change is deliberately NOT applied by `drizzle-kit push` — push offers to truncate when adding the unique constraint. Apply packages/db/ddl/2026-08-08-membership-decouple.sql by hand; it back-fills membership_history before dropping the column it replaces. Verified: typecheck, 373 tests, lint --max-warnings 0, build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
|
Visit the preview URL for this PR (updated for commit 949f316): https://hacklytics2027--pr-316-clvbhvx7.web.app (expires Sat, 15 Aug 2026 23:12:32 GMT) 🔥 via Firebase Hosting GitHub Action 🌎 Sign: c48ba34db61581e25fe2978355160b5eefe0e83f |
|
| Filename | Overview |
|---|---|
| packages/db/ddl/2026-08-08-membership-decouple.sql | Safely backfills membership history, removes edition scoping, adds per-user uniqueness, and now explicitly documents the required follow-up schema push. |
| packages/db/src/services/membership.ts | Centralizes annual membership creation and renewal while recording each resulting term in membership history. |
| packages/api/src/routers/stripe.ts | Reconciliation now detects linked payments without corresponding membership history and retries their membership grant. |
| sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts | Persists paid Stripe records before attempting membership work so grant failures retain a recoverable payment record. |
| packages/db/src/schemas/judge.ts | Extends judging projects and queues with submission provenance, withdrawal, QR, and arrival state required by the new judging workflows. |
| packages/api/src/routers/judge/admin.ts | Adds guarded submission promotion and queue-rebuild behavior while preserving completed judging work. |
Sequence Diagram
sequenceDiagram
participant Stripe
participant Webhook
participant PaymentDB as stripe_payment
participant Membership as Membership service
participant History as membership_history
participant User
Stripe->>Webhook: Paid event
Webhook->>PaymentDB: Persist paid payment
Webhook->>Membership: Grant or renew membership
Membership->>History: Record joined or renewed term
alt Membership grant fails
Webhook-->>Stripe: Acknowledge recorded payment
User->>PaymentDB: Reconcile my payments
PaymentDB-->>User: Linked paid payment
User->>History: Check whether payment was honoured
User->>Membership: Retry unhonoured grant
Membership->>History: Record recovered term
end
Reviews (2): Last reviewed commit: "fix(stripe): recover a payment whose mem..." | Re-trigger Greptile
| await db | ||
| .update(judgingProjects) | ||
| .set({ withdrawnAt: new Date() }) | ||
| .where(eq(judgingProjects.sourceProjectId, input.projectId)); |
There was a problem hiding this comment.
Withdrawn projects stay in queues
High Severity
adminWithdrawProject sets withdrawnAt on judging_project, but judge queue and portal paths never filter on it—only rankings do. After a forced withdraw, judges can still be routed to that table via existing judge_queue rows while results exclude the project.
Reviewed by Cursor Bugbot for commit b6eb251. Configure here.
| } | ||
|
|
||
| // Verify with Stripe that payment actually succeeded | ||
| pi = await stripe.paymentIntents.retrieve(input.paymentIntentId); |
There was a problem hiding this comment.
Confirm still rolls back payment
High Severity
confirmMembershipAfterPayment still inserts the stripe_payment row and calls createOrUpdateMembership inside one database transaction. If membership work throws, the payment insert rolls back too—charged customer, no row, and recovery paths that search for a payment find nothing.
Reviewed by Cursor Bugbot for commit b6eb251. Configure here.
| submission.team?.name ?? | ||
| (submission.teamMembers?.length | ||
| ? submission.teamMembers.join(", ") | ||
| : null), |
There was a problem hiding this comment.
Promote prefers team name
Medium Severity
promoteSubmissions sets judging teamMembers to submission.team?.name before joining submission.teamMembers. Team submissions with both populated show the team label on judges’ screens instead of the participant name list the submission carries.
Reviewed by Cursor Bugbot for commit b6eb251. Configure here.
Both points raised by Greptile on #316. **A linked payment could never be retried.** The webhook records the payment first and grants the membership after — deliberately, because sharing a transaction meant a failed grant rolled the payment row back and lost the charge entirely. But that ordering leaves a real state: payment row linked to the user, no membership. Every recovery path skipped already-linked payments (`if (existing.linkedUserId) continue;`), so that state was permanent — the customer was charged, the payment was on file, and nothing ever retried. reconcileMyPayments now treats "linked" as "not proof of a grant" and checks membership_history instead. Every grant writes a history row, so a payment with no history row at or after its own timestamp was never honoured. That also distinguishes a failed grant from a membership that was granted a year ago and has since lapsed, which must not be silently renewed off an old payment. Both directions are tested, and the guard was mutation-tested. **The DDL file overstated what it covers.** It handles only the change drizzle-kit push cannot be trusted with (the unique constraint, where push offers to truncate), but the same release also changes the judging tables. The file now says so and gives the order: apply it, run migrate:push for the rest, then push must report "No changes detected". Verified: typecheck, 375 tests, lint --max-warnings 0, build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both P1s were real. Fixed in 949f316. 1. Linked payments become unrecoverable — correct, and the PR description was wrong to claim the link paths could recover it. The webhook records the payment before granting (deliberately: sharing a transaction meant a failed grant rolled the payment row back and lost the charge), which leaves exactly the state you describe — linked row, no membership — and every recovery path skipped linked payments.
2. Release migration leaves schema incomplete — also correct. The file covers only the change Gate re-run on the new commit: typecheck · 375 tests · lint |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
There are 5 total unresolved issues (including 3 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 949f316. Configure here.
| }) | ||
| : undefined; | ||
|
|
||
| if (honoured) continue; |
There was a problem hiding this comment.
Honour check skips wrong payments
High Severity
Linked-payment recovery treats any membership_history row with createdAt on or after the payment row’s createdAt as proof that payment was honoured. That matches unrelated renewals, a later successful intent, or migration backfill timestamps—not necessarily this payment—so reconcileMyPayments can skip recovery while the customer remains under-granted.
Reviewed by Cursor Bugbot for commit 949f316. Configure here.
| lastName: parts.slice(1).join(" ") || "Member", | ||
| bootcampMember: pi.metadata?.bootcamp === "true", | ||
| }); | ||
| recovered += 1; |
There was a problem hiding this comment.
Recovery grant lacks idempotency guard
Medium Severity
The linked-payment recovery path calls createOrUpdateMembership outside a transaction and without marking the payment reconciled. If the grant partially completes, throws, or two reconciles race before history is written, a retry can run createOrUpdateMembership again and stack another year on the same payment.
Reviewed by Cursor Bugbot for commit 949f316. Configure here.


What this is
The first of a stack. It carries the earlier uncommitted work on this machine (scanner route, table cards, results tab, audit middleware, draft-edition visibility, cron removal) plus the membership fix, because the two could not be separated — several files carry both and there is no snapshot of their pre-existing state.
The bug this closes
A club membership was welded to a hackathon edition.
memberwasunique(userId, hackathonId)NOT NULL and every read resolved throughresolveCurrentHackathonId. The day next year's edition flips toopen, every paying member silently becomes a non-member — the portal shows the pay button again, club check-in throws at the door, initiatives lock out, and re-paying takes the insert branch and discards whatever months were left.Membership is now annual and keyed on the person.
membership_historyis written on join and renewal, because the edition column had been doing accidental duty as the only record of which year somebody was a member — dropping it without that would have destroyed the answer to "who was a member in 2026".Also in here
attemptAutoLink,linkPaidPaymentByVerifiedEmail,reconcileMyPayments) can help because they look for a row that was never written.drizzle-kit pushoffers to truncate when adding the unique constraint, so the change is written out instead:It back-fills
membership_historyfrom existing rows first, then drops the column. Safe only while no user holds two member rows — the file includes the check query. Apply it before this deploys.Verification
typecheck · 373 tests · lint
--max-warnings 0· build — all green on this commit.🤖 Generated with Claude Code
Note
High Risk
Changes membership identity, Stripe grant/recovery, and super-admin-only deletion alongside broad hackathon/judging API behavior—incorrect rollout or missing DDL could strand paid members or expose draft data.
Overview
Club membership is no longer tied to a hackathon edition. Reads and writes (
member.register,checkStatus, club event check-in, initiative gates, hackathon registration perks) resolve the member by user id instead of(userId, hackathonId), with cache keys and tests updated so a newly opened edition does not make paying members look inactive.Payments and recovery are hardened. Mock Stripe mode is expected to grant membership through the real confirm path; production refuses synthetic
pi_mock_*ids.reconcileMyPaymentscan grant membership when a payment row exists butmembership_historynever recorded the honor. Payment recording is separated from grant failures so a charged customer is not left with no row.Staff and visibility controls tighten across hackathons and judging. A volunteer admin role can scan passes but not full admin actions; hackathon delete requires super_admin and a typed confirm name. Draft editions are hidden from public schedule/gallery/results via
assertHackathonVisible. Mass acceptance mail tracksacceptanceEmailSentAt, supportsresend, and reports approved vs emailed separately.Operational and judging workflows expand. Audit logging runs on destructive admin actions with retention pruned on write (weekly cron workflow removed). Attendee admin APIs add pagination, export, and DB-side analytics; cache eviction is narrowed so scans do not wipe whole hackathon caches. Judging moves to
promoteSubmissions(replacing bulk project/judge CSV imports), drops hackathon maps, adds queue rebuild force guards, and improves overtime reassignment. New announcement emails, results compute/publish guards, club event update, and configurable DDoS limits in apphosting are included.Deploy note: README documents a one-off SQL path when
project_leaderalready exists; PR description referencespackages/db/ddl/2026-08-08-membership-decouple.sqlbefore deploy.Reviewed by Cursor Bugbot for commit 949f316. Bugbot is set up for automated code reviews on this repo. Configure here.