Compare commits

..

9 commits

Author SHA1 Message Date
33ba082b82 docs: consolidate open issues into BACKLOG.md
Some checks failed
Test / vitest (push) Successful in 11m7s
Test / playwright (push) Failing after 9m38s
Test / visual (push) Failing after 9m31s
Test / Notify on failure (push) Successful in 2s
Single source of truth for every open issue across the codebase. Pulls
from LAUNCH_READINESS.md (post-launch sections), TODO.md (deferred
features + simplify follow-ups + wave-Slack pilot), and a fresh sweep
of in-code TODO/FIXME comments.

LAUNCH_READINESS.md now keeps only the pre-cutover deploy checklist and
points to BACKLOG.md for everything else. Cutover note corrected — it
has not happened yet.

Force-added BACKLOG.md despite the /docs/ gitignore rule because
LAUNCH_READINESS.md is tracked and now references it.
2026-04-30 15:37:26 +01:00
a949252915 Merge branch 'chore/simplify-followups-and-backlog-consolidation'
Three small wins from the 2026-04-29 simplify-pass review:
- STATUS_LABELS triplication in admin/members/index.vue replaced with v-for
- ImageUpload alt-text input now has :focus styling via scoped CSS
- paymentBridge → signupBridge rename (cookie + functions + JWT scope)
2026-04-30 15:36:00 +01:00
9b79ae6bf4 refactor(auth): rename paymentBridge → signupBridge
After commit 90acc35 issued the cookie for $0 signups too, the "payment"
framing was wrong — there's no payment in a $0 signup. The cookie is
about bridging the gap between signup-form submit and email verify, not
about payment specifically.

Changes:
- setPaymentBridgeCookie  → setSignupBridgeCookie
- getPaymentBridgeMember  → getSignupBridgeMember
- Cookie wire name        payment-bridge → signup-bridge
- JWT scope               payment_bridge → signup_bridge

Touches both /api/helcim/subscription (signup activation) and
/api/helcim/initialize-payment (paid Helcim checkout) which both consume
the cookie. In-flight signup sessions started before this lands will
need to re-submit the form (cookie name mismatch); cutover hasn't
happened yet, so the only impact is local dev sessions.
2026-04-30 15:31:54 +01:00
c6a5e25d06 fix(ImageUpload): restore :focus styling on alt-text input
The alt-text input was hard-coding border/bg via inline style="..." after
the phantom-Tailwind sweep, which can't carry pseudo-class rules.
Per CLAUDE.md, inputs focus to --candle. Moved to a scoped style block
with a real :focus rule.
2026-04-30 15:29:35 +01:00
441a5f5608 refactor(admin): drive members status <select>s from STATUS_LABELS
The status options were duplicated three times in admin/members/index.vue
(filter dropdown, edit-modal dropdown, statusLabel helper). The recent
"Pending Payment" → "Payment setup incomplete" rename only landed in
two of the three sites. Both <select>s now v-for over the existing
STATUS_LABELS const, so any future label change happens in one place.

Side effect: the edit-modal dropdown order is now
(active, pending_payment, suspended, cancelled) to match the filter
dropdown — was previously pending_payment-first.
2026-04-30 15:28:36 +01:00
d9444b022b Merge branch 'fix/launch-flow-copy-and-pre-reg-link'
Ships the 5 launch-flow fixes decided 2026-04-30:
- /join, dashboard, welcome-email copy aligned to monthly-waves model
- welcome email now sends on free /accept-invite activations
- /join signups auto-link to matching PreRegistration records
2026-04-30 15:06:32 +01:00
da5e7efcb7 fix(launch-flow): auto-link /join signups to existing PreRegistration
When a /join submitter's email matches a pending/selected/invited
PreRegistration, mark the pre-reg as accepted and link memberId to the
new Member. Prevents the same person from appearing as both an active
member and an unaccepted pre-registrant. Silent — no email, no UI.

Adds the PreRegistration mock to helcim-customer and free-signup-flow
test suites, since both invoke the customer handler at runtime.
2026-04-30 14:43:02 +01:00
d4000c18cf fix(launch-flow): send welcome email on free /accept-invite activation
Free invite acceptance previously created a Member and signed them in
without sending the welcome email — pre-registrants got nothing as the
join confirmation. Wire sendWelcomeEmail into the free branch matching
the pattern in members/create.post.js.

Paid /accept-invite activations continue to receive the welcome email
via /api/helcim/subscription on the pending_payment → active transition,
so this only changes the free path.
2026-04-30 14:40:13 +01:00
313b8598df fix(launch-flow): align Slack-wait copy across join, dashboard, welcome email
- /join "How membership works" lists community (not Slack) as a benefit;
  adds a note that Slack invitations come in monthly onboarding waves.
- Dashboard slack-coming note drops "2–3 weeks" timeline; uses the same
  monthly-waves phrasing.
- Welcome email no longer points new members to Slack (which they don't
  yet have access to); directs them to reply instead.
2026-04-30 14:39:47 +01:00
16 changed files with 215 additions and 110 deletions

View file

@ -77,12 +77,7 @@
<input <input
:value="modelValue.alt || ''" :value="modelValue.alt || ''"
placeholder="Describe this image..." placeholder="Describe this image..."
class="w-full px-3 py-2" class="w-full px-3 py-2 alt-text-input"
style="
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
"
@input="updateAltText($event.target.value)" @input="updateAltText($event.target.value)"
> >
</div> </div>
@ -225,3 +220,16 @@ const updateAltText = (altText) => {
}); });
}; };
</script> </script>
<style scoped>
.alt-text-input {
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
}
.alt-text-input:focus {
outline: none;
border-color: var(--candle);
}
</style>

View file

@ -41,10 +41,11 @@
<div class="field" style="margin-bottom: 0"> <div class="field" style="margin-bottom: 0">
<select v-model="statusFilter" aria-label="Filter by status"> <select v-model="statusFilter" aria-label="Filter by status">
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="active">Active</option> <option
<option value="pending_payment">Payment setup incomplete</option> v-for="(label, value) in STATUS_LABELS"
<option value="suspended">Suspended</option> :key="value"
<option value="cancelled">Cancelled</option> :value="value"
>{{ label }}</option>
</select> </select>
</div> </div>
</div> </div>
@ -371,10 +372,11 @@
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
<select v-model="editingMember.status"> <select v-model="editingMember.status">
<option value="pending_payment">Payment setup incomplete</option> <option
<option value="active">Active</option> v-for="(label, value) in STATUS_LABELS"
<option value="suspended">Suspended</option> :key="value"
<option value="cancelled">Cancelled</option> :value="value"
>{{ label }}</option>
</select> </select>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">

View file

@ -317,13 +317,17 @@
<ParchmentInset> <ParchmentInset>
<h2>How membership works</h2> <h2>How membership works</h2>
<ul> <ul>
<li>Full access to the knowledge commons, Slack, and peer support</li> <li>Full access to the knowledge commons, events and workshops, and community</li>
<li>Free access to all Ghost Guild events</li> <li>Free access to all Ghost Guild events</li>
<li>Equal access for every member, regardless of contribution</li> <li>Equal access for every member, regardless of contribution</li>
<li>Your circle reflects where you are, not rank</li> <li>Your circle reflects where you are, not rank</li>
<li>Pay what you can ($0&ndash;$50+/month, separate from circle)</li> <li>Pay what you can ($0&ndash;$50+/month, separate from circle)</li>
<li>Higher contributions create solidarity spots for others</li> <li>Higher contributions create solidarity spots for others</li>
</ul> </ul>
<p>
Community connection happens in our Slack workspace, joined in monthly
onboarding waves &mdash; there may be a short wait after you join.
</p>
</ParchmentInset> </ParchmentInset>
<!-- THREE CIRCLES --> <!-- THREE CIRCLES -->

View file

@ -39,8 +39,8 @@
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span> <span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
</div> </div>
<p v-if="showSlackComingNote" class="slack-coming-note"> <p v-if="showSlackComingNote" class="slack-coming-note">
Slack workspace access is part of your membership. Your invitation Slack workspace access is part of your membership. Invitations are
typically arrives within 2&ndash;3 weeks of joining. sent in monthly onboarding waves &mdash; we'll be in touch.
</p> </p>
</PageHeader> </PageHeader>

100
docs/BACKLOG.md Normal file
View file

@ -0,0 +1,100 @@
# Ghost Guild — Open Backlog
_Last consolidated: 2026-04-30. Single source of truth for every open issue across the codebase. Pulls from `LAUNCH_READINESS.md`, `TODO.md`, the post-launch backlog memory, and a fresh sweep of in-code TODO/FIXME comments._
Cutover has not happened yet. Deploy steps live separately in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md).
---
## Pre-cutover (do once)
Operational steps that have to run during cutover. Full details + env-var list in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md).
- [ ] Provision the Dokploy app, set env vars (full list in LAUNCH_READINESS.md), confirm `BASE_URL` exact-matches the public origin and `NODE_ENV=production`.
- [ ] Add the daily Dokploy Scheduled Task that POSTs to `/api/internal/reconcile-payments` with `X-Reconcile-Token`.
- [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.**
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` and `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Dokploy.
- [ ] Set `NUXT_RECONCILE_TOKEN` to a 32+ char random string.
- [ ] Push local `main` to `origin/main`.
- [ ] Deploy.
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic.**
- [ ] Audit prod for pre-fix series-pass bypass registrations (registrations on pass-only series children with `registeredAt < 2026-04-20` from non-pass-holders). Decide per case.
- [ ] In Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303 (we send our own CRA-safe version via Resend).
- [ ] Run one real test charge and verify (a) Payment doc in Mongo and (b) exactly one CRA-compliant confirmation email.
- [ ] Rotate `HELCIM_API_TOKEN` in the Helcim merchant portal and update the Dokploy env var.
- [ ] Trigger the daily reconcile task once manually in Dokploy to confirm it's wired correctly.
## Pilot smoke walks (before first wave)
Once cutover lands, before the first Slack onboarding wave goes out:
- [ ] **Pilot smoke walk for Slack-invited workflow.** One admin manually clicks "Mark as Slack invited" against a real test member in production, confirms the row updates in place, and confirms the dashboard "Slack coming" note disappears for that member. Unit tests cover the pieces; nothing covers the live admin-to-member round-trip.
---
## Bylaws-decoupling (waiting on amendment ratification)
Membership status is being decoupled from payment status. Copy + UI gates already align; behavioral changes below remain.
- [ ] **B1.** `server/api/members/cancel-subscription.post.js:31,48` flips status to `pending_payment` on cancel. Under the new bylaws, cancellation should keep status `active` (just zero contribution). Update the `findByIdAndUpdate` payload + response, the comment at line 26, and add coverage in `tests/server/api/cancel-subscription.test.js`.
- ~~B3 cancelled.~~ `pending_payment` stays.
- ~~B4 admin "Pending Payment" → "Payment setup incomplete"~~ shipped 2026-04-29 (`59d2be2`).
---
## Known gotchas (post-launch)
- **Admin edit does not sync Helcim `recurringAmount`.** `/admin/members/[id]` PUT writes `contributionAmount` direct to Mongo by design. Admins must PATCH Helcim manually. The admin form already shows an `--ember`-bordered notice (commit `e756170`); a real sync flow is a future enhancement.
- **Cadence switch rejected on active subscriptions.** `server/api/members/update-contribution.post.js:206` refuses cadence changes mid-subscription with a TODO comment pointing here. No UI toggle exists on `/member/account`. Adding cadence switch requires a Helcim subscription replacement flow, not a plain update.
- **S2 test fixture id/slug mismatch (local dev only).** Seeded S2 series has `id: 'test-s2-drop-in-allowed'` but `slug: 'test-s2-drop-in-allowed-series'`. Doesn't affect prod — fix the seed script if anyone re-runs fixtures.
---
## Accessibility / a11y
- [ ] **Button minimum target size.** Site-wide `.btn` renders ~35px tall. WCAG AA 2.5.8 (24×24) passes; AAA 2.5.5 (44×44) fails. Bumping padding affects every button — design call, not a drop-in fix. Flagged 2026-04-11.
---
## Deferred features (own session each)
- [ ] **Email automation system.** Patterned after Tranzac's implementation (separate project, already built). HTML email bodies with template management and drip sequences. Deferred 2026-04-20 — ruled wasted work given the larger system is designed elsewhere. Current transactional email lives in `server/utils/resend.js` + inline in `server/api/auth/login.post.js`, `server/routes/oidc/interaction/login.post.ts`, `server/api/admin/{members,pre-registrants}/invite.post.js`. Copy dump at `docs/email-copy-dump.md`. See memory: `project_email_automation_future`.
- [ ] **Receipts for event ticket purchases (Phase 2).** Phase 1 receipts only cover membership payments. Event tickets — especially guest purchases without member accounts — need a receipt flow. Likely an emailed PDF/HTML receipt at purchase time. Build target: JuneOct 2026, live Jan 2027. See memory: `project_receipts`.
- [ ] **Series/event waitlist.** Admin can configure `tickets.waitlist.enabled` and `maxSize`; `server/utils/tickets.js` returns `waitlistAvailable: true` when full; `app/components/SeriesPassPurchase.vue:341` and `EventTicketPurchase.vue` have stub `handleJoinWaitlist` that toasts "Waitlist Coming Soon." No server endpoint, no confirmation email, no `event_waitlisted` activity hook. Either implement end-to-end or hide the button by removing the `v-if="availability?.waitlistAvailable"` branches in `EventSeriesTicketCard.vue:175` and `EventTicketCard.vue:73`.
- [ ] **ASVS Phase 4.** File-upload validation pipeline, granular RBAC, credential encryption.
---
## Wave-Slack pilot follow-ups
- [ ] **E2E coverage for `e2e/wave-slack-onboarding.spec.js`.** 16 scaffolded tests, all `.skip`ed with TODOs. Filling them in needs fixture work the spec didn't scope. Test 7.8 (final copy match) and 6.9 (sortable column / "no Slack yet" filter) gated on Open Questions in the test plan. Defer until pilot wraps unless a regression surfaces.
- [ ] **Pilot exit decision (~8 weeks post-launch).** Either restore `server/_archive/utils/checkSlackJoins.js` + its plugin if polling is needed, or delete the archive permanently. Driven by whether the manual-invite cadence is sustainable post-pilot.
- [ ] **`slack_invite_failed` enum slug cleanup.** Detector and alert removed in `d15458b`, but the slug remains in `server/models/adminAlertDismissal.js` enum so historical dismissal rows continue to validate. Full removal needs a one-shot cleanup of stale dismissal rows in the DB. Roll into a future schema-tidy pass.
---
## Simplify-pass follow-ups (still open)
Items surfaced during the 2026-04-29 /simplify review. The 2026-04-30 small-wins batch shipped 3 items (STATUS_LABELS dedup, ImageUpload focus, signupBridge rename). Remaining:
- [ ] **Extract `.tint-candle` / `.tint-ember` utility classes.** The `color-mix(in srgb, var(--candle) 15%, transparent)` + matching border pattern is now inlined as `style=""` in ~9 sites across `EventSeriesTicketCard.vue`, `SeriesPassPurchase.vue`, `NaturalDateInput.vue`, `ImageUpload.vue`. Promote to utility classes in `app/assets/css/main.css` so future tints don't keep multiplying inline styles (and so `:hover` / `:focus` variants are reachable).
- [ ] **Audit `member &&` truthy checks in sibling ticket/subscription routes.** Commit `f66455e` fixed `server/api/events/[id]/tickets/available.get.js:115` to use `hasMemberAccess(member)`. Same anti-pattern likely exists in adjacent routes (`tickets/purchase.post.js`, subscription endpoints). Guests/suspended/cancelled members would currently look like full members for any feature gated on truthiness alone.
---
## Optional / low-priority
- [ ] **Welcome-email Slack-timing mention.** Currently the welcome email doesn't mention Slack timing — the dashboard carries that note. Could add a one-line "Slack invitation comes in monthly waves — there may be a short wait" if the dashboard turns out not to be enough signal.
---
## Deeplink memories
- `project_post_launch_backlog.md` — high-level digest of this file
- `project_launch_readiness.md` — cutover status (NOT YET happened)
- `project_launch_flow_map.md` — onboarding flow + Slack wave model
- `project_pre_registrants.md` — invitation system + pre-reg lifecycle
- `project_helcim_plan_model.md` — cadence-keyed plan model
- `project_contribution_amount_redesign.md` — arbitrary $ amount + guidance presets
- `project_receipts.md` — Phase 1 done, Phase 2 pending
- `project_email_automation_future.md` — Tranzac reference for full system

View file

@ -1,8 +1,8 @@
# Launch Readiness # Launch Readiness
**Status as of 2026-04-20.** Target launch: before 2026-05-01. **Status as of 2026-04-30. Cutover has not happened yet.** Code is on local `main`; deploy steps below still need to execute.
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`. Pre-cutover deploy checklist is the live content on this page. Everything else (post-launch work, bylaws decoupling, deferred features, simplify follow-ups, a11y) lives in [`BACKLOG.md`](./BACKLOG.md). Completed launch-blocker items are archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`.
--- ---
@ -106,60 +106,7 @@ None outstanding. All launch-blocking flows verified via local dev or cloudflare
--- ---
## Bylaws decoupling — follow-ups (added 2026-04-18) ## Post-launch & deferred work
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. Bylaws decoupling, post-launch a11y, ASVS Phase 4, deferred features, simplify-pass follow-ups, known gotchas, wave-Slack pilot follow-ups — **everything that isn't a deploy step has moved to [`BACKLOG.md`](./BACKLOG.md).**
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 contribution amount to `0`. Under the new model, cancelling a payment plan moves the member to the $0 contribution — 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.
### 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 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.
---
## Post-launch backlog
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~~ — 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).
- ~~`tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members~~ — fixed 2026-04-29 (commit `f66455e`); `memberSavings` now gated on `hasMemberAccess(member)`.
- Simplify-pass follow-ups (2026-04-25): SHIPPED 2026-04-27 on branch `chore/simplify-pass-follow-ups` (pending merge). See `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_simplify_pass_2026_04_25.md`.
- ~~Reconcile `customerCode` bug~~ — fixed on `main` in commit `3c38333` ("pass customerCode (not helcimCustomerId) to Helcim transactions API"). Verified in `server/api/internal/reconcile-payments.post.js:97`.
- ~~Drive-by from 2026-04-29 phantom-Tailwind sweep: `app/components/EventSeriesBadge.vue` has zero usages~~ — deleted 2026-04-29 (commit `f85f284`); 81 lines removed.
- Simplify-pass follow-ups (2026-04-29): smallest wins shipped in commit `26791cc`; deferred items (rename `setPaymentBridgeCookie`, dedup admin `STATUS_LABELS`, extract `.tint-candle`/`.tint-ember` utilities, audit `member &&` truthy checks in sibling routes, restore `ImageUpload` alt-text input focus styling) tracked in `docs/TODO.md` § _Simplify-pass follow-ups — 2026-04-29_.
### Known gotchas worth addressing post-launch
- **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.
- **S2 test fixture `id`/`slug` inconsistency.** (Local dev only.) Seeded S2 series has `id: 'test-s2-drop-in-allowed'` but `slug: 'test-s2-drop-in-allowed-series'`. Doesn't affect prod — fix the seed script if anyone re-runs fixtures and is confused why `id`-based Mongo queries return empty.
### Events-surface visual audit — deferred items (2026-04-21)
Context: Phase 4 audit against `docs/specs/events-visual-audit-findings.md` fixed all critical phantom-palette, rounded-corner, CTA-mismatch, and input-styling issues across `EventTicketCard`, `EventTicketPurchase`, `EventSeriesTicketCard`, `SeriesPassPurchase`. Items below were explicitly deferred or out of reach.
- ~~**Success-state color convention (4 instances).**~~ Resolved 2026-04-29: gold (`--candle`) chosen as zine-consistent. Phantom-Tailwind cleanup shipped in `dc2becf` (`EventSeriesTicketCard.vue` + `SeriesPassPurchase.vue` member-benefit notice).
- ~~**Sidebar breakpoint unverified.**~~ Verified clean 2026-04-29 — `.events-mini` hides at ≤1024px cleanly across 1023/1024/1025/1100. Actual rule lives in `EventsMiniSidebar.vue:129` + `ColumnsLayout.vue:83` (audit doc cited the wrong line).
- ~~**`EventTicketPurchase.vue:469` magic padding.**~~ Fixed 2026-04-29 (commit `7e44809`); consent block now uses a grid approach.
- ~~**`.section-label` extraction candidate.**~~ Verified 2026-04-29 — utility already exists at `main.css:128` and is used in 30+ places. Two scoped overrides intentionally diverge.
- ~~**Past-events toggle component.**~~ Audited 2026-04-29 — consistent with the design system (dashed-border button, gold active state, valid `aria-pressed` toggle). Added missing `:focus-visible` outline in commit `dadec1a`; no other changes warranted.
### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
SHIPPED 2026-04-29 in commit `955217a` (admin column header, dropdown labels, handler rename, log message).

View file

@ -2,8 +2,9 @@ import { getRequestHeader, getRequestIP } from 'h3'
import Member from '../../models/member.js' import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import { createHelcimCustomer } from '../../utils/helcim.js' import { createHelcimCustomer } from '../../utils/helcim.js'
import PreRegistration from '../../models/preRegistration.js'
import { sendMagicLink } from '../../utils/magicLink.js' import { sendMagicLink } from '../../utils/magicLink.js'
import { setPaymentBridgeCookie } from '../../utils/auth.js' import { setSignupBridgeCookie } from '../../utils/auth.js'
import { rateLimit } from '../../utils/rateLimit.js' import { rateLimit } from '../../utils/rateLimit.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -82,6 +83,32 @@ export default defineEventHandler(async (event) => {
}) })
} }
// If this email matches a pending pre-registrant, mark the PreRegistration
// as accepted and link it to the new Member. Silent — keeps /join and
// /admin/pre-registrants from showing the same person twice.
try {
const preReg = await PreRegistration.findOne({ email: normalizedEmail })
if (
preReg &&
!preReg.memberId &&
['pending', 'selected', 'invited'].includes(preReg.status)
) {
await PreRegistration.findByIdAndUpdate(
preReg._id,
{
$set: {
status: 'accepted',
acceptedAt: new Date(),
memberId: member._id,
},
},
{ runValidators: false }
)
}
} catch (linkError) {
console.error('Failed to link PreRegistration to new member:', linkError)
}
await sendMagicLink(normalizedEmail, { await sendMagicLink(normalizedEmail, {
subject: 'Verify your Ghost Guild signup', subject: 'Verify your Ghost Guild signup',
intro: 'Verify your email to finish your Ghost Guild signup:', intro: 'Verify your email to finish your Ghost Guild signup:',
@ -89,10 +116,10 @@ export default defineEventHandler(async (event) => {
}) })
// Signup completes (paid checkout or free activation) before the magic // Signup completes (paid checkout or free activation) before the magic
// link is clicked, so issue a short-lived, payment-only bridge cookie // link is clicked, so issue a short-lived signup-bridge cookie that lets
// that lets /api/helcim/initialize-payment and /api/helcim/subscription // /api/helcim/initialize-payment and /api/helcim/subscription identify
// identify the member without a verified auth session. // the member without a verified auth session.
setPaymentBridgeCookie(event, member) setSignupBridgeCookie(event, member)
return { return {
success: true, success: true,

View file

@ -2,7 +2,7 @@ import Member from '../../models/member.js'
import { loadPublicEvent } from '../../utils/loadEvent.js' import { loadPublicEvent } from '../../utils/loadEvent.js'
import { loadPublicSeries } from '../../utils/loadSeries.js' import { loadPublicSeries } from '../../utils/loadSeries.js'
import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js' import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js'
import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js' import { requireAuth, getOptionalMember, getSignupBridgeMember } from '../../utils/auth.js'
import { initializeHelcimPaySession } from '../../utils/helcim.js' import { initializeHelcimPaySession } from '../../utils/helcim.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -17,7 +17,7 @@ export default defineEventHandler(async (event) => {
if (!isTicket) { if (!isTicket) {
if (isMembershipSignup) { if (isMembershipSignup) {
const bridgeMember = await getPaymentBridgeMember(event) const bridgeMember = await getSignupBridgeMember(event)
if (!bridgeMember) { if (!bridgeMember) {
await requireAuth(event) await requireAuth(event)
} }

View file

@ -3,7 +3,7 @@ import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js'
import Member from '../../models/member.js' import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import { getSlackService } from '../../utils/slack.ts' import { getSlackService } from '../../utils/slack.ts'
import { requireAuth, getPaymentBridgeMember } from '../../utils/auth.js' import { requireAuth, getSignupBridgeMember } from '../../utils/auth.js'
import { createHelcimSubscription, generateIdempotencyKey, listHelcimCustomerTransactions } from '../../utils/helcim.js' import { createHelcimSubscription, generateIdempotencyKey, listHelcimCustomerTransactions } from '../../utils/helcim.js'
import { sendWelcomeEmail } from '../../utils/resend.js' import { sendWelcomeEmail } from '../../utils/resend.js'
import { upsertPaymentFromHelcim } from '../../utils/payments.js' import { upsertPaymentFromHelcim } from '../../utils/payments.js'
@ -11,8 +11,8 @@ import { upsertPaymentFromHelcim } from '../../utils/payments.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// Membership signup completes subscription before email verify; allow the // Membership signup completes subscription before email verify; allow the
// payment-bridge cookie set by /api/helcim/customer to satisfy auth here. // signup-bridge cookie set by /api/helcim/customer to satisfy auth here.
const bridgeMember = await getPaymentBridgeMember(event) const bridgeMember = await getSignupBridgeMember(event)
if (!bridgeMember) { if (!bridgeMember) {
await requireAuth(event) await requireAuth(event)
} }

View file

@ -5,6 +5,7 @@ import { connectDB } from '../../utils/mongoose.js'
import { setAuthCookie } from '../../utils/auth.js' import { setAuthCookie } from '../../utils/auth.js'
import { assignMemberNumber } from '../../utils/memberNumber.js' import { assignMemberNumber } from '../../utils/memberNumber.js'
import { createHelcimCustomer } from '../../utils/helcim.js' import { createHelcimCustomer } from '../../utils/helcim.js'
import { sendWelcomeEmail } from '../../utils/resend.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await validateBody(event, inviteAcceptSchema) const body = await validateBody(event, inviteAcceptSchema)
@ -88,6 +89,15 @@ export default defineEventHandler(async (event) => {
// For free tier, redirect to welcome // For free tier, redirect to welcome
if (body.contributionAmount === 0) { if (body.contributionAmount === 0) {
await autoFlagPreExistingSlackAccess(member) await autoFlagPreExistingSlackAccess(member)
try {
await sendWelcomeEmail(member)
logActivity(member._id, 'email_sent', {
emailType: 'welcome',
subject: 'Welcome to Ghost Guild'
})
} catch (emailError) {
console.error('Failed to send welcome email:', emailError)
}
return { return {
success: true, success: true,
requiresPayment: false, requiresPayment: false,

View file

@ -23,26 +23,27 @@ export function setAuthCookie(event, member) {
} }
/** /**
* Issue a 30-minute payment-bridge cookie scoped to membership-signup checkout. * Issue a 30-minute signup-bridge cookie scoped to membership-signup flow.
* *
* The signup flow (POST /api/helcim/customer) defers the full session cookie * The signup flow (POST /api/helcim/customer) defers the full session cookie
* to email-verify (magic link). For paid tiers the user still needs to complete * to email-verify (magic link). The bridge cookie lets the in-progress signup
* Helcim checkout in the same browser tab this short-lived, payment-only * complete its activation step (free or paid) before that magic link is
* token lets `/api/helcim/initialize-payment` accept the call without a full * clicked: /api/helcim/subscription accepts it for $0 activation, and
* session. The cookie is NOT honored by requireAuth and grants nothing else. * /api/helcim/initialize-payment accepts it for paid Helcim checkout.
* The cookie is NOT honored by requireAuth and grants nothing else.
*/ */
export function setPaymentBridgeCookie(event, member) { export function setSignupBridgeCookie(event, member) {
const token = jwt.sign( const token = jwt.sign(
{ {
memberId: member._id.toString(), memberId: member._id.toString(),
email: member.email, email: member.email,
scope: 'payment_bridge' scope: 'signup_bridge'
}, },
useRuntimeConfig(event).jwtSecret, useRuntimeConfig(event).jwtSecret,
{ expiresIn: '30m' } { expiresIn: '30m' }
) )
setCookie(event, 'payment-bridge', token, { setCookie(event, 'signup-bridge', token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', sameSite: 'lax',
@ -52,12 +53,12 @@ export function setPaymentBridgeCookie(event, member) {
} }
/** /**
* Verify a payment-bridge cookie and return the associated Member, or null. * Verify a signup-bridge cookie and return the associated Member, or null.
* Used by /api/helcim/initialize-payment to allow the membership-signup * Used by /api/helcim/subscription and /api/helcim/initialize-payment to
* checkout to proceed before email verification. * let the in-progress signup complete activation before email verification.
*/ */
export async function getPaymentBridgeMember(event) { export async function getSignupBridgeMember(event) {
const token = getCookie(event, 'payment-bridge') const token = getCookie(event, 'signup-bridge')
if (!token) return null if (!token) return null
let decoded let decoded
@ -67,7 +68,7 @@ export async function getPaymentBridgeMember(event) {
return null return null
} }
if (decoded.scope !== 'payment_bridge') return null if (decoded.scope !== 'signup_bridge') return null
await connectDB() await connectDB()
const member = await Member.findById(decoded.memberId) const member = await Member.findById(decoded.memberId)

View file

@ -282,7 +282,7 @@ Welcome to Ghost Guild! You're now part of the ${member.circle} circle.
Sign in to your dashboard to get started: Sign in to your dashboard to get started:
${baseUrl}/member/dashboard ${baseUrl}/member/dashboard
If you have questions, reach out to jennie + eileen on Slack or reply to this email.`, If you have questions, just reply to this email.`,
}); });
if (error) { if (error) {

View file

@ -45,7 +45,7 @@ vi.mock('../../../server/models/preRegistration.js', () => ({
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/auth.js', () => ({ vi.mock('../../../server/utils/auth.js', () => ({
requireAuth: vi.fn(), requireAuth: vi.fn(),
getPaymentBridgeMember: vi.fn().mockResolvedValue(null), getSignupBridgeMember: vi.fn().mockResolvedValue(null),
setAuthCookie: vi.fn() setAuthCookie: vi.fn()
})) }))
vi.mock('../../../server/utils/slack.ts', () => ({ vi.mock('../../../server/utils/slack.ts', () => ({

View file

@ -20,6 +20,9 @@ vi.mock('../../../server/models/member.js', () => ({
findOneAndUpdate: vi.fn() findOneAndUpdate: vi.fn()
} }
})) }))
vi.mock('../../../server/models/preRegistration.js', () => ({
default: { findOne: vi.fn().mockResolvedValue(null), findByIdAndUpdate: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/helcim.js', () => ({ vi.mock('../../../server/utils/helcim.js', () => ({
createHelcimCustomer: vi.fn(), createHelcimCustomer: vi.fn(),
@ -57,9 +60,9 @@ const SUBSCRIPTION_BODY = {
function extractBridgeCookie(event) { function extractBridgeCookie(event) {
const setCookie = event.node.res.getHeader('set-cookie') const setCookie = event.node.res.getHeader('set-cookie')
const cookies = Array.isArray(setCookie) ? setCookie : [setCookie].filter(Boolean) const cookies = Array.isArray(setCookie) ? setCookie : [setCookie].filter(Boolean)
const match = cookies.find(c => typeof c === 'string' && c.startsWith('payment-bridge=')) const match = cookies.find(c => typeof c === 'string' && c.startsWith('signup-bridge='))
if (!match) return null if (!match) return null
return match.match(/payment-bridge=([^;]+)/)[1] return match.match(/signup-bridge=([^;]+)/)[1]
} }
describe('signup → subscription bridge-cookie hand-off', () => { describe('signup → subscription bridge-cookie hand-off', () => {
@ -101,7 +104,7 @@ describe('signup → subscription bridge-cookie hand-off', () => {
expect(result1.member.status).toBe('pending_payment') expect(result1.member.status).toBe('pending_payment')
const bridgeToken = extractBridgeCookie(customerEvent) const bridgeToken = extractBridgeCookie(customerEvent)
expect(bridgeToken, 'payment-bridge cookie missing on $0 signup').toBeTruthy() expect(bridgeToken, 'signup-bridge cookie missing on $0 signup').toBeTruthy()
Member.findOneAndUpdate.mockResolvedValue({ _id: MEMBER_ID, status: 'pending_payment' }) Member.findOneAndUpdate.mockResolvedValue({ _id: MEMBER_ID, status: 'pending_payment' })
Member.findById.mockResolvedValue({ Member.findById.mockResolvedValue({
@ -117,7 +120,7 @@ describe('signup → subscription bridge-cookie hand-off', () => {
method: 'POST', method: 'POST',
path: '/api/helcim/subscription', path: '/api/helcim/subscription',
headers: { origin: ALLOWED_ORIGIN }, headers: { origin: ALLOWED_ORIGIN },
cookies: { 'payment-bridge': bridgeToken }, cookies: { 'signup-bridge': bridgeToken },
body: SUBSCRIPTION_BODY body: SUBSCRIPTION_BODY
}) })

View file

@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import Member from '../../../server/models/member.js' import Member from '../../../server/models/member.js'
import { createHelcimCustomer } from '../../../server/utils/helcim.js' import { createHelcimCustomer } from '../../../server/utils/helcim.js'
import { sendMagicLink } from '../../../server/utils/magicLink.js' import { sendMagicLink } from '../../../server/utils/magicLink.js'
import { setAuthCookie, setPaymentBridgeCookie } from '../../../server/utils/auth.js' import { setAuthCookie, setSignupBridgeCookie } from '../../../server/utils/auth.js'
import customerHandler from '../../../server/api/helcim/customer.post.js' import customerHandler from '../../../server/api/helcim/customer.post.js'
import { resetRateLimit } from '../../../server/utils/rateLimit.js' import { resetRateLimit } from '../../../server/utils/rateLimit.js'
import { createMockEvent } from '../helpers/createMockEvent.js' import { createMockEvent } from '../helpers/createMockEvent.js'
@ -12,6 +12,9 @@ import { createMockEvent } from '../helpers/createMockEvent.js'
vi.mock('../../../server/models/member.js', () => ({ vi.mock('../../../server/models/member.js', () => ({
default: { findOne: vi.fn(), create: vi.fn(), findByIdAndUpdate: vi.fn() } default: { findOne: vi.fn(), create: vi.fn(), findByIdAndUpdate: vi.fn() }
})) }))
vi.mock('../../../server/models/preRegistration.js', () => ({
default: { findOne: vi.fn().mockResolvedValue(null), findByIdAndUpdate: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/helcim.js', () => ({ vi.mock('../../../server/utils/helcim.js', () => ({
createHelcimCustomer: vi.fn() createHelcimCustomer: vi.fn()
@ -21,7 +24,7 @@ vi.mock('../../../server/utils/magicLink.js', () => ({
})) }))
vi.mock('../../../server/utils/auth.js', () => ({ vi.mock('../../../server/utils/auth.js', () => ({
setAuthCookie: vi.fn(), setAuthCookie: vi.fn(),
setPaymentBridgeCookie: vi.fn() setSignupBridgeCookie: vi.fn()
})) }))
// helcimCustomerSchema is auto-imported in the handler — stub it to a passthrough // helcimCustomerSchema is auto-imported in the handler — stub it to a passthrough
@ -300,7 +303,7 @@ describe('POST /api/helcim/customer', () => {
'guest@example.com', 'guest@example.com',
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' }) expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
) )
expect(setPaymentBridgeCookie).toHaveBeenCalled() expect(setSignupBridgeCookie).toHaveBeenCalled()
expect(setAuthCookie).not.toHaveBeenCalled() expect(setAuthCookie).not.toHaveBeenCalled()
// Response shape mirrors new-signup case AND surfaces the preserved _id. // Response shape mirrors new-signup case AND surfaces the preserved _id.
@ -362,7 +365,7 @@ describe('POST /api/helcim/customer', () => {
) )
}) })
it('sets a payment-bridge cookie on paid-tier signup so checkout can proceed', async () => { it('sets a signup-bridge cookie on paid-tier signup so checkout can proceed', async () => {
const event = build({ const event = build({
body: { body: {
name: 'Paid User', name: 'Paid User',
@ -373,7 +376,7 @@ describe('POST /api/helcim/customer', () => {
} }
}) })
await customerHandler(event) await customerHandler(event)
expect(setPaymentBridgeCookie).toHaveBeenCalled() expect(setSignupBridgeCookie).toHaveBeenCalled()
expect(sendMagicLink).toHaveBeenCalledWith( expect(sendMagicLink).toHaveBeenCalledWith(
'paid@example.com', 'paid@example.com',
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' }) expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })

View file

@ -15,7 +15,7 @@ vi.mock('../../../server/models/member.js', () => ({
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/auth.js', () => ({ vi.mock('../../../server/utils/auth.js', () => ({
requireAuth: vi.fn(), requireAuth: vi.fn(),
getPaymentBridgeMember: vi.fn().mockResolvedValue(null) getSignupBridgeMember: vi.fn().mockResolvedValue(null)
})) }))
vi.mock('../../../server/utils/slack.ts', () => ({ vi.mock('../../../server/utils/slack.ts', () => ({
getSlackService: vi.fn().mockReturnValue(null) getSlackService: vi.fn().mockReturnValue(null)