feat: invitation deletion
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user