docs(launch): consolidate launch readiness; archive completed P0/P1

This commit is contained in:
Jennie Robinson Faber 2026-04-19 12:14:18 +01:00
parent 36829eb1ef
commit 67cc488c6a

View file

@ -1,103 +1,65 @@
# Launch Readiness
**Status as of 2026-04-17 (audited).** Target launch: before 2026-05-01.
**Status as of 2026-04-18.** 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`.
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`.
---
## 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 03.
- Helcim plan consolidation migration ran against prod 2026-04-18 (Monthly plan id `50302`, Annual plan id `50303`). All six paid-flow manual tests pass via tunnel.
- Remaining launch blockers: see lists below.
---
## 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.
None outstanding. Privacy/Terms pages shipped, duplicate-customer bug fixed, pre-deploy migrations run.
---
## 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.
### UX polish on the paid `/join` flow (noted 2026-04-18 during Test 1)
- Code entry points: `app/pages/join.vue` (form + `handleSubmit:478`, `processPayment:532`), `app/composables/useHelcimPay.js` (modal trigger at 137).
- Flow works end-to-end, but the single most important conversion point has three rough spots:
- **Join form sits too low on the page.** First-time visitors have to scroll to find it. Raise it above the fold, or lead the page with the form and move supporting copy below.
- **"Open Helcim modal" button is placed oddly.** After the first form submit, the next action (trigger the HelcimPay.js iframe) is easy to miss. Needs clearer visual hierarchy, ideally auto-opened or prominently CTA'd.
- **Return-to-join-page flash after payment success.** After HelcimPay.js reports SUCCESS, the user briefly sees the join form again before the redirect to `/member/dashboard` fires. Reads as an error.
- **Ideal:** wrap the whole flow (form submit → HelcimPay.js → success → dashboard) in a modal or at least an overlay with a clear step indicator, so the user never returns to the join page between states.
- Not blocking launch — flow works — but should land before inviting the full pre-registrant list, since this is where first impressions happen.
---
## Pre-deploy runbook
## Deploy checklist
Run in this order before cutover. All scripts require `MONGODB_URI` (production) in env:
Pre-deploy migrations have all been run. What's left:
```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'
- [ ] Merge `feature/helcim-plan-consolidation` into `main`.
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` in Netlify production env.
- [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Netlify production env.
# 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:**
**Env vars required in production (reference):**
- `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`)
- `NUXT_HELCIM_MONTHLY_PLAN_ID`
- `NUXT_HELCIM_ANNUAL_PLAN_ID`
- `SLACK_BOT_TOKEN`
- `BASE_URL`
- `OIDC_COOKIE_SECRET`
- `NUXT_PUBLIC_HELCIM_PORTAL_URL` (P1 above)
- `NUXT_PUBLIC_HELCIM_PORTAL_URL`
---
## Manual browser tests still needed
These cannot be verified by the Vitest suite — all require a real browser + real Helcim test card + real email.
Cannot be verified by Vitest. 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`).
- [x] **Paid → free downgrade.** Confirms Helcim subscription is cancelled. ✅ 2026-04-18 — verified via tunnel; Mongo updated (`contributionTier:"0"`, `helcimSubscriptionId:null`) and Helcim sub deleted. One bug found and fixed (`helcimFetch` threw on empty 204 response body, masked as best-effort success).
- [x] **Paid → paid tier swap.** Confirms existing subscription is updated, not recreated. ✅ 2026-04-18 — covered by annual tier swap below (same `updateHelcimSubscription` code path, same `recurringAmount` PATCH).
- [x] **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`. ✅ 2026-04-18 — verified via tunnel; one label bug found and fixed (`$50/yr` was ambiguous between `$5 tier annual` and `$50 tier monthly`; dropdown now shows `$5/mo → $50/yr`).
- [x] **Annual tier swap within-cadence.** Annual $15 member changes to $50 annual, `updateHelcimSubscription` called with `{ recurringAmount: 500 }`, Mongo `contributionTier` updated, `billingCadence` unchanged. ✅ 2026-04-18 — verified via tunnel; TierPicker now shows `$500/yr` + `$50/mo tier` subtitle for clarity.
- [ ] **Event ticket purchase with payment** (HelcimPay.js iframe; use cloudflared tunnel or ngrok HTTPS).
- [ ] **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.
@ -108,7 +70,7 @@ These cannot be verified by the Vitest suite — all require a real browser + re
## 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.
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). Server-side status gating shipped as B2 (see archive). 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.
@ -118,31 +80,16 @@ Not blocking launch — the amendment hasn't passed yet, and the user-visible co
- **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.
- Larger refactor — break out into its own ticket once B1 lands.
### 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:
@ -152,3 +99,4 @@ See `docs/TODO.md` for:
- Admin dashboard quick-action button contrast.
- Members table NAME column clipping.
- 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.