- 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
225 lines
7.3 KiB
TypeScript
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}`);
|
|
}
|