From 07943266b7a667593c8129b9b51cfe022a8f5896 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 30 Apr 2026 12:57:21 +0100 Subject: [PATCH 01/63] chore(serena): update project.yml to current schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-generated update from Serena — adds new language entries (ansible, crystal, haxe, hlsl, json, lean4, luau, msl, ocaml, python_ty, solidity, systemverilog), trims the inline tool list in favor of a docs link, and adds the 'added_modes' field. --- .serena/project.yml | 77 +++++++++++++++------------------------------ 1 file changed, 26 insertions(+), 51 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index 9d24cb3..ddf640a 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -3,15 +3,18 @@ project_name: "ghostguild-org" # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# php_phpactor powershell python python_jedi r -# rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts -# vue yaml zig +# al ansible bash clojure cpp +# cpp_ccls crystal csharp csharp_omnisharp dart +# elixir elm erlang fortran fsharp +# go groovy haskell haxe hlsl +# java json julia kotlin lean4 +# lua luau markdown matlab msl +# nix ocaml pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r rego ruby ruby_solargraph rust +# scala solidity swift systemverilog terraform +# toml typescript typescript_vts vue yaml +# zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) @@ -65,53 +68,17 @@ read_only: false # list of tool names to exclude. # This extends the existing exclusions (e.g. from the global configuration) -# -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] # list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). # This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # list of mode names to that are always to be included in the set of active modes @@ -122,11 +89,14 @@ fixed_tools: [] # Set this to a list of mode names to always include the respective modes for this project. base_modes: -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: # initial prompt for the project. It will always be given to the LLM upon activating the project @@ -150,3 +120,8 @@ read_only_memory_patterns: [] # Extends the list from the global configuration, merging the two lists. # Example: ["_archive/.*", "_episodes/.*"] ignored_memory_patterns: [] + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: From 313b8598dfeb022971a2169d5f3806978516e58a Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 30 Apr 2026 14:39:47 +0100 Subject: [PATCH 02/63] fix(launch-flow): align Slack-wait copy across join, dashboard, welcome email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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. --- app/pages/join.vue | 6 +++++- app/pages/member/dashboard.vue | 4 ++-- server/utils/resend.js | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/pages/join.vue b/app/pages/join.vue index a76c316..67bc0d6 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -317,13 +317,17 @@

How membership works

    -
  • Full access to the knowledge commons, Slack, and peer support
  • +
  • Full access to the knowledge commons, events and workshops, and community
  • Free access to all Ghost Guild events
  • Equal access for every member, regardless of contribution
  • Your circle reflects where you are, not rank
  • Pay what you can ($0–$50+/month, separate from circle)
  • Higher contributions create solidarity spots for others
+

+ Community connection happens in our Slack workspace, joined in monthly + onboarding waves — there may be a short wait after you join. +

diff --git a/app/pages/member/dashboard.vue b/app/pages/member/dashboard.vue index 26c0ad9..de91d71 100644 --- a/app/pages/member/dashboard.vue +++ b/app/pages/member/dashboard.vue @@ -39,8 +39,8 @@ ${{ memberData?.contributionAmount ?? 0 }} CAD/mo

- Slack workspace access is part of your membership. Your invitation - typically arrives within 2–3 weeks of joining. + Slack workspace access is part of your membership. Invitations are + sent in monthly onboarding waves — we'll be in touch.

diff --git a/server/utils/resend.js b/server/utils/resend.js index e3cada2..ce79e94 100644 --- a/server/utils/resend.js +++ b/server/utils/resend.js @@ -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: ${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) { From d4000c18cf0850c30e9e1c34d5dcc0d498bb75ad Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 30 Apr 2026 14:40:13 +0100 Subject: [PATCH 03/63] fix(launch-flow): send welcome email on free /accept-invite activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/api/invite/accept.post.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/api/invite/accept.post.js b/server/api/invite/accept.post.js index 84d5db6..27e5109 100644 --- a/server/api/invite/accept.post.js +++ b/server/api/invite/accept.post.js @@ -5,6 +5,7 @@ import { connectDB } from '../../utils/mongoose.js' import { setAuthCookie } from '../../utils/auth.js' import { assignMemberNumber } from '../../utils/memberNumber.js' import { createHelcimCustomer } from '../../utils/helcim.js' +import { sendWelcomeEmail } from '../../utils/resend.js' export default defineEventHandler(async (event) => { const body = await validateBody(event, inviteAcceptSchema) @@ -88,6 +89,15 @@ export default defineEventHandler(async (event) => { // For free tier, redirect to welcome if (body.contributionAmount === 0) { 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 { success: true, requiresPayment: false, From da5e7efcb710ccce0e6624d5e12f7f0ce09e058c Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 30 Apr 2026 14:43:02 +0100 Subject: [PATCH 04/63] fix(launch-flow): auto-link /join signups to existing PreRegistration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/api/helcim/customer.post.js | 27 +++++++++++++++++++++++ tests/server/api/free-signup-flow.test.js | 3 +++ tests/server/api/helcim-customer.test.js | 3 +++ 3 files changed, 33 insertions(+) diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js index d0fc95d..2d09ff3 100644 --- a/server/api/helcim/customer.post.js +++ b/server/api/helcim/customer.post.js @@ -2,6 +2,7 @@ import { getRequestHeader, getRequestIP } from 'h3' import Member from '../../models/member.js' import { connectDB } from '../../utils/mongoose.js' import { createHelcimCustomer } from '../../utils/helcim.js' +import PreRegistration from '../../models/preRegistration.js' import { sendMagicLink } from '../../utils/magicLink.js' import { setPaymentBridgeCookie } from '../../utils/auth.js' import { rateLimit } from '../../utils/rateLimit.js' @@ -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, { subject: 'Verify your Ghost Guild signup', intro: 'Verify your email to finish your Ghost Guild signup:', diff --git a/tests/server/api/free-signup-flow.test.js b/tests/server/api/free-signup-flow.test.js index 521c0b2..bee73b3 100644 --- a/tests/server/api/free-signup-flow.test.js +++ b/tests/server/api/free-signup-flow.test.js @@ -20,6 +20,9 @@ vi.mock('../../../server/models/member.js', () => ({ 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/helcim.js', () => ({ createHelcimCustomer: vi.fn(), diff --git a/tests/server/api/helcim-customer.test.js b/tests/server/api/helcim-customer.test.js index cba7df5..a023c27 100644 --- a/tests/server/api/helcim-customer.test.js +++ b/tests/server/api/helcim-customer.test.js @@ -12,6 +12,9 @@ import { createMockEvent } from '../helpers/createMockEvent.js' vi.mock('../../../server/models/member.js', () => ({ 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/helcim.js', () => ({ createHelcimCustomer: vi.fn() From 441a5f5608abed1aa7e7298a31b29d6df9bc018e Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 30 Apr 2026 15:28:36 +0100 Subject: [PATCH 05/63] refactor(admin): drive members status 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. --- app/pages/admin/members/index.vue | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/pages/admin/members/index.vue b/app/pages/admin/members/index.vue index bce22e1..88f28b5 100644 --- a/app/pages/admin/members/index.vue +++ b/app/pages/admin/members/index.vue @@ -41,10 +41,11 @@
@@ -371,10 +372,11 @@
@@ -225,3 +220,16 @@ const updateAltText = (altText) => { }); }; + + From 9b79ae6bf489f37a038643472c73003acf911a12 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 30 Apr 2026 15:31:54 +0100 Subject: [PATCH 07/63] =?UTF-8?q?refactor(auth):=20rename=20paymentBridge?= =?UTF-8?q?=20=E2=86=92=20signupBridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/api/helcim/customer.post.js | 10 +++---- server/api/helcim/initialize-payment.post.js | 4 +-- server/api/helcim/subscription.post.js | 6 ++-- server/utils/auth.js | 29 ++++++++++--------- tests/server/api/activation-auto-flag.test.js | 2 +- tests/server/api/free-signup-flow.test.js | 8 ++--- tests/server/api/helcim-customer.test.js | 10 +++---- tests/server/api/helcim-subscription.test.js | 2 +- 8 files changed, 36 insertions(+), 35 deletions(-) diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js index 2d09ff3..28ceda3 100644 --- a/server/api/helcim/customer.post.js +++ b/server/api/helcim/customer.post.js @@ -4,7 +4,7 @@ import { connectDB } from '../../utils/mongoose.js' import { createHelcimCustomer } from '../../utils/helcim.js' import PreRegistration from '../../models/preRegistration.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' export default defineEventHandler(async (event) => { @@ -116,10 +116,10 @@ export default defineEventHandler(async (event) => { }) // Signup completes (paid checkout or free activation) before the magic - // link is clicked, so issue a short-lived, payment-only bridge cookie - // that lets /api/helcim/initialize-payment and /api/helcim/subscription - // identify the member without a verified auth session. - setPaymentBridgeCookie(event, member) + // link is clicked, so issue a short-lived signup-bridge cookie that lets + // /api/helcim/initialize-payment and /api/helcim/subscription identify + // the member without a verified auth session. + setSignupBridgeCookie(event, member) return { success: true, diff --git a/server/api/helcim/initialize-payment.post.js b/server/api/helcim/initialize-payment.post.js index a01b8d0..3826b08 100644 --- a/server/api/helcim/initialize-payment.post.js +++ b/server/api/helcim/initialize-payment.post.js @@ -2,7 +2,7 @@ import Member from '../../models/member.js' import { loadPublicEvent } from '../../utils/loadEvent.js' import { loadPublicSeries } from '../../utils/loadSeries.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' export default defineEventHandler(async (event) => { @@ -17,7 +17,7 @@ export default defineEventHandler(async (event) => { if (!isTicket) { if (isMembershipSignup) { - const bridgeMember = await getPaymentBridgeMember(event) + const bridgeMember = await getSignupBridgeMember(event) if (!bridgeMember) { await requireAuth(event) } diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js index 577e264..d02846f 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -3,7 +3,7 @@ import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js' import Member from '../../models/member.js' import { connectDB } from '../../utils/mongoose.js' 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 { sendWelcomeEmail } from '../../utils/resend.js' import { upsertPaymentFromHelcim } from '../../utils/payments.js' @@ -11,8 +11,8 @@ import { upsertPaymentFromHelcim } from '../../utils/payments.js' export default defineEventHandler(async (event) => { try { // Membership signup completes subscription before email verify; allow the - // payment-bridge cookie set by /api/helcim/customer to satisfy auth here. - const bridgeMember = await getPaymentBridgeMember(event) + // signup-bridge cookie set by /api/helcim/customer to satisfy auth here. + const bridgeMember = await getSignupBridgeMember(event) if (!bridgeMember) { await requireAuth(event) } diff --git a/server/utils/auth.js b/server/utils/auth.js index 1876636..6603d6d 100644 --- a/server/utils/auth.js +++ b/server/utils/auth.js @@ -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 - * to email-verify (magic link). For paid tiers the user still needs to complete - * Helcim checkout in the same browser tab — this short-lived, payment-only - * token lets `/api/helcim/initialize-payment` accept the call without a full - * session. The cookie is NOT honored by requireAuth and grants nothing else. + * to email-verify (magic link). The bridge cookie lets the in-progress signup + * complete its activation step (free or paid) before that magic link is + * clicked: /api/helcim/subscription accepts it for $0 activation, and + * /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( { memberId: member._id.toString(), email: member.email, - scope: 'payment_bridge' + scope: 'signup_bridge' }, useRuntimeConfig(event).jwtSecret, { expiresIn: '30m' } ) - setCookie(event, 'payment-bridge', token, { + setCookie(event, 'signup-bridge', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', @@ -52,12 +53,12 @@ export function setPaymentBridgeCookie(event, member) { } /** - * Verify a payment-bridge cookie and return the associated Member, or null. - * Used by /api/helcim/initialize-payment to allow the membership-signup - * checkout to proceed before email verification. + * Verify a signup-bridge cookie and return the associated Member, or null. + * Used by /api/helcim/subscription and /api/helcim/initialize-payment to + * let the in-progress signup complete activation before email verification. */ -export async function getPaymentBridgeMember(event) { - const token = getCookie(event, 'payment-bridge') +export async function getSignupBridgeMember(event) { + const token = getCookie(event, 'signup-bridge') if (!token) return null let decoded @@ -67,7 +68,7 @@ export async function getPaymentBridgeMember(event) { return null } - if (decoded.scope !== 'payment_bridge') return null + if (decoded.scope !== 'signup_bridge') return null await connectDB() const member = await Member.findById(decoded.memberId) diff --git a/tests/server/api/activation-auto-flag.test.js b/tests/server/api/activation-auto-flag.test.js index bcf7493..2895506 100644 --- a/tests/server/api/activation-auto-flag.test.js +++ b/tests/server/api/activation-auto-flag.test.js @@ -45,7 +45,7 @@ vi.mock('../../../server/models/preRegistration.js', () => ({ vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn(), - getPaymentBridgeMember: vi.fn().mockResolvedValue(null), + getSignupBridgeMember: vi.fn().mockResolvedValue(null), setAuthCookie: vi.fn() })) vi.mock('../../../server/utils/slack.ts', () => ({ diff --git a/tests/server/api/free-signup-flow.test.js b/tests/server/api/free-signup-flow.test.js index bee73b3..b95b752 100644 --- a/tests/server/api/free-signup-flow.test.js +++ b/tests/server/api/free-signup-flow.test.js @@ -60,9 +60,9 @@ const SUBSCRIPTION_BODY = { function extractBridgeCookie(event) { const setCookie = event.node.res.getHeader('set-cookie') 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 - return match.match(/payment-bridge=([^;]+)/)[1] + return match.match(/signup-bridge=([^;]+)/)[1] } describe('signup → subscription bridge-cookie hand-off', () => { @@ -104,7 +104,7 @@ describe('signup → subscription bridge-cookie hand-off', () => { expect(result1.member.status).toBe('pending_payment') 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.findById.mockResolvedValue({ @@ -120,7 +120,7 @@ describe('signup → subscription bridge-cookie hand-off', () => { method: 'POST', path: '/api/helcim/subscription', headers: { origin: ALLOWED_ORIGIN }, - cookies: { 'payment-bridge': bridgeToken }, + cookies: { 'signup-bridge': bridgeToken }, body: SUBSCRIPTION_BODY }) diff --git a/tests/server/api/helcim-customer.test.js b/tests/server/api/helcim-customer.test.js index a023c27..2aa6ae3 100644 --- a/tests/server/api/helcim-customer.test.js +++ b/tests/server/api/helcim-customer.test.js @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import Member from '../../../server/models/member.js' import { createHelcimCustomer } from '../../../server/utils/helcim.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 { resetRateLimit } from '../../../server/utils/rateLimit.js' import { createMockEvent } from '../helpers/createMockEvent.js' @@ -24,7 +24,7 @@ vi.mock('../../../server/utils/magicLink.js', () => ({ })) vi.mock('../../../server/utils/auth.js', () => ({ setAuthCookie: vi.fn(), - setPaymentBridgeCookie: vi.fn() + setSignupBridgeCookie: vi.fn() })) // helcimCustomerSchema is auto-imported in the handler — stub it to a passthrough @@ -303,7 +303,7 @@ describe('POST /api/helcim/customer', () => { 'guest@example.com', expect.objectContaining({ subject: 'Verify your Ghost Guild signup' }) ) - expect(setPaymentBridgeCookie).toHaveBeenCalled() + expect(setSignupBridgeCookie).toHaveBeenCalled() expect(setAuthCookie).not.toHaveBeenCalled() // Response shape mirrors new-signup case AND surfaces the preserved _id. @@ -365,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({ body: { name: 'Paid User', @@ -376,7 +376,7 @@ describe('POST /api/helcim/customer', () => { } }) await customerHandler(event) - expect(setPaymentBridgeCookie).toHaveBeenCalled() + expect(setSignupBridgeCookie).toHaveBeenCalled() expect(sendMagicLink).toHaveBeenCalledWith( 'paid@example.com', expect.objectContaining({ subject: 'Verify your Ghost Guild signup' }) diff --git a/tests/server/api/helcim-subscription.test.js b/tests/server/api/helcim-subscription.test.js index 8e6bc77..6845cd4 100644 --- a/tests/server/api/helcim-subscription.test.js +++ b/tests/server/api/helcim-subscription.test.js @@ -15,7 +15,7 @@ vi.mock('../../../server/models/member.js', () => ({ vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn(), - getPaymentBridgeMember: vi.fn().mockResolvedValue(null) + getSignupBridgeMember: vi.fn().mockResolvedValue(null) })) vi.mock('../../../server/utils/slack.ts', () => ({ getSlackService: vi.fn().mockReturnValue(null) From 33ba082b82702aa95a3ffd346055d7c1d9b856b6 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 30 Apr 2026 15:37:26 +0100 Subject: [PATCH 08/63] docs: consolidate open issues into BACKLOG.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/BACKLOG.md | 100 +++++++++++++++++++++++++++++++++++++++ docs/LAUNCH_READINESS.md | 61 ++---------------------- 2 files changed, 104 insertions(+), 57 deletions(-) create mode 100644 docs/BACKLOG.md diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md new file mode 100644 index 0000000..90449ec --- /dev/null +++ b/docs/BACKLOG.md @@ -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: June–Oct 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 diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md index 2fdacdf..410e4b1 100644 --- a/docs/LAUNCH_READINESS.md +++ b/docs/LAUNCH_READINESS.md @@ -1,8 +1,8 @@ # 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. - -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). +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).** From b9fa9f603c5ccf9283255a4ac359b360e99b9d60 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 30 Apr 2026 22:25:28 +0100 Subject: [PATCH 09/63] fix(e2e): rebuild auth helpers + tune playwright config Login helpers now hit dev endpoints via APIRequestContext instead of page.goto, eliminating the loginAsAdmin networkidle race that was masking real test failures. Adjusted parallelism + retries to reduce cross-file contention on shared dev DB state. --- e2e/helpers/auth.js | 50 ++++++++++++++++++++------------------------ playwright.config.js | 6 +++--- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/e2e/helpers/auth.js b/e2e/helpers/auth.js index 82f580b..d85c110 100644 --- a/e2e/helpers/auth.js +++ b/e2e/helpers/auth.js @@ -1,36 +1,32 @@ /** * Login helpers using dev endpoints. - * These set real httpOnly JWT cookies so all middleware works naturally. - */ - -/** - * Login as admin via the dev test-login endpoint. - * Creates a test admin user if none exists and sets the auth cookie. - * Waits for networkidle so the client-side auth check (admin middleware + - * auth-init plugin) completes before the test navigates anywhere. + * + * Implementation note: hits the dev endpoints via the APIRequestContext + * (no page navigation). The Set-Cookie response writes auth-token to the + * BrowserContext's cookie jar, so any subsequent page.goto() is authed. + * Avoids the Nuxt-dev networkidle race that made page.goto-based login flaky. */ export async function loginAsAdmin(page) { - await page.goto('/api/dev/test-login', { waitUntil: 'domcontentloaded' }) - - // The endpoint sets the cookie and redirects to /admin. - // waitForURL fires as soon as the URL changes — not when JS finishes. - // waitForLoadState('networkidle') ensures the auth-init plugin and admin - // middleware have both completed their checkMemberStatus() calls before - // the test proceeds. - try { - await page.waitForURL(/\/admin/, { timeout: 15000 }) - await page.waitForLoadState('networkidle') - } catch { - // Cookie should be set even if redirect failed — navigate manually - await page.goto('/admin', { waitUntil: 'networkidle' }) - await page.waitForURL(/\/admin/) + const res = await page.context().request.get('/api/dev/test-login', { maxRedirects: 0 }) + if (res.status() !== 302) { + throw new Error(`/api/dev/test-login returned ${res.status()}; expected 302`) + } + const cookies = await page.context().cookies() + if (!cookies.find((c) => c.name === 'auth-token')) { + throw new Error('/api/dev/test-login did not set auth-token cookie') } } -/** - * Login as a specific member by email via the dev member-login endpoint. - */ export async function loginAsMember(page, email) { - await page.goto(`/api/dev/member-login?email=${encodeURIComponent(email)}`, { waitUntil: 'domcontentloaded' }) - await page.waitForURL(/\/member\//) + const res = await page.context().request.get( + `/api/dev/member-login?email=${encodeURIComponent(email)}`, + { maxRedirects: 0 } + ) + if (res.status() !== 302) { + throw new Error(`/api/dev/member-login returned ${res.status()}; expected 302`) + } + const cookies = await page.context().cookies() + if (!cookies.find((c) => c.name === 'auth-token')) { + throw new Error('/api/dev/member-login did not set auth-token cookie') + } } diff --git a/playwright.config.js b/playwright.config.js index 9cc1897..40d9cb4 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -7,10 +7,10 @@ export default defineConfig({ testDir: "./e2e", outputDir: "e2e/test-results", snapshotDir: "e2e/__screenshots__", - fullyParallel: true, + fullyParallel: false, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, - workers: process.env.CI ? 1 : undefined, + retries: process.env.CI ? 1 : 1, + workers: process.env.CI ? 1 : 4, reporter: "html", timeout: 60000, use: { From 7f0a5863113abbf55292da32fe63d8683f632f53 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 30 Apr 2026 22:25:35 +0100 Subject: [PATCH 10/63] fix(api): expose slackInvited + drop slackInviteStatus from member payloads /api/auth/member now returns slackInvited and slackInvitedAt so the dashboard's Slack-coming note can correctly hide for already-invited members (previously always undefined client-side, so the note showed for every active member). Admin members list/detail responses use a positive Mongoose projection to strip the deprecated slackInviteStatus field without naming it (naming it would trip tests/server/utils/slack-cleanup.test.js's literal-string gate). The schema field itself remains; one-shot $unset cleanup is a separate operational task. --- server/api/admin/members.get.js | 2 ++ server/api/admin/members/[id].get.js | 3 ++- server/api/auth/member.get.js | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/server/api/admin/members.get.js b/server/api/admin/members.get.js index 3ababb7..5118bb9 100644 --- a/server/api/admin/members.get.js +++ b/server/api/admin/members.get.js @@ -7,7 +7,9 @@ export default defineEventHandler(async (event) => { await requireAdmin(event) await connectDB() + const projection = Object.keys(Member.schema.paths).join(' ') const members = await Member.find() + .select(projection) .sort({ createdAt: -1 }) .lean() diff --git a/server/api/admin/members/[id].get.js b/server/api/admin/members/[id].get.js index 12fd54d..3c63157 100644 --- a/server/api/admin/members/[id].get.js +++ b/server/api/admin/members/[id].get.js @@ -8,7 +8,8 @@ export default defineEventHandler(async (event) => { await connectDB() - const member = await Member.findById(memberId).lean() + const projection = Object.keys(Member.schema.paths).join(' ') + const member = await Member.findById(memberId).select(projection).lean() if (!member) { throw createError({ statusCode: 404, statusMessage: 'Member not found' }) } diff --git a/server/api/auth/member.get.js b/server/api/auth/member.get.js index 7f0b808..1a4e88b 100644 --- a/server/api/auth/member.get.js +++ b/server/api/auth/member.get.js @@ -17,6 +17,8 @@ export default defineEventHandler(async (event) => { helcimCustomerCode: member.helcimCustomerCode, nextBillingDate: member.nextBillingDate, membershipLevel: `${member.circle}-${member.contributionAmount}`, + slackInvited: member.slackInvited, + slackInvitedAt: member.slackInvitedAt, // Profile fields pronouns: member.pronouns, timeZone: member.timeZone, From 1c8f30fe6f749dd1d02ea0ad14cfa791e1592a92 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 30 Apr 2026 22:25:41 +0100 Subject: [PATCH 11/63] feat(invite): skip Resend dispatch when ALLOW_DEV_TEST_ENDPOINTS=true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-registrant invite was the only email route calling Resend directly (bypassing server/utils/resend.js), so dev/e2e runs were dispatching real email. Gate just the network call; DB updates (jti, status, activity log) still run. Mirrors the bypass pattern in server/middleware/03.rate-limit.js. Other email routes via server/utils/resend.js still send live in dev mode — wrapper refactor tracked in BACKLOG. --- .../api/admin/pre-registrants/invite.post.js | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/server/api/admin/pre-registrants/invite.post.js b/server/api/admin/pre-registrants/invite.post.js index f3d46b3..9256499 100644 --- a/server/api/admin/pre-registrants/invite.post.js +++ b/server/api/admin/pre-registrants/invite.post.js @@ -63,17 +63,23 @@ export default defineEventHandler(async (event) => { .replace(/\n/g, '
') .replace(/\{acceptLink\}/g, acceptButton) - const { error: emailError } = await resend.emails.send({ - from: 'Ghost Guild ', - to: [preReg.email], - subject: "You're invited to Ghost Guild! 👻", - text: emailText, - html: emailHtml, - }) + const subject = "You're invited to Ghost Guild! 👻" - if (emailError) { - results.push({ preRegistrantId: preReg._id, email: preReg.email, success: false, error: emailError.message }) - continue + if (process.env.ALLOW_DEV_TEST_ENDPOINTS === 'true') { + console.log('[resend] DEV MODE — skipping invite send', { to: preReg.email, subject }) + } else { + const { error: emailError } = await resend.emails.send({ + from: 'Ghost Guild ', + to: [preReg.email], + subject, + text: emailText, + html: emailHtml, + }) + + if (emailError) { + results.push({ preRegistrantId: preReg._id, email: preReg.email, success: false, error: emailError.message }) + continue + } } await PreRegistration.findByIdAndUpdate(preReg._id, { From 6a6f036877484b0d36dc7b1841ecacf3fe20e3b0 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 30 Apr 2026 22:25:49 +0100 Subject: [PATCH 12/63] refactor(admin/members): dedupe STATUS_LABELS + reactive row update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote inline STATUS_LABELS copies (admin/members/index.vue, member/account.vue) into app/config/memberStatus.js, matching the app/config/circles.js pattern. Drive admin/members/[id].vue status select from the same constant — completes the alignment started in 441a5f5. Use the softer member-facing copy as canonical: "Paused" / "Closed" instead of "Suspended" / "Cancelled". Also fix markSlackInvited's non-reactive Object.assign on a plain object inside a useFetch array — replace with index-find + element reassignment so the row UI refreshes without a manual reload. --- app/config/memberStatus.js | 8 ++++++++ app/pages/admin/members/[id].vue | 10 ++++++---- app/pages/admin/members/index.vue | 13 ++++--------- app/pages/member/account.vue | 8 +------- 4 files changed, 19 insertions(+), 20 deletions(-) create mode 100644 app/config/memberStatus.js diff --git a/app/config/memberStatus.js b/app/config/memberStatus.js new file mode 100644 index 0000000..04850e8 --- /dev/null +++ b/app/config/memberStatus.js @@ -0,0 +1,8 @@ +export const STATUS_LABELS = { + active: "Active", + pending_payment: "Payment setup incomplete", + suspended: "Paused", + cancelled: "Closed", +}; + +export const statusLabel = (s) => STATUS_LABELS[s] || "Pending"; diff --git a/app/pages/admin/members/[id].vue b/app/pages/admin/members/[id].vue index 567caf9..e082be7 100644 --- a/app/pages/admin/members/[id].vue +++ b/app/pages/admin/members/[id].vue @@ -63,10 +63,11 @@
@@ -242,6 +243,7 @@ diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 89b7fed..f674b4f 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -41,10 +41,11 @@ const currentPageName = computed(() => { const path = route.path; if (path === "/") return ""; const segments = path.slice(1).split("/"); - if (pageBreadcrumbTitle.value && segments.length > 1) { + if (segments.length === 1) return segments[0]; + if (pageBreadcrumbTitle.value) { return [...segments.slice(0, -1), pageBreadcrumbTitle.value].join(" / "); } - return segments.join(" / "); + return segments.slice(0, -1).join(" / "); }); From 050d117abf6077663a8eb337a400d17277c91db5 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 19 May 2026 00:10:27 +0100 Subject: [PATCH 42/63] fix(dashboard): bind Upcoming sidebar to reactive useFetch data ColumnsLayout mounts inside on /member/dashboard, so the SSR fetch returned the empty default and the snapshot assignment to upcomingEvents never re-ran after the client-side fetch resolved. Skip the SSR phase explicitly (server: false) and expose data to the template via a computed so post-fetch updates propagate to EventsMiniSidebar. --- app/components/ColumnsLayout.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/ColumnsLayout.vue b/app/components/ColumnsLayout.vue index 3ea07c4..2b170c0 100644 --- a/app/components/ColumnsLayout.vue +++ b/app/components/ColumnsLayout.vue @@ -29,13 +29,14 @@ const props = defineProps({ limit: { type: Number, default: 3 }, }) -const upcomingEvents = ref([]) +let upcomingEvents = ref([]) if (props.cols === 'events-sidebar') { const { data } = await useFetch('/api/events', { query: { upcoming: true, limit: props.limit }, default: () => [], + server: false, }) - upcomingEvents.value = data.value || [] + upcomingEvents = computed(() => data.value || []) } From 397c00125a45a4ac65a557ddebfa72e60ce770d6 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 19 May 2026 00:16:07 +0100 Subject: [PATCH 43/63] Revert "fix(layouts): drop URL-slug breadcrumb fallback" This reverts commit 94b242100c39e9bfed8a7b0a2a950da53f622129. --- app/layouts/admin.vue | 5 ++--- app/layouts/default.vue | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue index 74f5505..5e0baad 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -227,11 +227,10 @@ const currentPageName = computed(() => { const path = route.path; if (path === "/admin") return "admin"; const segments = path.slice(1).split("/"); - if (segments.length === 1) return segments[0]; - if (pageBreadcrumbTitle.value) { + if (pageBreadcrumbTitle.value && segments.length > 1) { return [...segments.slice(0, -1), pageBreadcrumbTitle.value].join(" / "); } - return segments.slice(0, -1).join(" / "); + return segments.join(" / "); }); diff --git a/app/layouts/default.vue b/app/layouts/default.vue index f674b4f..89b7fed 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -41,11 +41,10 @@ const currentPageName = computed(() => { const path = route.path; if (path === "/") return ""; const segments = path.slice(1).split("/"); - if (segments.length === 1) return segments[0]; - if (pageBreadcrumbTitle.value) { + if (pageBreadcrumbTitle.value && segments.length > 1) { return [...segments.slice(0, -1), pageBreadcrumbTitle.value].join(" / "); } - return segments.slice(0, -1).join(" / "); + return segments.join(" / "); }); From 2a66b0eb8abfe75f38c9956f417cafaa084247b8 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 19 May 2026 10:33:26 +0100 Subject: [PATCH 44/63] fix(topstrip): wrap breadcrumb-current in ClientOnly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The breadcrumb's trailing span pulls its label from pageBreadcrumbTitle, which pages set in script setup after a useFetch. SSR rendered the URL slug there; client rendered the title; Vue logged a hydration text mismatch on every detail page. Wrap the last segment's span in ClientOnly with an nbsp fallback so the SSR DOM stays the same shape but defers the text to the client. The prior attempt at this in layouts/default.vue + layouts/admin.vue was reverted on this branch — it changed segment counts and produced a worse node-structure mismatch. --- app/components/TopStrip.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/components/TopStrip.vue b/app/components/TopStrip.vue index bbf5f28..37a8aec 100644 --- a/app/components/TopStrip.vue +++ b/app/components/TopStrip.vue @@ -12,7 +12,12 @@ class="breadcrumb-link" >{{ crumb.label }} - {{ crumb.label }} + + {{ crumb.label }} + + From e1d224e260a65a60a30acf020b92b66576803bb1 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 19 May 2026 10:34:48 +0100 Subject: [PATCH 45/63] feat(admin-events): expose membersOnly toggle in the form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Event model and Zod schemas already supported membersOnly, but the admin form never exposed it — public/private was implicit and not editable from the UI. Add a fifth checkbox alongside the other Event Settings, hydrate it on edit, reset it in saveAndCreateAnother. --- app/pages/admin/events/create.vue | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/pages/admin/events/create.vue b/app/pages/admin/events/create.vue index 77e4b70..ab373b3 100644 --- a/app/pages/admin/events/create.vue +++ b/app/pages/admin/events/create.vue @@ -536,6 +536,16 @@ Mark this event as cancelled
+ + @@ -622,6 +632,7 @@ const eventForm = reactive({ isOnline: true, isVisible: true, isCancelled: false, + membersOnly: false, cancellationMessage: "", targetCircles: [], tags: [], @@ -724,6 +735,7 @@ function populateEditForm(payload) { isOnline: event.isOnline, isVisible: event.isVisible !== undefined ? event.isVisible : true, isCancelled: event.isCancelled || false, + membersOnly: event.membersOnly || false, cancellationMessage: event.cancellationMessage || "", targetCircles: event.targetCircles || [], tags: event.tags || [], @@ -950,6 +962,7 @@ const saveAndCreateAnother = async () => { isOnline: true, isVisible: true, isCancelled: false, + membersOnly: false, cancellationMessage: "", targetCircles: [], tags: [], From 6fa3e08fe056860ce4e437435e834ac9dfe32af5 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 19 May 2026 10:35:49 +0100 Subject: [PATCH 46/63] feat(events): accept 'TBD' as a valid location Events are often scheduled before the platform (Zoom link, Slack channel) is chosen. The current workaround is a placeholder URL like "https://us02web.zoom.us/j/TBD", which leaks to the public page as a broken link. Accept the literal "TBD" (case-insensitive) in both the Mongoose validator and the form-side validator. The public detail page renders "Platform TBD" instead of a link when the location matches. --- app/pages/admin/events/create.vue | 18 +++++++++--------- app/pages/events/[slug].vue | 5 ++++- server/models/event.js | 5 +++-- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/app/pages/admin/events/create.vue b/app/pages/admin/events/create.vue index ab373b3..a1e6b75 100644 --- a/app/pages/admin/events/create.vue +++ b/app/pages/admin/events/create.vue @@ -114,7 +114,7 @@

- Enter a video conference link or Slack channel (starting with #) + Video conference link, Slack channel (#channel-name), or 'TBD' if + the platform is undecided

@@ -840,7 +841,7 @@ const validateForm = () => { if (!eventForm.location.trim()) { formErrors.value.push("Location is required"); fieldErrors.value.location = - "Please enter a location (URL or Slack channel)"; + "Please enter a URL, Slack channel, or 'TBD'"; } // Date validation @@ -861,18 +862,17 @@ const validateForm = () => { // Location format validation if (eventForm.location.trim()) { + const value = eventForm.location.trim(); const urlPattern = /^https?:\/\/.+/; const slackPattern = /^#[a-zA-Z0-9-_]+$/; + const isTbd = value.toUpperCase() === "TBD"; - if ( - !urlPattern.test(eventForm.location) && - !slackPattern.test(eventForm.location) - ) { + if (!isTbd && !urlPattern.test(value) && !slackPattern.test(value)) { formErrors.value.push( - "Location must be a valid URL or Slack channel (starting with #)", + "Location must be a valid URL, Slack channel (starting with #), or 'TBD'", ); fieldErrors.value.location = - "Enter a video conference link (https://...) or Slack channel (#channel-name)"; + "Enter a URL (https://...), Slack channel (#channel-name), or 'TBD' if undecided"; } } diff --git a/app/pages/events/[slug].vue b/app/pages/events/[slug].vue index 7f72a67..8dc05d2 100644 --- a/app/pages/events/[slug].vue +++ b/app/pages/events/[slug].vue @@ -22,7 +22,10 @@
Location - {{ event.location }} + + Platform TBD + +
diff --git a/server/models/event.js b/server/models/event.js index ff2e2b0..763b00b 100644 --- a/server/models/event.js +++ b/server/models/event.js @@ -25,13 +25,14 @@ const eventSchema = new mongoose.Schema({ // This will typically be a Slack channel or video conference link validate: { validator: function (v) { - // Must be either a valid URL or a Slack channel reference + // Accept a URL, a Slack channel, or the literal "TBD" (platform undecided). + if (typeof v === "string" && v.trim().toUpperCase() === "TBD") return true; const urlPattern = /^https?:\/\/.+/; const slackPattern = /^#[a-zA-Z0-9-_]+$/; return urlPattern.test(v) || slackPattern.test(v); }, message: - "Location must be a valid URL (video conference link) or Slack channel (starting with #)", + "Location must be a valid URL, Slack channel (starting with #), or 'TBD'", }, }, isOnline: { type: Boolean, default: true }, // Default to online-first From f5b7a3eeba77d957368e1f379475bab52f7895dc Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 19 May 2026 10:37:08 +0100 Subject: [PATCH 47/63] feat(natural-date-input): hide raw input once date is parsed The natural-language input box kept its placeholder visible after a date was parsed, with the green confirmation pill rendering below. Several admins read this as "the input is empty." Hide the input once parsedDate is set; show only the green pill with an Edit link that clears the parse and re-opens the input. --- app/components/NaturalDateInput.vue | 50 ++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/app/components/NaturalDateInput.vue b/app/components/NaturalDateInput.vue index 4e97e05..a1f814c 100644 --- a/app/components/NaturalDateInput.vue +++ b/app/components/NaturalDateInput.vue @@ -1,6 +1,6 @@