feat: edit guest from guest groups
This commit is contained in:
@@ -6,13 +6,26 @@ import { redirect } from "next/navigation"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { isAdminEmail } from "@/lib/admin-auth"
|
import { isAdminEmail } from "@/lib/admin-auth"
|
||||||
import { deleteInvitationGroup } from "@/lib/db/queries/invitations"
|
import { deleteInvitationGroup } from "@/lib/db/queries/invitations"
|
||||||
|
import {
|
||||||
|
deleteInvitationMember,
|
||||||
|
updateInvitationMemberName,
|
||||||
|
} from "@/lib/db/queries/members"
|
||||||
import { deleteUploadObject } from "@/lib/storage/uploads"
|
import { deleteUploadObject } from "@/lib/storage/uploads"
|
||||||
import { deleteInvitationSchema } from "@/lib/validation/invitation"
|
import { deleteInvitationSchema } from "@/lib/validation/invitation"
|
||||||
|
import {
|
||||||
|
deleteInvitationMemberSchema,
|
||||||
|
updateInvitationMemberSchema,
|
||||||
|
} from "@/lib/validation/member"
|
||||||
|
|
||||||
export interface DeleteInvitationActionState {
|
export interface DeleteInvitationActionState {
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemberActionState {
|
||||||
|
error: string | null
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteInvitationAction(
|
export async function deleteInvitationAction(
|
||||||
_previousState: DeleteInvitationActionState,
|
_previousState: DeleteInvitationActionState,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
@@ -62,3 +75,82 @@ export async function deleteInvitationAction(
|
|||||||
revalidatePath("/admin/guests")
|
revalidatePath("/admin/guests")
|
||||||
redirect("/admin/guests?deleted=1")
|
redirect("/admin/guests?deleted=1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateInvitationMemberAction(
|
||||||
|
_previousState: MemberActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<MemberActionState> {
|
||||||
|
const session = await auth()
|
||||||
|
const adminEmail = session?.user?.email
|
||||||
|
|
||||||
|
if (!isAdminEmail(adminEmail)) {
|
||||||
|
return { error: "You are not allowed to edit guest members.", success: false }
|
||||||
|
}
|
||||||
|
const authorizedAdminEmail = adminEmail ?? ""
|
||||||
|
|
||||||
|
const parsed = updateInvitationMemberSchema.safeParse({
|
||||||
|
invitationId: formData.get("invitationId")?.toString(),
|
||||||
|
memberId: formData.get("memberId")?.toString(),
|
||||||
|
name: formData.get("name")?.toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) return { error: "Enter a valid member name.", success: false }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateInvitationMemberName({
|
||||||
|
...parsed.data,
|
||||||
|
adminEmail: authorizedAdminEmail,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === "not_found") {
|
||||||
|
return { error: "This guest member was not found.", success: false }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update invitation member", error)
|
||||||
|
return { error: "Could not update this guest member. Please try again.", success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/admin/guests/${parsed.data.invitationId}`)
|
||||||
|
revalidatePath("/admin/guests")
|
||||||
|
revalidatePath("/admin")
|
||||||
|
return { error: null, success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInvitationMemberAction(
|
||||||
|
_previousState: MemberActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<MemberActionState> {
|
||||||
|
const session = await auth()
|
||||||
|
const adminEmail = session?.user?.email
|
||||||
|
|
||||||
|
if (!isAdminEmail(adminEmail)) {
|
||||||
|
return { error: "You are not allowed to delete guest members.", success: false }
|
||||||
|
}
|
||||||
|
const authorizedAdminEmail = adminEmail ?? ""
|
||||||
|
|
||||||
|
const parsed = deleteInvitationMemberSchema.safeParse({
|
||||||
|
invitationId: formData.get("invitationId")?.toString(),
|
||||||
|
memberId: formData.get("memberId")?.toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) return { error: "Could not delete this guest member.", success: false }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deleteInvitationMember({ ...parsed.data, adminEmail: authorizedAdminEmail })
|
||||||
|
|
||||||
|
if (result.status === "not_found") {
|
||||||
|
return { error: "This guest member was already removed.", success: false }
|
||||||
|
}
|
||||||
|
if (result.status === "last_member") {
|
||||||
|
return { error: "A guest group must have at least one member.", success: false }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete invitation member", error)
|
||||||
|
return { error: "Could not delete this guest member. Please try again.", success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/admin/guests/${parsed.data.invitationId}`)
|
||||||
|
revalidatePath("/admin/guests")
|
||||||
|
revalidatePath("/admin")
|
||||||
|
return { error: null, success: true }
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { DeleteInvitationDialog } from "@/components/admin/delete-invitation-dialog"
|
||||||
import { InvitationLanguageSelect } from "@/components/admin/invitation-language-select"
|
import { InvitationLanguageSelect } from "@/components/admin/invitation-language-select"
|
||||||
|
import { MemberManagement } from "@/components/admin/member-management"
|
||||||
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"
|
||||||
@@ -195,6 +196,7 @@ export default async function GuestDetailPage({
|
|||||||
<TableHead>Attending</TableHead>
|
<TableHead>Attending</TableHead>
|
||||||
<TableHead>Meal</TableHead>
|
<TableHead>Meal</TableHead>
|
||||||
<TableHead>Dietary restrictions</TableHead>
|
<TableHead>Dietary restrictions</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -205,6 +207,14 @@ export default async function GuestDetailPage({
|
|||||||
<TableCell>{formatBoolean(member.attending)}</TableCell>
|
<TableCell>{formatBoolean(member.attending)}</TableCell>
|
||||||
<TableCell>{member.meal_preference ?? "—"}</TableCell>
|
<TableCell>{member.meal_preference ?? "—"}</TableCell>
|
||||||
<TableCell>{member.dietary_restrictions ?? "—"}</TableCell>
|
<TableCell>{member.dietary_restrictions ?? "—"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<MemberManagement
|
||||||
|
invitationId={detail.invitation.id}
|
||||||
|
memberId={member.id}
|
||||||
|
memberName={member.name}
|
||||||
|
canDelete={detail.members.length > 1}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteInvitationMemberAction,
|
||||||
|
updateInvitationMemberAction,
|
||||||
|
} from "@/app/admin/guests/[id]/actions"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
|
interface MemberManagementProps {
|
||||||
|
invitationId: string
|
||||||
|
memberId: string
|
||||||
|
memberName: string
|
||||||
|
canDelete: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubmitButton({ children, variant = "default", pending }: { children: React.ReactNode; variant?: "default" | "destructive"; pending: boolean }) {
|
||||||
|
return (
|
||||||
|
<Button type="submit" variant={variant} disabled={pending}>
|
||||||
|
{pending ? "Saving..." : children}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberManagement({
|
||||||
|
invitationId,
|
||||||
|
memberId,
|
||||||
|
memberName,
|
||||||
|
canDelete,
|
||||||
|
}: MemberManagementProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [name, setName] = useState(memberName)
|
||||||
|
const [editError, setEditError] = useState<string | null>(null)
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
const [pendingAction, setPendingAction] = useState<"edit" | "delete" | null>(null)
|
||||||
|
|
||||||
|
async function submitEdit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setPendingAction("edit")
|
||||||
|
setEditError(null)
|
||||||
|
const result = await updateInvitationMemberAction({ error: null, success: false }, new FormData(event.currentTarget))
|
||||||
|
setPendingAction(null)
|
||||||
|
if (result.error) {
|
||||||
|
setEditError(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEditOpen(false)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDelete(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setPendingAction("delete")
|
||||||
|
setDeleteError(null)
|
||||||
|
const result = await deleteInvitationMemberAction({ error: null, success: false }, new FormData(event.currentTarget))
|
||||||
|
setPendingAction(null)
|
||||||
|
if (result.error) {
|
||||||
|
setDeleteError(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDeleteOpen(false)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<AlertDialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button type="button" size="sm" variant="outline">Edit</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<form onSubmit={submitEdit} className="space-y-4">
|
||||||
|
<input type="hidden" name="invitationId" value={invitationId} />
|
||||||
|
<input type="hidden" name="memberId" value={memberId} />
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Edit guest member</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Update the member's name.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
aria-label="Member name"
|
||||||
|
autoFocus
|
||||||
|
maxLength={200}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{editError ? <p className="text-sm text-destructive">{editError}</p> : null}
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel type="button">Cancel</AlertDialogCancel>
|
||||||
|
<SubmitButton pending={pendingAction === "edit"}>Save changes</SubmitButton>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</form>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{canDelete ? (
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button type="button" size="sm" variant="destructive">Delete</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<form onSubmit={submitDelete} className="space-y-4">
|
||||||
|
<input type="hidden" name="invitationId" value={invitationId} />
|
||||||
|
<input type="hidden" name="memberId" value={memberId} />
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete {memberName}?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This removes the member from the invitation. If they are the primary contact,
|
||||||
|
another member will become primary.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
{deleteError ? <p className="text-sm text-destructive">{deleteError}</p> : null}
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel type="button">Cancel</AlertDialogCancel>
|
||||||
|
<SubmitButton variant="destructive" pending={pendingAction === "delete"}>Delete member</SubmitButton>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</form>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { db } from "@/lib/db/client"
|
import { db } from "@/lib/db/client"
|
||||||
|
|
||||||
|
export type MemberMutationResult =
|
||||||
|
| { status: "updated"; memberId: string }
|
||||||
|
| { status: "deleted"; memberId: string }
|
||||||
|
| { status: "not_found" }
|
||||||
|
| { status: "last_member" }
|
||||||
|
|
||||||
export async function listInvitationMembers(invitationId: string) {
|
export async function listInvitationMembers(invitationId: string) {
|
||||||
return db
|
return db
|
||||||
.selectFrom("invitation_members")
|
.selectFrom("invitation_members")
|
||||||
@@ -9,3 +15,152 @@ export async function listInvitationMembers(invitationId: string) {
|
|||||||
.orderBy("created_at", "asc")
|
.orderBy("created_at", "asc")
|
||||||
.execute()
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateInvitationMemberName({
|
||||||
|
invitationId,
|
||||||
|
memberId,
|
||||||
|
name,
|
||||||
|
adminEmail,
|
||||||
|
}: {
|
||||||
|
invitationId: string
|
||||||
|
memberId: string
|
||||||
|
name: string
|
||||||
|
adminEmail: string
|
||||||
|
}): Promise<MemberMutationResult> {
|
||||||
|
return db.transaction().execute(async (trx) => {
|
||||||
|
const member = await trx
|
||||||
|
.selectFrom("invitation_members")
|
||||||
|
.innerJoin("invitations", "invitations.id", "invitation_members.invitation_id")
|
||||||
|
.select(["invitation_members.id", "invitation_members.name", "invitations.display_name"])
|
||||||
|
.where("invitation_members.id", "=", memberId)
|
||||||
|
.where("invitation_members.invitation_id", "=", invitationId)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!member) return { status: "not_found" }
|
||||||
|
|
||||||
|
const updated = await trx
|
||||||
|
.updateTable("invitation_members")
|
||||||
|
.set({ name, updated_at: new Date() })
|
||||||
|
.where("id", "=", memberId)
|
||||||
|
.where("invitation_id", "=", invitationId)
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!updated) return { status: "not_found" }
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.updateTable("invitations")
|
||||||
|
.set({ updated_at: new Date() })
|
||||||
|
.where("id", "=", invitationId)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insertInto("admin_audit_events")
|
||||||
|
.values({
|
||||||
|
admin_email: adminEmail,
|
||||||
|
action: "invitation_member.updated",
|
||||||
|
target_type: "invitation_member",
|
||||||
|
target_id: memberId,
|
||||||
|
metadata: {
|
||||||
|
invitationId,
|
||||||
|
displayName: member.display_name,
|
||||||
|
previousName: member.name,
|
||||||
|
newName: name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
return { status: "updated", memberId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInvitationMember({
|
||||||
|
invitationId,
|
||||||
|
memberId,
|
||||||
|
adminEmail,
|
||||||
|
}: {
|
||||||
|
invitationId: string
|
||||||
|
memberId: string
|
||||||
|
adminEmail: string
|
||||||
|
}): Promise<MemberMutationResult> {
|
||||||
|
return db.transaction().execute(async (trx) => {
|
||||||
|
const member = await trx
|
||||||
|
.selectFrom("invitation_members")
|
||||||
|
.innerJoin("invitations", "invitations.id", "invitation_members.invitation_id")
|
||||||
|
.select([
|
||||||
|
"invitation_members.id",
|
||||||
|
"invitation_members.name",
|
||||||
|
"invitation_members.is_primary_contact",
|
||||||
|
"invitations.display_name",
|
||||||
|
])
|
||||||
|
.where("invitation_members.id", "=", memberId)
|
||||||
|
.where("invitation_members.invitation_id", "=", invitationId)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!member) return { status: "not_found" }
|
||||||
|
|
||||||
|
const count = await trx
|
||||||
|
.selectFrom("invitation_members")
|
||||||
|
.select((eb) => eb.fn.countAll<string>().as("count"))
|
||||||
|
.where("invitation_id", "=", invitationId)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
const memberCount = Number(count.count)
|
||||||
|
|
||||||
|
if (memberCount <= 1) return { status: "last_member" }
|
||||||
|
|
||||||
|
let promotedMemberId: string | null = null
|
||||||
|
if (member.is_primary_contact) {
|
||||||
|
const replacement = await trx
|
||||||
|
.selectFrom("invitation_members")
|
||||||
|
.select(["id"])
|
||||||
|
.where("invitation_id", "=", invitationId)
|
||||||
|
.where("id", "!=", memberId)
|
||||||
|
.orderBy("created_at", "asc")
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (replacement) {
|
||||||
|
promotedMemberId = replacement.id
|
||||||
|
await trx
|
||||||
|
.updateTable("invitation_members")
|
||||||
|
.set({ is_primary_contact: true, updated_at: new Date() })
|
||||||
|
.where("id", "=", replacement.id)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await trx
|
||||||
|
.deleteFrom("invitation_members")
|
||||||
|
.where("id", "=", memberId)
|
||||||
|
.where("invitation_id", "=", invitationId)
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!deleted) return { status: "not_found" }
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.updateTable("invitations")
|
||||||
|
.set({ updated_at: new Date() })
|
||||||
|
.where("id", "=", invitationId)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insertInto("admin_audit_events")
|
||||||
|
.values({
|
||||||
|
admin_email: adminEmail,
|
||||||
|
action: "invitation_member.deleted",
|
||||||
|
target_type: "invitation_member",
|
||||||
|
target_id: memberId,
|
||||||
|
metadata: {
|
||||||
|
invitationId,
|
||||||
|
displayName: member.display_name,
|
||||||
|
name: member.name,
|
||||||
|
wasPrimary: member.is_primary_contact,
|
||||||
|
promotedMemberId,
|
||||||
|
remainingMembers: memberCount - 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
return { status: "deleted", memberId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const memberName = z.string().trim().min(1, "Member name is required").max(200, "Member name is too long")
|
||||||
|
|
||||||
|
export const updateInvitationMemberSchema = z.object({
|
||||||
|
invitationId: z.uuid(),
|
||||||
|
memberId: z.uuid(),
|
||||||
|
name: memberName,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteInvitationMemberSchema = z.object({
|
||||||
|
invitationId: z.uuid(),
|
||||||
|
memberId: z.uuid(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type UpdateInvitationMemberInput = z.infer<typeof updateInvitationMemberSchema>
|
||||||
|
export type DeleteInvitationMemberInput = z.infer<typeof deleteInvitationMemberSchema>
|
||||||
Reference in New Issue
Block a user