diff --git a/app/admin/guests/[id]/actions.ts b/app/admin/guests/[id]/actions.ts new file mode 100644 index 0000000..2114217 --- /dev/null +++ b/app/admin/guests/[id]/actions.ts @@ -0,0 +1,64 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" + +import { auth } from "@/auth" +import { isAdminEmail } from "@/lib/admin-auth" +import { deleteInvitationGroup } from "@/lib/db/queries/invitations" +import { deleteUploadObject } from "@/lib/storage/uploads" +import { deleteInvitationSchema } from "@/lib/validation/invitation" + +export interface DeleteInvitationActionState { + error: string | null +} + +export async function deleteInvitationAction( + _previousState: DeleteInvitationActionState, + formData: FormData, +): Promise { + const session = await auth() + const adminEmail = session?.user?.email + + if (!isAdminEmail(adminEmail)) { + return { error: "You are not allowed to delete invitations." } + } + + const authorizedAdminEmail = adminEmail ?? "" + + const parsed = deleteInvitationSchema.safeParse({ + invitationId: formData.get("invitationId"), + }) + + if (!parsed.success) { + return { error: "Could not delete this guest group." } + } + + let deleted: Awaited> + + try { + deleted = await deleteInvitationGroup(parsed.data.invitationId, authorizedAdminEmail) + } catch (error) { + console.error("Failed to delete invitation group", error) + return { error: "Could not delete this guest group. Please try again." } + } + + if (!deleted) { + return { error: "This guest group was already removed." } + } + + if (deleted.photoObjectKey) { + try { + await deleteUploadObject(deleted.photoObjectKey) + } catch (error) { + console.error("Failed to delete uploaded photo object", { + invitationId: deleted.id, + objectKey: deleted.photoObjectKey, + error, + }) + } + } + + revalidatePath("/admin/guests") + redirect("/admin/guests?deleted=1") +} diff --git a/app/admin/guests/[id]/page.tsx b/app/admin/guests/[id]/page.tsx index e43d6d0..44025a0 100644 --- a/app/admin/guests/[id]/page.tsx +++ b/app/admin/guests/[id]/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link" import { notFound } from "next/navigation" import { CopyInviteButton } from "@/components/admin/copy-invite-button" +import { DeleteInvitationDialog } from "@/components/admin/delete-invitation-dialog" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -104,11 +105,15 @@ export default async function GuestDetailPage({

Invitation details and member list.

-
+
+
diff --git a/app/admin/guests/page.tsx b/app/admin/guests/page.tsx index 6d4cc41..232bd92 100644 --- a/app/admin/guests/page.tsx +++ b/app/admin/guests/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link" import { CopyInviteButton } from "@/components/admin/copy-invite-button" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { @@ -33,7 +34,12 @@ function getStatusVariant(status: string): "default" | "secondary" | "outline" { return "outline" } -export default async function GuestsPage() { +export default async function GuestsPage({ + searchParams, +}: { + searchParams: Promise<{ deleted?: string }> +}) { + const { deleted } = await searchParams const invitations = await listInvitations() return ( @@ -50,6 +56,15 @@ export default async function GuestsPage() { + {deleted ? ( + + Guest group deleted + + The invitation and its associated guest information were permanently removed. + + + ) : null} + Invitation list diff --git a/components/admin/delete-invitation-dialog.tsx b/components/admin/delete-invitation-dialog.tsx new file mode 100644 index 0000000..6030abe --- /dev/null +++ b/components/admin/delete-invitation-dialog.tsx @@ -0,0 +1,75 @@ +"use client" + +import { useActionState, useState } from "react" +import { useFormStatus } from "react-dom" + +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import type { DeleteInvitationActionState } from "@/app/admin/guests/[id]/actions" +import { deleteInvitationAction } from "@/app/admin/guests/[id]/actions" + +interface DeleteInvitationDialogProps { + invitationId: string + displayName: string +} + +const initialState: DeleteInvitationActionState = { error: null } + +function DeleteSubmitButton() { + const { pending } = useFormStatus() + + return ( + + ) +} + +export function DeleteInvitationDialog({ + invitationId, + displayName, +}: DeleteInvitationDialogProps) { + const [open, setOpen] = useState(false) + const [state, formAction] = useActionState(deleteInvitationAction, initialState) + + return ( + + + + + +
+ + + + Delete {displayName}? + + This permanently removes this invitation group, its members, RSVP information, + toast requests, and uploaded photo. This action cannot be undone. + + + + {state.error ? ( +

+ {state.error} +

+ ) : null} + + + Cancel + + +
+
+
+ ) +} diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..ff37090 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,148 @@ +"use client" + +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/lib/db/queries/invitations.ts b/lib/db/queries/invitations.ts index dfeb519..03d3b68 100644 --- a/lib/db/queries/invitations.ts +++ b/lib/db/queries/invitations.ts @@ -75,6 +75,69 @@ export async function createInvitationWithMembers(input: CreateInvitationInput) }) } +export interface DeletedInvitationResult { + id: string + displayName: string + photoObjectKey: string | null +} + +export async function deleteInvitationGroup( + invitationId: string, + adminEmail: string, +): Promise { + return db.transaction().execute(async (trx) => { + const invitation = await trx + .selectFrom("invitations") + .select(["id", "display_name", "token"]) + .where("id", "=", invitationId) + .executeTakeFirst() + + if (!invitation) return null + + const photoUpload = await trx + .selectFrom("photo_uploads") + .select(["object_key"]) + .where("invitation_id", "=", invitationId) + .executeTakeFirst() + + const membersCount = await trx + .selectFrom("invitation_members") + .select((eb) => eb.fn.countAll().as("count")) + .where("invitation_id", "=", invitationId) + .executeTakeFirst() + + await trx + .insertInto("admin_audit_events") + .values({ + admin_email: adminEmail, + action: "invitation.deleted", + target_type: "invitation", + target_id: invitationId, + metadata: { + displayName: invitation.display_name, + token: invitation.token, + membersCount: Number(membersCount?.count ?? 0), + photoObjectKey: photoUpload?.object_key ?? null, + }, + }) + .execute() + + const deleted = await trx + .deleteFrom("invitations") + .where("id", "=", invitationId) + .returning(["id", "display_name"]) + .executeTakeFirst() + + if (!deleted) return null + + return { + id: deleted.id, + displayName: deleted.display_name, + photoObjectKey: photoUpload?.object_key ?? null, + } + }) +} + export async function listInvitations(): Promise { const invitations = await db .selectFrom("invitations") diff --git a/lib/validation/invitation.ts b/lib/validation/invitation.ts index 357f737..e01e239 100644 --- a/lib/validation/invitation.ts +++ b/lib/validation/invitation.ts @@ -45,4 +45,9 @@ export const createInvitationSchema = z path: ["members"], }) +export const deleteInvitationSchema = z.object({ + invitationId: z.uuid(), +}) + export type CreateInvitationInput = z.infer +export type DeleteInvitationInput = z.infer