diff --git a/app/api/listings/[id]/route.ts b/app/api/listings/[id]/route.ts index 401f224..d2c660c 100644 --- a/app/api/listings/[id]/route.ts +++ b/app/api/listings/[id]/route.ts @@ -7,7 +7,10 @@ import { updateListing, } from "@/services/listings/listings"; import { ListingInput } from "@/models/Listing"; -import { uploadListingImages } from "@/lib/listing-images"; +import { + getValidImageFiles, + uploadListingImages, +} from "@/lib/listing-images"; import { getSession } from "@/lib/rbac"; const objectIdSchema = z @@ -34,13 +37,16 @@ const listingValidationSchema = z .partial() .strict(); +type ListingRouteContext = { params: Promise<{ id: string }> }; + /** * Get a listing entry by ID * @param id the ID of the listing to get * ex req: GET /listings/001 HTTP/1.1 * @returns the listing as a JS object in a JSON response */ -async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { +async function GET(request: Request, context: ListingRouteContext) { + const { id } = await context.params; const { allowed, reason } = await getSession("inventory:view"); if (!allowed) { return NextResponse.json( @@ -61,7 +67,6 @@ async function GET(request: Request, { params }: { params: Promise<{ id: string ); } - const { id } = await params; const parsedId = objectIdSchema.safeParse(id); if (!parsedId.success) { return NextResponse.json( @@ -74,7 +79,7 @@ async function GET(request: Request, { params }: { params: Promise<{ id: string } try { - const listing = await getListing(parsedId.data); // don't need mongo doc features + const listing = await getListing(parsedId.data); if (!listing) { return NextResponse.json( { success: false, message: "Listing not found." }, @@ -95,7 +100,8 @@ async function GET(request: Request, { params }: { params: Promise<{ id: string * @param id the ID of the listing to get as part of the path params * @returns the updated listing as a JS object in a JSON response */ -async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { +async function PUT(request: Request, context: ListingRouteContext) { + const { id } = await context.params; const { allowed, reason } = await getSession("inventory:update"); if (!allowed) { return NextResponse.json( @@ -116,7 +122,6 @@ async function PUT(request: Request, { params }: { params: Promise<{ id: string ); } - const { id } = await params; const parsedId = objectIdSchema.safeParse(id); if (!parsedId.success) { return NextResponse.json( @@ -129,17 +134,18 @@ async function PUT(request: Request, { params }: { params: Promise<{ id: string } const formData = await request.formData(); - const updateData: Partial = { - ...Object.fromEntries(formData.entries()), - }; + const textEntries: [string, FormDataEntryValue][] = []; + for (const [key, value] of formData.entries()) { + if (key === "image" || key === "images" || key === "hazardTags") continue; + textEntries.push([key, value]); + } + const updateData: Partial = Object.fromEntries(textEntries); - // handle array fields const hazardTags = formData.getAll("hazardTags"); if (hazardTags.length > 0) { updateData.hazardTags = hazardTags as ListingInput["hazardTags"]; } - // type conversions if (updateData.quantityAvailable !== undefined) { updateData.quantityAvailable = Number(updateData.quantityAvailable); } @@ -157,9 +163,6 @@ async function PUT(request: Request, { params }: { params: Promise<{ id: string } } - // handle image uploads if provided - const imageFiles = formData.getAll("images") as File[]; - const parsedRequest = listingValidationSchema.safeParse(updateData); if (!parsedRequest.success) { return NextResponse.json( @@ -172,16 +175,12 @@ async function PUT(request: Request, { params }: { params: Promise<{ id: string } try { - let imageUrls: string[] | undefined; + const updatePayload: Partial = { ...parsedRequest.data }; + const imageFiles = getValidImageFiles(formData); if (imageFiles.length > 0) { - imageUrls = await uploadListingImages(imageFiles); + updatePayload.imageUrls = await uploadListingImages(imageFiles); } - const updatePayload: Partial = { - ...parsedRequest.data, - ...(imageUrls ? { imageUrls } : {}), - }; - const updatedListing = await updateListing(parsedId.data, updatePayload); if (!updatedListing) { @@ -218,10 +217,8 @@ async function PUT(request: Request, { params }: { params: Promise<{ id: string * @param id the ID of the listing to get as part of the path params * @returns JSON response signaling the success of the listing deletion */ -async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { +async function DELETE(request: Request, context: ListingRouteContext) { + const { id } = await context.params; const { allowed, reason } = await getSession("inventory:delete"); if (!allowed) { return NextResponse.json( @@ -242,7 +239,6 @@ async function DELETE( ); } - const { id } = await params; const parsedId = objectIdSchema.safeParse(id); if (!parsedId.success) { return NextResponse.json( diff --git a/app/api/listings/__tests__/route.test.ts b/app/api/listings/__tests__/route.test.ts index a2828b9..e1eeda4 100644 --- a/app/api/listings/__tests__/route.test.ts +++ b/app/api/listings/__tests__/route.test.ts @@ -22,11 +22,13 @@ jest.mock("@/lib/googleCloud", () => ({ jest.mock("@/lib/rbac", () => ({ getSession: jest.fn().mockResolvedValue({ allowed: true, - user: null, + user: { email: "seller@ucsd.edu" }, reason: undefined, }), })); +const routeParams = (id: string) => ({ params: Promise.resolve({ id }) }); + /** import after mocking */ import { GET, POST } from "@/app/api/listings/route"; import { GET as GET_BY_ID, PUT, DELETE } from "@/app/api/listings/[id]/route"; @@ -127,7 +129,7 @@ describe("API: Successful Responses", () => { (getListing as jest.Mock).mockResolvedValue(listingData); const req = new Request(`http://localhost/api/listings/${id}`); - const res = await GET_BY_ID(req, { params: { id: id } }); + const res = await GET_BY_ID(req, routeParams(id)); const body = await res.json(); expect(res.status).toEqual(200); @@ -212,6 +214,7 @@ describe("API: Successful Responses", () => { price: 50, expiryDate: new Date(listingData.expiryDate), hazardTags: ["Physical", "Chemical"], + sellerEmail: "seller@ucsd.edu", }) ); }); @@ -264,6 +267,7 @@ describe("API: Successful Responses", () => { expect(addListing).toHaveBeenCalledWith( expect.objectContaining({ imageUrls: [], + sellerEmail: "seller@ucsd.edu", }) ); }); @@ -371,7 +375,7 @@ describe("API: Successful Responses", () => { body: formData, }); - const res = await PUT(req, { params: { id } }); + const res = await PUT(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(200); @@ -403,7 +407,7 @@ describe("API: Successful Responses", () => { const req = new Request(`http://localhost/api/listings/${id}`, { method: "DELETE", }); - const res = await DELETE(req, { params: { id } }); + const res = await DELETE(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(200); @@ -464,7 +468,7 @@ describe("API: Error Responses", () => { (connectToDatabase as jest.Mock).mockRejectedValue(new Error("DB Error")); const req = new Request(`http://localhost/api/listings/${id}`); - const res = await GET_BY_ID(req, { params: { id: id } }); + const res = await GET_BY_ID(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(500); @@ -478,7 +482,7 @@ describe("API: Error Responses", () => { (connectToDatabase as jest.Mock).mockResolvedValue({}); const req = new Request(`http://localhost/api/listings/${id}`); - const res = await GET_BY_ID(req, { params: { id: id } }); + const res = await GET_BY_ID(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(400); @@ -495,7 +499,7 @@ describe("API: Error Responses", () => { (getListing as jest.Mock).mockResolvedValue(null); const req = new Request(`http://localhost/api/listings/${id}`); - const res = await GET_BY_ID(req, { params: { id: id } }); + const res = await GET_BY_ID(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(404); @@ -511,7 +515,7 @@ describe("API: Error Responses", () => { (getListing as jest.Mock).mockRejectedValue(new Error("DB Error")); const req = new Request(`http://localhost/api/listings/${id}`); - const res = await GET_BY_ID(req, { params: { id: id } }); + const res = await GET_BY_ID(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(500); @@ -621,7 +625,7 @@ describe("API: Error Responses", () => { method: "PUT", body: formData, }); - const res = await PUT(req, { params: { id } }); + const res = await PUT(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(500); @@ -643,7 +647,7 @@ describe("API: Error Responses", () => { method: "PUT", body: formData, }); - const res = await PUT(req, { params: { id } }); + const res = await PUT(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(400); @@ -665,7 +669,7 @@ describe("API: Error Responses", () => { body: formData, }); - const res = await PUT(req, { params: { id } }); + const res = await PUT(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(400); @@ -688,7 +692,7 @@ describe("API: Error Responses", () => { method: "PUT", body: formData, }); - const res = await PUT(req, { params: { id } }); + const res = await PUT(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(404); @@ -717,7 +721,7 @@ describe("API: Error Responses", () => { method: "PUT", body: formData, }); - const res = await PUT(req, { params: { id } }); + const res = await PUT(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(500); @@ -741,7 +745,7 @@ describe("API: Error Responses", () => { const req = new Request(`http://localhost/api/listings/${id}`, { method: "DELETE", }); - const res = await DELETE(req, { params: { id } }); + const res = await DELETE(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(500); @@ -757,7 +761,7 @@ describe("API: Error Responses", () => { const req = new Request(`http://localhost/api/listings/${id}`, { method: "DELETE", }); - const res = await DELETE(req, { params: { id } }); + const res = await DELETE(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(400); @@ -776,7 +780,7 @@ describe("API: Error Responses", () => { const req = new Request(`http://localhost/api/listings/${id}`, { method: "DELETE", }); - const res = await DELETE(req, { params: { id } }); + const res = await DELETE(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(404); @@ -794,7 +798,7 @@ describe("API: Error Responses", () => { const req = new Request(`http://localhost/api/listings/${id}`, { method: "DELETE", }); - const res = await DELETE(req, { params: { id } }); + const res = await DELETE(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(500); diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index f03c352..48bb8d9 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -114,10 +114,10 @@ async function GET(request: Request) { * @returns JSON response with success message and req body echoed */ async function POST(request: Request) { - const { allowed, reason } = await getSession("listing:create"); - if (!allowed) { + const { allowed, reason, user } = await getSession("listing:create"); + if (!allowed || !user?.email) { return NextResponse.json( - { success: false, message: reason }, + { success: false, message: reason ?? "Unauthenticated" }, { status: 403 } ); } @@ -181,6 +181,7 @@ async function POST(request: Request) { const listingData = { ...parsedBody.data, imageUrls, + sellerEmail: user.email, createdAt: new Date(), } as ListingInput; const listing = await addListing(listingData); diff --git a/app/listings/[id]/page.tsx b/app/listings/[id]/page.tsx index d9cdeee..13df374 100644 --- a/app/listings/[id]/page.tsx +++ b/app/listings/[id]/page.tsx @@ -10,16 +10,20 @@ interface ListingPageProps { }>; } -/** - * Resolve a contact email for the listing until proper transaction - * functionality exists. - */ -function getListingContactEmail() { - return ( +function isUsableEmail(email: string) { + return email.includes("@") && email.includes("."); +} + +function getContactEmailForListing(listing: Listing) { + const seller = listing.sellerEmail?.trim() ?? ""; + if (seller && isUsableEmail(seller)) { + return seller; + } + const fallback = process.env.NEXT_PUBLIC_MARKETPLACE_CONTACT_EMAIL ?? process.env.MARKETPLACE_CONTACT_EMAIL ?? - "marketplace@lab.example" - ); + ""; + return fallback.trim(); } /** @@ -57,7 +61,7 @@ export default async function ListingPage({ params }: ListingPageProps) { return ( ); @@ -66,7 +70,7 @@ export default async function ListingPage({ params }: ListingPageProps) { return ( ); diff --git a/components/listings/ContactModal.tsx b/components/listings/ContactModal.tsx index 73186a8..b684b8f 100644 --- a/components/listings/ContactModal.tsx +++ b/components/listings/ContactModal.tsx @@ -90,6 +90,9 @@ export function ContactModal({ return null; } + const hasValidEmail = + contactEmail.includes("@") && contactEmail.includes("."); + return (
- - {contactEmail} - + {hasValidEmail ? ( + + {contactEmail} + + ) : ( +

Seller email is not available.

+ )}
- - Email Seller - + {hasValidEmail ? ( + + Email Seller + + ) : ( + + )}
diff --git a/models/Listing.ts b/models/Listing.ts index 21455a3..53e2e1d 100644 --- a/models/Listing.ts +++ b/models/Listing.ts @@ -38,6 +38,7 @@ const listingSchema = new Schema( enum: ["Physical", "Chemical", "Biological", "Other"], }, ], + sellerEmail: { type: String, trim: true, lowercase: true }, }, { toJSON: {