'use server'; import { notFound } from 'next/navigation'; import { revalidatePath } from 'next/cache'; import { prisma } from '@/lib/prisma'; import { rsvpSchema, rsvpUpdateSchema, manualRsvpSchema } from '@/lib/validation'; import { sendRsvpGuestConfirmation, sendRsvpHostNotification } from '@/lib/mail'; import { appUrl } from '@/lib/slug'; import { checkRateLimit, getClientIp } from '@/lib/rateLimit'; import { sendPushToUser } from '@/lib/push'; import { accessibleEventWhere, requireEventAccess } from '@/lib/eventAccess'; import { requireUser } from '@/lib/session'; import { looksLikeEmail, normalizePhoneNumber } from '@/lib/phone'; export type ActionState = { error?: string; success?: string }; const SUCCESS_MESSAGE = 'Thanks! Your RSVP has been recorded.'; export async function submitRsvpAction(_prev: ActionState, formData: FormData): Promise { // Honeypot: a real guest never fills this hidden field. Bots that // blindly fill every input do, so pretend success without doing anything. if (formData.get('website')) { return { success: SUCCESS_MESSAGE }; } const parsed = rsvpSchema.safeParse({ eventId: formData.get('eventId'), guestName: formData.get('guestName'), guestEmail: formData.get('guestEmail'), status: formData.get('status'), numGuests: formData.get('numGuests') || '1', note: formData.get('note') ?? '', }); if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? 'Invalid input' }; } const data = parsed.data; const guestEmail = data.guestEmail.toLowerCase(); const ip = await getClientIp(); const rateLimited = !checkRateLimit(`rsvp:ip:${ip}`, 20, 60 * 1000) || !checkRateLimit(`rsvp:event:${data.eventId}:${guestEmail}`, 5, 60 * 60 * 1000); if (rateLimited) { return { error: 'Too many submissions. Please wait a bit and try again.' }; } const event = await prisma.event.findUnique({ where: { id: data.eventId }, include: { host: true } }); if (!event) { return { error: 'Event not found' }; } if (event.restrictRsvpToInvited) { const onGuestList = await prisma.guestInvite.findUnique({ where: { eventId_email: { eventId: event.id, email: guestEmail } }, }); if (!onGuestList) { return { error: 'This event is invite-only. Please RSVP using the email address the host invited you with, or contact them directly.', }; } } const numGuests = event.allowPlusOnes ? data.numGuests : 1; const existing = await prisma.rsvp.findUnique({ where: { eventId_guestEmail: { eventId: event.id, guestEmail } }, }); await prisma.rsvp.upsert({ where: { eventId_guestEmail: { eventId: event.id, guestEmail } }, create: { eventId: event.id, guestName: data.guestName, guestEmail, status: data.status, numGuests, note: data.note, }, update: { guestName: data.guestName, status: data.status, numGuests, note: data.note, }, }); const inviteUrl = appUrl(`/invite/${event.slug}`); await sendRsvpGuestConfirmation({ to: guestEmail, guestName: data.guestName, eventTitle: event.title, status: data.status, inviteUrl, }); if (event.host.notifyOnRsvp) { const manageUrl = appUrl(`/dashboard/events/${event.id}/guests`); await sendRsvpHostNotification({ to: event.host.email, guestName: data.guestName, eventTitle: event.title, status: data.status, numGuests, note: data.note, manageUrl, isUpdate: Boolean(existing), }); await sendPushToUser(event.host.id, { title: existing ? 'RSVP updated' : 'New RSVP', body: `${data.guestName} ${existing ? 'updated their RSVP to' : 'responded'} ${data.status} for ${event.title}`, url: manageUrl, }); } revalidatePath(`/invite/${event.slug}`); return { success: SUCCESS_MESSAGE }; } async function requireOwnedRsvp(rsvpId: string) { const user = await requireUser(); const rsvp = await prisma.rsvp.findUnique({ where: { id: rsvpId }, include: { event: true } }); if (!rsvp) notFound(); const accessibleEvent = await prisma.event.findFirst({ where: accessibleEventWhere(rsvp.eventId, user.id), }); if (!accessibleEvent) notFound(); return rsvp; } export type RsvpEditState = { error?: string }; export async function updateRsvpAction( rsvpId: string, _prev: RsvpEditState, formData: FormData ): Promise { const rsvp = await requireOwnedRsvp(rsvpId); const parsed = rsvpUpdateSchema.safeParse({ guestName: formData.get('guestName'), status: formData.get('status'), numGuests: formData.get('numGuests') || '1', note: formData.get('note') ?? '', }); if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? 'Invalid input' }; } const data = parsed.data; await prisma.rsvp.update({ where: { id: rsvp.id }, data: { guestName: data.guestName, status: data.status, numGuests: data.status === 'NO' ? 1 : data.numGuests, note: data.note, }, }); revalidatePath(`/dashboard/events/${rsvp.eventId}/guests`); revalidatePath(`/invite/${rsvp.event.slug}`); return {}; } /** * Records an RSVP the host heard about outside the app (a text, a call, a * comment at the mailbox) rather than through the guest submitting the * form themselves. Upserts on the same email/phone key the guest-facing * form uses, so if that guest later does submit the form it just updates * this row instead of creating a duplicate. */ export async function addRsvpAction( eventId: string, _prev: RsvpEditState, formData: FormData ): Promise { const { event } = await requireEventAccess(eventId); const parsed = manualRsvpSchema.safeParse({ guestName: formData.get('guestName'), contact: formData.get('contact'), status: formData.get('status'), numGuests: formData.get('numGuests') || '1', note: formData.get('note') ?? '', }); if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? 'Invalid input' }; } const data = parsed.data; let guestEmail: string | null = null; let guestPhone: string | null = null; if (looksLikeEmail(data.contact)) { const emailResult = rsvpSchema.shape.guestEmail.safeParse(data.contact); if (!emailResult.success) return { error: 'Enter a valid email address' }; guestEmail = emailResult.data.toLowerCase(); } else { guestPhone = normalizePhoneNumber(data.contact); if (!guestPhone) return { error: 'Enter a valid email address or phone number' }; } const numGuests = data.status === 'NO' ? 1 : data.numGuests; await prisma.rsvp.upsert({ where: guestEmail ? { eventId_guestEmail: { eventId: event.id, guestEmail } } : { eventId_guestPhone: { eventId: event.id, guestPhone: guestPhone as string } }, create: { eventId: event.id, guestName: data.guestName, guestEmail, guestPhone, status: data.status, numGuests, note: data.note }, update: { guestName: data.guestName, status: data.status, numGuests, note: data.note }, }); revalidatePath(`/dashboard/events/${event.id}/guests`); revalidatePath(`/invite/${event.slug}`); return {}; } export async function deleteRsvpAction(rsvpId: string) { const rsvp = await requireOwnedRsvp(rsvpId); await prisma.rsvp.delete({ where: { id: rsvp.id } }); revalidatePath(`/dashboard/events/${rsvp.eventId}/guests`); revalidatePath(`/invite/${rsvp.event.slug}`); }