Update project config and documentation, add admin invite script,
implement membersOnly event visibility
This commit is contained in:
parent
96470a604a
commit
9e18560ebf
9 changed files with 387 additions and 50 deletions
0
.husky/pre-push
Normal file → Executable file
0
.husky/pre-push
Normal file → Executable 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: []
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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 June–Oct 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)
|
||||
|
|
|
|||
72
scripts/create-admin-and-invite.cjs
Normal file
72
scripts/create-admin-and-invite.cjs
Normal 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)
|
||||
})
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
172
tests/server/api/events/members-only-visibility.test.js
Normal file
172
tests/server/api/events/members-only-visibility.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue