Update project config and documentation, add admin invite script,

implement membersOnly event visibility
This commit is contained in:
Jennie Robinson Faber 2026-05-19 13:26:05 +01:00
parent 96470a604a
commit 9e18560ebf
9 changed files with 387 additions and 50 deletions

0
.husky/pre-push Normal file → Executable file
View file

View file

@ -3,24 +3,26 @@ project_name: "ghostguild-org"
# list of languages for which language servers are started; choose from:
# 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
# al angular ansible bash clojure
# cpp cpp_ccls crystal csharp csharp_omnisharp
# dart elixir elm erlang fortran
# fsharp go groovy haskell haxe
# hlsl html 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 scss 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.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
@ -125,3 +127,14 @@ ignored_memory_patterns: []
# 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:
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []

View file

@ -1,5 +1,9 @@
`
<template>
<PageShell title="Community Guidelines" subtitle="What you're agreeing to when you join Ghost Guild">
<PageShell
title="Community Guidelines"
subtitle="What you're agreeing to when you join Ghost Guild"
>
<div class="guidelines-prose">
<section class="guidelines-section">
<h2>Welcome</h2>
@ -24,12 +28,12 @@
contribute financially.
</p>
<p>
When you join Ghost Guild, you become a Class B member of Baby
Ghosts, our parent charity. Class A membership is held by a small
group involved in governance, mainly our directors. Class A and
Class B have equal access to resources, community, events, and the
Solidarity Fund. Voting at the Annual General Meeting is limited
to Class A members, as set out in our
When you join Ghost Guild, you become a Class B member of Baby Ghosts,
our parent charity. Class A membership is held by a small group
involved in governance, mainly our directors. Class A and Class B have
equal access to resources, community, events, and the Solidarity Fund.
Voting at the Annual General Meeting is limited to Class A members, as
set out in our
<NuxtLink to="/policies/by-laws">by-laws</NuxtLink>.
</p>
@ -82,7 +86,9 @@
Equal access to resources, events, community spaces, and the
Solidarity Fund, regardless of circle or contribution level
</li>
<li>Support from the Solidarity Fund if you face financial barriers</li>
<li>
Support from the Solidarity Fund if you face financial barriers
</li>
<li>The ability to move between circles as your journey evolves</li>
<li>
Privacy protection in line with our
@ -105,8 +111,8 @@
at all times
</li>
<li>
Participating within your capacity. This is a community of
practice. Show up in whatever way works for you.
Participating within your capacity. This is a community of practice.
Show up in whatever way works for you.
</li>
<li>
Contributing dues in line with your ability, or working with the
@ -114,7 +120,9 @@
</li>
<li>
Approaching disagreements with openness and using our
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>
<NuxtLink to="/policies/conflict-resolution"
>Conflict Resolution Policy</NuxtLink
>
when conflicts arise
</li>
</ol>
@ -126,14 +134,13 @@
</p>
<ul>
<li>
Don't share screenshots, message content, or other community
content externally without the explicit consent of everyone
involved
Don't share screenshots, message content, or other community content
externally without the explicit consent of everyone involved
</li>
<li>
Don't contribute community conversations, messages, or member
content to generative AI tools like ChatGPT or Claude. This
protects everyone's privacy and contributions.
content to generative AI tools like ChatGPT or Claude. This protects
everyone's privacy and contributions.
</li>
<li>
Violations of these privacy norms can result in removal from the
@ -149,7 +156,10 @@
<a href="https://wiki.ghostguild.org">wiki.ghostguild.org</a> is a
knowledge commons. Anything you contribute to it is automatically and
irrevocably licensed under the
<a href="https://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International License</a>
<a href="https://creativecommons.org/licenses/by-sa/4.0/"
>Creative Commons Attribution-ShareAlike 4.0 International
License</a
>
(CC-BY-SA 4.0) at the moment you post it.
</p>
<p>In plain terms:</p>
@ -162,13 +172,13 @@
credit you and release their derivatives under the same license
</li>
<li>
You can't withdraw your contribution from the commons later, even
if you leave Ghost Guild
You can't withdraw your contribution from the commons later, even if
you leave Ghost Guild
</li>
<li>
If wiki material gets republished elsewhere (like on
<a href="https://coop.love">coop.love</a>), it stays under
CC-BY-SA 4.0 and you stay credited
<a href="https://coop.love">coop.love</a>), it stays under CC-BY-SA
4.0 and you stay credited
</li>
</ul>
<p>
@ -188,8 +198,8 @@
<section class="guidelines-section">
<h2>Our Privacy Commitments</h2>
<p>
Your personal information is used to administer your membership and
to communicate with you about Ghost Guild.
Your personal information is used to administer your membership and to
communicate with you about Ghost Guild.
</p>
<p>
We use a small number of third-party services to run the platform
@ -220,8 +230,9 @@
You can end your membership at any time by contacting the Membership
Committee. In rare cases, membership may be ended for serious
violations of these guidelines, following the process in our
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>.
Dues are not refunded.
<NuxtLink to="/policies/conflict-resolution"
>Conflict Resolution Policy</NuxtLink
>. Dues are not refunded.
</p>
<p>
If you leave, your wiki contributions remain in the commons under
@ -235,8 +246,14 @@
<h2>Related Policies</h2>
<p>These policies are part of what you agree to by joining:</p>
<ul>
<li><NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink></li>
<li><NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink></li>
<li>
<NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink>
</li>
<li>
<NuxtLink to="/policies/conflict-resolution"
>Conflict Resolution Policy</NuxtLink
>
</li>
<li><NuxtLink to="/policies/privacy">Privacy Policy</NuxtLink></li>
<li><NuxtLink to="/policies/terms">Terms of Service</NuxtLink></li>
</ul>
@ -257,8 +274,8 @@
<script setup>
useHead({
title: 'Community Guidelines · Ghost Guild',
})
title: "Community Guidelines · Ghost Guild",
});
</script>
<style scoped>
@ -375,3 +392,4 @@ useHead({
}
}
</style>
`

View file

@ -1,8 +1,10 @@
# 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._
_Last consolidated: 2026-05-18. 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).
Cutover has not happened yet. Deploy steps + Activation + Open decisions live separately in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md). This file is the everything-else.
**Launch shape (2026-05-18):** site live with events ASAP, applications open immediately, Slack invites delivered in waves. Entire waitlist invited to apply at launch. See `LAUNCH_READINESS.md` for the full shape, the activation steps, and the open product decisions that gate the launch comms.
---
@ -36,7 +38,7 @@ Once cutover lands, before the first Slack onboarding wave goes out:
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`.
- ~~B1 cancel-subscription leaves status `active`.~~ Verified shipped 2026-05-18: `server/api/members/cancel-subscription.post.js:31,50` writes `status: 'active'`. Test coverage in `tests/server/api/cancel-subscription.test.js` (Fix #9 in LAUNCH_READINESS).
- ~~B3 cancelled.~~ `pending_payment` stays.
- ~~B4 admin "Pending Payment" → "Payment setup incomplete"~~ shipped 2026-04-29 (`59d2be2`).

View file

@ -1,18 +1,30 @@
# Launch Readiness
**Status as of 2026-04-30. Cutover has not happened yet.** Code is on local `main`; deploy steps below still need to execute.
**Status as of 2026-05-18. Cutover has not happened yet.** Code is on local `main`; deploy steps below still need to execute.
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`.
---
## Launch shape (2026-05-18)
The launch decision: **site live with events ASAP, applications open immediately, Slack invitations sent later in waves.**
- Anyone can hit the site, see events, buy a ticket (members and guests both supported on `main`).
- Anyone can join — `/join` (anonymous) and `/accept-invite` (waitlist pre-registrants) both render the same `SignupFlowOverlay` and call the same Helcim signup path. New members become `active` immediately on payment; `slackInvited=false` until an admin marks them in a wave.
- The entire waitlist is invited to apply at launch via the pre-registrant invitation tool. They go through the same flow as anonymous signups, just with email pre-filled and a token-bound pre-reg.
Open decisions that gate the launch comms — see [Open decisions](#open-decisions-before-launch-comms) below.
---
## Current state
- Vitest snapshot 2026-04-25 ~18:23 local: **703 passing / 8 failing / 2 skipped (713 total)**. The previously-flagged 6 helcim-payment failures are now green. The 8 current failures are in `tests/server/api/auth-verify.test.js` and `tests/server/api/cancel-subscription.smoke.test.js`, both belonging to in-flight Phase 5 fixes (#10 and #9) being landed by parallel impl subagents — they will resolve as those branches merge.
- All launch code is on local `main`: Helcim plan consolidation, contribution-amount redesign, cadence UX unification, and receipts Phase 1. Not pushed — site is not on Netlify yet.
- All launch code is on local `main`: Helcim plan consolidation, contribution-amount redesign + migration script, cadence UX unification, receipts Phase 1, and `feature/guest-event-accounts` (merged in `e96d493`). Not pushed — site is not deployed yet.
- Helcim plan consolidation migration **ran against prod 2026-04-18** (Monthly plan id `50302`, Annual plan id `50303`).
- Contribution-amount migration has **NOT** yet been run against prod.
- Receipts Phase 1 code is shipped; remaining work is deploy-time only (see Deploy checklist).
- `cancel-subscription` correctly keeps status `active` per ratified bylaws (Fix #9 in this doc; the stale B1 entry in BACKLOG was marked done 2026-05-18).
---
@ -59,6 +71,36 @@ Applies when the app is deployed to **Dokploy on Hetzner**. Build is via the in-
- [ ] **Rotate HELCIM_API_TOKEN** in the Helcim merchant portal and update the Dokploy env var. The token was previously exposed in `window.__NUXT__` payload until commit `208638e`.
- [ ] **Trigger the daily reconcile task once manually** in Dokploy to confirm scheduled task + token are wired correctly. Expect a `[reconcile] done {...}` log line.
### Activation (after Cutover passes)
The site is deployed but not yet public. These are the steps that flip the switch.
- [ ] **Disable the coming-soon gate.** Set `NUXT_PUBLIC_COMING_SOON=false` (or remove the var) in Dokploy and redeploy. The gate lives in `app/middleware/coming-soon.global.js:4` and is purely env-driven. Verify `/`, `/about`, `/events`, `/board` all render without a redirect when logged out.
- [ ] **Publish first event(s).** Confirm at least one event or series is live and visible publicly. Walk through the guest ticket-purchase flow end-to-end (anonymous → buy ticket → registered → confirmation email).
- [ ] **Pre-flight real-money signup test on prod.** Have one trusted person (ideally outside the immediate build team) go through `/join` from scratch: choose a small contribution, pay, receive welcome email, land on dashboard, see "Slack coming" note. This catches end-to-end issues that no internal test reproduces.
- [ ] **Send waitlist invitation batch** via the pre-registrant admin tool. Decide cadence first (see [Open decisions](#open-decisions-before-launch-comms)). Smoke-test by inviting yourself or one friend first; only fan out once that round-trip is clean.
### Open decisions before launch comms
These do not block deploy but need answers before the waitlist invite goes out. Each carries a small amount of work depending on the answer.
- [ ] **Apply-framing decision.** Today's CTAs say "Join Ghost Guild" / "Become a member"; there is no "Apply" copy in the codebase. Both `/join` and `/accept-invite` use the same `SignupFlowOverlay`, so the mechanical flow is single-source. Pick one:
- **A (no code work).** Keep "Join" everywhere on-site; use "apply" only in external comms (waitlist email, social, etc.).
- **B (small code work).** Rename to "Apply" across CTAs + page copy. Touches `app/pages/index.vue:11`, `app/pages/about.vue:86`, `app/pages/join.vue:5,109,111,301`, `app/components/LoginModal.vue:66`, and at least the waitlist invite + welcome email copy. Likely ~30 min of search-and-replace + screenshot review.
- [ ] **First Slack wave date.** A publicly-stated date or cadence rule (e.g. "end of each month"). Used in three places: waitlist invite email, welcome email, dashboard "Slack coming" note. Without this, every new member emails support asking when Slack is coming.
- [ ] **Non-member event CTA — ticket-first or membership-first?** Event pages render to anonymous visitors with both paths viable. Pick which one is primary: "Buy ticket" lowers friction, "Apply for membership" protects the funnel. Write the CTA copy once and use consistently across events.
- [ ] **Receipts for guest ticket purchases.** Phase 1 receipts cover membership payments only. Guest ticket buyers will get no CRA-compliant receipt at launch. Options: (a) ship a basic transactional receipt for tickets pre-launch, (b) accept the gap until Phase 2 (build JuneOct 2026, live Jan 2027).
- [ ] **Waitlist invite cadence.** Single blast vs staggered (e.g., 50/day over 4 days). Trade-off is Day-1 support load — a stagger gives you time to catch real issues from early batches before the rest of the list hits.
### Pre-launch code cleanup (recommended, not blocking)
Items from [`BACKLOG.md`](./BACKLOG.md) that materially affect the launch-window experience. None are deploy blockers, but each shows up to real users:
- [ ] **`/api/auth/member` returns `slackInvited`.** Without this, the dashboard "Slack coming" note shows for every active member regardless of state. Highest-priority of the wave-Slack bugs because every new member sees the broken case.
- [ ] **Admin members-list row reactivity** on "Mark as Slack invited" — admin has to manually reload after clicking. Hits operators, not members, but operators are us.
- [ ] **`/board` color-contrast fix** (`.block-label`, `.slack-handle``#746a58` on `#e8dfc8` → 4.01:1, needs ≥4.5:1). Single CSS-var change, currently the only red item in `e2e/a11y.spec.js`.
- [ ] **Spec vs UI mismatch on wave language.** `docs/specs/wave-based-slack-onboarding-tests.md` §7.5 says "no wave/cohort/batch language" but shipped copy uses "monthly onboarding waves." Pick a side and align before launch comms go out.
**Env vars required in Dokploy (reference):**
- `NODE_ENV=production`
- `BASE_URL` (exact public origin, no trailing slash)

View file

@ -0,0 +1,72 @@
require('dotenv').config()
const mongoose = require('mongoose')
const jwt = require('jsonwebtoken')
const { randomUUID } = require('crypto')
const EMAIL = process.argv[2]
const NAME = process.argv[3]
if (!EMAIL || !NAME) {
console.error('Usage: node scripts/create-admin-and-invite.cjs <email> <name>')
process.exit(1)
}
const secret = process.env.NUXT_JWT_SECRET || process.env.JWT_SECRET
if (!secret) {
console.error('Missing NUXT_JWT_SECRET / JWT_SECRET in .env')
process.exit(1)
}
const baseUrl = (process.env.BASE_URL || '').replace(/\/$/, '')
if (!baseUrl) {
console.error('Missing BASE_URL in .env (e.g. https://ghostguild.org)')
process.exit(1)
}
if (!process.env.MONGODB_URI) {
console.error('Missing MONGODB_URI in .env')
process.exit(1)
}
;(async () => {
await mongoose.connect(process.env.MONGODB_URI)
const members = mongoose.connection.db.collection('members')
const email = EMAIL.toLowerCase()
const jti = randomUUID()
const now = new Date()
const res = await members.findOneAndUpdate(
{ email },
{
$setOnInsert: {
email,
name: NAME,
circle: 'founder',
contributionAmount: 0,
role: 'admin',
status: 'active',
createdAt: now,
},
$set: {
magicLinkJti: jti,
magicLinkJtiUsed: false,
},
},
{ upsert: true, returnDocument: 'after' },
)
const member = res.value || (await members.findOne({ email }))
const token = jwt.sign({ memberId: member._id, jti }, secret, { expiresIn: '15m' })
const link = `${baseUrl}/verify#${token}`
console.log('\nAdmin:', member.email, '(role:', member.role + ', status:', member.status + ')')
console.log('\nMagic link (expires in 15 min):\n')
console.log(link, '\n')
await mongoose.disconnect()
})().catch((err) => {
console.error(err)
process.exit(1)
})

View file

@ -1,4 +1,6 @@
import { loadPublicEvent } from '../../utils/loadEvent.js'
import { getOptionalMember } from '../../utils/auth.js'
import { hasMemberAccess } from '../../utils/tickets.js'
export default defineEventHandler(async (event) => {
try {
@ -8,6 +10,17 @@ export default defineEventHandler(async (event) => {
select: '-registrations.email'
})
// Members-only events are hidden from non-members (parallel to isVisible).
// Registration/ticket endpoints still surface a "members only" error so an
// authenticated guest sees actionable copy when posting; here we just 404.
if (eventData.membersOnly) {
const requester = await getOptionalMember(event)
const canSee = requester?.role === 'admin' || hasMemberAccess(requester)
if (!canSee) {
throw createError({ statusCode: 404, statusMessage: 'Event not found' })
}
}
return {
...eventData,
id: eventData._id.toString(),

View file

@ -1,5 +1,7 @@
import Event from "../../models/event.js";
import { connectDB } from "../../utils/mongoose.js";
import { getOptionalMember } from "../../utils/auth.js";
import { hasMemberAccess } from "../../utils/tickets.js";
export default defineEventHandler(async (event) => {
try {
@ -24,9 +26,12 @@ export default defineEventHandler(async (event) => {
filter.eventType = query.eventType;
}
// Filter for members-only events
if (query.membersOnly !== undefined) {
filter.membersOnly = query.membersOnly === "true";
// Hide members-only events from non-members. Admins and members see them.
const requester = await getOptionalMember(event);
const canSeeMembersOnly =
requester?.role === "admin" || hasMemberAccess(requester);
if (!canSeeMembersOnly) {
filter.membersOnly = { $ne: true };
}
// Fetch events from database

View file

@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockEvent } from '../../helpers/createMockEvent.js'
const {
mockFind,
mockSort,
mockSelect,
mockLean,
mockGetOptionalMember,
mockLoadPublicEvent
} = vi.hoisted(() => ({
mockFind: vi.fn(),
mockSort: vi.fn(),
mockSelect: vi.fn(),
mockLean: vi.fn(),
mockGetOptionalMember: vi.fn(),
mockLoadPublicEvent: vi.fn()
}))
vi.mock('../../../../server/models/event.js', () => ({
default: { find: mockFind }
}))
vi.mock('../../../../server/utils/mongoose.js', () => ({
connectDB: vi.fn()
}))
vi.mock('../../../../server/utils/auth.js', () => ({
getOptionalMember: mockGetOptionalMember
}))
vi.mock('../../../../server/utils/loadEvent.js', () => ({
loadPublicEvent: mockLoadPublicEvent
}))
function setupFindChain(result = []) {
mockLean.mockResolvedValue(result)
mockSelect.mockReturnValue({ lean: mockLean })
mockSort.mockReturnValue({ select: mockSelect })
mockFind.mockReturnValue({ sort: mockSort })
}
function mkReq({ method = 'GET', path = '/', id } = {}) {
const req = createMockEvent({ method, path })
if (id) req.context = { params: { id } }
return req
}
describe('GET /api/events — membersOnly visibility', () => {
let listHandler
beforeEach(async () => {
vi.clearAllMocks()
vi.resetModules()
setupFindChain([])
listHandler = (await import('../../../../server/api/events/index.get.js')).default
})
it('hides membersOnly events from anonymous callers', async () => {
mockGetOptionalMember.mockResolvedValue(null)
await listHandler(mkReq({ path: '/api/events' }))
const filter = mockFind.mock.calls[0][0]
expect(filter.membersOnly).toEqual({ $ne: true })
})
it('hides membersOnly events from guest/cancelled/suspended members', async () => {
mockGetOptionalMember.mockResolvedValue({ _id: 'm1', role: 'member', status: 'guest' })
await listHandler(mkReq({ path: '/api/events' }))
const filter = mockFind.mock.calls[0][0]
expect(filter.membersOnly).toEqual({ $ne: true })
})
it('shows membersOnly events to active members', async () => {
mockGetOptionalMember.mockResolvedValue({ _id: 'm1', role: 'member', status: 'active' })
await listHandler(mkReq({ path: '/api/events' }))
const filter = mockFind.mock.calls[0][0]
expect(filter.membersOnly).toBeUndefined()
})
it('shows membersOnly events to pending_payment members', async () => {
mockGetOptionalMember.mockResolvedValue({ _id: 'm1', role: 'member', status: 'pending_payment' })
await listHandler(mkReq({ path: '/api/events' }))
const filter = mockFind.mock.calls[0][0]
expect(filter.membersOnly).toBeUndefined()
})
it('shows membersOnly events to admins regardless of status', async () => {
mockGetOptionalMember.mockResolvedValue({ _id: 'm1', role: 'admin', status: 'cancelled' })
await listHandler(mkReq({ path: '/api/events' }))
const filter = mockFind.mock.calls[0][0]
expect(filter.membersOnly).toBeUndefined()
})
})
describe('GET /api/events/[id] — membersOnly visibility', () => {
let detailHandler
beforeEach(async () => {
vi.clearAllMocks()
vi.resetModules()
detailHandler = (await import('../../../../server/api/events/[id].get.js')).default
})
const baseEvent = (overrides = {}) => ({
_id: 'e1',
slug: 'members-only-event',
title: 'Members Only',
isVisible: true,
membersOnly: true,
registrations: [],
...overrides
})
it('404s a membersOnly event for anonymous callers', async () => {
mockLoadPublicEvent.mockResolvedValue(baseEvent())
mockGetOptionalMember.mockResolvedValue(null)
await expect(
detailHandler(mkReq({ path: '/api/events/members-only-event', id: 'members-only-event' }))
).rejects.toMatchObject({ statusCode: 404 })
})
it('404s a membersOnly event for guest members', async () => {
mockLoadPublicEvent.mockResolvedValue(baseEvent())
mockGetOptionalMember.mockResolvedValue({ _id: 'm1', role: 'member', status: 'guest' })
await expect(
detailHandler(mkReq({ path: '/api/events/members-only-event', id: 'members-only-event' }))
).rejects.toMatchObject({ statusCode: 404 })
})
it('returns the event to active members', async () => {
mockLoadPublicEvent.mockResolvedValue(baseEvent())
mockGetOptionalMember.mockResolvedValue({ _id: 'm1', role: 'member', status: 'active' })
const result = await detailHandler(
mkReq({ path: '/api/events/members-only-event', id: 'members-only-event' })
)
expect(result.slug).toBe('members-only-event')
})
it('returns the event to admins', async () => {
mockLoadPublicEvent.mockResolvedValue(baseEvent())
mockGetOptionalMember.mockResolvedValue({ _id: 'm1', role: 'admin', status: 'active' })
const result = await detailHandler(
mkReq({ path: '/api/events/members-only-event', id: 'members-only-event' })
)
expect(result.slug).toBe('members-only-event')
})
it('does not gate non-membersOnly events (auth check skipped)', async () => {
mockLoadPublicEvent.mockResolvedValue(baseEvent({ membersOnly: false }))
const result = await detailHandler(
mkReq({ path: '/api/events/members-only-event', id: 'members-only-event' })
)
expect(result.slug).toBe('members-only-event')
expect(mockGetOptionalMember).not.toHaveBeenCalled()
})
})