From c3c8b6bcd429836e3a5b307622d4d9ca60218cb2 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 5 Mar 2026 18:40:37 +0000 Subject: [PATCH] Refactor email templates to use plain text format and update sender addresses - Simplified the magic link email format to plain text for better compatibility. - Updated the welcome email to use plain text and changed the sender address to match the domain. - Enhanced event registration email format to plain text, removing HTML styling for a cleaner approach. --- server/api/admin/members/[id]/role.patch.js | 35 ++ server/api/auth/login.post.js | 26 +- server/emails/welcome.js | 37 +- server/utils/resend.js | 656 +++----------------- server/utils/schemas.js | 4 + 5 files changed, 132 insertions(+), 626 deletions(-) create mode 100644 server/api/admin/members/[id]/role.patch.js diff --git a/server/api/admin/members/[id]/role.patch.js b/server/api/admin/members/[id]/role.patch.js new file mode 100644 index 0000000..652f64e --- /dev/null +++ b/server/api/admin/members/[id]/role.patch.js @@ -0,0 +1,35 @@ +import Member from '../../../../models/member.js' +import { connectDB } from '../../../../utils/mongoose.js' +import { validateBody } from '../../../../utils/validateBody.js' +import { adminRoleUpdateSchema } from '../../../../utils/schemas.js' + +export default defineEventHandler(async (event) => { + const admin = await requireAdmin(event) + await connectDB() + + const { role } = await validateBody(event, adminRoleUpdateSchema) + const memberId = getRouterParam(event, 'id') + + // Prevent self-demotion + if (admin._id.toString() === memberId && role !== 'admin') { + throw createError({ + statusCode: 400, + statusMessage: 'You cannot remove your own admin role.' + }) + } + + const member = await Member.findByIdAndUpdate( + memberId, + { role }, + { new: true } + ) + + if (!member) { + throw createError({ + statusCode: 404, + statusMessage: 'Member not found.' + }) + } + + return { success: true, member } +}) diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js index b60986f..8f0c452 100644 --- a/server/api/auth/login.post.js +++ b/server/api/auth/login.post.js @@ -9,7 +9,6 @@ import { emailSchema } from "../../utils/schemas.js"; const resend = new Resend(process.env.RESEND_API_KEY); export default defineEventHandler(async (event) => { - // Connect to database await connectDB(); const { email } = await validateBody(event, emailSchema); @@ -19,14 +18,12 @@ export default defineEventHandler(async (event) => { const member = await Member.findOne({ email }); if (!member) { - // Return same response shape to prevent enumeration return { success: true, message: GENERIC_MESSAGE, }; } - // Generate magic link token (use runtime config for consistency with verify/requireAuth) const config = useRuntimeConfig(event); const token = jwt.sign( { memberId: member._id }, @@ -34,33 +31,22 @@ export default defineEventHandler(async (event) => { { expiresIn: "15m" }, ); - // Get the base URL for the magic link const headers = getHeaders(event); const baseUrl = process.env.BASE_URL || `${headers.host?.includes("localhost") ? "http" : "https"}://${headers.host}`; - // Send magic link via Resend try { await resend.emails.send({ from: "Ghost Guild ", to: email, subject: "Your Ghost Guild login link", - html: ` -
-

Welcome back to Ghost Guild!

-

Click the button below to sign in to your account:

-
- - Sign In to Ghost Guild - -
-

- This link will expire in 15 minutes for security. If you didn't request this login link, you can safely ignore this email. -

-
- `, + text: `Hi, + +Sign in to Ghost Guild: +${baseUrl}/api/auth/verify?token=${token} + +This link expires in 15 minutes. If you didn't request it, ignore this email.`, }); return { diff --git a/server/emails/welcome.js b/server/emails/welcome.js index c4960fb..6f2153c 100644 --- a/server/emails/welcome.js +++ b/server/emails/welcome.js @@ -1,29 +1,14 @@ // server/emails/welcome.js export const welcomeEmail = (member) => ({ - from: 'Ghost Guild ', + from: 'Ghost Guild ', to: member.email, - subject: 'Welcome to Ghost Guild! 👻', - html: ` -
-

Welcome to the community, ${member.name}!

- -

You've joined the ${member.circle} circle - with a ${member.contributionTier}/month contribution.

- -

Your next steps:

-
    -
  1. Watch for your Slack invite (within 24 hours)
  2. -
  3. Explore the resource library
  4. -
  5. Introduce yourself in #introductions
  6. -
- -

Thank you for being part of our solidarity economy!

- -
- -

- Questions? Reply to this email or reach out in Slack. -

-
- ` -}) \ No newline at end of file + subject: 'Welcome to Ghost Guild', + text: `Hi ${member.name}, + +Welcome to the ${member.circle} circle. + +Next steps: +1. Watch for your Slack invite (within 24 hours) +2. Explore the resource library: https://ghostguild.org/members/resources +3. Introduce yourself in #introductions` +}) diff --git a/server/utils/resend.js b/server/utils/resend.js index 5f0b179..181013e 100644 --- a/server/utils/resend.js +++ b/server/utils/resend.js @@ -1,5 +1,4 @@ import { Resend } from "resend"; -import { escapeHtml } from "./escapeHtml.js"; const resend = new Resend(process.env.RESEND_API_KEY); @@ -30,166 +29,44 @@ export async function sendEventRegistrationEmail(registration, eventData) { return `${timeFormat.format(start)} - ${timeFormat.format(end)}`; }; + const baseUrl = process.env.BASE_URL || "https://ghostguild.org"; + const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`; + + let ticketSection = ""; + if ( + registration.ticketType && + registration.ticketType !== "guest" && + registration.amountPaid > 0 + ) { + ticketSection = `\nTicket: ${registration.ticketType === "member" ? "Member Ticket" : "Public Ticket"} +Paid: $${registration.amountPaid.toFixed(2)} CAD`; + if (registration.paymentId) { + ticketSection += `\nTransaction: ${registration.paymentId}`; + } + ticketSection += "\n"; + } else if ( + registration.ticketType === "member" && + registration.amountPaid === 0 + ) { + ticketSection = "\nThis event is free for Ghost Guild members.\n"; + } + try { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", to: [registration.email], - subject: `You're registered for ${escapeHtml(eventData.title)}`, - html: ` - - - - - - - - -
-

You're Registered! 🎉

-
+ subject: `You're registered for ${eventData.title}`, + text: `Hi ${registration.name}, -
-

Hi ${escapeHtml(registration.name)},

+You're registered for ${eventData.title}. -

Thank you for registering for ${escapeHtml(eventData.title)}!

+Date: ${formatDate(eventData.startDate)} +Time: ${formatTime(eventData.startDate, eventData.endDate)} +Location: ${eventData.location} +${eventData.description ? `\n${eventData.description}\n` : ""}${ticketSection} +View event: ${eventUrl} -
-
-
Date
-
${formatDate(eventData.startDate)}
-
- -
-
Time
-
${formatTime(eventData.startDate, eventData.endDate)}
-
- -
-
Location
-
${escapeHtml(eventData.location)}
-
-
- - ${eventData.description ? `

${escapeHtml(eventData.description)}

` : ""} - - ${ - registration.ticketType && - registration.ticketType !== "guest" && - registration.amountPaid > 0 - ? ` -
-

Ticket Information

-
-
Ticket Type
-
${registration.ticketType === "member" ? "Member Ticket" : "Public Ticket"}
-
-
-
Amount Paid
-
$${registration.amountPaid.toFixed(2)} CAD
-
- ${ - registration.paymentId - ? ` -
-
Transaction ID
-
${escapeHtml(registration.paymentId)}
-
- ` - : "" - } -
- ` - : registration.ticketType === "member" && - registration.amountPaid === 0 - ? ` -
-

- ✨ Member Benefit: This event is free for Ghost Guild members! -

-
- ` - : "" - } - -
- - View Event Details - -
- -

- Need to cancel?
- Visit the event page and click "Cancel Registration" to remove yourself from the attendee list. -

-
- - - - - `, +To cancel, visit the event page and click "Cancel Registration."`, }); if (error) { @@ -208,86 +85,19 @@ export async function sendEventRegistrationEmail(registration, eventData) { * Send event cancellation confirmation email */ export async function sendEventCancellationEmail(registration, eventData) { + const baseUrl = process.env.BASE_URL || "https://ghostguild.org"; + try { const { data, error } = await resend.emails.send({ - from: "Ghost Guild ", + from: "Ghost Guild ", to: [registration.email], - subject: `Registration cancelled: ${escapeHtml(eventData.title)}`, - html: ` - - - - - - - - -
-

Registration Cancelled

-
+ subject: `Registration cancelled: ${eventData.title}`, + text: `Hi ${registration.name}, -
-

Hi ${escapeHtml(registration.name)},

+Your registration for ${eventData.title} has been cancelled. -

Your registration for ${escapeHtml(eventData.title)} has been cancelled.

- -

We're sorry you can't make it. You can always register again if your plans change.

- -
- - Browse Other Events - -
-
- - - - - `, +You can register again if your plans change: +${baseUrl}/events`, }); if (error) { @@ -329,144 +139,25 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) { return `${timeFormat.format(start)} - ${timeFormat.format(end)}`; }; + const baseUrl = process.env.BASE_URL || "https://ghostguild.org"; + const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`; + try { const { data, error } = await resend.emails.send({ - from: "Ghost Guild ", + from: "Ghost Guild ", to: [waitlistEntry.email], - subject: `A spot opened up for ${escapeHtml(eventData.title)}!`, - html: ` - - - - - - - - -
-

A Spot Just Opened Up! 🎉

-
+ subject: `A spot opened up for ${eventData.title}`, + text: `Hi ${waitlistEntry.name}, -
-

Hi ${escapeHtml(waitlistEntry.name)},

+A spot opened up for ${eventData.title}. -

Great news! A spot has become available for ${escapeHtml(eventData.title)}, and you're on the waitlist.

+Date: ${formatDate(eventData.startDate)} +Time: ${formatTime(eventData.startDate, eventData.endDate)} +Location: ${eventData.location} -
-

- ⏰ Act fast! Spots are filled on a first-come, first-served basis. -

-
+Register now: ${eventUrl} -
-
-
Event
-
${escapeHtml(eventData.title)}
-
- -
-
Date
-
${formatDate(eventData.startDate)}
-
- -
-
Time
-
${formatTime(eventData.startDate, eventData.endDate)}
-
- -
-
Location
-
${escapeHtml(eventData.location)}
-
-
- -
- - Register Now → - -
- -

- If you can no longer attend, no worries! Just ignore this email and the spot will go to the next person on the waitlist. -

-
- - - - - `, +If you can no longer attend, ignore this email and the spot goes to the next person.`, }); if (error) { @@ -518,234 +209,39 @@ export async function sendSeriesPassConfirmation(options) { }).format(price); }; - const seriesTypeLabels = { - workshop_series: "Workshop Series", - recurring_meetup: "Recurring Meetup", - multi_day: "Multi-Day Event", - course: "Course", - tournament: "Tournament", - }; + const freeMemberLine = + ticket.isFree && ticket.type === "member" + ? "\nThis series pass is free for Ghost Guild members.\n" + : ""; + + const eventList = events + .map( + (evt, index) => + ` ${index + 1}. ${evt.title} + ${formatDate(evt.startDate)} + ${formatTime(evt.startDate, evt.endDate)} + ${evt.location}`, + ) + .join("\n\n"); try { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", to: [to], - subject: `Your Series Pass for ${escapeHtml(series.title)}`, - html: ` - - - - - - - - -
-

🎫 Your Series Pass is Ready!

-

You're registered for all events

-
+ subject: `Your series pass for ${series.title}`, + text: `Hi ${name}, -
-

Hi ${escapeHtml(name)},

+Your series pass for ${series.title} is confirmed. You're registered for all ${events.length} events. +${freeMemberLine} +Pass details: + Series: ${series.title} + Type: ${ticket.type === "member" ? "Member Pass" : "Public Pass"} + Paid: ${formatPrice(ticket.price, ticket.currency)}${paymentId ? `\n Transaction: ${paymentId}` : ""} + Events: ${events.length} -

- Great news! Your series pass for ${escapeHtml(series.title)} is confirmed. - You're now registered for all ${events.length} events in this ${seriesTypeLabels[series.type] || "series"}. -

+Your events: - ${ - ticket.isFree && ticket.type === "member" - ? ` -
-

- ✨ Member Benefit -

-

- This series pass is free for Ghost Guild members. Thank you for being part of our community! -

-
- ` - : "" - } - -
-

Series Pass Details

- -
-
Series
-
${escapeHtml(series.title)}
-
- - ${ - series.description - ? ` -
-
About
-
${escapeHtml(series.description)}
-
- ` - : "" - } - -
-
Pass Type
-
${ticket.type === "member" ? "Member Pass" : "Public Pass"}
-
- -
-
Amount Paid
-
${formatPrice(ticket.price, ticket.currency)}
-
- - ${ - paymentId - ? ` -
-
Transaction ID
-
${escapeHtml(paymentId)}
-
- ` - : "" - } - -
-
Total Events
-
${events.length} events included
-
-
- -
-

Your Event Schedule

-

- You're automatically registered for all of these events: -

- - ${events - .map( - (event, index) => ` -
-
- Event ${index + 1}: ${escapeHtml(event.title)} -
-
- 📅 ${formatDate(event.startDate)} -
-
- 🕐 ${formatTime(event.startDate, event.endDate)} -
-
- 📍 ${escapeHtml(event.location)} -
-
- `, - ) - .join("")} -
- - - -
-

What's Next?

-
    -
  • Mark these dates in your calendar
  • -
  • You'll receive individual reminders before each event
  • -
  • Event links and materials will be shared closer to each date
  • -
  • Join the conversation in our Slack community
  • -
-
-
- - - - - `, +${eventList}`, }); if (error) { diff --git a/server/utils/schemas.js b/server/utils/schemas.js index c319bfb..5bd2522 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -326,3 +326,7 @@ export const adminMemberCreateSchema = z.object({ circle: z.enum(['community', 'founder', 'practitioner']), contributionTier: z.enum(['0', '5', '15', '30', '50']) }) + +export const adminRoleUpdateSchema = z.object({ + role: z.enum(['admin', 'member']) +})