diff --git a/app/admin/guests/[id]/actions.ts b/app/admin/guests/[id]/actions.ts index 2114217..c66b838 100644 --- a/app/admin/guests/[id]/actions.ts +++ b/app/admin/guests/[id]/actions.ts @@ -6,13 +6,26 @@ import { redirect } from "next/navigation" import { auth } from "@/auth" import { isAdminEmail } from "@/lib/admin-auth" import { deleteInvitationGroup } from "@/lib/db/queries/invitations" +import { + deleteInvitationMember, + updateInvitationMemberName, +} from "@/lib/db/queries/members" import { deleteUploadObject } from "@/lib/storage/uploads" import { deleteInvitationSchema } from "@/lib/validation/invitation" +import { + deleteInvitationMemberSchema, + updateInvitationMemberSchema, +} from "@/lib/validation/member" export interface DeleteInvitationActionState { error: string | null } +export interface MemberActionState { + error: string | null + success: boolean +} + export async function deleteInvitationAction( _previousState: DeleteInvitationActionState, formData: FormData, @@ -62,3 +75,82 @@ export async function deleteInvitationAction( revalidatePath("/admin/guests") redirect("/admin/guests?deleted=1") } + +export async function updateInvitationMemberAction( + _previousState: MemberActionState, + formData: FormData, +): Promise { + 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 { + 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 } +} diff --git a/app/admin/guests/[id]/page.tsx b/app/admin/guests/[id]/page.tsx index 878cb32..016628e 100644 --- a/app/admin/guests/[id]/page.tsx +++ b/app/admin/guests/[id]/page.tsx @@ -4,6 +4,7 @@ import { notFound } from "next/navigation" import { CopyInviteButton } from "@/components/admin/copy-invite-button" import { DeleteInvitationDialog } from "@/components/admin/delete-invitation-dialog" import { InvitationLanguageSelect } from "@/components/admin/invitation-language-select" +import { MemberManagement } from "@/components/admin/member-management" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -195,6 +196,7 @@ export default async function GuestDetailPage({ Attending Meal Dietary restrictions + Actions @@ -205,6 +207,14 @@ export default async function GuestDetailPage({ {formatBoolean(member.attending)} {member.meal_preference ?? "—"} {member.dietary_restrictions ?? "—"} + + 1} + /> + ))} diff --git a/components/admin/member-management.tsx b/components/admin/member-management.tsx new file mode 100644 index 0000000..7b6edd8 --- /dev/null +++ b/components/admin/member-management.tsx @@ -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 ( + + ) +} + +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(null) + const [deleteError, setDeleteError] = useState(null) + const [pendingAction, setPendingAction] = useState<"edit" | "delete" | null>(null) + + async function submitEdit(event: React.FormEvent) { + 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) { + 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 ( +
+ + + + + +
+ + + + Edit guest member + Update the member's name. + + setName(event.target.value)} + aria-label="Member name" + autoFocus + maxLength={200} + required + /> + {editError ?

{editError}

: null} + + Cancel + Save changes + +
+
+
+ + {canDelete ? ( + + + + + +
+ + + + Delete {memberName}? + + This removes the member from the invitation. If they are the primary contact, + another member will become primary. + + + {deleteError ?

{deleteError}

: null} + + Cancel + Delete member + +
+
+
+ ) : null} +
+ ) +} diff --git a/lib/db/queries/members.ts b/lib/db/queries/members.ts index aadd9b8..61992d8 100644 --- a/lib/db/queries/members.ts +++ b/lib/db/queries/members.ts @@ -1,5 +1,11 @@ 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) { return db .selectFrom("invitation_members") @@ -9,3 +15,152 @@ export async function listInvitationMembers(invitationId: string) { .orderBy("created_at", "asc") .execute() } + +export async function updateInvitationMemberName({ + invitationId, + memberId, + name, + adminEmail, +}: { + invitationId: string + memberId: string + name: string + adminEmail: string +}): Promise { + 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 { + 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().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 } + }) +} diff --git a/lib/validation/member.ts b/lib/validation/member.ts new file mode 100644 index 0000000..c427317 --- /dev/null +++ b/lib/validation/member.ts @@ -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 +export type DeleteInvitationMemberInput = z.infer