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:
|
# list of languages for which language servers are started; choose from:
|
||||||
# al ansible bash clojure cpp
|
# al angular ansible bash clojure
|
||||||
# cpp_ccls crystal csharp csharp_omnisharp dart
|
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||||
# elixir elm erlang fortran fsharp
|
# dart elixir elm erlang fortran
|
||||||
# go groovy haskell haxe hlsl
|
# fsharp go groovy haskell haxe
|
||||||
# java json julia kotlin lean4
|
# hlsl html java json julia
|
||||||
# lua luau markdown matlab msl
|
# kotlin lean4 lua luau markdown
|
||||||
# nix ocaml pascal perl php
|
# matlab msl nix ocaml pascal
|
||||||
# php_phpactor powershell python python_jedi python_ty
|
# perl php php_phpactor powershell python
|
||||||
# r rego ruby ruby_solargraph rust
|
# python_jedi python_ty r rego ruby
|
||||||
# scala solidity swift systemverilog terraform
|
# ruby_solargraph rust scala scss solidity
|
||||||
# toml typescript typescript_vts vue yaml
|
# swift systemverilog terraform toml typescript
|
||||||
# zig
|
# typescript_vts vue yaml zig
|
||||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
# (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
|
# 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.)
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
# Note:
|
# Note:
|
||||||
# - For C, use cpp
|
# - For C, use cpp
|
||||||
# - For JavaScript, use typescript
|
# - 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
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
# Special requirements:
|
# Special requirements:
|
||||||
# Some languages require additional setup/installations.
|
# 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.
|
# 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
|
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||||
added_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>
|
<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">
|
<div class="guidelines-prose">
|
||||||
<section class="guidelines-section">
|
<section class="guidelines-section">
|
||||||
<h2>Welcome</h2>
|
<h2>Welcome</h2>
|
||||||
|
|
@ -24,12 +28,12 @@
|
||||||
contribute financially.
|
contribute financially.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
When you join Ghost Guild, you become a Class B member of Baby
|
When you join Ghost Guild, you become a Class B member of Baby Ghosts,
|
||||||
Ghosts, our parent charity. Class A membership is held by a small
|
our parent charity. Class A membership is held by a small group
|
||||||
group involved in governance, mainly our directors. Class A and
|
involved in governance, mainly our directors. Class A and Class B have
|
||||||
Class B have equal access to resources, community, events, and the
|
equal access to resources, community, events, and the Solidarity Fund.
|
||||||
Solidarity Fund. Voting at the Annual General Meeting is limited
|
Voting at the Annual General Meeting is limited to Class A members, as
|
||||||
to Class A members, as set out in our
|
set out in our
|
||||||
<NuxtLink to="/policies/by-laws">by-laws</NuxtLink>.
|
<NuxtLink to="/policies/by-laws">by-laws</NuxtLink>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -82,7 +86,9 @@
|
||||||
Equal access to resources, events, community spaces, and the
|
Equal access to resources, events, community spaces, and the
|
||||||
Solidarity Fund, regardless of circle or contribution level
|
Solidarity Fund, regardless of circle or contribution level
|
||||||
</li>
|
</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>The ability to move between circles as your journey evolves</li>
|
||||||
<li>
|
<li>
|
||||||
Privacy protection in line with our
|
Privacy protection in line with our
|
||||||
|
|
@ -105,8 +111,8 @@
|
||||||
at all times
|
at all times
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Participating within your capacity. This is a community of
|
Participating within your capacity. This is a community of practice.
|
||||||
practice. Show up in whatever way works for you.
|
Show up in whatever way works for you.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Contributing dues in line with your ability, or working with the
|
Contributing dues in line with your ability, or working with the
|
||||||
|
|
@ -114,7 +120,9 @@
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Approaching disagreements with openness and using our
|
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
|
when conflicts arise
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
@ -126,14 +134,13 @@
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
Don't share screenshots, message content, or other community
|
Don't share screenshots, message content, or other community content
|
||||||
content externally without the explicit consent of everyone
|
externally without the explicit consent of everyone involved
|
||||||
involved
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Don't contribute community conversations, messages, or member
|
Don't contribute community conversations, messages, or member
|
||||||
content to generative AI tools like ChatGPT or Claude. This
|
content to generative AI tools like ChatGPT or Claude. This protects
|
||||||
protects everyone's privacy and contributions.
|
everyone's privacy and contributions.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Violations of these privacy norms can result in removal from the
|
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
|
<a href="https://wiki.ghostguild.org">wiki.ghostguild.org</a> is a
|
||||||
knowledge commons. Anything you contribute to it is automatically and
|
knowledge commons. Anything you contribute to it is automatically and
|
||||||
irrevocably licensed under the
|
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.
|
(CC-BY-SA 4.0) at the moment you post it.
|
||||||
</p>
|
</p>
|
||||||
<p>In plain terms:</p>
|
<p>In plain terms:</p>
|
||||||
|
|
@ -162,13 +172,13 @@
|
||||||
credit you and release their derivatives under the same license
|
credit you and release their derivatives under the same license
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
You can't withdraw your contribution from the commons later, even
|
You can't withdraw your contribution from the commons later, even if
|
||||||
if you leave Ghost Guild
|
you leave Ghost Guild
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
If wiki material gets republished elsewhere (like on
|
If wiki material gets republished elsewhere (like on
|
||||||
<a href="https://coop.love">coop.love</a>), it stays under
|
<a href="https://coop.love">coop.love</a>), it stays under CC-BY-SA
|
||||||
CC-BY-SA 4.0 and you stay credited
|
4.0 and you stay credited
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -188,8 +198,8 @@
|
||||||
<section class="guidelines-section">
|
<section class="guidelines-section">
|
||||||
<h2>Our Privacy Commitments</h2>
|
<h2>Our Privacy Commitments</h2>
|
||||||
<p>
|
<p>
|
||||||
Your personal information is used to administer your membership and
|
Your personal information is used to administer your membership and to
|
||||||
to communicate with you about Ghost Guild.
|
communicate with you about Ghost Guild.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We use a small number of third-party services to run the platform
|
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
|
You can end your membership at any time by contacting the Membership
|
||||||
Committee. In rare cases, membership may be ended for serious
|
Committee. In rare cases, membership may be ended for serious
|
||||||
violations of these guidelines, following the process in our
|
violations of these guidelines, following the process in our
|
||||||
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>.
|
<NuxtLink to="/policies/conflict-resolution"
|
||||||
Dues are not refunded.
|
>Conflict Resolution Policy</NuxtLink
|
||||||
|
>. Dues are not refunded.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
If you leave, your wiki contributions remain in the commons under
|
If you leave, your wiki contributions remain in the commons under
|
||||||
|
|
@ -235,8 +246,14 @@
|
||||||
<h2>Related Policies</h2>
|
<h2>Related Policies</h2>
|
||||||
<p>These policies are part of what you agree to by joining:</p>
|
<p>These policies are part of what you agree to by joining:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink></li>
|
<li>
|
||||||
<li><NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink></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/privacy">Privacy Policy</NuxtLink></li>
|
||||||
<li><NuxtLink to="/policies/terms">Terms of Service</NuxtLink></li>
|
<li><NuxtLink to="/policies/terms">Terms of Service</NuxtLink></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -257,8 +274,8 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
useHead({
|
useHead({
|
||||||
title: 'Community Guidelines · Ghost Guild',
|
title: "Community Guidelines · Ghost Guild",
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -375,3 +392,4 @@ useHead({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
`
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
# Ghost Guild — Open Backlog
|
# 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.
|
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.
|
- ~~B3 cancelled.~~ `pending_payment` stays.
|
||||||
- ~~B4 admin "Pending Payment" → "Payment setup incomplete"~~ shipped 2026-04-29 (`59d2be2`).
|
- ~~B4 admin "Pending Payment" → "Payment setup incomplete"~~ shipped 2026-04-29 (`59d2be2`).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,30 @@
|
||||||
# Launch Readiness
|
# 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`.
|
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
|
## 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 + migration script, cadence UX unification, receipts Phase 1, and `feature/guest-event-accounts` (merged in `e96d493`). Not pushed — site is not deployed yet.
|
||||||
- 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.
|
|
||||||
- Helcim plan consolidation migration **ran against prod 2026-04-18** (Monthly plan id `50302`, Annual plan id `50303`).
|
- 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.
|
- 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).
|
- 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`.
|
- [ ] **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.
|
- [ ] **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):**
|
**Env vars required in Dokploy (reference):**
|
||||||
- `NODE_ENV=production`
|
- `NODE_ENV=production`
|
||||||
- `BASE_URL` (exact public origin, no trailing slash)
|
- `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 { loadPublicEvent } from '../../utils/loadEvent.js'
|
||||||
|
import { getOptionalMember } from '../../utils/auth.js'
|
||||||
|
import { hasMemberAccess } from '../../utils/tickets.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -8,6 +10,17 @@ export default defineEventHandler(async (event) => {
|
||||||
select: '-registrations.email'
|
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 {
|
return {
|
||||||
...eventData,
|
...eventData,
|
||||||
id: eventData._id.toString(),
|
id: eventData._id.toString(),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import Event from "../../models/event.js";
|
import Event from "../../models/event.js";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
|
import { getOptionalMember } from "../../utils/auth.js";
|
||||||
|
import { hasMemberAccess } from "../../utils/tickets.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -24,9 +26,12 @@ export default defineEventHandler(async (event) => {
|
||||||
filter.eventType = query.eventType;
|
filter.eventType = query.eventType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter for members-only events
|
// Hide members-only events from non-members. Admins and members see them.
|
||||||
if (query.membersOnly !== undefined) {
|
const requester = await getOptionalMember(event);
|
||||||
filter.membersOnly = query.membersOnly === "true";
|
const canSeeMembersOnly =
|
||||||
|
requester?.role === "admin" || hasMemberAccess(requester);
|
||||||
|
if (!canSeeMembersOnly) {
|
||||||
|
filter.membersOnly = { $ne: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch events from database
|
// 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