Let hosts edit or remove individual RSVPs

Hosts can now fix a guest's headcount, correct their name/note, change
their status, or remove an RSVP entirely from the guest list page -
covers cases where a guest messages the host directly instead of
resubmitting the form.
This commit is contained in:
chris 2026-07-12 12:14:25 -04:00
parent 2c78112a4f
commit cb99fec940
5 changed files with 215 additions and 10 deletions

View File

@ -1,12 +1,15 @@
'use server';
import { notFound } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { rsvpSchema } from '@/lib/validation';
import { rsvpSchema, rsvpUpdateSchema } 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 } from '@/lib/eventAccess';
import { requireUser } from '@/lib/session';
export type ActionState = { error?: string; success?: string };
@ -111,3 +114,58 @@ export async function submitRsvpAction(_prev: ActionState, formData: FormData):
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 {};
}
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}`);
}

View File

@ -17,6 +17,7 @@ import { togglePhotoHiddenAction, deletePhotoAction, toggleForceCloseAction } fr
import { formatEventDateRange } from '@/lib/format';
import { isPhotoUploadOpen } from '@/lib/photoWindow';
import { photoUrl } from '@/lib/photoStorage';
import { RsvpRow } from '@/components/RsvpRow';
export default async function EventGuestsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@ -208,19 +209,12 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
<th className="py-2 font-medium">Status</th>
<th className="py-2 font-medium">Guests</th>
<th className="py-2 font-medium">Note</th>
<th className="py-2 font-medium" />
</tr>
</thead>
<tbody>
{event.rsvps.map((rsvp) => (
<tr key={rsvp.id} className="border-b last:border-0">
<td className="py-2">
<div>{rsvp.guestName}</div>
<div className="text-xs text-gray-400">{rsvp.guestEmail}</div>
</td>
<td className="py-2">{rsvp.status}</td>
<td className="py-2">{rsvp.numGuests}</td>
<td className="py-2 text-gray-500">{rsvp.note}</td>
</tr>
<RsvpRow key={rsvp.id} rsvp={rsvp} />
))}
</tbody>
</table>

View File

@ -0,0 +1,82 @@
'use client';
import { useActionState } from 'react';
import { updateRsvpAction, type RsvpEditState } from '@/app/actions/rsvp';
import { SubmitButton } from '@/components/SubmitButton';
const initialState: RsvpEditState = {};
const inputClass =
'w-full rounded-md border border-gray-300 px-2 py-1 text-sm focus:border-indigo-500 focus:outline-none';
export function EditRsvpForm({
rsvpId,
guestName,
status,
numGuests,
note,
onDone,
}: {
rsvpId: string;
guestName: string;
status: 'YES' | 'NO' | 'MAYBE';
numGuests: number;
note: string;
onDone: () => void;
}) {
const action = updateRsvpAction.bind(null, rsvpId);
const [state, formAction] = useActionState(action, initialState);
return (
<form
action={async (formData) => {
await formAction(formData);
onDone();
}}
className="flex flex-col gap-2 rounded-md bg-gray-50 p-3"
>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div className="col-span-2 sm:col-span-1">
<label className="block text-xs font-medium text-gray-500">Name</label>
<input name="guestName" defaultValue={guestName} required className={inputClass} />
</div>
<div>
<label className="block text-xs font-medium text-gray-500">Status</label>
<select name="status" defaultValue={status} className={inputClass}>
<option value="YES">Yes</option>
<option value="MAYBE">Maybe</option>
<option value="NO">Can&apos;t make it</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500">Guests</label>
<input
name="numGuests"
type="number"
min={1}
max={50}
defaultValue={numGuests}
className={inputClass}
/>
</div>
<div className="col-span-2 sm:col-span-1">
<label className="block text-xs font-medium text-gray-500">Note</label>
<input name="note" defaultValue={note} className={inputClass} />
</div>
</div>
{state.error && <p className="text-xs text-red-700">{state.error}</p>}
<div className="flex gap-2">
<SubmitButton className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
Save
</SubmitButton>
<button
type="button"
onClick={onDone}
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
);
}

View File

@ -0,0 +1,64 @@
'use client';
import { useState } from 'react';
import { deleteRsvpAction } from '@/app/actions/rsvp';
import { EditRsvpForm } from '@/components/EditRsvpForm';
type Status = 'YES' | 'NO' | 'MAYBE';
export function RsvpRow({
rsvp,
}: {
rsvp: {
id: string;
guestName: string;
guestEmail: string | null;
guestPhone: string | null;
status: Status;
numGuests: number;
note: string;
};
}) {
const [editing, setEditing] = useState(false);
if (editing) {
return (
<tr className="border-b last:border-0">
<td colSpan={5} className="py-2">
<EditRsvpForm
rsvpId={rsvp.id}
guestName={rsvp.guestName}
status={rsvp.status}
numGuests={rsvp.numGuests}
note={rsvp.note}
onDone={() => setEditing(false)}
/>
</td>
</tr>
);
}
return (
<tr className="border-b last:border-0">
<td className="py-2">
<div>{rsvp.guestName}</div>
<div className="text-xs text-gray-400">{rsvp.guestEmail ?? rsvp.guestPhone}</div>
</td>
<td className="py-2">{rsvp.status}</td>
<td className="py-2">{rsvp.numGuests}</td>
<td className="py-2 text-gray-500">{rsvp.note}</td>
<td className="py-2 text-right">
<div className="flex justify-end gap-3 text-xs">
<button type="button" onClick={() => setEditing(true)} className="text-gray-500 hover:text-gray-800">
Edit
</button>
<form action={deleteRsvpAction.bind(null, rsvp.id)}>
<button type="submit" className="text-red-500 hover:text-red-700">
Remove
</button>
</form>
</div>
</td>
</tr>
);
}

View File

@ -88,6 +88,13 @@ export const rsvpSchema = z.object({
note: z.string().trim().max(1000).default(''),
});
export const rsvpUpdateSchema = z.object({
guestName: z.string().trim().min(1, 'Name is required').max(150),
status: z.enum(['YES', 'NO', 'MAYBE']),
numGuests: z.coerce.number().int().min(1).max(50),
note: z.string().trim().max(1000).default(''),
});
export const commentSchema = z.object({
eventId: z.string().min(1),
authorName: z.string().trim().min(1, 'Name is required').max(150),