Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
48 changes: 22 additions & 26 deletions app/api/listings/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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." },
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -129,17 +134,18 @@ async function PUT(request: Request, { params }: { params: Promise<{ id: string
}

const formData = await request.formData();
const updateData: Partial<ListingInput> = {
...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<ListingInput> = 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);
}
Expand All @@ -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(
Expand All @@ -172,16 +175,12 @@ async function PUT(request: Request, { params }: { params: Promise<{ id: string
}

try {
let imageUrls: string[] | undefined;
const updatePayload: Partial<ListingInput> = { ...parsedRequest.data };
const imageFiles = getValidImageFiles(formData);
if (imageFiles.length > 0) {
imageUrls = await uploadListingImages(imageFiles);
updatePayload.imageUrls = await uploadListingImages(imageFiles);
}

const updatePayload: Partial<ListingInput> = {
...parsedRequest.data,
...(imageUrls ? { imageUrls } : {}),
};

const updatedListing = await updateListing(parsedId.data, updatePayload);

if (!updatedListing) {
Expand Down Expand Up @@ -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(
Expand All @@ -242,7 +239,6 @@ async function DELETE(
);
}

const { id } = await params;
const parsedId = objectIdSchema.safeParse(id);
if (!parsedId.success) {
return NextResponse.json(
Expand Down
38 changes: 21 additions & 17 deletions app/api/listings/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -212,6 +214,7 @@ describe("API: Successful Responses", () => {
price: 50,
expiryDate: new Date(listingData.expiryDate),
hazardTags: ["Physical", "Chemical"],
sellerEmail: "seller@ucsd.edu",
})
);
});
Expand Down Expand Up @@ -264,6 +267,7 @@ describe("API: Successful Responses", () => {
expect(addListing).toHaveBeenCalledWith(
expect.objectContaining({
imageUrls: [],
sellerEmail: "seller@ucsd.edu",
})
);
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
7 changes: 4 additions & 3 deletions app/api/listings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
}
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 14 additions & 10 deletions app/listings/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -57,7 +61,7 @@ export default async function ListingPage({ params }: ListingPageProps) {

return (
<ListingDetails
contactEmail={getListingContactEmail()}
contactEmail={getContactEmailForListing(listing)}
listing={listing}
/>
);
Expand All @@ -66,7 +70,7 @@ export default async function ListingPage({ params }: ListingPageProps) {

return (
<ListingDetails
contactEmail={getListingContactEmail()}
contactEmail={getContactEmailForListing(fallbackListing)}
listing={fallbackListing}
/>
);
Expand Down
Loading
Loading