diff --git a/backend/package-lock.json b/backend/package-lock.json index 1814cb4..52a708a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -4797,7 +4797,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "license": "Apache-2.0", "optional": true, "peer": true, "dependencies": { diff --git a/backend/src/app.ts b/backend/src/app.ts index e8d80d9..c9f2a1b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,8 @@ import { isHttpError } from "http-errors"; import productRoutes from "src/routes/product"; import userRoutes from "src/routes/user"; import interestEmailRoute from "src/routes/interestEmail"; +import studentOrganizationRoutes from "src/routes/studentOrganization"; +import merchRoutes from "src/routes/merch"; const app = express(); // initializes Express to accept JSON in the request/response body @@ -28,6 +30,8 @@ app.use( app.use("/api/products", productRoutes); app.use("/api/users", userRoutes); app.use("/api/interestEmail", interestEmailRoute); +app.use("/api/student-organizations", studentOrganizationRoutes); +app.use("/api/merch", merchRoutes); /** * Error handler; all errors thrown by server are handled here. * Explicit typings required here because TypeScript cannot infer the argument types. diff --git a/backend/src/controllers/merch.ts b/backend/src/controllers/merch.ts new file mode 100644 index 0000000..930093b --- /dev/null +++ b/backend/src/controllers/merch.ts @@ -0,0 +1,252 @@ +import { Response } from "express"; +import MerchModel from "src/models/merch"; +import StudentOrganizationModel from "src/models/studentOrganization"; +import { AuthenticatedRequest } from "src/validators/authUserMiddleware"; +import mongoose from "mongoose"; +import { bucket } from "src/config/firebase"; +import { getStorage, ref, getDownloadURL } from "firebase/storage"; +import { v4 as uuidv4 } from "uuid"; +import { initializeApp } from "firebase/app"; +import { firebaseConfig } from "src/config/firebaseConfig"; +import multer from "multer"; + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB limit +}).single("image"); + +/** + * Get all merch items + */ +export const getAllMerch = async (req: AuthenticatedRequest, res: Response) => { + try { + const merchItems = await MerchModel.find().populate("studentOrganizationId"); + res.status(200).json(merchItems); + } catch (error) { + res.status(500).json({ message: "Error fetching merch items", error }); + } +}; + +/** + * Get merch by ID + */ +export const getMerchById = async (req: AuthenticatedRequest, res: Response) => { + try { + const id = req.params.id; + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid ID format" }); + } + const merch = await MerchModel.findById(id).populate("studentOrganizationId"); + if (!merch) { + return res.status(404).json({ message: "Merch item not found" }); + } + res.status(200).json(merch); + } catch (error) { + res.status(500).json({ message: "Error getting merch item", error }); + } +}; + +/** + * Get all merch items for a specific student organization + */ +export const getMerchByOrganization = async (req: AuthenticatedRequest, res: Response) => { + try { + const organizationId = req.params.organizationId; + if (!mongoose.Types.ObjectId.isValid(organizationId)) { + return res.status(400).json({ message: "Invalid organization ID format" }); + } + const merchItems = await MerchModel.find({ studentOrganizationId: organizationId }); + res.status(200).json(merchItems); + } catch (error) { + res.status(500).json({ message: "Error fetching merch items", error }); + } +}; + +/** + * Get all merch items for the authenticated user's organization + */ +export const getMyOrganizationMerch = async (req: AuthenticatedRequest, res: Response) => { + try { + if (!req.user) return res.status(404).json({ message: "User not found" }); + + const firebaseUid = req.user.firebaseUid; + const organization = await StudentOrganizationModel.findOne({ firebaseUid }); + + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + const merchItems = await MerchModel.find({ studentOrganizationId: organization._id }); + res.status(200).json(merchItems); + } catch (error) { + res.status(500).json({ message: "Error fetching merch items", error }); + } +}; + +/** + * Add merch item to a student organization + */ +export const addMerch = [ + upload, + async (req: AuthenticatedRequest, res: Response) => { + try { + const { name, price, description } = req.body; + if (!req.user) return res.status(404).json({ message: "User not found" }); + + const firebaseUid = req.user.firebaseUid; + const organization = await StudentOrganizationModel.findOne({ firebaseUid }); + + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + if (!name || !price) { + return res.status(400).json({ message: "Name and price are required." }); + } + + let imageUrl = ""; + if (req.file) { + const app = initializeApp(firebaseConfig); + const storage = getStorage(app); + const fileName = `${uuidv4()}-${req.file.originalname}`; + const firebaseFile = bucket.file(fileName); + + await firebaseFile.save(req.file.buffer, { + metadata: { contentType: req.file.mimetype }, + }); + + imageUrl = await getDownloadURL(ref(storage, fileName)); + } + + const newMerch = new MerchModel({ + name, + price: parseFloat(price), + description: description || "", + image: imageUrl, + studentOrganizationId: organization._id, + timeCreated: new Date(), + timeUpdated: new Date(), + }); + + const savedMerch = await newMerch.save(); + res.status(201).json(savedMerch); + } catch (error) { + res.status(500).json({ message: "Error adding merch item", error }); + } + }, +]; + +/** + * Update merch item + */ +export const updateMerch = [ + upload, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = req.params.id; + if (!req.user) return res.status(404).json({ message: "User not found" }); + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid ID format" }); + } + + const firebaseUid = req.user.firebaseUid; + const organization = await StudentOrganizationModel.findOne({ firebaseUid }); + + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + const merch = await MerchModel.findById(id); + if (!merch) { + return res.status(404).json({ message: "Merch item not found" }); + } + + // Verify the merch belongs to the user's organization + if (merch.studentOrganizationId.toString() !== organization._id.toString()) { + return res.status(403).json({ message: "You don't have permission to edit this merch item" }); + } + + const { name, price, description, existingImage } = req.body; + + let imageUrl = existingImage || merch.image; + if (req.file) { + const app = initializeApp(firebaseConfig); + const storage = getStorage(app); + const fileName = `${uuidv4()}-${req.file.originalname}`; + const firebaseFile = bucket.file(fileName); + + await firebaseFile.save(req.file.buffer, { + metadata: { contentType: req.file.mimetype }, + }); + + imageUrl = await getDownloadURL(ref(storage, fileName)); + } + + const updatedMerch = await MerchModel.findByIdAndUpdate( + id, + { + name: name || merch.name, + price: price !== undefined ? parseFloat(price) : merch.price, + description: description !== undefined ? description : merch.description, + image: imageUrl, + timeUpdated: new Date(), + }, + { new: true }, + ); + + if (!updatedMerch) { + return res.status(404).json({ message: "Merch item not found" }); + } + + res.status(200).json({ + message: "Merch item successfully updated", + merch: updatedMerch, + }); + } catch (error) { + res.status(500).json({ message: "Error updating merch item", error }); + } + }, +]; + +/** + * Delete merch item + */ +export const deleteMerch = async (req: AuthenticatedRequest, res: Response) => { + try { + const id = req.params.id; + if (!req.user) return res.status(404).json({ message: "User not found" }); + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid ID format" }); + } + + const firebaseUid = req.user.firebaseUid; + const organization = await StudentOrganizationModel.findOne({ firebaseUid }); + + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + const merch = await MerchModel.findById(id); + if (!merch) { + return res.status(404).json({ message: "Merch item not found" }); + } + + // Verify the merch belongs to the user's organization + if (merch.studentOrganizationId.toString() !== organization._id.toString()) { + return res.status(403).json({ message: "You don't have permission to delete this merch item" }); + } + + const deletedMerch = await MerchModel.findByIdAndDelete(id); + if (!deletedMerch) { + return res.status(404).json({ message: "Merch item not found" }); + } + + res.status(200).json({ + message: "Merch item successfully deleted", + merch: deletedMerch, + }); + } catch (error) { + res.status(500).json({ message: "Error deleting merch item", error }); + } +}; + diff --git a/backend/src/controllers/studentOrganizations.ts b/backend/src/controllers/studentOrganizations.ts new file mode 100644 index 0000000..ff1ec2e --- /dev/null +++ b/backend/src/controllers/studentOrganizations.ts @@ -0,0 +1,235 @@ +import { Response } from "express"; +import StudentOrganizationModel from "src/models/studentOrganization"; +import { AuthenticatedRequest } from "src/validators/authUserMiddleware"; +import mongoose from "mongoose"; +import { bucket } from "src/config/firebase"; +import { getStorage, ref, getDownloadURL } from "firebase/storage"; +import { v4 as uuidv4 } from "uuid"; +import { initializeApp } from "firebase/app"; +import { firebaseConfig } from "src/config/firebaseConfig"; +import multer from "multer"; + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB limit +}).single("profilePicture"); + +/** + * Get all student organizations + */ +export const getStudentOrganizations = async (req: AuthenticatedRequest, res: Response) => { + try { + const organizations = await StudentOrganizationModel.find(); + res.status(200).json(organizations); + } catch (error) { + res.status(500).json({ message: "Error fetching student organizations", error }); + } +}; + +/** + * Get student organization by ID + */ +export const getStudentOrganizationById = async (req: AuthenticatedRequest, res: Response) => { + try { + const id = req.params.id; + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid ID format" }); + } + const organization = await StudentOrganizationModel.findById(id); + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + res.status(200).json(organization); + } catch (error) { + res.status(500).json({ message: "Error getting student organization", error }); + } +}; + +/** + * Get student organization by Firebase UID + */ +export const getStudentOrganizationByFirebaseUid = async ( + req: AuthenticatedRequest, + res: Response, +) => { + try { + const firebaseUid = req.params.firebaseUid; + const organization = await StudentOrganizationModel.findOne({ firebaseUid }); + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + res.status(200).json(organization); + } catch (error) { + res.status(500).json({ message: "Error getting student organization", error }); + } +}; + +/** + * Create a new student organization profile + */ +export const createStudentOrganization = [ + upload, + async (req: AuthenticatedRequest, res: Response) => { + try { + if (!req.user) return res.status(404).json({ message: "User not found" }); + + const { organizationName, bio, location, contactInfo, merchLocation } = req.body; + const firebaseUid = req.user.firebaseUid; + + if (!organizationName) { + return res.status(400).json({ message: "Organization name is required." }); + } + + // Check if organization already exists for this user + const existingOrg = await StudentOrganizationModel.findOne({ firebaseUid }); + if (existingOrg) { + return res.status(409).json({ + message: "Student organization profile already exists for this user", + organization: existingOrg, + }); + } + + let profilePictureUrl = ""; + if (req.file) { + const app = initializeApp(firebaseConfig); + const storage = getStorage(app); + const fileName = `${uuidv4()}-${req.file.originalname}`; + const firebaseFile = bucket.file(fileName); + + await firebaseFile.save(req.file.buffer, { + metadata: { contentType: req.file.mimetype }, + }); + + profilePictureUrl = await getDownloadURL(ref(storage, fileName)); + } + + // Parse contactInfo if it's a string + let parsedContactInfo = { + email: "", + instagram: "", + website: "", + other: "", + }; + if (contactInfo) { + if (typeof contactInfo === "string") { + parsedContactInfo = JSON.parse(contactInfo); + } else { + parsedContactInfo = contactInfo; + } + } + + const newOrganization = new StudentOrganizationModel({ + organizationName, + profilePicture: profilePictureUrl, + bio: bio || "", + location: location || "", + contactInfo: parsedContactInfo, + merchLocation: merchLocation || "", + firebaseUid, + timeCreated: new Date(), + timeUpdated: new Date(), + }); + + const savedOrganization = await newOrganization.save(); + res.status(201).json(savedOrganization); + } catch (error) { + res.status(500).json({ message: "Error creating student organization", error }); + } + }, +]; + +/** + * Update student organization profile + */ +export const updateStudentOrganization = [ + upload, + async (req: AuthenticatedRequest, res: Response) => { + try { + if (!req.user) return res.status(404).json({ message: "User not found" }); + + const firebaseUid = req.user.firebaseUid; + const organization = await StudentOrganizationModel.findOne({ firebaseUid }); + + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + const { organizationName, bio, location, contactInfo, merchLocation, existingProfilePicture } = + req.body; + + // Handle profile picture upload + let profilePictureUrl = existingProfilePicture || organization.profilePicture; + if (req.file) { + const app = initializeApp(firebaseConfig); + const storage = getStorage(app); + const fileName = `${uuidv4()}-${req.file.originalname}`; + const firebaseFile = bucket.file(fileName); + + await firebaseFile.save(req.file.buffer, { + metadata: { contentType: req.file.mimetype }, + }); + + profilePictureUrl = await getDownloadURL(ref(storage, fileName)); + } + + // Parse contactInfo if it's a string + let parsedContactInfo = organization.contactInfo; + if (contactInfo) { + if (typeof contactInfo === "string") { + parsedContactInfo = JSON.parse(contactInfo); + } else { + parsedContactInfo = contactInfo; + } + } + + const updatedOrganization = await StudentOrganizationModel.findOneAndUpdate( + { firebaseUid }, + { + organizationName: organizationName || organization.organizationName, + profilePicture: profilePictureUrl, + bio: bio !== undefined ? bio : organization.bio, + location: location !== undefined ? location : organization.location, + contactInfo: parsedContactInfo, + merchLocation: merchLocation !== undefined ? merchLocation : organization.merchLocation, + timeUpdated: new Date(), + }, + { new: true }, + ); + + if (!updatedOrganization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + res.status(200).json({ + message: "Student organization successfully updated", + organization: updatedOrganization, + }); + } catch (error) { + res.status(500).json({ message: "Error updating student organization", error }); + } + }, +]; + +/** + * Delete student organization profile + */ +export const deleteStudentOrganization = async (req: AuthenticatedRequest, res: Response) => { + try { + if (!req.user) return res.status(404).json({ message: "User not found" }); + + const firebaseUid = req.user.firebaseUid; + const organization = await StudentOrganizationModel.findOneAndDelete({ firebaseUid }); + + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + res.status(200).json({ + message: "Student organization successfully deleted", + organization, + }); + } catch (error) { + res.status(500).json({ message: "Error deleting student organization", error }); + } +}; + diff --git a/backend/src/models/merch.ts b/backend/src/models/merch.ts new file mode 100644 index 0000000..d42c1ce --- /dev/null +++ b/backend/src/models/merch.ts @@ -0,0 +1,40 @@ +import { HydratedDocument, InferSchemaType, Schema, model } from "mongoose"; + +const merchSchema = new Schema({ + name: { + type: String, + required: true, + }, + price: { + type: Number, + required: true, + }, + description: { + type: String, + default: "", + }, + image: { + type: String, + default: "", + }, + studentOrganizationId: { + type: Schema.Types.ObjectId, + ref: "StudentOrganization", + required: true, + }, + timeCreated: { + type: Date, + required: true, + default: Date.now, + }, + timeUpdated: { + type: Date, + required: true, + default: Date.now, + }, +}); + +export type Merch = HydratedDocument>; + +export default model("Merch", merchSchema); + diff --git a/backend/src/models/studentOrganization.ts b/backend/src/models/studentOrganization.ts new file mode 100644 index 0000000..45cd6c0 --- /dev/null +++ b/backend/src/models/studentOrganization.ts @@ -0,0 +1,62 @@ +import { HydratedDocument, InferSchemaType, Schema, model } from "mongoose"; + +const studentOrganizationSchema = new Schema({ + organizationName: { + type: String, + required: true, + }, + profilePicture: { + type: String, + default: "", + }, + bio: { + type: String, + default: "", + }, + location: { + type: String, + default: "", + }, + contactInfo: { + email: { + type: String, + default: "", + }, + instagram: { + type: String, + default: "", + }, + website: { + type: String, + default: "", + }, + other: { + type: String, + default: "", + }, + }, + merchLocation: { + type: String, + default: "", + }, + firebaseUid: { + type: String, + required: true, + unique: true, + }, + timeCreated: { + type: Date, + required: true, + default: Date.now, + }, + timeUpdated: { + type: Date, + required: true, + default: Date.now, + }, +}); + +export type StudentOrganization = HydratedDocument>; + +export default model("StudentOrganization", studentOrganizationSchema); + diff --git a/backend/src/routes/merch.ts b/backend/src/routes/merch.ts new file mode 100644 index 0000000..6e6d6e4 --- /dev/null +++ b/backend/src/routes/merch.ts @@ -0,0 +1,24 @@ +import express from "express"; +import { + getAllMerch, + getMerchById, + getMerchByOrganization, + getMyOrganizationMerch, + addMerch, + updateMerch, + deleteMerch, +} from "src/controllers/merch"; +import { authenticateUser } from "src/validators/authUserMiddleware"; + +const router = express.Router(); + +router.get("/", authenticateUser, getAllMerch); +router.get("/my-organization", authenticateUser, getMyOrganizationMerch); +router.get("/organization/:organizationId", authenticateUser, getMerchByOrganization); +router.get("/:id", authenticateUser, getMerchById); +router.post("/", authenticateUser, addMerch); +router.patch("/:id", authenticateUser, updateMerch); +router.delete("/:id", authenticateUser, deleteMerch); + +export default router; + diff --git a/backend/src/routes/studentOrganization.ts b/backend/src/routes/studentOrganization.ts new file mode 100644 index 0000000..60ab4e2 --- /dev/null +++ b/backend/src/routes/studentOrganization.ts @@ -0,0 +1,22 @@ +import express from "express"; +import { + getStudentOrganizations, + getStudentOrganizationById, + getStudentOrganizationByFirebaseUid, + createStudentOrganization, + updateStudentOrganization, + deleteStudentOrganization, +} from "src/controllers/studentOrganizations"; +import { authenticateUser } from "src/validators/authUserMiddleware"; + +const router = express.Router(); + +router.get("/", authenticateUser, getStudentOrganizations); +router.get("/:id", authenticateUser, getStudentOrganizationById); +router.get("/firebase/:firebaseUid", authenticateUser, getStudentOrganizationByFirebaseUid); +router.post("/", authenticateUser, createStudentOrganization); +router.patch("/", authenticateUser, updateStudentOrganization); +router.delete("/", authenticateUser, deleteStudentOrganization); + +export default router; + diff --git a/frontend/.env.development b/frontend/.env.development index 3cd2602..375ff15 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,3 +1,3 @@ # Don't stop the React webpack build if there are lint errors. ESLINT_NO_DEV_ERRORS=true -VITE_API_BASE_URL=http://localhost:5000 +VITE_API_BASE_URL=http://localhost:5001 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2a21084..4ff198b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,8 @@ import { IndividualProductPage } from "../src/pages/Individual-product-page"; import { PageNotFound } from "../src/pages/PageNotFound"; import FirebaseProvider from "../src/utils/FirebaseProvider"; import { SavedProducts } from "./pages/SavedProducts"; +import { StudentOrgProfile } from "./pages/StudentOrgProfile"; +import { StudentOrganizations } from "./pages/StudentOrganizations"; const router = createBrowserRouter([ { @@ -58,6 +60,22 @@ const router = createBrowserRouter([ ), }, + { + path: "/student-org-profile", + element: ( + + + + ), + }, + { + path: "/student-organizations", + element: ( + + + + ), + }, { path: "*", element: , diff --git a/frontend/src/api/requests.ts b/frontend/src/api/requests.ts index befaa9c..0475c11 100644 --- a/frontend/src/api/requests.ts +++ b/frontend/src/api/requests.ts @@ -19,6 +19,7 @@ type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; * in Vite projects. */ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; +console.log("API_BASE_URL:", API_BASE_URL); /** * A wrapper around the built-in `fetch()` function that abstracts away some of diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index b35b3c6..abdd1e6 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,4 +1,4 @@ -import { faBars, faCartShopping, faUser, faXmark, faHeart } from "@fortawesome/free-solid-svg-icons"; +import { faBars, faCartShopping, faUser, faXmark, faHeart, faUsers, faStore, faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useContext, useEffect, useRef, useState } from "react"; import { FirebaseContext } from "src/utils/FirebaseProvider"; @@ -6,8 +6,10 @@ import { FirebaseContext } from "src/utils/FirebaseProvider"; export function Navbar() { const { user, signOutFromFirebase, openGoogleAuthentication } = useContext(FirebaseContext); const [isMobileMenuOpen, setMobileMenuOpen] = useState(false); + const [isStudentOrgDropdownOpen, setStudentOrgDropdownOpen] = useState(false); const menuRef = useRef(null); const buttonRef = useRef(null); + const dropdownRef = useRef(null); const toggleMobileMenu = () => { setMobileMenuOpen(!isMobileMenuOpen); @@ -23,6 +25,12 @@ export function Navbar() { ) { setMobileMenuOpen(false); } + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setStudentOrgDropdownOpen(false); + } }; const handleResize = () => { @@ -79,6 +87,57 @@ export function Navbar() { Saved +
  • + + {isStudentOrgDropdownOpen && ( +
    + + +
    + )} +
  • {user ? (
  • +
  • + +
  • +
  • +
    + + {isStudentOrgDropdownOpen && ( +
    + + +
    + )} +
    +
  • {user ? (