diff --git a/.serena/.gitignore b/.serena/.gitignore
new file mode 100644
index 0000000..2e510af
--- /dev/null
+++ b/.serena/.gitignore
@@ -0,0 +1,2 @@
+/cache
+/project.local.yml
diff --git a/.serena/project.yml b/.serena/project.yml
new file mode 100644
index 0000000..9d24cb3
--- /dev/null
+++ b/.serena/project.yml
@@ -0,0 +1,152 @@
+# the name by which the project can be referenced within Serena
+project_name: "ghostguild-org"
+
+
+# list of languages for which language servers are started; choose from:
+# al bash clojure cpp csharp
+# csharp_omnisharp dart elixir elm erlang
+# fortran fsharp go groovy haskell
+# java julia kotlin lua markdown
+# matlab nix pascal perl php
+# php_phpactor powershell python python_jedi r
+# rego ruby ruby_solargraph rust scala
+# swift terraform toml typescript typescript_vts
+# vue yaml zig
+# (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 Free Pascal/Lazarus, use pascal
+# Special requirements:
+# Some languages require additional setup/installations.
+# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
+# When using multiple languages, the first language server that supports a given file will be used for that file.
+# The first language is the default language and the respective language server will be used as a fallback.
+# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
+languages:
+- vue
+
+# the encoding used by text files in the project
+# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
+encoding: "utf-8"
+
+# line ending convention to use when writing source files.
+# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
+# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
+line_ending:
+
+# The language backend to use for this project.
+# If not set, the global setting from serena_config.yml is used.
+# Valid values: LSP, JetBrains
+# Note: the backend is fixed at startup. If a project with a different backend
+# is activated post-init, an error will be returned.
+language_backend:
+
+# whether to use project's .gitignore files to ignore files
+ignore_all_files_in_gitignore: true
+
+# advanced configuration option allowing to configure language server-specific options.
+# Maps the language key to the options.
+# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
+# No documentation on options means no options are available.
+ls_specific_settings: {}
+
+# list of additional paths to ignore in this project.
+# Same syntax as gitignore, so you can use * and **.
+# Note: global ignored_paths from serena_config.yml are also applied additively.
+ignored_paths: []
+
+# whether the project is in read-only mode
+# If set to true, all editing tools will be disabled and attempts to use them will result in an error
+# Added on 2025-04-18
+read_only: false
+
+# list of tool names to exclude.
+# This extends the existing exclusions (e.g. from the global configuration)
+#
+# Below is the complete list of tools for convenience.
+# To make sure you have the latest list of tools, and to view their descriptions,
+# execute `uv run scripts/print_tool_overview.py`.
+#
+# * `activate_project`: Activates a project by name.
+# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
+# * `create_text_file`: Creates/overwrites a file in the project directory.
+# * `delete_lines`: Deletes a range of lines within a file.
+# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
+# * `execute_shell_command`: Executes a shell command.
+# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
+# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
+# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
+# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
+# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
+# * `initial_instructions`: Gets the initial instructions for the current project.
+# Should only be used in settings where the system prompt cannot be set,
+# e.g. in clients you have no control over, like Claude Desktop.
+# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
+# * `insert_at_line`: Inserts content at a given line in a file.
+# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
+# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
+# * `list_memories`: Lists memories in Serena's project-specific memory store.
+# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
+# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
+# * `read_file`: Reads a file within the project directory.
+# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
+# * `remove_project`: Removes a project from the Serena configuration.
+# * `replace_lines`: Replaces a range of lines within a file with new content.
+# * `replace_symbol_body`: Replaces the full definition of a symbol.
+# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
+# * `search_for_pattern`: Performs a search for a pattern in the project.
+# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
+# * `switch_modes`: Activates modes by providing a list of their names
+# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
+# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
+# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
+# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
+excluded_tools: []
+
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
+# This extends the existing inclusions (e.g. from the global configuration).
+included_optional_tools: []
+
+# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
+# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+fixed_tools: []
+
+# list of mode names to that are always to be included in the set of active modes
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this setting overrides the global configuration.
+# Set this to [] to disable base modes for this project.
+# Set this to a list of mode names to always include the respective modes for this project.
+base_modes:
+
+# list of mode names that are to be activated by default.
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# This setting can, in turn, be overridden by CLI parameters (--mode).
+default_modes:
+
+# initial prompt for the project. It will always be given to the LLM upon activating the project
+# (contrary to the memories, which are loaded on demand).
+initial_prompt: ""
+
+# time budget (seconds) per tool call for the retrieval of additional symbol information
+# such as docstrings or parameter information.
+# This overrides the corresponding setting in the global configuration; see the documentation there.
+# If null or missing, use the setting from the global configuration.
+symbol_info_budget:
+
+# list of regex patterns which, when matched, mark a memory entry as read‑only.
+# Extends the list from the global configuration, merging the two lists.
+read_only_memory_patterns: []
+
+# list of regex patterns for memories to completely ignore.
+# Matching memories will not appear in list_memories or activate_project output
+# and cannot be accessed via read_memory or write_memory.
+# To access ignored memory files, use the read_file tool on the raw file path.
+# Extends the list from the global configuration, merging the two lists.
+# Example: ["_archive/.*", "_episodes/.*"]
+ignored_memory_patterns: []
diff --git a/CLAUDE.md b/CLAUDE.md
index ef32320..ff59a89 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -71,7 +71,10 @@ Copy `.env.example` to `.env`. Required: `MONGODB_URI`, `JWT_SECRET`, `RESEND_AP
- Use `USwitch` (not `UToggle`) — this is the correct Nuxt UI 3+ component name
- No fallback/placeholder data — always use real data
- Follow Nuxt 4 file-based routing conventions for route naming
-- Always check Nuxt UI 3 latest documentation on the web when implementing UI components
+- Always check Nuxt UI 4 latest documentation on the web when implementing UI components
+- Auth API responses (`/api/auth/status`, `/api/auth/member`) must include `status` in the returned member object — `useMemberStatus` defaults to `PENDING_PAYMENT` if missing
+- Helcim payment testing requires ngrok: `npx nuxi dev --https` then `ngrok http https://localhost:3000` — Helcim blocks localhost origins
+- The `/api/helcim/initialize-payment` endpoint skips auth for `event_ticket` type payments (public users can buy tickets)
## Product Spec
@@ -90,8 +93,3 @@ The sections below describe planned and in-progress features for reference.
### Resources (Planned)
- Learning paths by circle, templates and tools, case studies
- Tag by circle relevance, download tracking, version control
-
-### Implementation Priority
-**Must have:** Payment processing, Slack automation, member dashboard, resource library, event listing/RSVP
-**Nice to have:** Member profiles, peer matching, Cal.com, member updates
-**Post-launch:** Etherpad integration, member-proposed events, advanced search, analytics dashboard
diff --git a/app/assets/css/main.css b/app/assets/css/main.css
index 06098dc..b4f6ac2 100644
--- a/app/assets/css/main.css
+++ b/app/assets/css/main.css
@@ -28,6 +28,7 @@
--text-dim: #5a5040;
--text-faint: #8a7e6a;
--parch: #2a2015;
+ --parch-hover: #3a3025;
--parch-text: #ede4d0;
--parch-text-dim: #b8ae98;
--c-community: #7a4838;
@@ -52,6 +53,7 @@
--text-dim: #8a7e6a;
--text-faint: #5a5040;
--parch: #ede4d0;
+ --parch-hover: #d4c8a8;
--parch-text: #2a2015;
--parch-text-dim: #5a5040;
--c-community: #a06850;
@@ -177,9 +179,17 @@ a:hover { text-decoration: underline; }
/* ---- SECTION DIVIDERS ---- */
.section-divider {
- border: none;
+ display: block;
+ width: 100%;
+ max-width: none;
+ box-sizing: border-box;
+ border: 0;
border-top: 1px dashed var(--border);
margin: 20px 0 14px;
+ padding: 0;
+ flex: 0 0 auto;
+ align-self: stretch;
+ min-width: 0;
}
/* ---- MOBILE ---- */
diff --git a/app/components/EventTicketCard.vue b/app/components/EventTicketCard.vue
index 5cd2e05..a392620 100644
--- a/app/components/EventTicketCard.vue
+++ b/app/components/EventTicketCard.vue
@@ -45,7 +45,7 @@
Early Bird
@@ -64,7 +64,7 @@
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}
diff --git a/app/components/EventsMiniSidebar.vue b/app/components/EventsMiniSidebar.vue
index cd99c95..84486de 100644
--- a/app/components/EventsMiniSidebar.vue
+++ b/app/components/EventsMiniSidebar.vue
@@ -1,17 +1,29 @@
- Upcoming
-
-
{{ formatDate(event.date) }}
-
{{ event.title }}
-
{{ event.circle }}
+
+
+
+
+ {{ formatDate(event.date) }}
+ {{ event.title }}
+ {{ event.circle }}
+
+
+
+
+
+ All events →
-
No upcoming events
-
All events →
@@ -29,9 +41,17 @@ const formatDate = (dateStr) => {
diff --git a/app/pages/member/dashboard.vue b/app/pages/member/dashboard.vue
index 8f62dc7..f1ed63f 100644
--- a/app/pages/member/dashboard.vue
+++ b/app/pages/member/dashboard.vue
@@ -21,6 +21,7 @@
+
@@ -149,6 +150,7 @@
+
@@ -321,14 +323,22 @@ useHead({
diff --git a/app/pages/updates/[id]/edit.vue b/app/pages/updates/[id]/edit.vue
new file mode 100644
index 0000000..3c1bf7e
--- /dev/null
+++ b/app/pages/updates/[id]/edit.vue
@@ -0,0 +1,157 @@
+
+
+
+ ← Back to My Updates
+
+
+
Loading update...
+
+
+
+
+
+
+
+
Update not found.
+
+
+
+
+
+
diff --git a/app/pages/updates/new.vue b/app/pages/updates/new.vue
new file mode 100644
index 0000000..71daa7c
--- /dev/null
+++ b/app/pages/updates/new.vue
@@ -0,0 +1,139 @@
+
+
+
+ ← Back to My Updates
+
+
+
+
+
+
+
Content
+
+
{{ form.content.length }} / 50000
+
+
+
+ Visibility
+
+ Members only
+ Public
+ Private (only you)
+
+
+
+
+ Cancel
+
+ {{ saving ? 'Posting...' : 'Post Update' }}
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/verify.vue b/app/pages/verify.vue
new file mode 100644
index 0000000..16fb6a3
--- /dev/null
+++ b/app/pages/verify.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
Ghost Guild
+
Verifying your login link…
+
+
+
+
Login Failed
+
{{ errorMessage }}
+
Back to home
+
+
+
+
+
+
+
+
diff --git a/server/api/admin/members/[id].put.js b/server/api/admin/members/[id].put.js
new file mode 100644
index 0000000..fa5aac2
--- /dev/null
+++ b/server/api/admin/members/[id].put.js
@@ -0,0 +1,42 @@
+import Member from '../../../models/member.js'
+import { connectDB } from '../../../utils/mongoose.js'
+
+export default defineEventHandler(async (event) => {
+ await requireAdmin(event)
+
+ const body = await validateBody(event, adminMemberUpdateSchema)
+ const memberId = getRouterParam(event, 'id')
+
+ await connectDB()
+
+ // If email changed, check for duplicates
+ const existing = await Member.findById(memberId)
+ if (!existing) {
+ throw createError({ statusCode: 404, statusMessage: 'Member not found' })
+ }
+
+ if (body.email !== existing.email) {
+ const emailTaken = await Member.findOne({ email: body.email })
+ if (emailTaken) {
+ throw createError({ statusCode: 409, statusMessage: 'Email already in use by another member' })
+ }
+ }
+
+ const updated = await Member.findByIdAndUpdate(memberId, {
+ name: body.name,
+ email: body.email,
+ circle: body.circle,
+ contributionTier: body.contributionTier,
+ status: body.status,
+ }, { new: true })
+
+ return {
+ _id: updated._id,
+ name: updated.name,
+ email: updated.email,
+ circle: updated.circle,
+ contributionTier: updated.contributionTier,
+ status: updated.status,
+ role: updated.role,
+ }
+})
diff --git a/server/api/admin/members/invite.post.js b/server/api/admin/members/invite.post.js
index ded0799..70e1c76 100644
--- a/server/api/admin/members/invite.post.js
+++ b/server/api/admin/members/invite.post.js
@@ -1,4 +1,5 @@
import jwt from 'jsonwebtoken'
+import { randomUUID } from 'crypto'
import { Resend } from 'resend'
import Member from '../../../models/member.js'
import { connectDB } from '../../../utils/mongoose.js'
@@ -10,12 +11,12 @@ export default defineEventHandler(async (event) => {
const { memberIds, emailTemplate } = await validateBody(event, memberInviteSchema)
await connectDB()
- const config = useRuntimeConfig(event)
- const headers = getHeaders(event)
- const baseUrl =
- process.env.BASE_URL ||
- `${headers.host?.includes('localhost') ? 'http' : 'https'}://${headers.host}`
+ const baseUrl = process.env.BASE_URL
+ if (!baseUrl) {
+ throw createError({ statusCode: 500, statusMessage: 'BASE_URL environment variable is not set' })
+ }
+ const config = useRuntimeConfig(event)
const members = await Member.find({ _id: { $in: memberIds } })
if (members.length === 0) {
@@ -28,15 +29,32 @@ export default defineEventHandler(async (event) => {
const results = []
for (const member of members) {
+ // Skip suspended/cancelled — do not reactivate silently
+ if (member.status === 'suspended' || member.status === 'cancelled') {
+ results.push({
+ memberId: member._id,
+ email: member.email,
+ success: false,
+ error: `Skipped: account is ${member.status}`,
+ })
+ continue
+ }
+
try {
- // Generate 48-hour magic login token (same format as login.post.js)
+ // Generate single-use invite token (48h), same jti pattern as login.post.js
+ const jti = randomUUID()
const token = jwt.sign(
- { memberId: member._id },
+ { memberId: member._id, jti },
config.jwtSecret,
- { expiresIn: '48h' }
+ { expiresIn: '48h' },
)
- const loginLink = `${baseUrl}/api/auth/verify?token=${token}`
+ // Store jti for single-use enforcement in verify.post.js
+ member.magicLinkJti = jti
+ member.magicLinkJtiUsed = false
+
+ // Token in fragment — never hits server logs
+ const loginLink = `${baseUrl}/verify#${token}`
// Interpolate template variables
const emailText = emailTemplate
@@ -59,9 +77,9 @@ export default defineEventHandler(async (event) => {
const { error: emailError } = await resend.emails.send({
from: 'Ghost Guild ',
to: [member.email],
- subject: 'You\'re invited to Ghost Guild',
+ subject: "You're invited to Ghost Guild",
text: emailText,
- html: emailHtml
+ html: emailHtml,
})
if (emailError) {
diff --git a/server/api/auth/member.get.js b/server/api/auth/member.get.js
index 146d13e..048b767 100644
--- a/server/api/auth/member.get.js
+++ b/server/api/auth/member.get.js
@@ -8,6 +8,7 @@ export default defineEventHandler(async (event) => {
id: member._id,
email: member.email,
name: member.name,
+ status: member.status,
role: member.role || 'member',
circle: member.circle,
contributionTier: member.contributionTier,
@@ -23,8 +24,11 @@ export default defineEventHandler(async (event) => {
offering: member.offering,
lookingFor: member.lookingFor,
showInDirectory: member.showInDirectory,
+ notifications: member.notifications,
privacy: member.privacy,
// Peer support
peerSupport: member.peerSupport,
+ // Timestamps
+ createdAt: member.createdAt,
};
});
diff --git a/server/api/auth/refresh.post.js b/server/api/auth/refresh.post.js
index 482fef9..88c4423 100644
--- a/server/api/auth/refresh.post.js
+++ b/server/api/auth/refresh.post.js
@@ -40,9 +40,16 @@ export default defineEventHandler(async (event) => {
})
}
- // Issue a fresh token
+ if (decoded.tv !== member.tokenVersion) {
+ throw createError({
+ statusCode: 401,
+ statusMessage: 'Session has been revoked'
+ })
+ }
+
+ // Issue a fresh token with current tokenVersion
const newToken = jwt.sign(
- { memberId: member._id, email: member.email },
+ { memberId: member._id, email: member.email, tv: member.tokenVersion },
useRuntimeConfig().jwtSecret,
{ expiresIn: '7d' }
)
@@ -51,7 +58,8 @@ export default defineEventHandler(async (event) => {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
- maxAge: 60 * 60 * 24 * 7 // 7 days
+ path: '/',
+ maxAge: 60 * 60 * 24 * 7, // 7 days
})
return { success: true }
diff --git a/server/api/auth/status.get.js b/server/api/auth/status.get.js
index ec47164..08928a3 100644
--- a/server/api/auth/status.get.js
+++ b/server/api/auth/status.get.js
@@ -19,7 +19,7 @@ export default defineEventHandler(async (event) => {
}
if (member.status === 'suspended' || member.status === 'cancelled') {
- return { authenticated: false, member: null, reason: 'account_' + member.status }
+ return { authenticated: false, member: null }
}
return {
@@ -29,6 +29,7 @@ export default defineEventHandler(async (event) => {
email: member.email,
name: member.name,
circle: member.circle,
+ status: member.status,
contributionTier: member.contributionTier,
membershipLevel: `${member.circle}-${member.contributionTier}`
}
diff --git a/server/api/dev/member-login.get.js b/server/api/dev/member-login.get.js
new file mode 100644
index 0000000..df4cd03
--- /dev/null
+++ b/server/api/dev/member-login.get.js
@@ -0,0 +1,41 @@
+import jwt from 'jsonwebtoken'
+import Member from '../../models/member.js'
+import { connectDB } from '../../utils/mongoose.js'
+
+export default defineEventHandler(async (event) => {
+ // Only allow in development
+ if (process.env.NODE_ENV === 'production') {
+ throw createError({ statusCode: 404, statusMessage: 'Not found' })
+ }
+
+ const query = getQuery(event)
+ const email = query.email
+
+ if (!email) {
+ throw createError({ statusCode: 400, statusMessage: 'email query param required' })
+ }
+
+ await connectDB()
+
+ const member = await Member.findOne({ email: email.toLowerCase() })
+
+ if (!member) {
+ throw createError({ statusCode: 404, statusMessage: `No member found with email: ${email}` })
+ }
+
+ const config = useRuntimeConfig(event)
+ const token = jwt.sign(
+ { memberId: member._id, email: member.email },
+ config.jwtSecret,
+ { expiresIn: '7d' }
+ )
+
+ setCookie(event, 'auth-token', token, {
+ httpOnly: true,
+ secure: false,
+ sameSite: 'lax',
+ maxAge: 60 * 60 * 24 * 7,
+ })
+
+ await sendRedirect(event, '/member/account', 302)
+})
diff --git a/server/api/events/[id]/cancel-registration.post.js b/server/api/events/[id]/cancel-registration.post.js
index a0dee6e..db20c62 100644
--- a/server/api/events/[id]/cancel-registration.post.js
+++ b/server/api/events/[id]/cancel-registration.post.js
@@ -3,12 +3,15 @@ import {
sendEventCancellationEmail,
sendWaitlistNotificationEmail,
} from "../../../utils/resend.js";
+import { connectDB } from "../../../utils/mongoose.js";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
const body = await validateBody(event, cancelRegistrationSchema);
const { email } = body;
+ await connectDB();
+
try {
// Check if id is a valid ObjectId or treat as slug
const isObjectId = /^[0-9a-fA-F]{24}$/.test(id);
@@ -46,13 +49,15 @@ export default defineEventHandler(async (event) => {
eventDoc.registrations[registrationIndex].membershipLevel,
};
- // Remove the registration
- eventDoc.registrations.splice(registrationIndex, 1);
-
- // Update registered count
- eventDoc.registeredCount = eventDoc.registrations.length;
-
- await eventDoc.save();
+ // Use $pull to avoid re-validating the whole document (e.g. legacy location formats)
+ await Event.findByIdAndUpdate(
+ eventDoc._id,
+ {
+ $pull: { registrations: { email: registration.email } },
+ $inc: { registeredCount: -1 },
+ },
+ { runValidators: false }
+ );
// Send cancellation confirmation email
try {
@@ -90,9 +95,13 @@ export default defineEventHandler(async (event) => {
if (waitlistEntry) {
await sendWaitlistNotificationEmail(waitlistEntry, eventData);
- // Mark as notified
- waitlistEntry.notified = true;
- await eventDoc.save();
+ // Mark as notified using findByIdAndUpdate to avoid re-validating the document
+ const entryIndex = eventDoc.tickets.waitlist.entries.indexOf(waitlistEntry);
+ await Event.findByIdAndUpdate(
+ eventDoc._id,
+ { $set: { [`tickets.waitlist.entries.${entryIndex}.notified`]: true } },
+ { runValidators: false }
+ );
}
} catch (waitlistError) {
// Log error but don't fail the cancellation
diff --git a/server/api/events/[id]/register.post.js b/server/api/events/[id]/register.post.js
index ce2bbcb..e82b845 100644
--- a/server/api/events/[id]/register.post.js
+++ b/server/api/events/[id]/register.post.js
@@ -76,8 +76,8 @@ export default defineEventHandler(async (event) => {
// If event requires payment and user is not a member, redirect to payment flow
if (
- eventData.pricing.paymentRequired &&
- !eventData.pricing.isFree &&
+ eventData.pricing?.paymentRequired &&
+ !eventData.pricing?.isFree &&
!member
) {
throw createError({
@@ -109,10 +109,13 @@ export default defineEventHandler(async (event) => {
registeredAt: new Date(),
};
- eventData.registrations.push(registration);
-
- // Save the updated event
- await eventData.save();
+ // Use $push to avoid re-validating the whole document (e.g. legacy location formats)
+ const result = await Event.findByIdAndUpdate(
+ eventData._id,
+ { $push: { registrations: registration } },
+ { new: true, runValidators: false }
+ );
+ const newRegistration = result.registrations[result.registrations.length - 1];
// Send confirmation email using Resend
try {
@@ -125,8 +128,7 @@ export default defineEventHandler(async (event) => {
return {
success: true,
message: "Successfully registered for the event",
- registrationId:
- eventData.registrations[eventData.registrations.length - 1]._id,
+ registrationId: newRegistration._id,
};
} catch (error) {
console.error("Error registering for event:", error);
diff --git a/server/api/helcim/create-plan.post.js b/server/api/helcim/create-plan.post.js
index 96b0314..c1565e6 100644
--- a/server/api/helcim/create-plan.post.js
+++ b/server/api/helcim/create-plan.post.js
@@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const body = await validateBody(event, helcimCreatePlanSchema)
- const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
+ const helcimToken = config.helcimApiToken
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
diff --git a/server/api/helcim/customer-code.get.js b/server/api/helcim/customer-code.get.js
index 9f9d406..df2253e 100644
--- a/server/api/helcim/customer-code.get.js
+++ b/server/api/helcim/customer-code.get.js
@@ -45,7 +45,7 @@ export default defineEventHandler(async (event) => {
})
}
- const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
+ const helcimToken = config.helcimApiToken
const response = await fetch(
`${HELCIM_API_BASE}/customers/${member.helcimCustomerId}`,
diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js
index 9393d06..fced502 100644
--- a/server/api/helcim/customer.post.js
+++ b/server/api/helcim/customer.post.js
@@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
}
// Get token directly from environment if not in config
- const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
+ const helcimToken = config.helcimApiToken
if (!helcimToken) {
throw createError({
diff --git a/server/api/helcim/get-or-create-customer.post.js b/server/api/helcim/get-or-create-customer.post.js
index 52a6abc..34baf28 100644
--- a/server/api/helcim/get-or-create-customer.post.js
+++ b/server/api/helcim/get-or-create-customer.post.js
@@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => {
})
}
- const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
+ const helcimToken = config.helcimApiToken
// First, search for existing customer
try {
diff --git a/server/api/helcim/initialize-payment.post.js b/server/api/helcim/initialize-payment.post.js
index 7064cba..f14c6ea 100644
--- a/server/api/helcim/initialize-payment.post.js
+++ b/server/api/helcim/initialize-payment.post.js
@@ -5,16 +5,16 @@ const HELCIM_API_BASE = "https://api.helcim.com/v2";
export default defineEventHandler(async (event) => {
try {
- await requireAuth(event);
const config = useRuntimeConfig(event);
const body = await validateBody(event, helcimInitializePaymentSchema);
-
- const helcimToken =
- config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
-
- // Determine payment type based on whether this is for a subscription or one-time payment
+ // Event ticket purchases can be made without authentication
const isEventTicket = body.metadata?.type === "event_ticket";
+ if (!isEventTicket) {
+ await requireAuth(event);
+ }
+
+ const helcimToken = config.helcimApiToken;
const amount = body.amount || 0;
// For event tickets with amount > 0, we do a purchase
diff --git a/server/api/helcim/plans.get.js b/server/api/helcim/plans.get.js
index 12278db..6621987 100644
--- a/server/api/helcim/plans.get.js
+++ b/server/api/helcim/plans.get.js
@@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => {
try {
await requireAdmin(event)
const config = useRuntimeConfig(event)
- const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
+ const helcimToken = config.helcimApiToken
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
method: 'GET',
diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js
index f5ebd3e..1bb9f32 100644
--- a/server/api/helcim/subscription.post.js
+++ b/server/api/helcim/subscription.post.js
@@ -157,7 +157,7 @@ export default defineEventHandler(async (event) => {
}
// Try to create subscription in Helcim
- const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
+ const helcimToken = config.helcimApiToken
// Generate a proper alphanumeric idempotency key (exactly 25 characters)
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
diff --git a/server/api/helcim/subscriptions.get.js b/server/api/helcim/subscriptions.get.js
index 6a29d44..6636503 100644
--- a/server/api/helcim/subscriptions.get.js
+++ b/server/api/helcim/subscriptions.get.js
@@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => {
try {
await requireAdmin(event)
const config = useRuntimeConfig(event)
- const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
+ const helcimToken = config.helcimApiToken
const response = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'GET',
diff --git a/server/api/helcim/update-billing.post.js b/server/api/helcim/update-billing.post.js
index 08054b3..5c4fdac 100644
--- a/server/api/helcim/update-billing.post.js
+++ b/server/api/helcim/update-billing.post.js
@@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
const { billingAddress } = body
- const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
+ const helcimToken = config.helcimApiToken
// Update customer billing address in Helcim
const response = await fetch(`${HELCIM_API_BASE}/customers/${body.customerId}`, {
diff --git a/server/api/helcim/verify-payment.post.js b/server/api/helcim/verify-payment.post.js
index 16fc074..1d0741c 100644
--- a/server/api/helcim/verify-payment.post.js
+++ b/server/api/helcim/verify-payment.post.js
@@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const body = await validateBody(event, paymentVerifySchema)
- const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
+ const helcimToken = config.helcimApiToken
if (!helcimToken) {
throw createError({
diff --git a/server/api/members/cancel-subscription.post.js b/server/api/members/cancel-subscription.post.js
index 5c7b30f..83c5b04 100644
--- a/server/api/members/cancel-subscription.post.js
+++ b/server/api/members/cancel-subscription.post.js
@@ -18,8 +18,7 @@ export default defineEventHandler(async (event) => {
};
}
- const helcimToken =
- config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
+ const helcimToken = config.helcimApiToken;
try {
// Cancel Helcim subscription
diff --git a/server/api/members/directory.get.js b/server/api/members/directory.get.js
index 3598647..ef78beb 100644
--- a/server/api/members/directory.get.js
+++ b/server/api/members/directory.get.js
@@ -89,7 +89,7 @@ export default defineEventHandler(async (event) => {
try {
const members = await Member.find(dbQuery)
.select(
- "name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport slackUserId createdAt",
+ "name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport createdAt",
)
.sort({ createdAt: -1 })
.lean();
@@ -124,10 +124,15 @@ export default defineEventHandler(async (event) => {
if (isVisible("offering")) filtered.offering = member.offering;
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
- // Always show peer support if enabled (it's opt-in, so public by nature)
+ // Peer support: expose only fields needed for matching/contact UX
+ // slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
if (member.peerSupport?.enabled) {
- filtered.peerSupport = member.peerSupport;
- filtered.slackUserId = member.slackUserId;
+ filtered.peerSupport = {
+ enabled: true,
+ skillTopics: member.peerSupport.skillTopics,
+ supportTopics: member.peerSupport.supportTopics,
+ availability: member.peerSupport.availability,
+ };
}
return filtered;
diff --git a/server/api/members/me/peer-support.patch.js b/server/api/members/me/peer-support.patch.js
index 6afb963..c3d51ea 100644
--- a/server/api/members/me/peer-support.patch.js
+++ b/server/api/members/me/peer-support.patch.js
@@ -1,120 +1,89 @@
-import jwt from "jsonwebtoken";
-import Member from "../../../models/member.js";
-import { connectDB } from "../../../utils/mongoose.js";
+import Member from '../../../models/member.js'
+import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
- await connectDB();
+ await connectDB()
+ const member = await requireAuth(event)
- const token = getCookie(event, "auth-token");
-
- if (!token) {
- throw createError({
- statusCode: 401,
- statusMessage: "Not authenticated",
- });
- }
-
- let memberId;
- try {
- const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
- memberId = decoded.memberId;
- } catch (err) {
- throw createError({
- statusCode: 401,
- statusMessage: "Invalid or expired token",
- });
- }
-
- const body = await validateBody(event, peerSupportUpdateSchema);
+ const body = await validateBody(event, peerSupportUpdateSchema)
// Build update object for peer support settings
const updateData = {
- "peerSupport.enabled": body.enabled || false,
- "peerSupport.skillTopics": body.skillTopics || [],
- "peerSupport.supportTopics": body.supportTopics || [],
- "peerSupport.availability": body.availability || "",
- "peerSupport.personalMessage": body.personalMessage || "",
- "peerSupport.slackUsername": body.slackUsername || "",
- };
+ 'peerSupport.enabled': body.enabled || false,
+ 'peerSupport.skillTopics': body.skillTopics || [],
+ 'peerSupport.supportTopics': body.supportTopics || [],
+ 'peerSupport.availability': body.availability || '',
+ 'peerSupport.personalMessage': body.personalMessage || '',
+ 'peerSupport.slackUsername': body.slackUsername || '',
+ }
// If Slack username provided and peer support enabled, try to fetch Slack user ID
if (body.enabled && body.slackUsername) {
try {
console.log(
`[Peer Support] Attempting to fetch Slack user ID for: ${body.slackUsername}`,
- );
+ )
- // Dynamically import the Slack service
- const { getSlackService } = await import("../../../utils/slack.ts");
- const slackService = getSlackService();
+ const { getSlackService } = await import('../../../utils/slack.ts')
+ const slackService = getSlackService()
if (slackService) {
- console.log(
- "[Peer Support] Slack service initialized, looking up user...",
- );
- const slackUserId = await slackService.findUserIdByUsername(
- body.slackUsername,
- );
+ console.log('[Peer Support] Slack service initialized, looking up user...')
+ const slackUserId = await slackService.findUserIdByUsername(body.slackUsername)
if (slackUserId) {
- updateData["slackUserId"] = slackUserId;
+ updateData['slackUserId'] = slackUserId
console.log(
`[Peer Support] ✓ Found Slack user ID for ${body.slackUsername}: ${slackUserId}`,
- );
+ )
- // Now get/create the DM channel
- console.log("[Peer Support] Opening DM channel...");
- const dmChannelId = await slackService.openDMChannel(slackUserId);
+ console.log('[Peer Support] Opening DM channel...')
+ const dmChannelId = await slackService.openDMChannel(slackUserId)
if (dmChannelId) {
- updateData["peerSupport.slackDMChannelId"] = dmChannelId;
- console.log(`[Peer Support] ✓ Got DM channel ID: ${dmChannelId}`);
+ updateData['peerSupport.slackDMChannelId'] = dmChannelId
+ console.log(`[Peer Support] ✓ Got DM channel ID: ${dmChannelId}`)
} else {
- console.warn("[Peer Support] Could not get DM channel ID");
+ console.warn('[Peer Support] Could not get DM channel ID')
}
} else {
console.warn(
`[Peer Support] Could not find Slack user ID for username: ${body.slackUsername}`,
- );
+ )
}
} else {
- console.log(
- "[Peer Support] Slack service not configured, skipping user ID lookup",
- );
+ console.log('[Peer Support] Slack service not configured, skipping user ID lookup')
}
} catch (error) {
- console.error(
- "[Peer Support] Error fetching Slack user ID:",
- error.message,
- );
- console.error("[Peer Support] Stack trace:", error.stack);
+ console.error('[Peer Support] Error fetching Slack user ID:', error.message)
+ console.error('[Peer Support] Stack trace:', error.stack)
// Continue anyway - we'll still save the username
}
}
try {
- const member = await Member.findByIdAndUpdate(
- memberId,
+ const updated = await Member.findByIdAndUpdate(
+ member._id,
{ $set: updateData },
{ new: true, runValidators: true },
- );
+ )
- if (!member) {
+ if (!updated) {
throw createError({
statusCode: 404,
- statusMessage: "Member not found",
- });
+ statusMessage: 'Member not found',
+ })
}
return {
success: true,
- peerSupport: member.peerSupport,
- };
+ peerSupport: updated.peerSupport,
+ }
} catch (error) {
- console.error("Peer support update error:", error);
+ console.error('Peer support update error:', error)
throw createError({
statusCode: 500,
- statusMessage: "Failed to update peer support settings",
- });
+ statusMessage: 'Failed to update peer support settings',
+ })
}
-});
+})
diff --git a/server/api/members/my-calendar.get.js b/server/api/members/my-calendar.get.js
index ab45f60..91fe918 100644
--- a/server/api/members/my-calendar.get.js
+++ b/server/api/members/my-calendar.get.js
@@ -1,114 +1,94 @@
-import Event from "../../models/event";
-import Member from "../../models/member";
+import { connectDB } from '../../utils/mongoose.js'
+import Event from '../../models/event'
export default defineEventHandler(async (event) => {
- const query = getQuery(event);
- const { memberId } = query;
-
- if (!memberId) {
- throw createError({
- statusCode: 400,
- statusMessage: "Member ID is required",
- });
- }
+ await connectDB()
+ const member = await requireAuth(event)
try {
- // Verify member exists
- const member = await Member.findById(memberId);
- if (!member) {
- throw createError({
- statusCode: 404,
- statusMessage: "Member not found",
- });
- }
-
- // Find all events where the user is registered
const events = await Event.find({
- "registrations.memberId": memberId,
+ 'registrations.memberId': member._id,
isCancelled: { $ne: true },
})
- .select("title slug description startDate endDate location")
- .sort({ startDate: 1 });
+ .select('title slug description startDate endDate location')
+ .sort({ startDate: 1 })
- // Generate iCal format
- const ical = generateICalendar(events, member);
+ const ical = generateICalendar(events, member)
- // Set headers for calendar subscription (not download)
- setHeader(event, "Content-Type", "text/calendar; charset=utf-8");
- setHeader(event, "Cache-Control", "no-cache, no-store, must-revalidate");
- setHeader(event, "Pragma", "no-cache");
- setHeader(event, "Expires", "0");
+ setHeader(event, 'Content-Type', 'text/calendar; charset=utf-8')
+ setHeader(event, 'Cache-Control', 'no-cache, no-store, must-revalidate')
+ setHeader(event, 'Pragma', 'no-cache')
+ setHeader(event, 'Expires', '0')
- return ical;
+ return ical
} catch (error) {
- console.error("Error generating calendar:", error);
+ console.error('Error generating calendar:', error)
if (error.statusCode) {
- throw error;
+ throw error
}
throw createError({
statusCode: 500,
- statusMessage: "Failed to generate calendar",
- });
+ statusMessage: 'Failed to generate calendar',
+ })
}
-});
+})
function generateICalendar(events, member) {
- const now = new Date();
+ const now = new Date()
const timestamp = now
.toISOString()
- .replace(/[-:]/g, "")
- .replace(/\.\d{3}/, "");
+ .replace(/[-:]/g, '')
+ .replace(/\.\d{3}/, '')
let ical = [
- "BEGIN:VCALENDAR",
- "VERSION:2.0",
- "PRODID:-//Ghost Guild//Events Calendar//EN",
- "CALSCALE:GREGORIAN",
- "METHOD:PUBLISH",
- "X-WR-CALNAME:Ghost Guild - My Events",
- "X-WR-TIMEZONE:UTC",
- "X-WR-CALDESC:Your registered Ghost Guild events",
- "REFRESH-INTERVAL;VALUE=DURATION:PT1H",
- "X-PUBLISHED-TTL:PT1H",
- ];
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Ghost Guild//Events Calendar//EN',
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:PUBLISH',
+ 'X-WR-CALNAME:Ghost Guild - My Events',
+ 'X-WR-TIMEZONE:UTC',
+ 'X-WR-CALDESC:Your registered Ghost Guild events',
+ 'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
+ 'X-PUBLISHED-TTL:PT1H',
+ ]
events.forEach((evt) => {
- const eventStart = new Date(evt.startDate);
- const eventEnd = new Date(evt.endDate);
+ const eventStart = new Date(evt.startDate)
+ const eventEnd = new Date(evt.endDate)
const dtstart = eventStart
.toISOString()
- .replace(/[-:]/g, "")
- .replace(/\.\d{3}/, "");
+ .replace(/[-:]/g, '')
+ .replace(/\.\d{3}/, '')
const dtend = eventEnd
.toISOString()
- .replace(/[-:]/g, "")
- .replace(/\.\d{3}/, "");
- const dtstamp = timestamp;
+ .replace(/[-:]/g, '')
+ .replace(/\.\d{3}/, '')
+ const dtstamp = timestamp
- // Clean description for iCal format
- const description = (evt.description || "")
- .replace(/\n/g, "\\n")
- .replace(/,/g, "\\,");
+ const description = (evt.description || '')
+ .replace(/\n/g, '\\n')
+ .replace(/,/g, '\\,')
- const eventUrl = `https://ghostguild.org/events/${evt.slug || evt._id}`;
+ const eventUrl = `https://ghostguild.org/events/${evt.slug || evt._id}`
- ical.push("BEGIN:VEVENT");
- ical.push(`UID:${evt._id}@ghostguild.org`);
- ical.push(`DTSTAMP:${dtstamp}`);
- ical.push(`DTSTART:${dtstart}`);
- ical.push(`DTEND:${dtend}`);
- ical.push(`SUMMARY:${evt.title}`);
- ical.push(`DESCRIPTION:${description}\\n\\nView event: ${eventUrl}`);
- ical.push(`LOCATION:${evt.location || "Online"}`);
- ical.push(`URL:${eventUrl}`);
- ical.push(`STATUS:CONFIRMED`);
- ical.push("END:VEVENT");
- });
+ ical.push('BEGIN:VEVENT')
+ ical.push(`UID:${evt._id}@ghostguild.org`)
+ ical.push(`DTSTAMP:${dtstamp}`)
+ ical.push(`DTSTART:${dtstart}`)
+ ical.push(`DTEND:${dtend}`)
+ ical.push(`SUMMARY:${evt.title}`)
+ ical.push(`DESCRIPTION:${description}\\n\\nView event: ${eventUrl}`)
+ ical.push(`LOCATION:${evt.location || 'Online'}`)
+ ical.push(`URL:${eventUrl}`)
+ ical.push('STATUS:CONFIRMED')
+ ical.push('END:VEVENT')
+ })
- ical.push("END:VCALENDAR");
+ ical.push('END:VCALENDAR')
- return ical.join("\r\n");
+ return ical.join('\r\n')
}
diff --git a/server/api/members/my-events.get.js b/server/api/members/my-events.get.js
index f87cbf6..718d822 100644
--- a/server/api/members/my-events.get.js
+++ b/server/api/members/my-events.get.js
@@ -1,60 +1,36 @@
-import Event from "../../models/event";
-import Member from "../../models/member";
+import { connectDB } from '../../utils/mongoose.js'
+import Event from '../../models/event'
export default defineEventHandler(async (event) => {
- const query = getQuery(event);
- const { memberId } = query;
-
- if (!memberId) {
- throw createError({
- statusCode: 400,
- statusMessage: "Member ID is required",
- });
- }
+ await connectDB()
+ const member = await requireAuth(event)
try {
- // Verify member exists
- const member = await Member.findById(memberId);
- if (!member) {
- throw createError({
- statusCode: 404,
- statusMessage: "Member not found",
- });
- }
-
- // Find all events where the user is registered
- // Filter out cancelled events and only show future events
- const now = new Date();
+ const now = new Date()
const events = await Event.find({
- "registrations.memberId": memberId,
+ 'registrations.memberId': member._id,
isCancelled: { $ne: true },
startDate: { $gte: now },
})
- .select(
- "title slug description startDate endDate location featureImage maxAttendees registeredCount",
- )
+ .select('title slug description startDate endDate location featureImage maxAttendees registeredCount')
.sort({ startDate: 1 })
- .limit(10);
-
- console.log(
- `Found ${events.length} registered events for member ${memberId}`,
- );
+ .limit(10)
return {
events,
count: events.length,
- };
+ }
} catch (error) {
- console.error("Error fetching member events:", error);
+ console.error('Error fetching member events:', error)
if (error.statusCode) {
- throw error;
+ throw error
}
throw createError({
statusCode: 500,
- statusMessage: "Failed to fetch registered events",
- });
+ statusMessage: 'Failed to fetch registered events',
+ })
}
-});
+})
diff --git a/server/api/members/profile.patch.js b/server/api/members/profile.patch.js
index bd396d6..5e9946a 100644
--- a/server/api/members/profile.patch.js
+++ b/server/api/members/profile.patch.js
@@ -19,6 +19,7 @@ export default defineEventHandler(async (event) => {
"location",
"socialLinks",
"showInDirectory",
+ "notifications",
];
// Privacy fields from validated body
@@ -96,6 +97,7 @@ export default defineEventHandler(async (event) => {
offering: member.offering,
lookingFor: member.lookingFor,
showInDirectory: member.showInDirectory,
+ notifications: member.notifications,
};
} catch (error) {
if (error.statusCode) throw error;
diff --git a/server/api/members/update-circle.post.js b/server/api/members/update-circle.post.js
new file mode 100644
index 0000000..c3a685b
--- /dev/null
+++ b/server/api/members/update-circle.post.js
@@ -0,0 +1,34 @@
+// Update member's circle
+import Member from '../../models/member.js'
+import { connectDB } from '../../utils/mongoose.js'
+import { requireAuth } from '../../utils/auth.js'
+
+export default defineEventHandler(async (event) => {
+ try {
+ const member = await requireAuth(event)
+ await connectDB()
+ const body = await validateBody(event, updateCircleSchema)
+
+ if (member.circle === body.circle) {
+ return { success: true, message: 'Already in this circle' }
+ }
+
+ await Member.findByIdAndUpdate(
+ member._id,
+ { $set: { circle: body.circle } },
+ { runValidators: false }
+ )
+
+ return {
+ success: true,
+ message: `Circle updated to ${body.circle}`,
+ }
+ } catch (error) {
+ if (error.statusCode) throw error
+ console.error('Error updating circle:', error)
+ throw createError({
+ statusCode: 500,
+ statusMessage: 'An unexpected error occurred',
+ })
+ }
+})
diff --git a/server/api/members/update-contribution.post.js b/server/api/members/update-contribution.post.js
index adc7f03..92e76cb 100644
--- a/server/api/members/update-contribution.post.js
+++ b/server/api/members/update-contribution.post.js
@@ -1,48 +1,19 @@
// Update member's contribution tier
-import jwt from "jsonwebtoken";
import {
getHelcimPlanId,
requiresPayment,
- isValidContributionValue,
} from "../../config/contributions.js";
-import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js";
+import Member from "../../models/member.js";
const HELCIM_API_BASE = "https://api.helcim.com/v2";
export default defineEventHandler(async (event) => {
try {
+ const member = await requireAuth(event);
await connectDB();
const config = useRuntimeConfig(event);
const body = await validateBody(event, updateContributionSchema);
- const token = getCookie(event, "auth-token");
-
- if (!token) {
- throw createError({
- statusCode: 401,
- statusMessage: "Not authenticated",
- });
- }
-
- // Decode JWT token
- let decoded;
- try {
- decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
- } catch (err) {
- throw createError({
- statusCode: 401,
- statusMessage: "Invalid or expired token",
- });
- }
-
- // Get member
- const member = await Member.findById(decoded.memberId);
- if (!member) {
- throw createError({
- statusCode: 404,
- statusMessage: "Member not found",
- });
- }
const oldTier = member.contributionTier;
const newTier = body.contributionTier;
@@ -55,8 +26,7 @@ export default defineEventHandler(async (event) => {
};
}
- const helcimToken =
- config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
+ const helcimToken = config.helcimApiToken;
const oldRequiresPayment = requiresPayment(oldTier);
const newRequiresPayment = requiresPayment(newTier);
@@ -73,8 +43,7 @@ export default defineEventHandler(async (event) => {
}
// Try to fetch customer info from Helcim to check for saved cards
- const helcimToken =
- config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
+ const helcimToken = config.helcimApiToken;
try {
const customerResponse = await fetch(
@@ -185,11 +154,11 @@ export default defineEventHandler(async (event) => {
}
// Update member record
- member.contributionTier = newTier;
- member.helcimSubscriptionId = subscription.id;
- member.paymentMethod = "card";
- member.status = "active";
- await member.save();
+ await Member.findByIdAndUpdate(
+ member._id,
+ { $set: { contributionTier: newTier, helcimSubscriptionId: subscription.id, paymentMethod: "card", status: "active" } },
+ { runValidators: false }
+ );
return {
success: true,
@@ -241,10 +210,11 @@ export default defineEventHandler(async (event) => {
}
// Update member to free tier
- member.contributionTier = newTier;
- member.helcimSubscriptionId = null;
- member.paymentMethod = "none";
- await member.save();
+ await Member.findByIdAndUpdate(
+ member._id,
+ { $set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none" } },
+ { runValidators: false }
+ );
return {
success: true,
@@ -303,8 +273,11 @@ export default defineEventHandler(async (event) => {
const subscriptionData = await response.json();
// Update member record
- member.contributionTier = newTier;
- await member.save();
+ await Member.findByIdAndUpdate(
+ member._id,
+ { $set: { contributionTier: newTier } },
+ { runValidators: false }
+ );
return {
success: true,
@@ -321,8 +294,11 @@ export default defineEventHandler(async (event) => {
}
// Case 4: Moving between free tiers (shouldn't happen but handle it)
- member.contributionTier = newTier;
- await member.save();
+ await Member.findByIdAndUpdate(
+ member._id,
+ { $set: { contributionTier: newTier } },
+ { runValidators: false }
+ );
return {
success: true,
diff --git a/server/middleware/02.security-headers.js b/server/middleware/02.security-headers.js
index 71cc91d..7129372 100644
--- a/server/middleware/02.security-headers.js
+++ b/server/middleware/02.security-headers.js
@@ -18,9 +18,9 @@ export default defineEventHandler((event) => {
headers['Content-Security-Policy'] = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://myposjs.helcim.com https://plausible.io",
- "style-src 'self' 'unsafe-inline'",
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: https://res.cloudinary.com https://*.cloudinary.com",
- "font-src 'self'",
+ "font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://api.helcim.com https://myposjs.helcim.com https://plausible.io",
"frame-src 'self' https://myposjs.helcim.com https://secure.helcim.com",
"base-uri 'self'",
diff --git a/server/middleware/03.rate-limit.js b/server/middleware/03.rate-limit.js
index 2641598..ac87ef7 100644
--- a/server/middleware/03.rate-limit.js
+++ b/server/middleware/03.rate-limit.js
@@ -35,7 +35,7 @@ function getClientIp(event) {
|| 'unknown'
}
-const AUTH_PATHS = new Set(['/api/auth/login'])
+const AUTH_PATHS = new Set(['/api/auth/login', '/api/auth/verify'])
const PAYMENT_PREFIXES = ['/api/helcim/']
const UPLOAD_PATHS = new Set(['/api/upload/image'])
diff --git a/server/models/member.js b/server/models/member.js
index e1bf537..3b744db 100644
--- a/server/models/member.js
+++ b/server/models/member.js
@@ -45,7 +45,7 @@ const memberSchema = new mongoose.Schema({
slackInvited: { type: Boolean, default: false },
slackInviteStatus: {
type: String,
- enum: ["pending", "sent", "failed", "accepted"],
+ enum: ["pending", "sent", "failed", "accepted", "joined"],
default: "pending",
},
slackUserId: String,
@@ -133,9 +133,22 @@ const memberSchema = new mongoose.Schema({
},
},
+ notifications: {
+ events: { type: Boolean, default: true },
+ updates: { type: Boolean, default: true },
+ peerRequests: { type: Boolean, default: true },
+ },
+
inviteEmailSent: { type: Boolean, default: false },
inviteEmailSentAt: Date,
+ // Magic link single-use enforcement
+ magicLinkJti: String,
+ magicLinkJtiUsed: { type: Boolean, default: false },
+
+ // Session revocation via token versioning
+ tokenVersion: { type: Number, default: 0 },
+
createdAt: { type: Date, default: Date.now },
lastLogin: Date,
});
diff --git a/server/utils/auth.js b/server/utils/auth.js
index a0cacfc..7398707 100644
--- a/server/utils/auth.js
+++ b/server/utils/auth.js
@@ -44,6 +44,14 @@ export async function requireAuth(event) {
})
}
+ // Verify session has not been revoked (tokenVersion incremented on logout)
+ if (decoded.tv !== member.tokenVersion) {
+ throw createError({
+ statusCode: 401,
+ statusMessage: 'Session has been revoked'
+ })
+ }
+
return member
}