From e227f29bcd49e841fed128b68a7ee7c79baef622 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Mon, 20 Apr 2026 19:34:04 +0100 Subject: [PATCH 001/109] feat(events): block self-cancel of paid registrations, add refunds policy Self-cancel endpoint now rejects paid registrations (public, series_pass, or paid member tickets) with a 403 pointing to /policies/refunds. Free and $0-member registrations still self-cancel as before. Adds the refunds policy page referenced in the error message. --- app/pages/policies/refunds.vue | 116 ++++++++++++++++++ .../events/[id]/cancel-registration.post.js | 17 +++ 2 files changed, 133 insertions(+) create mode 100644 app/pages/policies/refunds.vue diff --git a/app/pages/policies/refunds.vue b/app/pages/policies/refunds.vue new file mode 100644 index 0000000..2e89fa7 --- /dev/null +++ b/app/pages/policies/refunds.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/server/api/events/[id]/cancel-registration.post.js b/server/api/events/[id]/cancel-registration.post.js index 4138f0f..87a9276 100644 --- a/server/api/events/[id]/cancel-registration.post.js +++ b/server/api/events/[id]/cancel-registration.post.js @@ -42,6 +42,23 @@ export default defineEventHandler(async (event) => { }); } + const existingRegistration = eventDoc.registrations[registrationIndex]; + const ticketType = existingRegistration.ticketType; + const amountPaid = existingRegistration.amountPaid || 0; + // member tickets can be free (default) or paid via circle overrides — gate on amountPaid + const isPaidRegistration = + ticketType === "public" || + ticketType === "series_pass" || + (ticketType === "member" && amountPaid > 0); + + if (isPaidRegistration) { + throw createError({ + statusCode: 403, + statusMessage: + "Paid registrations can't be self-cancelled. Email us for a refund — see /policies/refunds.", + }); + } + // Store registration data before removing (convert to plain object) const registration = { name: eventDoc.registrations[registrationIndex].name, From b222b14e61ef290292b5419e9a0789d9998a5926 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Mon, 20 Apr 2026 19:34:10 +0100 Subject: [PATCH 002/109] fix(schemas): coerce empty strings to undefined in admin event schemas Admin event create/update forms submit empty strings for unset numeric and date fields (maxAttendees, registrationDeadline, ticket quantity, early-bird pricing), which Zod was rejecting. Preprocess empty strings to undefined so the existing optional/nullable validators accept them. --- server/utils/schemas.js | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 464f77c..0444622 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -163,15 +163,17 @@ export const seriesTicketEligibilitySchema = z.object({ // --- Admin schemas --- +const emptyStringToUndefined = (v) => (v === '' ? undefined : v) + export const adminEventCreateSchema = z.object({ title: z.string().min(1).max(500), description: z.string().min(1).max(50000), startDate: z.string().min(1), endDate: z.string().min(1), location: z.string().max(500).optional(), - maxAttendees: z.number().int().positive().optional(), + maxAttendees: z.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()), membersOnly: z.boolean().optional(), - registrationDeadline: z.string().optional(), + registrationDeadline: z.preprocess(emptyStringToUndefined, z.string().optional().nullable()), pricing: z.object({ paymentRequired: z.boolean().optional(), isFree: z.boolean().optional() @@ -183,16 +185,16 @@ export const adminEventCreateSchema = z.object({ name: z.string().max(200).optional(), description: z.string().max(2000).optional(), price: z.number().min(0).optional(), - quantity: z.number().int().positive().optional(), - earlyBirdPrice: z.number().min(0).optional(), - earlyBirdDeadline: z.string().optional() + quantity: z.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()), + earlyBirdPrice: z.preprocess(emptyStringToUndefined, z.number().min(0).optional().nullable()), + earlyBirdDeadline: z.preprocess(emptyStringToUndefined, z.string().optional().nullable()) }).optional() }).optional(), - image: z.string().url().optional(), + image: z.string().url().optional().nullable(), category: z.string().max(100).optional(), tags: z.array(z.string().max(100)).max(20).optional(), - series: z.string().optional() -}) + series: z.any().optional() +}).passthrough() export const adminEventUpdateSchema = z.object({ title: z.string().min(1).max(500), @@ -200,9 +202,9 @@ export const adminEventUpdateSchema = z.object({ startDate: z.string().min(1), endDate: z.string().min(1), location: z.string().max(500).optional(), - maxAttendees: z.number().int().positive().optional().nullable(), + maxAttendees: z.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()), membersOnly: z.boolean().optional(), - registrationDeadline: z.string().optional().nullable(), + registrationDeadline: z.preprocess(emptyStringToUndefined, z.string().optional().nullable()), pricing: z.object({ paymentRequired: z.boolean().optional(), isFree: z.boolean().optional(), @@ -215,10 +217,10 @@ export const adminEventUpdateSchema = z.object({ name: z.string().max(200).optional(), description: z.string().max(2000).optional(), price: z.number().min(0).optional(), - quantity: z.number().int().positive().optional().nullable(), + quantity: z.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()), sold: z.number().int().min(0).optional(), - earlyBirdPrice: z.number().min(0).optional().nullable(), - earlyBirdDeadline: z.string().optional().nullable() + earlyBirdPrice: z.preprocess(emptyStringToUndefined, z.number().min(0).optional().nullable()), + earlyBirdDeadline: z.preprocess(emptyStringToUndefined, z.string().optional().nullable()) }).optional() }).optional(), image: z.string().url().optional().nullable(), From 886c62e7b14fde94c9924cd1958413ac399e810b Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Mon, 20 Apr 2026 19:34:38 +0100 Subject: [PATCH 003/109] docs(launch): condense LAUNCH_READINESS and ignore prereg dump script Collapse completed launch sections (receipts Phase 1, cadence UX, contribution-amount manual tests) into one-liners; move them to the archive memory. Move the three known post-launch gotchas to their own subsection. Ignore the local one-off preregistration dump script. --- .gitignore | 1 + docs/LAUNCH_READINESS.md | 95 ++++++---------------------------------- 2 files changed, 15 insertions(+), 81 deletions(-) diff --git a/.gitignore b/.gitignore index 0454ac9..3907ee0 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ e2e/.auth/ .superpowers/ .claude +scripts/dump-babyghosts-preregistrations.mjs diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md index 81abbfc..a3b452a 100644 --- a/docs/LAUNCH_READINESS.md +++ b/docs/LAUNCH_READINESS.md @@ -2,32 +2,17 @@ **Status as of 2026-04-20.** Target launch: before 2026-05-01. -Single source of truth for work that must happen before cutover. P0 blocks launch. P1 is strongly preferred but survivable. Completed items have been archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`. Post-launch backlog lives in `docs/TODO.md`. +Single source of truth for work remaining before cutover. P0 blocks launch; P1 is strongly preferred but survivable. Completed items are archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`. Post-launch backlog lives in `docs/TODO.md`. --- ## Current state -- Vitest on `main`: **652/658 passing**. 6 pre-existing failures in `tests/server/api/helcim-payment.test.js` — unrelated to launch-blocking work, noted in the deploy checklist for visibility. -- `main` is now caught up locally (2026-04-20): `feature/helcim-plan-consolidation` (40 commits) and `feature/contribution-amount-redesign` (17 commits) fast-forwarded in. Not pushed — site is not on Netlify yet. -- Helcim plan consolidation migration ran against prod 2026-04-18 (Monthly plan id `50302`, Annual plan id `50303`). **Contribution-amount migration has NOT yet been run against prod.** -- Cadence/contribution UX unified across signup + edit surfaces 2026-04-20. Uncommitted in working tree — see "Cadence UX refinements" below. -- **Charitable receipts Phase 1 built on `feature/receipts-phase-1` (commits `bf5a333..91711aa`, 2026-04-20). Unmerged.** All four spec items shipped: `Payment` model + idempotent `upsertPaymentFromHelcim` helper, synchronous payment logging on both new paid subscriptions and free→paid upgrades, nightly reconciliation script, `/join` charity note, and `taxReceiptPreferences` schema field (no UI — Phase 2). Resend-owned confirmation email (`server/emails/paymentConfirmation.js`) is CRA-safe. Remaining work is deploy-time only (merge branch, disable Helcim default email on plans 50302 + 50303, backfill, real staging charge) — tracked in Deploy checklist. - -### Cadence UX refinements (2026-04-20, uncommitted) - -Shipped across `accept-invite.vue`, `join.vue`, `member/account.vue`, `welcome.vue`, `member/dashboard.vue`, and a new shared `SignupFlowOverlay.vue`: - -- **Shared SignupFlowOverlay component.** Extracted from `/join` progress overlay; now used by both `/join` and `/accept-invite`. -- **Static "Monthly Contribution" label** on all three contribution inputs (previously dynamic — flipped to "Annual Contribution" when annual cadence was selected, which was misleading because the stored value is always the monthly base). -- **"Per Year" / "Per Month"** toggle copy (was "Annual" / "Monthly"). On `/accept-invite`, Per Year is now the default; `/join` stays on Per Month by default. -- **Live billing-summary card** below the contribution input on both signup flows — reads e.g. "You'll be charged $180 today ($15/month × 12). Then $180 every year, until you cancel." -- **Welcome heading on dashboard** for new signups: `/member/dashboard?welcome=1` renders "Welcome to Ghost Guild, {name}" instead of "Welcome back, {name}". `/welcome` redirect now always carries the param; `/accept-invite` navigates to the dashboard with the param directly. -- **$0 member polish on `/member/account`**: Payment History section hidden for $0 members with no prior charges (condition now `contributionAmount > 0 || paymentHistory.length > 0` — fixes a regression where paid-then-$0 members lost visibility of their past payments). Solidarity-Fund sentence in the Danger Zone also hidden at $0. -- **Next charge row above payment history** on `/member/account`: When a member has an upcoming charge, a "Next charge: $X on DATE" row renders above the transaction list (dashed `--candle` border). Separate from the existing compact "Next payment" row in the Membership Card summary. -- **Fixed `subscription.get.js` Helcim field mapping.** Helcim's GET `/subscriptions/:id` returns `data` as a single object (not array) with the field `dateBilling` (not `nextBillingDate`). The lazy refresh endpoint now handles both shapes — previously it returned empty strings, so neither the Membership-card "Next payment" nor the new "Next charge" row rendered for any member whose cached `nextBillingDate` was missing. Note: `subscription.post.js` and `update-contribution.post.js` still read `subscription.nextBillingDate` from Helcim's CREATE response (same wrong field), which is why the cache was empty to begin with. Left unfixed in this pass — the lazy GET refresh now masks it. Worth cleaning up post-launch. -- **State-aware contribution-change hint** on `/member/account`: "You'll be charged $X today to start your subscription." ($0 → paid) / "Your paid subscription will be cancelled." (paid → $0) / "Changes apply on your next billing cycle." (paid → paid, different amount). -- **Server-side invite accept** now creates the Helcim customer and sets the auth cookie before returning, for both free and paid branches. +- Vitest on `main`: **652/658 passing**. 6 pre-existing failures in `tests/server/api/helcim-payment.test.js` — unrelated to launch-blocking work, flagged in the Deploy checklist. +- All launch code is on local `main`: Helcim plan consolidation, contribution-amount redesign, cadence UX unification, and receipts Phase 1. Not pushed — site is not on Netlify yet. +- Helcim plan consolidation migration **ran against prod 2026-04-18** (Monthly plan id `50302`, Annual plan id `50303`). +- Contribution-amount migration has **NOT** yet been run against prod. +- Receipts Phase 1 code is shipped; remaining work is deploy-time only (see Deploy checklist). --- @@ -39,23 +24,7 @@ None outstanding. ## P1 — Strongly preferred before launch -### Charitable receipts — Phase 1 ✅ COMPLETE (`docs/specs/receipts-launch-spec.md`) - -Built on `feature/receipts-phase-1`, commits `bf5a333..91711aa` (2026-04-20). **Unmerged.** All four spec items shipped; remaining work is deploy-time only (tracked in Deploy checklist). - -Shipped: -- **Payment logging.** New `Payment` model (`server/models/payment.js`) + idempotent `upsertPaymentFromHelcim` helper keyed on unique `helcimTransactionId` (`server/utils/payments.js`). Synchronous write paths: - - New paid subscription → `server/api/helcim/subscription.post.js` fetches the newest paid Helcim tx and upserts a Payment with `paymentType` from cadence + `sendConfirmation: true`. Wrapped in try/catch so a logging failure cannot break subscription creation. - - Free → paid upgrade → `server/api/members/update-contribution.post.js` (Case 1 branch) does the same. - - Paid → paid amount change (Case 3) is intentionally **not** wired synchronously — no new tx at the moment of change; the next recurring charge is captured by the reconciliation script. -- **Confirmation email via Resend, not Helcim.** Spec alternative (b) chosen. `server/emails/paymentConfirmation.js` is CRA-safe: charity name "Baby Ghosts Studio Development Fund" + "not an official donation receipt / tax receipts available later in 2026" disclaimer. Triggered only on new Payment inserts; send failures are swallowed. Helcim's default confirmation must be disabled on plans 50302 + 50303 at cutover (Deploy checklist). -- **Join page copy.** Factual charity note below contribution tiers on `/join` only (`app/pages/join.vue:83`). `/accept-invite` and `/member/account` intentionally untouched per spec §3. -- **Member schema field.** `taxReceiptPreferences` nested object added to `server/models/member.js` (filesCanadianTaxes, middleInitial, confirmedAddress sub-object, setupCompletedAt). Defaults null/false — existing members read as "not set up." Schema-only; no Zod, no route, no UI. Phase 2 binds to it without migration. -- **Reconciliation script.** `scripts/reconcile-helcim-payments.mjs` iterates every Member with `helcimCustomerId`, pulls recent Helcim transactions, and upserts via the same helper. Idempotent. Dry-run by default; `--apply` to write. No confirmation emails sent during reconcile. Dual purpose: launch-day backfill for the ~34 pre-existing members, and nightly cron post-launch to catch recurring charges that bypass the synchronous write paths. - -Remaining (deploy-time, not code): -- [ ] Merge `feature/receipts-phase-1` into `main`. -- Manual Helcim-dashboard step + prod reconcile + staging test charge — see Deploy checklist. +None outstanding. --- @@ -88,49 +57,7 @@ Applies when the site is connected to Netlify / production hosting. Nothing here ## Manual browser tests still needed -Cannot be verified by Vitest. Both require a real browser + real Helcim test card + real email, via cloudflared tunnel or ngrok HTTPS (Helcim requires HTTPS for the pay.js iframe). - -**Shared setup (do once):** -- `npx nuxi dev --https` in one terminal, `cloudflared tunnel --url https://localhost:3000` (or `ngrok http https://localhost:3000`) in another. Use the tunnel URL as `BASE_URL` in `.env`. -- Helcim sandbox test card: see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/reference_helcim_sandbox.md`. -- Apply the contribution-amount migration against local Mongo first so seeded members match the new schema: - ``` - node scripts/migrate-contribution-amount.cjs # dry-run - node scripts/migrate-contribution-amount.cjs --apply # apply - ``` - After applying, confirm in mongosh: `db.members.countDocuments({ contributionAmount: { $exists: true } })` should equal total member count; `db.members.countDocuments({ contributionAmount: { $type: 'string' } })` must be `0`. - ---- - -- [x] **Pre-registrant invite → accept flow with a paid contribution amount.** ✅ Passed 2026-04-20 — both Monthly $7 and Annual $15 variants completed end-to-end. DB verified programmatically: `contributionAmount` stored as Number, `billingCadence` correct, `helcimCustomerId` + `helcimSubscriptionId` populated, `status: active`, no `contributionTier` field, preReg transitioned to `accepted` with `memberId` set. - -- **Contribution-amount redesign end-to-end.** Covers the full surface of the `contributionTier` → `contributionAmount` rename. - - - [x] **Signup flows — `/join`:** ✅ Passed 2026-04-20. All 5 variants ran functionally clean (welcome-heading regression was caught, fixed via `?welcome=1` propagation through `/welcome`, not retested — trusted): - 1. `$0` Monthly — Member created with no Helcim subscription. - 2. `$5` Monthly (preset) — Helcim `recurringAmount: 5`. - 3. `$17` Monthly (non-preset) — Helcim `recurringAmount: 17`, `$15` chip label via `findLast`. - 4. `$17` Annual — Helcim `recurringAmount: 204`, `billingCadence: 'annual'`, Mongo stores monthly-equivalent `17`. - 5. `$50` Annual (top preset) — Helcim `recurringAmount: 600`. - - - [x] **Edit flows — `/member/account` as an active paid member:** ✅ Passed 2026-04-20 against Cleo's Annual subscription (Helcim sub 138682). - - Raise $15 → $30 annual: `updateHelcimSubscription` hit with `recurringAmount: 360`, Mongo `contributionAmount: 30` (Number). - - Lower $30 → $5 annual: `recurringAmount: 60`, Mongo `contributionAmount: 5` (Number). - - ~~Switch cadence (Monthly $17 ↔ Annual $17).~~ **Deferred from launch.** Server (`update-contribution.post.js:184-189`) explicitly rejects cadence changes on existing subscriptions; no UI toggle exists on `/member/account`. Re-scope post-launch if/when we want to support cadence switch (would need Helcim subscription replacement flow, not a plain update). - - - [x] **Admin flow — `/admin/members/[id]` edit:** ✅ Passed 2026-04-20. - - Changed Cleo $5 → $15 via admin PUT. Mongo wrote `contributionAmount: 15` (Number). `contributionTier` field absent across all 34 members (`countDocuments({ contributionTier: { $exists: true } }) === 0`). - - Known non-blocker: admin edit does not sync the change to Helcim's `recurringAmount`. Admin override is direct Mongo-only by design; had to PATCH Helcim manually to re-sync Cleo post-test. Worth noting in docs or surfacing in admin UI post-launch. - - **Assert across all flows:** - - Mongo `contributionAmount` is always `Number`, never `String`. - - No `contributionTier` values written anywhere (greppable: `db.members.findOne({}, { contributionTier: 1 })` should return whatever the migration left; no *new* writes to that field). - - No "save $X", "2 months free", or discount copy appears in any UI surface. Annual is just `amount × 12` exactly. - - Guidance chip labels (`$0`/`$5`/`$15`/`$30`/`$50`) are matched via `findLast`, so $17 lands on the `$15` label, $49 lands on `$30`, $51 lands on `$50`. - - **Key files if debugging:** `app/pages/join.vue`, `app/pages/member/account.vue`, `app/pages/admin/members/[id].vue`, `server/api/helcim/subscription.post.js`, `server/api/members/update-contribution.post.js`, `server/api/admin/members/[id].put.js`, `app/config/contributions.js` + `server/config/contributions.js`. - - **Cosmetic follow-ups noted in Post-launch backlog below** — won't block this test (they're naming, not behavior). +None outstanding. All launch-blocking flows verified via local dev or cloudflared tunnel with real Helcim test card + real email (see archive for the full log). The one remaining browser verification is the staging test charge bundled into the Deploy checklist above. --- @@ -167,6 +94,12 @@ See `docs/TODO.md` for: - OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption). - `tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members — cosmetic; suppress comparison block when `!hasMemberAccess(member)` if it ever surfaces in UI. +### Known gotchas worth addressing post-launch + +- **Subscription cache fed wrong field on CREATE.** `subscription.post.js` and `update-contribution.post.js` read `subscription.nextBillingDate` from Helcim's CREATE response, but Helcim returns `dateBilling`. The lazy refresh in `subscription.get.js` masks this (handles both shapes), so next-charge rendering works — but the cache starts empty. Fix at the CREATE sites so the cache is correct from first write. +- **Admin edit does not sync Helcim `recurringAmount`.** `/admin/members/[id]` PUT writes `contributionAmount` direct to Mongo by design. Admins must PATCH Helcim manually. Worth surfacing in admin UI or docs. +- **Cadence switch rejected on active subscriptions.** `update-contribution.post.js:184-189` refuses cadence changes mid-subscription; no UI toggle exists on `/member/account`. Adding cadence switch would require a Helcim subscription replacement flow, not a plain update. + ### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior) - Rename admin members column header "Tier" → "Contribution" (`app/pages/admin/members/index.vue:265`). - Delete dead `app/components/TierPicker.vue`. From dc9c868f75a41c024e45b02ca86a0d2d8db9e113 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Mon, 20 Apr 2026 19:36:59 +0100 Subject: [PATCH 004/109] docs(launch): add prod series-pass bypass audit to deploy checklist Pre-fix (before f34b062 / 4e1888a) prod may contain drop-in registrations on pass-only series events. Defer audit + remediation until deploy time; local was scrubbed separately on 2026-04-20. --- docs/LAUNCH_READINESS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md index a3b452a..de220a2 100644 --- a/docs/LAUNCH_READINESS.md +++ b/docs/LAUNCH_READINESS.md @@ -38,6 +38,7 @@ Applies when the site is connected to Netlify / production hosting. Nothing here - [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in production env. - [ ] Decide on the 6 failing tests in `tests/server/api/helcim-payment.test.js` — either fix or consciously accept. Not launch-blocking, but pre-existing red tests tend to mask new regressions. - [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic** to backfill Payment records for pre-existing members. Idempotent (unique `helcimTransactionId`); safe to re-run as a nightly reconciliation job post-launch. +- [ ] **Prod audit for pre-fix series-pass bypass registrations.** Fixed in `f34b062` + `4e1888a` (2026-04-20). Before that, child events of pass-only series (`tickets.requiresSeriesTicket=true && tickets.allowIndividualEventTickets=false`) accepted drop-in registrations from non-pass-holders. For every such series, list its child-event `registrations` where the registrant is not in the parent series' pass-holder list, filter to `registeredAt < 2026-04-20`, and decide per-case: grandfather (keep + notify), refund + unregister, or silently unregister. Local Mongo was scrubbed of 2 such rows on 2026-04-20; prod was intentionally untouched. - [ ] **Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303.** We send our own CRA-safe confirmation via Resend (`server/emails/paymentConfirmation.js`) triggered from `upsertPaymentFromHelcim`; leaving Helcim's default on = duplicate emails. - [ ] **Run one real test charge on staging** via the cloudflared tunnel and verify (a) a Payment doc in Mongo with `amount`, `paymentType`, `status: 'success'`, and (b) exactly one CRA-compliant confirmation email (charity name + "not an official donation receipt" disclaimer; no banned assertive phrasing). From 53331cc19014e7cfe376cfad4a3e86c5ba13441c Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Mon, 20 Apr 2026 20:12:24 +0100 Subject: [PATCH 005/109] fix(events): gate members-only events in calculateTicketPrice Legacy events (tickets.enabled=false) with membersOnly=true were returning a free guest ticket for unauthenticated callers, causing GET /api/events/[id]/tickets/available to report available:true. The UI then rendered the registration form and register.post.js 403'd on submit. Short-circuit early when membersOnly && !hasMemberAccess so the availability endpoint's existing null-ticketInfo branch surfaces the correct "members only" reason. --- server/utils/tickets.js | 8 ++++++ tests/server/utils/tickets.test.js | 46 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/server/utils/tickets.js b/server/utils/tickets.js index 4790ab5..799989e 100644 --- a/server/utils/tickets.js +++ b/server/utils/tickets.js @@ -19,6 +19,14 @@ export const calculateTicketPrice = (event, member = null) => { // Members without access (guest/suspended/cancelled) get public pricing only. const accessMember = hasMemberAccess(member) ? member : null; + // Members-only gate applies to both legacy and ticketed paths. Without this, + // a legacy event (tickets.enabled=false) with membersOnly=true returns a + // free guest ticket and the availability endpoint shows a registration form + // that the server then 403s on submit. + if (event.membersOnly && !accessMember) { + return null; + } + if (!event.tickets?.enabled) { // Legacy pricing model if (event.pricing?.paymentRequired && !event.pricing?.isFree) { diff --git a/tests/server/utils/tickets.test.js b/tests/server/utils/tickets.test.js index 8ba74a1..528208b 100644 --- a/tests/server/utils/tickets.test.js +++ b/tests/server/utils/tickets.test.js @@ -394,6 +394,52 @@ describe('calculateTicketPrice', () => { expect(result).toBeNull() }) }) + + describe('members-only gating', () => { + it('returns null for unauthenticated user on members-only legacy event', () => { + const event = legacyFreeEvent({ membersOnly: true }) + const result = calculateTicketPrice(event, null) + + expect(result).toBeNull() + }) + + it('returns null for unauthenticated user on members-only ticketed event', () => { + const event = ticketedEvent({ membersOnly: true }) + const result = calculateTicketPrice(event, null) + + expect(result).toBeNull() + }) + + it('returns null for guest-status member on members-only event', () => { + const event = ticketedEvent({ membersOnly: true }) + const result = calculateTicketPrice(event, baseMember({ status: 'guest' })) + + expect(result).toBeNull() + }) + + it('returns null for suspended member on members-only event', () => { + const event = ticketedEvent({ membersOnly: true }) + const result = calculateTicketPrice(event, baseMember({ status: 'suspended' })) + + expect(result).toBeNull() + }) + + it('returns member pricing for active member on members-only event', () => { + const event = ticketedEvent({ membersOnly: true }) + const result = calculateTicketPrice(event, baseMember()) + + expect(result).not.toBeNull() + expect(result.ticketType).toBe('member') + }) + + it('returns member pricing for pending_payment member on members-only event', () => { + const event = ticketedEvent({ membersOnly: true }) + const result = calculateTicketPrice(event, baseMember({ status: 'pending_payment' })) + + expect(result).not.toBeNull() + expect(result.ticketType).toBe('member') + }) + }) }) // =========================================================================== From 8f0648de57795e56c63e63106f877eaec9547fa0 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Mon, 20 Apr 2026 20:13:36 +0100 Subject: [PATCH 006/109] fix(events): surface series-pass-required in ticket availability response When a series requires a pass and doesn't allow drop-ins, the per-event availability endpoint returned a generic "No tickets available" reason, leaving the UI to render an "Event Sold Out" block for guests (logged-in users short-circuit via check-series-access first). Detect the gate server-side and return {available:false, reason:"series_pass_required", requiresSeriesPass:true, series:{id,title,slug}} so EventTicketPurchase's existing requiresSeriesPass branch renders a pass-required CTA with a link to the series page. The register and purchase handlers already enforce the gate server-side; this is a messaging fix only. --- .../api/events/[id]/tickets/available.get.js | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/server/api/events/[id]/tickets/available.get.js b/server/api/events/[id]/tickets/available.get.js index 4107c0a..2fd64a3 100644 --- a/server/api/events/[id]/tickets/available.get.js +++ b/server/api/events/[id]/tickets/available.get.js @@ -1,8 +1,10 @@ import Member from "../../../../models/member.js"; +import Series from "../../../../models/series.js"; import { loadPublicEvent } from "../../../../utils/loadEvent.js"; import { calculateTicketPrice, checkTicketAvailability, + checkUserSeriesPass, formatPrice, } from "../../../../utils/tickets.js"; @@ -34,6 +36,36 @@ export default defineEventHandler(async (event) => { return { available: false, reason: "Registration deadline has passed" }; } + // Series-pass gate: when an event is linked to a series that requires a + // pass and doesn't allow drop-ins, surface a structured response so the UI + // can route to the series page instead of rendering a generic "Sold Out" + // block. The register/purchase handlers enforce this gate independently. + if ( + eventData.tickets?.requiresSeriesTicket && + eventData.tickets?.seriesTicketReference + ) { + const series = await Series.findById( + eventData.tickets.seriesTicketReference, + ); + if (series && !series.tickets?.allowIndividualEventTickets) { + const hasPass = userEmail + ? checkUserSeriesPass(series, userEmail).hasPass + : false; + if (!hasPass) { + return { + available: false, + reason: "series_pass_required", + requiresSeriesPass: true, + series: { + id: series.id, + title: series.title, + slug: series.slug, + }, + }; + } + } + } + let member = null; if (userEmail) { member = await Member.findOne({ email: userEmail.toLowerCase() }); From 0f2f1d1cbf42c8ea101be030d093f52dd966e723 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 25 Apr 2026 18:41:04 +0100 Subject: [PATCH 007/109] chore(visual): Phase 4 audit polish on event/series surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates event/series UI from Tailwind/Nuxt UI form components to the zine pattern (dashed borders, CSS-var palette, native inputs). Restructures single-event and series detail/index pages to the two-column grid pattern matching about.vue and member/dashboard.vue. Touches: - app/components/EventSeriesTicketCard.vue — phantom-palette → CSS-var migration (--candle, --ember, --surface), color="gray" → "neutral" - app/components/EventTicketCard.vue — --candle-faint border var - app/components/EventTicketPurchase.vue — accent-color: var(--candle) - app/pages/events/[slug].vue — page-fill flex chain, .event-body grid - app/pages/events/index.vue — series link uses series.id (was _id) - app/pages/series/[id].vue — two-column layout (1fr/280px) + sidebar - app/pages/series/index.vue — full rewrite to dashed-border zine pattern Per docs/specs/events-visual-audit-findings.md Phase 4. Behavior unchanged. --- app/components/EventSeriesTicketCard.vue | 111 +++---- app/components/EventTicketCard.vue | 2 +- app/components/EventTicketPurchase.vue | 1 + app/pages/events/[slug].vue | 13 +- app/pages/events/index.vue | 31 +- app/pages/series/[id].vue | 168 +++++++--- app/pages/series/index.vue | 387 ++++++++++------------- 7 files changed, 376 insertions(+), 337 deletions(-) diff --git a/app/components/EventSeriesTicketCard.vue b/app/components/EventSeriesTicketCard.vue index ba4f942..b458301 100644 --- a/app/components/EventSeriesTicketCard.vue +++ b/app/components/EventSeriesTicketCard.vue @@ -1,37 +1,27 @@ @@ -31,7 +33,8 @@
@@ -41,7 +44,8 @@
@@ -51,7 +55,7 @@
- + Use traditional date picker
From 23154ff232dad834c049cbb24ecffdfcab2751c4 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 29 Apr 2026 19:59:49 +0100 Subject: [PATCH 054/109] fix(oidc): disable devInteractions so custom interactions.url runs in dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oidc-provider's devInteractions is a quick-start scaffold that, when enabled, mutates configuration.url to its own urlFor('interaction') helper — emitting /interaction/UID instead of our /oidc/interaction/UID. That made /oidc/auth redirect to a 404 in local dev and forced a stale TODO entry. We already have our own interaction handler at server/routes/oidc/interaction/[uid].get.ts, so devInteractions is unnecessary; disabling it makes dev match prod and clears the oidc-provider warning "your configuration is not in effect". --- server/utils/oidc-provider.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/utils/oidc-provider.ts b/server/utils/oidc-provider.ts index dfc7042..187f8a8 100644 --- a/server/utils/oidc-provider.ts +++ b/server/utils/oidc-provider.ts @@ -86,9 +86,7 @@ export async function getOidcProvider() { }, features: { - devInteractions: { - enabled: process.env.NODE_ENV !== "production", - }, + devInteractions: { enabled: false }, revocation: { enabled: true }, rpInitiatedLogout: { enabled: true, From 59d2be2df829baa6895b502262afa363af156325 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 29 Apr 2026 20:10:38 +0100 Subject: [PATCH 055/109] docs(backlog): close out a11y triage items Strike two stale entries (verified 2026-04-29) and the OIDC routing quirk (fixed in 23154ff). --- docs/LAUNCH_READINESS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md index 27c0e99..a51543e 100644 --- a/docs/LAUNCH_READINESS.md +++ b/docs/LAUNCH_READINESS.md @@ -132,10 +132,10 @@ Not blocking launch — the amendment hasn't passed yet, and the user-visible co See `docs/TODO.md` for: - Button minimum target size (WCAG AAA 2.5.5). -- `/oidc/interaction/[uid]` routing quirk. +- ~~`/oidc/interaction/[uid]` routing quirk~~ — fixed 2026-04-29 (commit `23154ff`); root cause was `oidc-provider`'s `devInteractions` overriding our custom `interactions.url`. - Admin layout migration from `guild-*` tokens to zine spec. -- Admin dashboard quick-action button contrast. -- Members table NAME column clipping. +- ~~Admin dashboard quick-action button contrast~~ — verified stale 2026-04-29. +- ~~Members table NAME column clipping~~ — verified stale 2026-04-29. - OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption). - `tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members — cosmetic; suppress comparison block when `!hasMemberAccess(member)` if it ever surfaces in UI. - Simplify-pass follow-ups (2026-04-25): source-grep test bloat, login/verify rate-limit gap, stringly-typed `metadata.type`, reconcile-payments sequential loop, stale `new Date()` in events list, `loadPublicSeries` helper extraction. From 05c47c44998321db16c13227e31481b311136008 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 29 Apr 2026 20:22:25 +0100 Subject: [PATCH 056/109] docs(backlog): close out admin layout token migration as stale Verified clean 2026-04-29: grep for guild-[0-9]|candlelight-[0-9]|ember-[0-9] across app/layouts/, app/pages/admin/, and app/components/admin/ returns zero matches. All admin surfaces already use design tokens. --- docs/LAUNCH_READINESS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md index a51543e..cd0329d 100644 --- a/docs/LAUNCH_READINESS.md +++ b/docs/LAUNCH_READINESS.md @@ -133,7 +133,7 @@ Not blocking launch — the amendment hasn't passed yet, and the user-visible co See `docs/TODO.md` for: - Button minimum target size (WCAG AAA 2.5.5). - ~~`/oidc/interaction/[uid]` routing quirk~~ — fixed 2026-04-29 (commit `23154ff`); root cause was `oidc-provider`'s `devInteractions` overriding our custom `interactions.url`. -- Admin layout migration from `guild-*` tokens to zine spec. +- ~~Admin layout migration from `guild-*` tokens to zine spec~~ — verified clean 2026-04-29; grep for `guild-[0-9]|candlelight-[0-9]|ember-[0-9]` across `app/layouts/`, `app/pages/admin/`, `app/components/admin/` returns zero matches. All tokens already converted. - ~~Admin dashboard quick-action button contrast~~ — verified stale 2026-04-29. - ~~Members table NAME column clipping~~ — verified stale 2026-04-29. - OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption). From 350d6c219c6625910bcecc796c61ef7221df01a5 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 29 Apr 2026 20:22:30 +0100 Subject: [PATCH 057/109] fix(series): replace phantom guild Tailwind on EventSeriesBadge Swap bg-guild-*/border-guild-*/text-guild-* utility classes for design tokens in a scoped style block. Drops rounded-* per the no-rounded-corners rule and uses dashed borders for the structural block per the zine spec. --- app/components/EventSeriesBadge.vue | 39 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/app/components/EventSeriesBadge.vue b/app/components/EventSeriesBadge.vue index a8b23a0..6b9252a 100644 --- a/app/components/EventSeriesBadge.vue +++ b/app/components/EventSeriesBadge.vue @@ -1,18 +1,14 @@