From a8cf23e9c00c88a409499cdacb89a53f3ea23626 Mon Sep 17 00:00:00 2001 From: kbeltr03 Date: Wed, 19 Aug 2026 13:44:42 -0700 Subject: [PATCH 1/2] lot of QOL fixes after getting feedback from spokes --- .gitignore | 3 + package.json | 2 +- src/app/api/__test__/EventsApi.test.ts | 51 ++- src/app/api/__test__/EventsValidation.test.ts | 12 + src/app/api/events/[eventId]/route.ts | 13 +- src/app/api/events/route.ts | 4 +- src/app/api/send/event-new/route.ts | 4 +- src/app/events/[eventId]/page.tsx | 4 +- src/components/events/EventCard.tsx | 64 ++-- .../events/EventCard/AdminEventCard.tsx | 19 +- .../events/EventCard/OrgEventCard.tsx | 33 +- .../EventModals/EventConfirmationModal.tsx | 18 +- .../events/pages/AdminEventsPage.tsx | 27 +- .../events/pages/EventsBoardPage.tsx | 20 +- src/components/events/pages/ListEventPage.tsx | 312 ++++++++++++++++-- .../events/pages/ManageEventsPage.tsx | 4 +- src/database/eventSchema.ts | 4 +- src/lib/events.ts | 7 +- 18 files changed, 465 insertions(+), 136 deletions(-) diff --git a/.gitignore b/.gitignore index de44e4f..5cbdccf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ /.pnp .pnp.js +# debugging images +/images + # testing /coverage diff --git a/package.json b/package.json index 87610ca..05decb5 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "framer-motion": "^11.15.0", "lucide-react": "^0.475.0", "mongoose": "^8.14.1", - "next": ">=14.2.25", + "next": "16.2.6", "next-themes": "^0.4.4", "react": "^18", "react-dom": "^18", diff --git a/src/app/api/__test__/EventsApi.test.ts b/src/app/api/__test__/EventsApi.test.ts index 159a77c..e655db2 100644 --- a/src/app/api/__test__/EventsApi.test.ts +++ b/src/app/api/__test__/EventsApi.test.ts @@ -2,7 +2,7 @@ import Event from "@/database/eventSchema"; import User from "@/database/userSchema"; import { resolveOrganizationName } from "@/lib/organizations"; import { GET, POST } from "@/app/api/events/route"; -import { GET as GET_EVENT, PUT } from "@/app/api/events/[eventId]/route"; +import { DELETE, GET as GET_EVENT, PUT } from "@/app/api/events/[eventId]/route"; const mockAuth = { userId: "user-1", @@ -26,6 +26,7 @@ jest.mock("@/database/eventSchema", () => ({ find: jest.fn(), findById: jest.fn(), findByIdAndUpdate: jest.fn(), + findByIdAndDelete: jest.fn(), findOneAndUpdate: jest.fn(), }, })); @@ -241,11 +242,59 @@ describe("Events API", () => { locationLink: "https://maps.example.com/updated-event", eventLocationGeneral: "North Coast", eventLocationCity: "Morro Bay", + eventStatus: "pending", + rejectionMessage: "", }, { new: true, strict: false }, ); }); + test("resubmits an owned approved event for review after editing", async () => { + (Event.findById as jest.Mock).mockResolvedValue({ + _id: "event-1", + createdByUserId: "user-1", + eventStatus: "approved", + }); + (Event.findByIdAndUpdate as jest.Mock).mockResolvedValue({ _id: "event-1", eventStatus: "pending" }); + + const response = await PUT(jsonRequest("/api/events/event-1", { eventName: "Updated Event" }), {}); + + expect(response.status).toBe(200); + expect(Event.findByIdAndUpdate).toHaveBeenCalledWith( + "event-1", + { eventName: "Updated Event", eventStatus: "pending", rejectionMessage: "" }, + { new: true, strict: false }, + ); + }); + + test("allows an admin to reject an event with feedback", async () => { + mockAuth.role = "spokes_admin"; + (Event.findById as jest.Mock).mockResolvedValue({ _id: "event-1", createdByUserId: "user-1" }); + (Event.findByIdAndUpdate as jest.Mock).mockResolvedValue({ _id: "event-1", eventStatus: "rejected" }); + + const response = await PUT( + jsonRequest("/api/events/event-1", { eventStatus: "rejected", rejectionMessage: "Missing event details" }), + {}, + ); + + expect(response.status).toBe(200); + expect(Event.findByIdAndUpdate).toHaveBeenCalledWith( + "event-1", + { eventStatus: "rejected", rejectionMessage: "Missing event details" }, + { new: true }, + ); + }); + + test("allows an owner to delete an event", async () => { + (Event.findById as jest.Mock).mockResolvedValue({ _id: "event-1", createdByUserId: "user-1" }); + (Event.findByIdAndDelete as jest.Mock).mockResolvedValue({ _id: "event-1" }); + + const response = await DELETE({ nextUrl: { pathname: "/api/events/event-1" } } as any, {}); + + expect(response.status).toBe(200); + expect(Event.findByIdAndDelete).toHaveBeenCalledWith("event-1"); + }); + test("GET blocks non-admin requests for private event statuses", async () => { mockAuth.userId = null as any; mockAuth.role = "job_seeker"; diff --git a/src/app/api/__test__/EventsValidation.test.ts b/src/app/api/__test__/EventsValidation.test.ts index cb0e709..139ba7c 100644 --- a/src/app/api/__test__/EventsValidation.test.ts +++ b/src/app/api/__test__/EventsValidation.test.ts @@ -49,4 +49,16 @@ describe("event validation service", () => { expect(validateEventPayload({ majorFundraisingEvent: false }, { partial: true })).toBeNull(); }); + + test("allows a new event without a time or venue", () => { + const payload = { ...validPayload, time: "", location: "" }; + + expect(validateEventPayload(payload)).toBeNull(); + expect(sanitizeEventPayload(payload)).toEqual( + expect.objectContaining({ + time: "", + location: "", + }), + ); + }); }); diff --git a/src/app/api/events/[eventId]/route.ts b/src/app/api/events/[eventId]/route.ts index bb83ecd..7a2b439 100644 --- a/src/app/api/events/[eventId]/route.ts +++ b/src/app/api/events/[eventId]/route.ts @@ -80,11 +80,6 @@ export const PUT = withApiAuth( return NextResponse.json({ message: "Event status updated successfully", event: updatedEvent }); } - // Handle regular updates (nonprofit can update their own pending events) - if (auth.role === "nonprofit" && existingEvent.eventStatus !== "pending") { - return NextResponse.json({ message: "Can only edit pending events" }, { status: 403 }); - } - const validationError = validateEventPayload(eventData, { partial: true }); if (validationError) { return NextResponse.json({ message: validationError }, { status: 400 }); @@ -95,7 +90,13 @@ export const PUT = withApiAuth( return NextResponse.json({ message: "No valid event fields provided" }, { status: 400 }); } - const updatedEvent = await Event.findByIdAndUpdate(eventId, sanitizedEventData, { new: true, strict: false }); + // Organization changes must be reviewed again. Admin edits remain live because + // they are already made by the reviewing role. + const updateData = + auth.role === "nonprofit" + ? { ...sanitizedEventData, eventStatus: EventStatus.pending, rejectionMessage: "" } + : sanitizedEventData; + const updatedEvent = await Event.findByIdAndUpdate(eventId, updateData, { new: true, strict: false }); return NextResponse.json({ message: "Event updated successfully", event: updatedEvent }); } catch (error) { return NextResponse.json({ message: "Error updating event", error }, { status: 500 }); diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index 07a573b..8b86633 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -94,8 +94,8 @@ export const POST = withApiAuth( organization, date: new Date(sanitizedEventData.date as string), eventName: sanitizedEventData.eventName as string, - time: sanitizedEventData.time as string, - location: sanitizedEventData.location as string, + time: (sanitizedEventData.time as string) || "", + location: (sanitizedEventData.location as string) || "", }; const duplicateUpdateFields: Record = {}; const insertEventData = { ...sanitizedEventData }; diff --git a/src/app/api/send/event-new/route.ts b/src/app/api/send/event-new/route.ts index 93acae5..e91f7e0 100644 --- a/src/app/api/send/event-new/route.ts +++ b/src/app/api/send/event-new/route.ts @@ -30,8 +30,8 @@ export const POST = withApiAuth( description: eventData.description, organization: eventData.organization, date: new Date(eventData.date).toLocaleDateString(), - time: eventData.time, - location: eventData.location, + time: eventData.time || "TBD", + location: eventData.location || "TBD", contactName: eventData.contactName, contactEmail: eventData.contactEmail, adminURL: getEventsAdminUrl(), diff --git a/src/app/events/[eventId]/page.tsx b/src/app/events/[eventId]/page.tsx index 1f20661..0c82765 100644 --- a/src/app/events/[eventId]/page.tsx +++ b/src/app/events/[eventId]/page.tsx @@ -49,10 +49,10 @@ export default async function EventPage({ params }: EventPageProps) { Date: {formatDate(event.date)}

- Time: {event.time} + Time: {event.time || "TBD"}

- Location: {event.location} + Location: {event.location || "TBD"}

{(eventCity || eventRegion) && (

diff --git a/src/components/events/EventCard.tsx b/src/components/events/EventCard.tsx index 6b06c08..b0b8062 100644 --- a/src/components/events/EventCard.tsx +++ b/src/components/events/EventCard.tsx @@ -59,40 +59,36 @@ export default function EventCard({ event, onEventView }: EventCardProps) { {formatDate(event.date)}

- {event.time && ( -

- - - - - {event.time} -

- )} - {event.location && ( -

- - - - - {event.location} -

- )} +

+ + + + + {event.time || "Time TBD"} +

+

+ + + + + {event.location || "Location TBD"} +

{(eventCity || eventRegion) && (

{[eventCity, eventRegion].filter(Boolean).join(", ")}

)} diff --git a/src/components/events/EventCard/AdminEventCard.tsx b/src/components/events/EventCard/AdminEventCard.tsx index 111c4ff..fc27006 100644 --- a/src/components/events/EventCard/AdminEventCard.tsx +++ b/src/components/events/EventCard/AdminEventCard.tsx @@ -6,7 +6,9 @@ import JobCardModal from "@/components/jobs/JobCard/JobCardModal"; import ActionButton from "@/components/jobs/JobCard/ActionButton"; import JobPostedDate from "@/components/jobs/JobCard/JobPostedDate"; import { getEventInfoLink, getEventLocationLink } from "@/lib/eventLinks"; -import { useToast } from "@chakra-ui/react"; +import { IconButton } from "@chakra-ui/react"; +import { FiEdit } from "react-icons/fi"; +import { useRouter } from "next/navigation"; interface AdminEventCardProps { event: IEvent; @@ -25,7 +27,7 @@ export default function AdminEventCard({ event, onUpdateEvent, innerRef }: Admin const [rejectionReason, setRejectionReason] = useState(""); const [isNewIndicatorDismissed, setIsNewIndicatorDismissed] = useState(false); const [isLoading, setIsLoading] = useState<"approve" | "reject" | null>(null); - const toast = useToast(); + const router = useRouter(); useEffect(() => { const dismissedState = localStorage.getItem(`new-event-indicator-${event._id}`); @@ -101,6 +103,15 @@ export default function AdminEventCard({ event, onUpdateEvent, innerRef }: Admin return (
+ } + size="sm" + borderColor="black" + position="absolute" + className="absolute top-4 right-[1rem]" + onClick={() => router.push(`/events/list?eventId=${event._id}&returnURL=/events/admin`)} + /> {/* New indicator dot */} {new Date(event.createdAt ?? event.date).getTime() > Date.now() - 24 * 60 * 60 * 1000 && !isNewIndicatorDismissed && ( @@ -126,10 +137,10 @@ export default function AdminEventCard({ event, onUpdateEvent, innerRef }: Admin Date: {formattedDate}
- Time: {event.time} + Time: {event.time || "TBD"}
- Location: {event.location} + Location: {event.location || "TBD"}
{(eventCity || eventRegion) && (
diff --git a/src/components/events/EventCard/OrgEventCard.tsx b/src/components/events/EventCard/OrgEventCard.tsx index 44de70b..59c5be7 100644 --- a/src/components/events/EventCard/OrgEventCard.tsx +++ b/src/components/events/EventCard/OrgEventCard.tsx @@ -14,7 +14,6 @@ import { ModalCloseButton, useDisclosure, Text, - useToast, } from "@chakra-ui/react"; import { FiEdit, FiMessageSquare } from "react-icons/fi"; import { useRouter } from "next/navigation"; @@ -22,14 +21,12 @@ import { useRouter } from "next/navigation"; export interface OrgEventCardProps extends ComponentProps<"div"> { className?: string; event: IEvent; - onEventStatusUpdate?: (event: IEvent) => void; } export const OrgEventCard = forwardRef( - ({ children, className, event, onEventStatusUpdate, ...props }, ref) => { + ({ children, className, event, ...props }, ref) => { const { isOpen, onOpen, onClose } = useDisclosure(); const router = useRouter(); - const toast = useToast(); const eventDate = new Date(event.date).toLocaleDateString("en-US", { month: "short", @@ -40,9 +37,9 @@ export const OrgEventCard = forwardRef( event.eventLocationGeneral === "Other" ? event.eventLocationGeneralOther : event.eventLocationGeneral; const eventCity = event.eventLocationCity === "Other" ? event.eventLocationCityOther : event.eventLocationCity; - function handleEditButton(e: React.ChangeEvent) { + function handleEditButton(e: React.MouseEvent) { e.preventDefault(); - router.push(`/events/list?eventId=${event._id}`); + router.push(`/events/list?eventId=${event._id}&returnURL=/events/manage`); } return ( @@ -61,7 +58,7 @@ export const OrgEventCard = forwardRef(
- {eventDate} · {event.time} · {event.location} + {[eventDate, event.time || "Time TBD", event.location || "Location TBD"].join(" · ")}
{(eventCity || eventRegion) && (
{[eventCity, eventRegion].filter(Boolean).join(", ")}
@@ -84,18 +81,16 @@ export const OrgEventCard = forwardRef( Feedback )} - {(event.eventStatus === "pending" || event.eventStatus === "rejected") && ( - - )} +
diff --git a/src/components/events/EventModals/EventConfirmationModal.tsx b/src/components/events/EventModals/EventConfirmationModal.tsx index 503127a..35f2ac2 100644 --- a/src/components/events/EventModals/EventConfirmationModal.tsx +++ b/src/components/events/EventModals/EventConfirmationModal.tsx @@ -17,9 +17,16 @@ import { useRouter } from "next/navigation"; interface EventConfirmationModalProps { isOpen: boolean; onClose: () => void; + onCreateAnother: () => void; + submittedForReview: boolean; } -export default function EventConfirmationModal({ isOpen, onClose }: EventConfirmationModalProps) { +export default function EventConfirmationModal({ + isOpen, + onClose, + onCreateAnother, + submittedForReview, +}: EventConfirmationModalProps) { const router = useRouter(); return ( @@ -37,13 +44,14 @@ export default function EventConfirmationModal({ isOpen, onClose }: EventConfirm

What's Next?

- Your event has been submitted for review. Upon approval, your event will be published to the Spokes event - board and made available to the public. + {submittedForReview + ? "Your event has been submitted for review. Upon approval, your event will be published to the Spokes event board and made available to the public." + : "Your event has been updated."} - - + {eventId ? ( + <> +
+
+ + {isSpokesAdmin && ( + + )} + +
+
+ + ← Return to {isSpokesAdmin ? "Admin Dashboard" : "Dashboard"} + + + ) : ( +
+ +
+ )} - setIsConfirmationModalOpen(false)} /> + setIsConfirmationModalOpen(false)} + onCreateAnother={createAnotherEvent} + submittedForReview={!eventId || !isSpokesAdmin} + /> setIsFailModalOpen(false)} /> + setIsDeleteConfirmationOpen(false)} isCentered> + + + Confirm Delete + + Are you sure you would like to delete this event? + + + + + + + setIsRejectConfirmationOpen(false)} isCentered> + + + Confirm Rejection + + + Are you sure you would like to reject this event? +