154 lines
13 KiB
Markdown
154 lines
13 KiB
Markdown
# Launch Readiness
|
||
|
||
**Status as of 2026-04-17 (audited).** Target launch: before 2026-05-01.
|
||
|
||
Single source of truth for the work that must happen before cutover to production. Anything marked P0 blocks launch. P1 is strongly preferred but survivable. Post-launch backlog lives in `docs/TODO.md`.
|
||
|
||
---
|
||
|
||
## Current state
|
||
|
||
- Vitest: 577/577 passing.
|
||
- Working tree clean on `main`.
|
||
- Infra in place: `/api/health`, CSRF + security headers + rate-limit middleware, `.env.example`, `Dockerfile`, `.dockerignore`, OWASP ASVS L1 phases 0–3.
|
||
|
||
---
|
||
|
||
## P0 — Must fix before launch
|
||
|
||
### 1. Write Privacy Policy and Terms of Service
|
||
- `/policies/privacy` and `/policies/terms` currently render a "being finalized" placeholder via `app/pages/policies/[slug].vue`.
|
||
- Community Guidelines links to both as part of the agreement members accept at signup, so they need real text before we can claim the agreement is complete.
|
||
- **Fix:** author both; policies live on THIS site (not Obsidian — unlike Code of Conduct and Conflict Resolution). Implementation path once copy exists: extend the `POLICIES` map in `[slug].vue` with a body field, or split into dedicated `privacy.vue` / `terms.vue` pages. Remove the "being finalized" placeholder either way. `/policies/by-laws` can stay as a placeholder until launch; by-laws are referenced but not user-visible at signup.
|
||
|
||
### 2. `get-or-create-customer` creates duplicate Helcim customers ✅ 2026-04-17
|
||
- `server/api/helcim/get-or-create-customer.post.js`
|
||
- Fixed: short-circuits on `member.helcimCustomerId` via `getHelcimCustomer()`, only falling through on 404 (other errors rethrow — silent recreate was the root cause). Email-search fallback now case-insensitive. Both `member.save()` calls converted to `findByIdAndUpdate(..., { runValidators: false })`.
|
||
- Requires a manual browser retest of the tier-upgrade flow before launch (already in the Manual browser tests list).
|
||
|
||
### 3. Run pre-deploy migration scripts against production ✅ 2026-04-17
|
||
- Imported 97 Baby Ghosts pre-registrants into `preregistrations` (2 more than the original 95 — new signups between doc authoring and run).
|
||
- Stripped stale `privacy` subdocs from members via mongo shell (script skipped; target data was already empty on real users).
|
||
- Dropped legacy `communityEcology` / `onboarding.ecologyPageVisited` fields and renamed `activitylogs.action: community_ecology_updated` → `board_updated` via mongo shell (rename script skipped to avoid `$rename` target-collision edge case).
|
||
- `test@example.com` removed from `preregistrations` post-import.
|
||
|
||
---
|
||
|
||
## P1 — Strongly preferred before launch
|
||
|
||
### 4. `helcim/subscription.post.js` Slack helper still uses `member.save()` ✅ 2026-04-17
|
||
- `server/api/helcim/subscription.post.js`
|
||
- Fixed: all three `member.save()` calls in the `inviteToSlack` helper converted to `Member.findByIdAndUpdate(member._id, { $set: { ... } }, { runValidators: false })`, scoped to the Slack fields each branch actually mutates. Matches the pattern applied to `login.post.js`, `create.post.js`, `verify.post.js`, `logout.post.js`, `cancel-subscription.post.js`, `update-contribution.post.js`, and `get-or-create-customer.post.js`.
|
||
|
||
### 5. Series pass pricing mismatch ✅ 2026-04-17
|
||
- `server/api/series/[id]/tickets/purchase.post.js:38-45` tries `requireAuth` then falls back to email lookup; line 67 rejects submitted `ticketType` that doesn't match the entitlement. Gap: if the client omits `ticketType`, no mismatch is raised, so the backend can still charge a paid cart while recording a member-tier registration.
|
||
- Fixed: `seriesTicketPurchaseSchema` in `server/utils/schemas.js` now requires `ticketType` as `z.enum(['member', 'public', 'guest'])` (no `.optional()`), so `validateBody` 400s any purchase that omits it — the existing mismatch check at `purchase.post.js:67` now runs on every request. `SeriesPassPurchase.vue` updated to send `ticketType: passInfo.ticket.type` (server still re-derives entitlement from the authenticated member / email lookup; the submitted value is only used for the consistency check). Schema coverage added in `tests/server/api/validation.test.js`.
|
||
|
||
### 6. Set `NUXT_PUBLIC_HELCIM_PORTAL_URL` in production ✅ 2026-04-18
|
||
- Wired in `nuxt.config.ts:102`. Without it, `account.vue` doesn't render the "Manage billing in Helcim" link for members with `helcimCustomerId`.
|
||
- Done: set in production env 2026-04-18.
|
||
|
||
---
|
||
|
||
## Pre-deploy runbook
|
||
|
||
Run in this order before cutover. All scripts require `MONGODB_URI` (production) in env:
|
||
|
||
```bash
|
||
# 1. Import pre-registrants from Baby Ghosts
|
||
BG_MONGODB_URI="<bg-uri>" MONGODB_URI="<prod-uri>" \
|
||
node server/migrations/import-babyghosts-preregistrations.js
|
||
# Verify: 95 records in `preregistrations` with status:'pending'
|
||
|
||
# 2. Remove stale privacy subdocs (per-field privacy was deleted 2026-04-15)
|
||
MONGODB_URI="<prod-uri>" node scripts/unset-member-privacy.js
|
||
|
||
# 3. Rename ecology collections → board
|
||
MONGODB_URI="<prod-uri>" node scripts/migrate-ecology-to-board.cjs
|
||
|
||
# 4. Consolidate Helcim plans (delete per-tier plans, create Monthly + Annual plans, backfill Mongo)
|
||
HELCIM_API_TOKEN="<prod-token>" MONGODB_URI="<prod-uri>" \
|
||
node scripts/helcim-plan-consolidation.js # dry-run first, inspect output
|
||
HELCIM_API_TOKEN="<prod-token>" MONGODB_URI="<prod-uri>" \
|
||
node scripts/helcim-plan-consolidation.js --confirm # destructive; copy the printed plan ids into env
|
||
```
|
||
|
||
**Env vars required in production:**
|
||
- `MONGODB_URI`
|
||
- `JWT_SECRET` (or `NUXT_JWT_SECRET` — the `NUXT_` variant wins)
|
||
- `RESEND_API_KEY`
|
||
- `HELCIM_API_TOKEN`
|
||
- `NUXT_HELCIM_MONTHLY_PLAN_ID` (set after running `scripts/helcim-plan-consolidation.js --confirm`)
|
||
- `NUXT_HELCIM_ANNUAL_PLAN_ID` (set after running `scripts/helcim-plan-consolidation.js --confirm`)
|
||
- `SLACK_BOT_TOKEN`
|
||
- `BASE_URL`
|
||
- `OIDC_COOKIE_SECRET`
|
||
- `NUXT_PUBLIC_HELCIM_PORTAL_URL` (P1 above)
|
||
|
||
---
|
||
|
||
## Manual browser tests still needed
|
||
|
||
These cannot be verified by the Vitest suite — all require a real browser + real Helcim test card + real email.
|
||
|
||
- [x] **Paid-tier join flow** end-to-end (blocked on localhost by HelcimPay.js iframe `X-Frame-Options: sameorigin`; use cloudflared tunnel or ngrok HTTPS). ✅ 2026-04-18 — verified via tunnel; two bugs found and fixed along the way (missing `tv` in JWT payload, missing welcome email).
|
||
- [ ] **Event ticket purchase with payment** (same iframe limitation).
|
||
- [x] **Tier upgrade free → paid.** Account → tier change → `/member/payment-setup?tier=&circle=` → Helcim $0 verify → subscription created. ✅ 2026-04-18 — verified via tunnel; one bug found and fixed (`ColumnsLayout cols="1"` silently dropped default slot on `payment-setup.vue`).
|
||
- [ ] **Paid → free downgrade.** Confirms Helcim subscription is cancelled.
|
||
- [ ] **Paid → paid tier swap.** Confirms existing subscription is updated, not recreated.
|
||
- [ ] **Annual join end-to-end.** New paid signup picks annual cadence at $15 tier, completes payment, Mongo record has `billingCadence: 'annual'` and Helcim sub is under `Annual Membership` with `recurringAmount: 150`.
|
||
- [ ] **Annual tier swap within-cadence.** Annual $15 member changes to $50 annual, `updateHelcimSubscription` called with `{ recurringAmount: 500 }`, Mongo `contributionTier` updated, `billingCadence` unchanged.
|
||
- [ ] **Pre-registrant invite → accept flow** with paid tier (exercises Helcim customer creation during acceptance).
|
||
- [ ] **Magic-link login** including 15-min expiry and jti burn on reuse.
|
||
- [ ] **Guest event signup** — four branches: new email + consent, new email without consent, existing guest, existing active member. Confirms cookie only sets for new/guest, and confirmation email appends `/login` link for real members.
|
||
- [ ] **Mobile responsive layout** — sidebar hides ≤1024px, nav works on phone.
|
||
- [ ] **`--text-dim` / `--text-faint` WCAG AA contrast check.**
|
||
|
||
---
|
||
|
||
## Bylaws decoupling — follow-ups (added 2026-04-18)
|
||
|
||
Context: bylaws are being amended to remove automatic termination for nonpayment. Membership status will be fully decoupled from payment status; failed payments trigger committee outreach, not status change. Copy + UI access gates already aligned in `useMemberStatus.js` and `account.vue` (2026-04-18). The behavioral changes below remain.
|
||
|
||
Not blocking launch — the amendment hasn't passed yet, and the user-visible copy/UI is already consistent. Pick up once the amendment is ratified.
|
||
|
||
### B1. `cancel-subscription` flips status to `pending_payment`
|
||
- `server/api/members/cancel-subscription.post.js:31,48`
|
||
- When a member cancels their paid subscription, status is set to `pending_payment` and tier to `'0'`. Under the new model, cancelling a payment plan moves the member to the $0 tier — status should stay `active`.
|
||
- **Fix:** change `status: 'pending_payment'` → `status: 'active'` in both the `findByIdAndUpdate` payload (line 31) and the response (line 48). Comment at line 26 also needs updating ("(not cancelled) so member can re-subscribe" → reflect new framing).
|
||
- Add coverage in `tests/server/api/cancel-subscription.test.js` if it doesn't already exist.
|
||
|
||
### B2. Server doesn't gate member-only events by `member.status` ✅ 2026-04-18
|
||
- Fixed: introduced `hasMemberAccess(member)` helper in `server/utils/tickets.js` — returns true only for `status` ∈ {`active`, `pending_payment`}. Used at every server-side enforcement surface:
|
||
- `server/utils/tickets.js`: `calculateTicketPrice`, `calculateSeriesTicketPrice`, and `validateTicketPurchase` (membersOnly gate) all route the member through `hasMemberAccess` before granting member-tier pricing or access.
|
||
- `server/api/events/[id]/register.post.js`: replaces the bare `if (!member)` gate with `hasMemberAccess(member)` for both the membersOnly check and the payment-required check; `isMember` / `membershipLevel` follow suit.
|
||
- `server/api/events/[id]/tickets/purchase.post.js`: replaces local `isRealMember` (which only excluded guests) with shared `hasMemberAccess`.
|
||
- `server/api/series/[id]/tickets/purchase.post.js`: derives `accessMember` and uses it for validation, registration `isMember`, and `membershipLevel`.
|
||
- Test coverage: 12 new cases in `tests/server/utils/tickets.test.js` covering pricing and membersOnly gating across all four inactive statuses (guest, suspended, cancelled, plus pending_payment as the access-conferring counterpart). `tests/server/api/event-registration.test.js` updated to assert the shared helper. `baseMember()` test factory now defaults to `status: 'active'` so the existing happy-path coverage remains valid.
|
||
- Net behavior: `pending_payment` keeps member access (payment is decoupled per bylaws). `guest`, `suspended`, `cancelled` are uniformly rejected from member-only events at the API and pay public pricing on open events. `tickets/available.get.js` did not need direct changes — it calls `calculateTicketPrice(event, member)` which now applies `hasMemberAccess` internally, so a suspended/cancelled lookup automatically returns public pricing. The cosmetic `memberSavings` block at `available.get.js:115` will report `$0 saved` for inactive members — harmless, but worth a follow-up to suppress the comparison block when `!hasMemberAccess(member)` if it ever surfaces in UI.
|
||
|
||
### B3. Vestigial `pending_payment` status
|
||
- Once payment is fully decoupled, `pending_payment` no longer gates anything and is functionally equivalent to `active`. Consider removing it from the enum (`server/models/member.js:38`, `server/utils/schemas.js:299`) and treating new signups as `active` from the moment of account creation.
|
||
- Touches: signup flow (`helcim/customer.post.js:34`, `invite/accept.post.js:48`), admin filter UI (`app/pages/admin/members/index.vue:45,382,499,1145`, `[id].vue:69,286`), admin alerts (`server/utils/adminAlerts.js:22,100-116`, `server/models/adminAlertDismissal.js:6`), and a data migration to flip existing `pending_payment` rows to `active`.
|
||
- Larger refactor — break out into its own ticket once B1/B2 land.
|
||
|
||
### B4. Admin "Pending Payment" filter label (cosmetic)
|
||
- `app/pages/admin/members/index.vue:45,499`, `[id].vue:69` show `pending_payment` as "Pending Payment". If B3 removes the status entirely, this disappears too. If we keep `pending_payment` for now, rename in admin UI to "Payment setup incomplete" so admins also stop conflating it with membership state.
|
||
|
||
---
|
||
|
||
## Stale TODOs in source (non-blocking)
|
||
|
||
- `server/api/events/[id]/tickets/purchase.post.js` asymmetry vs. series endpoint confirmed safe 2026-04-17. No P1-equivalent pricing bypass: `ticketPurchaseSchema` accepts no `ticketType`, so the server is authoritative — `calculateTicketPrice(event, member)` derives the tier from the `Member` doc looked up by submitted email, with `isRealMember` excluding `status:"guest"`. Submitting another member's email registers the ticket under *their* email (not the attacker's), sends the confirmation to them, and the auto-login guard at lines 141-148 explicitly refuses to issue a cookie for a real member, so the attacker gains no ticket, no cookie, and no email. Residual griefing concern (forcing an unsolicited registration onto another member's email) is not a pricing bypass and is out of scope.
|
||
|
||
---
|
||
|
||
## Post-launch backlog
|
||
|
||
See `docs/TODO.md` for:
|
||
- Button minimum target size (WCAG AAA 2.5.5).
|
||
- `/oidc/interaction/[uid]` routing quirk.
|
||
- Admin layout migration from `guild-*` tokens to zine spec.
|
||
- Admin dashboard quick-action button contrast.
|
||
- Members table NAME column clipping.
|
||
- OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption).
|