Skip to content

Decouple club membership from the hackathon edition - #316

Merged
aamoghS merged 3 commits into
mainfrom
stack/1-foundations-and-membership
Aug 8, 2026
Merged

Decouple club membership from the hackathon edition#316
aamoghS merged 3 commits into
mainfrom
stack/1-foundations-and-membership

Conversation

@aamoghS

@aamoghS aamoghS commented Aug 8, 2026

Copy link
Copy Markdown
Member

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. member was unique(userId, hackathonId) NOT NULL and every read resolved through resolveCurrentHackathonId. The day next year's edition flips to open, 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_history is 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

  • The Stripe payment row commits before any membership work. They shared a transaction, so a grant that threw rolled the payment row back too: charged customer, no record anywhere, and none of the recovery paths (attemptAutoLink, linkPaidPaymentByVerifiedEmail, reconcileMyPayments) can help because they look for a row that was never written.
  • Mock mode grants a real membership through the production confirm path. The previous test asserted the returned shape, which is exactly how "Access Granted with nothing written" survived a 377-test suite.
  • verify-email calls the shared membership service instead of its own third copy, which restarted the term from today and wrote no history.

⚠️ Database

drizzle-kit push offers to truncate when adding the unique constraint, so the change is written out instead:

packages/db/ddl/2026-08-08-membership-decouple.sql

It back-fills membership_history from 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. reconcileMyPayments can grant membership when a payment row exists but membership_history never 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 tracks acceptanceEmailSentAt, supports resend, 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_leader already exists; PR description references packages/db/ddl/2026-08-08-membership-decouple.sql before deploy.

Reviewed by Cursor Bugbot for commit 949f316. Bugbot is set up for automated code reviews on this repo. Configure here.

aamoghS and others added 2 commits August 5, 2026 23:25
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>
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Dependency Review

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

Scanned Files

None

@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

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

@greptile-apps

greptile-apps Bot commented Aug 8, 2026

Copy link
Copy Markdown

Greptile Summary

The PR decouples annual club membership from hackathon editions and adds durable membership history and payment-reconciliation behavior. It also includes a broad set of hackathon administration, judging, visibility, audit, and portal changes.

  • Migrates membership identity from (userId, hackathonId) to userId and records joins and renewals in membership_history.
  • Persists Stripe payment records before granting membership and lets users reconcile linked payments whose grants failed.
  • Adds judging queue, submission promotion, results publication, attendee management, announcement, and role-gating behavior.
  • Documents the required manual membership DDL followed by the remaining Drizzle schema push.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains from the previously reported issues.

The deployment instructions now explicitly require the manual membership DDL followed by the complete schema push, and linked paid records whose membership grant failed can now be recovered through payment reconciliation.

Important Files Changed

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
Loading

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b6eb251. Configure here.

}

// Verify with Stripe that payment actually succeeded
pi = await stripe.paymentIntents.retrieve(input.paymentIntentId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b6eb251. Configure here.

Comment thread packages/api/src/routers/stripe.ts
Comment thread packages/api/src/middleware/cache.ts
submission.team?.name ??
(submission.teamMembers?.length
? submission.teamMembers.join(", ")
: null),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

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

aamoghS commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

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.

reconcileMyPayments now treats linked as no proof of a grant, and checks membership_history instead: every grant writes a row, so a payment with no history row at or after its own timestamp was never honoured. That also keeps the other direction safe — a membership granted a year ago and since lapsed must not be silently renewed off the old payment, which a naive "no active membership → grant" check would have done. Both directions are tested, and the guard was mutation-tested (breaking it fails the test).

2. Release migration leaves schema incomplete — also correct. The file covers only the change drizzle-kit push cannot be trusted with (the unique constraint, where push offers to truncate); the judging columns, the source_project_id cascade→set null change and the results snapshot are push-safe but still required. The file no longer claims push will report "No changes detected" straight after — it now gives the order: apply this, run migrate:push for the rest, then push must be clean, and warns that stopping after step 1 leaves judging code querying columns that do not exist.

Gate re-run on the new commit: typecheck · 375 tests · lint --max-warnings 0 · build.

@aamoghS
aamoghS merged commit 06247dd into main Aug 8, 2026
20 of 21 checks passed

@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 2 potential issues.

There are 5 total unresolved issues (including 3 from previous reviews).

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 949f316. Configure here.

})
: undefined;

if (honoured) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 949f316. Configure here.

lastName: parts.slice(1).join(" ") || "Member",
bootcampMember: pi.metadata?.bootcamp === "true",
});
recovered += 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 949f316. Configure here.

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