Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions src/api/routes/tickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<FastifyZodOpenApiTypeProvider>().post(
Expand Down
145 changes: 138 additions & 7 deletions src/ui/pages/tickets/ViewTickets.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -72,6 +77,14 @@ const ViewTicketsPage: React.FC = () => {
const [pageSize, setPageSize] = useState<string>("10");
const pageSizeOptions = ["10", "25", "50", "100"];

// Confirmation modal states
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [confirmEmail, setConfirmEmail] = useState("");
const [ticketToFulfill, setTicketToFulfill] = useState<TicketEntry | null>(
null,
);
const [confirmError, setConfirmError] = useState("");

const copyEmails = (mode: TicketsCopyMode) => {
try {
let emailsToCopy: string[] = [];
Expand Down Expand Up @@ -109,28 +122,71 @@ 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("");
};

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({
title: "Error marking as fulfilled",
message: "Failed to fulfill item. Please try again later.",
color: "red",
});
handleCloseConfirmModal();
}
};

async function checkInUser(ticket: TicketEntry) {
handleOpenConfirmModal(ticket);
}
const getTickets = async () => {
try {
Expand Down Expand Up @@ -280,6 +336,81 @@ const ViewTicketsPage: React.FC = () => {
/>
</Group>
</div>

{/* Confirmation Modal */}
<Modal
opened={showConfirmModal}
onClose={handleCloseConfirmModal}
title="Confirm Fulfillment"
size="md"
centered
>
<Stack>
<Alert
icon={<IconAlertCircle size={16} />}
title="Warning"
color="red"
variant="light"
>
<Text size="sm" fw={500}>
This action cannot be undone and will be logged!
</Text>
</Alert>

{ticketToFulfill && (
<>
<Text size="sm" fw={600}>
Purchase Details:
</Text>
<Text size="sm">
<strong>Email:</strong> {ticketToFulfill.purchaserData.email}
</Text>
<Text size="sm">
<strong>Quantity:</strong>{" "}
{ticketToFulfill.purchaserData.quantity}
</Text>
{ticketToFulfill.purchaserData.size && (
<Text size="sm">
<strong>Size:</strong> {ticketToFulfill.purchaserData.size}
</Text>
)}
</>
)}

<TextInput
label="Confirm Email Address"
placeholder="Enter the email address to confirm"
value={confirmEmail}
onChange={(e) => {
setConfirmEmail(e.currentTarget.value);
setConfirmError("");
}}
error={confirmError}
required
autoComplete="off"
data-autofocus
/>

<Text size="xs" c="dimmed">
Please enter the email address{" "}
<strong>{ticketToFulfill?.purchaserData.email}</strong> to confirm
that you want to mark this purchase as fulfilled.
</Text>

<Group justify="flex-end" mt="md">
<Button variant="subtle" onClick={handleCloseConfirmModal}>
Cancel
</Button>
<Button
color="blue"
onClick={handleConfirmFulfillment}
disabled={!confirmEmail.trim()}
>
Confirm Fulfillment
</Button>
</Group>
</Stack>
</Modal>
</AuthGuard>
);
};
Expand Down
Loading