feat: invitation deletion
Deploy On Server / Prepare Repository Remotely (push) Successful in 10s
Deploy On Server / Docker Compose Up (push) Successful in 18s
Deploy On Server / Prune Images (push) Successful in 3s
Deploy On Server / Remove .env File (push) Successful in 2s

This commit is contained in:
Raul Lugo
2026-08-11 22:18:25 +02:00
parent d4c542b93b
commit aed8684e09
7 changed files with 377 additions and 2 deletions
+64
View File
@@ -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<DeleteInvitationActionState> {
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<ReturnType<typeof deleteInvitationGroup>>
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")
}
+6 -1
View File
@@ -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({
</h1>
<p className="text-muted-foreground">Invitation details and member list.</p>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href="/admin/guests">Back to list</Link>
</Button>
<CopyInviteButton inviteUrl={inviteUrl} />
<DeleteInvitationDialog
invitationId={detail.invitation.id}
displayName={detail.invitation.display_name}
/>
</div>
</div>
+16 -1
View File
@@ -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() {
</Button>
</div>
{deleted ? (
<Alert>
<AlertTitle>Guest group deleted</AlertTitle>
<AlertDescription>
The invitation and its associated guest information were permanently removed.
</AlertDescription>
</Alert>
) : null}
<Card>
<CardHeader>
<CardTitle>Invitation list</CardTitle>
@@ -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 (
<Button type="submit" variant="destructive" disabled={pending}>
{pending ? "Deleting..." : "Delete permanently"}
</Button>
)
}
export function DeleteInvitationDialog({
invitationId,
displayName,
}: DeleteInvitationDialogProps) {
const [open, setOpen] = useState(false)
const [state, formAction] = useActionState(deleteInvitationAction, initialState)
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete group</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<form action={formAction} className="space-y-4">
<input type="hidden" name="invitationId" value={invitationId} />
<AlertDialogHeader>
<AlertDialogTitle>Delete {displayName}?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes this invitation group, its members, RSVP information,
toast requests, and uploaded photo. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
{state.error ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{state.error}
</p>
) : null}
<AlertDialogFooter>
<AlertDialogCancel type="button">Cancel</AlertDialogCancel>
<DeleteSubmitButton />
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
)
}
+148
View File
@@ -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<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+63
View File
@@ -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<DeletedInvitationResult | null> {
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<string>().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<InvitationListItem[]> {
const invitations = await db
.selectFrom("invitations")
+5
View File
@@ -45,4 +45,9 @@ export const createInvitationSchema = z
path: ["members"],
})
export const deleteInvitationSchema = z.object({
invitationId: z.uuid(),
})
export type CreateInvitationInput = z.infer<typeof createInvitationSchema>
export type DeleteInvitationInput = z.infer<typeof deleteInvitationSchema>