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 { notFound } from "next/navigation"
|
||||||
|
|
||||||
import { CopyInviteButton } from "@/components/admin/copy-invite-button"
|
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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -104,11 +105,15 @@ export default async function GuestDetailPage({
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">Invitation details and member list.</p>
|
<p className="text-muted-foreground">Invitation details and member list.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/admin/guests">Back to list</Link>
|
<Link href="/admin/guests">Back to list</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<CopyInviteButton inviteUrl={inviteUrl} />
|
<CopyInviteButton inviteUrl={inviteUrl} />
|
||||||
|
<DeleteInvitationDialog
|
||||||
|
invitationId={detail.invitation.id}
|
||||||
|
displayName={detail.invitation.display_name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
import { CopyInviteButton } from "@/components/admin/copy-invite-button"
|
import { CopyInviteButton } from "@/components/admin/copy-invite-button"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +34,12 @@ function getStatusVariant(status: string): "default" | "secondary" | "outline" {
|
|||||||
return "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()
|
const invitations = await listInvitations()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,6 +56,15 @@ export default async function GuestsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{deleted ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Guest group deleted</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
The invitation and its associated guest information were permanently removed.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Invitation list</CardTitle>
|
<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[]> {
|
export async function listInvitations(): Promise<InvitationListItem[]> {
|
||||||
const invitations = await db
|
const invitations = await db
|
||||||
.selectFrom("invitations")
|
.selectFrom("invitations")
|
||||||
|
|||||||
@@ -45,4 +45,9 @@ export const createInvitationSchema = z
|
|||||||
path: ["members"],
|
path: ["members"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const deleteInvitationSchema = z.object({
|
||||||
|
invitationId: z.uuid(),
|
||||||
|
})
|
||||||
|
|
||||||
export type CreateInvitationInput = z.infer<typeof createInvitationSchema>
|
export type CreateInvitationInput = z.infer<typeof createInvitationSchema>
|
||||||
|
export type DeleteInvitationInput = z.infer<typeof deleteInvitationSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user