invite/src/app/actions/rsvp.ts
chris a6d1ade53c Add manual RSVP entry, status pills, and delete confirmation
- Hosts can record an RSVP for a guest who let them know directly
  (call, text, in person) instead of using the invite form
- RSVP status now renders as a colored pill instead of plain text
- Removing an RSVP now asks for confirmation, since it's a hard delete
- Fixes a bug in the edit-RSVP form: useActionState's dispatch isn't
  awaitable, so the previous await-then-close logic could close the
  row and swallow a validation error before the update actually
  finished; now watches isPending settling instead
2026-07-12 12:18:12 -04:00

225 lines
7.3 KiB
TypeScript

'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<ActionState> {
// 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<RsvpEditState> {
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<RsvpEditState> {
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}`);
}