diff --git a/src/app/actions/rsvp.ts b/src/app/actions/rsvp.ts index c9f5785..2460af8 100644 --- a/src/app/actions/rsvp.ts +++ b/src/app/actions/rsvp.ts @@ -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 { + 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}`); +} diff --git a/src/app/dashboard/events/[id]/guests/page.tsx b/src/app/dashboard/events/[id]/guests/page.tsx index fc3a818..068e1c5 100644 --- a/src/app/dashboard/events/[id]/guests/page.tsx +++ b/src/app/dashboard/events/[id]/guests/page.tsx @@ -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 Status Guests Note + {event.rsvps.map((rsvp) => ( - - -
{rsvp.guestName}
-
{rsvp.guestEmail}
- - {rsvp.status} - {rsvp.numGuests} - {rsvp.note} - + ))} diff --git a/src/components/EditRsvpForm.tsx b/src/components/EditRsvpForm.tsx new file mode 100644 index 0000000..d621f03 --- /dev/null +++ b/src/components/EditRsvpForm.tsx @@ -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 ( +
{ + await formAction(formData); + onDone(); + }} + className="flex flex-col gap-2 rounded-md bg-gray-50 p-3" + > +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {state.error &&

{state.error}

} +
+ + Save + + +
+
+ ); +} diff --git a/src/components/RsvpRow.tsx b/src/components/RsvpRow.tsx new file mode 100644 index 0000000..9840ce8 --- /dev/null +++ b/src/components/RsvpRow.tsx @@ -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 ( + + + setEditing(false)} + /> + + + ); + } + + return ( + + +
{rsvp.guestName}
+
{rsvp.guestEmail ?? rsvp.guestPhone}
+ + {rsvp.status} + {rsvp.numGuests} + {rsvp.note} + +
+ +
+ +
+
+ + + ); +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 2688e84..a67a25f 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -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),