fix: use private helcimApiToken for all server-side Helcim API calls

This commit is contained in:
Jennie Robinson Faber 2026-04-04 13:37:34 +01:00
parent ccd1d0783a
commit d31b5b4dac
53 changed files with 1755 additions and 572 deletions

View file

@ -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,
}
})

View file

@ -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 <welcome@babyghosts.org>',
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) {

View file

@ -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,
};
});

View file

@ -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 }

View file

@ -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}`
}

View file

@ -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)
})

View file

@ -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

View file

@ -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);

View file

@ -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`, {

View file

@ -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}`,

View file

@ -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({

View file

@ -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 {

View file

@ -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

View file

@ -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',

View file

@ -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'

View file

@ -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',

View file

@ -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}`, {

View file

@ -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({

View file

@ -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

View file

@ -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;

View file

@ -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',
})
}
});
})

View file

@ -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')
}

View file

@ -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',
})
}
});
})

View file

@ -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;

View file

@ -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',
})
}
})

View file

@ -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,

View file

@ -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'",

View file

@ -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'])

View file

@ -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,
});

View file

@ -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
}