diff --git a/src/api/routes/tickets.ts b/src/api/routes/tickets.ts index ee93ed9f..ed571eb3 100644 --- a/src/api/routes/tickets.ts +++ b/src/api/routes/tickets.ts @@ -392,6 +392,12 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { withTags(["Tickets/Merchandise"], { summary: "Mark a ticket/merch item as fulfilled by QR code data.", body: postSchema, + headers: z.object({ + "x-auditlog-context": z.optional(z.string().min(1)).meta({ + description: + "optional additional context to add to the audit log.", + }), + }), }), ), onRequest: fastify.authorizeFromSchema, @@ -513,22 +519,23 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { message: "Could not set ticket to used - database operation failed", }); } - reply.send({ - valid: true, - type: request.body.type, - ticketId, - purchaserData, - }); + const headerReason = request.headers["x-auditlog-context"]; await createAuditLogEntry({ - dynamoClient: UsEast1DynamoClient, + dynamoClient: fastify.dynamoClient, entry: { module: Modules.TICKETS, actor: request.username!, target: ticketId, - message: `checked in ticket of type "${request.body.type}" ${request.body.type === "merch" ? `purchased by email ${request.body.email}.` : "."}`, + message: `checked in ticket of type "${request.body.type}" ${request.body.type === "merch" ? `purchased by email ${request.body.email}.` : "."}${headerReason ? `\nUser-provided context: "${headerReason}"` : ""}`, requestId: request.id, }, }); + return reply.send({ + valid: true, + type: request.body.type, + ticketId, + purchaserData, + }); }, ); fastify.withTypeProvider().post( diff --git a/src/ui/pages/tickets/ViewTickets.page.tsx b/src/ui/pages/tickets/ViewTickets.page.tsx index 9ba85a31..c7ed04b9 100644 --- a/src/ui/pages/tickets/ViewTickets.page.tsx +++ b/src/ui/pages/tickets/ViewTickets.page.tsx @@ -7,7 +7,12 @@ import { Badge, Title, Button, + Modal, + Stack, + TextInput, + Alert, } from "@mantine/core"; +import { IconAlertCircle } from "@tabler/icons-react"; import { notifications } from "@mantine/notifications"; import pluralize from "pluralize"; import React, { useEffect, useState } from "react"; @@ -59,6 +64,7 @@ enum TicketsCopyMode { FULFILLED, UNFULFILLED, } +const WAIT_BEFORE_FULFILLING_SECS = 15; const ViewTicketsPage: React.FC = () => { const { eventId } = useParams(); @@ -72,6 +78,57 @@ const ViewTicketsPage: React.FC = () => { const [pageSize, setPageSize] = useState("10"); const pageSizeOptions = ["10", "25", "50", "100"]; + // Confirmation modal states + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [confirmEmail, setConfirmEmail] = useState(""); + const [ticketToFulfill, setTicketToFulfill] = useState( + null, + ); + const [confirmError, setConfirmError] = useState(""); + const [confirmButtonEnabled, setConfirmButtonEnabled] = useState(false); + const [countdown, setCountdown] = useState(3); + + useEffect(() => { + if (showConfirmModal) { + setConfirmButtonEnabled(false); + setCountdown(WAIT_BEFORE_FULFILLING_SECS); + + const handleVisibilityChange = () => { + if (document.hidden) { + // Reset the timer when user leaves the page + setCountdown(WAIT_BEFORE_FULFILLING_SECS + 1); + setConfirmButtonEnabled(false); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + const countdownInterval = setInterval(() => { + // Only count down if the page is focused + if (document.hidden) { + return; + } + + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(countdownInterval); + setConfirmButtonEnabled(true); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => { + clearInterval(countdownInterval); + document.removeEventListener( + "visibilitychange", + handleVisibilityChange, + ); + }; + } + }, [showConfirmModal]); + const copyEmails = (mode: TicketsCopyMode) => { try { let emailsToCopy: string[] = []; @@ -109,20 +166,60 @@ const ViewTicketsPage: React.FC = () => { } }; - async function checkInUser(ticket: TicketEntry) { + const handleOpenConfirmModal = (ticket: TicketEntry) => { + setTicketToFulfill(ticket); + setConfirmEmail(""); + setConfirmError(""); + setShowConfirmModal(true); + }; + + const handleCloseConfirmModal = () => { + setShowConfirmModal(false); + setTicketToFulfill(null); + setConfirmEmail(""); + setConfirmError(""); + setConfirmButtonEnabled(false); + setCountdown(WAIT_BEFORE_FULFILLING_SECS); + }; + + const handleConfirmFulfillment = async () => { + if (!ticketToFulfill) { + return; + } + + // Validate email matches + if ( + confirmEmail.toLowerCase().trim() !== + ticketToFulfill.purchaserData.email.toLowerCase().trim() + ) { + setConfirmError( + "Email does not match. Please enter the exact email address.", + ); + return; + } + try { - const response = await api.post(`/api/v1/tickets/checkIn`, { - type: ticket.type, - email: ticket.purchaserData.email, - stripePi: ticket.ticketId, - }); + const response = await api.post( + `/api/v1/tickets/checkIn`, + { + type: ticketToFulfill.type, + email: ticketToFulfill.purchaserData.email, + stripePi: ticketToFulfill.ticketId, + }, + { + headers: { + "x-auditlog-context": "Manually marked as fulfilled.", + }, + }, + ); if (!response.data.valid) { throw new Error("Ticket is invalid."); } notifications.show({ title: "Fulfilled", - message: "Marked item as fulfilled.", + message: "Marked item as fulfilled. This action has been logged.", }); + handleCloseConfirmModal(); await getTickets(); } catch { notifications.show({ @@ -130,7 +227,12 @@ const ViewTicketsPage: React.FC = () => { message: "Failed to fulfill item. Please try again later.", color: "red", }); + handleCloseConfirmModal(); } + }; + + async function checkInUser(ticket: TicketEntry) { + handleOpenConfirmModal(ticket); } const getTickets = async () => { try { @@ -280,6 +382,83 @@ const ViewTicketsPage: React.FC = () => { /> + + {/* Confirmation Modal */} + + + } + title="Warning" + color="red" + variant="light" + > + + This action cannot be undone and will be logged! + + + + {ticketToFulfill && ( + <> + + Purchase Details: + + + Email: {ticketToFulfill.purchaserData.email} + + + Quantity:{" "} + {ticketToFulfill.purchaserData.quantity} + + {ticketToFulfill.purchaserData.size && ( + + Size: {ticketToFulfill.purchaserData.size} + + )} + + )} + + { + setConfirmEmail(e.currentTarget.value); + setConfirmError(""); + }} + error={confirmError} + required + autoComplete="off" + data-autofocus + /> + + + Please enter the email address{" "} + {ticketToFulfill?.purchaserData.email} to confirm + that you want to mark this purchase as fulfilled. + + + + + + + + ); };