diff --git a/backend/routes/auth.js b/backend/routes/auth.js index a8f603d7..4190525d 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -1,335 +1,346 @@ -// Module imports -const express = require('express'); -const bcrypt = require('bcrypt'); -const jwt = require('jsonwebtoken'); -const { storage, cloudinary } = require('../utils/cloudinary'); -const multer = require('multer'); -const upload = multer({ storage }); -const { verifyToken } = require('../utils/verify_token.js'); -const { OAuth2Client } = require('google-auth-library'); -require('dotenv').config(); - -// Model imports -const User = require('../models/user'); - -// Router instance -const router = express.Router(); - -// Google OAuth client instance -const client = new OAuth2Client(); - -/** - * ! POST Login and/or register a User using Google - * - * Login and/or register a Google user - * - * @async - * @returns {200} Responds with 200 status code if user is successfully registered/logged in - * @throws {409} If the user already exists as a non-Google account - * @throws {403} If the email is not a Monash Student email - * @throws {500} If an error occurs whilst registering a user - */ -router.post('/google/authenticate', async function (req, res) { - const { idToken } = req.body; - try { - const ticket = await client.verifyIdToken({ - idToken: idToken, - audience: process.env.GOOGLE_CLIENT_ID - }); - - const payload = ticket.getPayload(); - // sub is the unique Google ID assigned to the user - const { email, name, picture, sub } = payload; - - // Regular expression to validate authcate and email - const studentEmailRegex = /^[a-zA-Z]{4}\d{4}@student\.monash\.edu$/ - const staffEmailRegex = /^[a-zA-Z]+\.[a-zA-Z]+@monash\.edu$/; - - // Invalid email error case - if (!studentEmailRegex.test(email) && !staffEmailRegex.test(email)) { - return res.status(403).json({ error: 'Access denied: Only students with a valid Monash email can log in.' }); - } - - // Check if the user already exists - let user = await User.findOne({ - $or: [ - { email: email }, - { googleID: sub } - ] - }); - - // register the user if they aren't registered - if (!user) { - // Generate username based on email format - let authcate; - if (studentEmailRegex.test(email)) { authcate = email.split('@')[0]; } - else if (staffEmailRegex.test(email)) { authcate = email.split('.')[0]; } - - user = new User({ - email: email, - username: authcate, - profileImg: picture, - isGoogleUser: true, - googleID: sub, - verified: true - }); - await user.save(); - } - - // if there is a user but they are NOT a Google user but same email - // (if they signed up using traditional way but then try logging in thru Google) - if (!user.isGoogleUser) { - return res.status(409).json({ message: "Account already exists as non-Google account." }); - } - - // Create json web token - const token = jwt.sign( - { id: user._id, isAdmin: user.admin }, - process.env.JWT_SECRET, - { expiresIn: '24h' } - ); - - // Return response as cookie with access token and user data. - return res.cookie('access_token', token, { - httpOnly: true, - sameSite: 'strict' - }) - .status(200) - .json({ message: 'Login successful', data: user }); - } - catch (error) { - return res.status(500).json({ error: error.message }); - } -}); - - -/** - * ! GET Get All Users - * - * Gets all users from the database. - * - * @async - * @returns {JSON} Responds with a list of all users in JSON format. - * @throws {500} If an error occurs whilst fetching users from the database. - */ -router.get('/', verifyToken, async function (req, res) { - try { - // Find all users - const users = await User.find({}); - - // Response 200 with list of users in json - return res.status(200).json(users); - } - catch (error) { - // Handle general errors - return res.status(500).json({ error: `An error occured while getting all Users: ${error.message}` }); - } -}); - - -/** - * ! DELETE Remove a User from the database - * - * Deletes a User from the database. Only admins or the user themselves can - * delete accounts. - * - * @async - * @returns {JSON} Responds with a success message in JSON - * @throws {403} If user is not authorised to delete this account - * @throws {500} If an error occurs - * @throws {404} User not found error - */ -router.delete('/delete/:userId', verifyToken, async function (req, res) { - try { - // Get the requesting user from the token - const requestingUser = await User.findById(req.user.id); - if (!requestingUser) return res.status(404).json({ error: 'Requesting user not found' }); - - // Get the target user by email - const targetUser = await User.findById(req.params.userId); - if (!targetUser) return res.status(404).json({ error: 'Target user not found' }); - - // Check authorisation - const isSameUser = requestingUser._id.toString() === targetUser._id.toString(); - const isAdminDeletingOther = requestingUser.admin && !isSameUser; - if (!isSameUser && !isAdminDeletingOther) - return res.status(403).json({ error: 'You are not authorised to delete this account' }); - - // Delete the user - await User.findOneAndDelete({ _id: targetUser._id }); - - // Return status 200 for successful deletion of user - return res.status(200).json({ message: "User successfully deleted" }); - } - catch (error) { - // Handle general errors status 500 - return res.status(500).json({ error: `Error occured while deleting user: ${error.message}` }); - } -}); - -/** - * ! POST Logout a User - * - * Clears the token cookie to log the user out - * - * @async - * @returns {JSON} Responds with a success message in JSON - */ -router.post('/logout', verifyToken, async function (req, res) { - try { - // Clear the cookie - res.clearCookie('access_token', { httpOnly: true, sameSite: 'strict' }); - - // Respond with success message - return res.status(200).json({ message: 'Logged out successfully' }); - } - catch (error) { - // Handle errors - return res.status(500).json({ error: `An error occurred during logout: ${error.message}` }); - } -}); - - -/** - * ! PUT Update a User's details - * - * Updates User's username and/or password. Only admins or the user themselves - * can update account details. - * - * @async - * @param {String} userId - The ID of the user to update - * @returns {JSON} Responds with status 200 and success message - * @throws {404} If the Unit is not found - * @throws {403} If the user is not authorised to update this account - * @throws {400} If no update fields are provided - * @throws {500} If some error occurs - */ -router.put('/update/:userId', verifyToken, async function (req, res) { - try { - // Get the requesting user from the token - const requestingUser = await User.findById(req.user.id); - if (!requestingUser) return res.status(404).json({ error: 'Requesting user not found' }); - - // Get the target user by userID - const targetUser = await User.findById(req.params.userId); - if (!targetUser) return res.status(404).json({ error: 'Target user not found' }); - - // Check authorisation - const isSameUser = requestingUser._id.toString() === targetUser._id.toString(); - const isAdminUpdatingOther = requestingUser.admin && !isSameUser; - if (!isSameUser && !isAdminUpdatingOther) - return res.status(403).json({ error: 'You are not authorised to update user details' }); - - // Get the updated email and/or password from the request body - const { username, password } = req.body; - - // Validate that either username or password is provided - if (!username && !password) - return res.status(400).json({ error: 'Either username or password is required to update' }); - - // Update fields if provided - if (username) { - targetUser.username = username; - } - - if (password) { - const hashedPassword = await bcrypt.hash(password, 10); - targetUser.password = hashedPassword; - } - - // Save the updated user - await targetUser.save(); - - // Return status 200 sending success message and updated user data - return res.status(200).json({ message: "User details successfully updated", username: targetUser.username }); - } - catch (error) { - // Handle general errors status 500 - return res.status(500).json({ error: `Error updating user details: ${error.message}` }); - } -}); - -/** - * ! GET Validates User - * - * Checks if the user has the access_token in their cookies to keep session. - * The payload also contains the user's data. - * - * @async - * @returns {JSON} Responds with status 200 and json containing message and decoded user data. - * @throws {401} If the user is not authenticated and has no access token - * @throws {403} If the given access_token is invalid - * @throws {404} If the user is not found - */ -router.get('/validate', async function (req, res) { - // Gets the access token from the user's cookies - const token = req.cookies.access_token; - - // Throw error if no access token - if (!token) - return res.status(401).json({ message: 'Not authenticated' }); - - try { - // Decode the token - const decoded = jwt.verify(token, process.env.JWT_SECRET); - - // Find and store the user without storing the password - const user = await User.findById(decoded.id, 'email username isGoogleUser reviews admin profileImg likedReviews dislikedReviews notifications'); - // User not found error case - if (!user) - return res.status(404).json({ message: 'User not found' }); - - // Return status 200 success with decoded user data - return res.status(200).json({ message: 'Authenticated', data: user }); - } - catch (error) { - // Invalid access token error - return res.status(403).json({ message: 'Invalid token' }); - } -}); - -/** - * ! POST Upload Avatar - * - * Uploads the given avatar to cloudinary via middlware, and then assigns the - * avatar as user's profileImg - * - * @async - * @returns {JSON} Responds with status 200 and json containing the success message and profileImg URL - * @throws {404} If the user is not found - * @throws {500} Internal server errors - */ -router.post('/upload-avatar', verifyToken, upload.single('avatar'), async function (req, res) { - try { - // Get the user by email - const { email } = req.body; - const user = await User.findOne({ email }); - - if (!user) - return res.status(404).json({ error: 'User not found' }); - - // If the user already has a profile image, remove it from Cloudinary - if (user.profileImg) { - // Extract the public id from the existing cloudinary URL - const urlParts = user.profileImg.split('/'); - const fileName = urlParts[urlParts.length - 1].split('.')[0]; // Extract the file name without its extension. - const publicId = `user_avatars/${fileName}`; - - // Delete the old avatar - await cloudinary.uploader.destroy(publicId); - } - - // Save the Cloudinary URL as the user's avatar - user.profileImg = req.file.path; // Cloudinary URL for the uploaded image - await user.save(); - - // Respond with status 200 and json containing success message and profile image - return res.status(200).json({ message: 'Avatar uploaded successfully', profileImg: user.profileImg }); - } - catch (error) { - return res.status(500).json({ error: `Error uploading avatar: ${error.message}` }); - } -}); - - +// Module imports +const express = require('express'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { storage, cloudinary } = require('../utils/cloudinary'); +const multer = require('multer'); +const upload = multer({ storage }); +const { verifyToken } = require('../utils/verify_token.js'); +const { OAuth2Client } = require('google-auth-library'); +require('dotenv').config(); + +// Model imports +const User = require('../models/user'); + +// Router instance +const router = express.Router(); + +// Google OAuth client instance +const client = new OAuth2Client(); + +/** + * ! POST Login and/or register a User using Google + * + * Login and/or register a Google user + * + * @async + * @returns {200} Responds with 200 status code if user is successfully registered/logged in + * @throws {409} If the user already exists as a non-Google account + * @throws {403} If the email is not a Monash Student email + * @throws {500} If an error occurs whilst registering a user + */ +router.post('/google/authenticate', async function (req, res) { + const { idToken } = req.body; + try { + const ticket = await client.verifyIdToken({ + idToken: idToken, + audience: process.env.GOOGLE_CLIENT_ID + }); + + const payload = ticket.getPayload(); + // sub is the unique Google ID assigned to the user + const { email, name, picture, sub } = payload; + + // Regular expression to validate authcate and email + const studentEmailRegex = /^[a-zA-Z]{4}\d{4}@student\.monash\.edu$/ + const staffEmailRegex = /^[a-zA-Z]+\.[a-zA-Z]+@monash\.edu$/; + + // Invalid email error case + if (!studentEmailRegex.test(email) && !staffEmailRegex.test(email)) { + return res.status(403).json({ error: 'Access denied: Only students with a valid Monash email can log in.' }); + } + + // Check if the user already exists + let user = await User.findOne({ + $or: [ + { email: email }, + { googleID: sub } + ] + }); + + // register the user if they aren't registered + if (!user) { + // Generate username based on email format + let authcate; + if (studentEmailRegex.test(email)) { authcate = email.split('@')[0]; } + else if (staffEmailRegex.test(email)) { authcate = email.split('.')[0]; } + + user = new User({ + email: email, + username: authcate, + profileImg: picture, + isGoogleUser: true, + googleID: sub, + verified: true + }); + await user.save(); + } + + // if there is a user but they are NOT a Google user but same email + // (if they signed up using traditional way but then try logging in thru Google) + if (!user.isGoogleUser) { + return res.status(409).json({ message: "Account already exists as non-Google account." }); + } + + // Create json web token + const token = jwt.sign( + { id: user._id, isAdmin: user.admin }, + process.env.JWT_SECRET, + { expiresIn: '24h' } + ); + + // Return response as cookie with access token and user data. + return res.cookie('access_token', token, { + httpOnly: true, + sameSite: 'strict' + }) + .status(200) + .json({ message: 'Login successful', data: user }); + } + catch (error) { + return res.status(500).json({ error: error.message }); + } +}); + + +/** + * ! GET Get All Users + * + * Gets all users from the database. + * + * @async + * @returns {JSON} Responds with a list of all users in JSON format. + * @throws {500} If an error occurs whilst fetching users from the database. + */ +router.get('/', verifyToken, async function (req, res) { + try { + // Find all users + const users = await User.find({}); + + // Response 200 with list of users in json + return res.status(200).json(users); + } + catch (error) { + // Handle general errors + return res.status(500).json({ error: `An error occured while getting all Users: ${error.message}` }); + } +}); + + +/** + * ! DELETE Remove a User from the database + * + * Deletes a User from the database. Only admins or the user themselves can + * delete accounts. + * + * @async + * @returns {JSON} Responds with a success message in JSON + * @throws {403} If user is not authorised to delete this account + * @throws {500} If an error occurs + * @throws {404} User not found error + */ +router.delete('/delete/:userId', verifyToken, async function (req, res) { + try { + // Get the requesting user from the token + const requestingUser = await User.findById(req.user.id); + if (!requestingUser) return res.status(404).json({ error: 'Requesting user not found' }); + + // Get the target user by email + const targetUser = await User.findById(req.params.userId); + if (!targetUser) return res.status(404).json({ error: 'Target user not found' }); + + // Check authorisation + const isSameUser = requestingUser._id.toString() === targetUser._id.toString(); + const isAdminDeletingOther = requestingUser.admin && !isSameUser; + if (!isSameUser && !isAdminDeletingOther) + return res.status(403).json({ error: 'You are not authorised to delete this account' }); + + // Delete the user + await User.findOneAndDelete({ _id: targetUser._id }); + + // Return status 200 for successful deletion of user + return res.status(200).json({ message: "User successfully deleted" }); + } + catch (error) { + // Handle general errors status 500 + return res.status(500).json({ error: `Error occured while deleting user: ${error.message}` }); + } +}); + +/** + * ! POST Logout a User + * + * Clears the token cookie to log the user out + * + * @async + * @returns {JSON} Responds with a success message in JSON + */ +router.post('/logout', verifyToken, async function (req, res) { + try { + // Clear the cookie + res.clearCookie('access_token', { httpOnly: true, sameSite: 'strict' }); + + // Respond with success message + return res.status(200).json({ message: 'Logged out successfully' }); + } + catch (error) { + // Handle errors + return res.status(500).json({ error: `An error occurred during logout: ${error.message}` }); + } +}); + + +/** + * ! PUT Update a User's details + * + * Updates User's username and/or password. Only admins or the user themselves + * can update account details. + * + * @async + * @param {String} userId - The ID of the user to update + * @returns {JSON} Responds with status 200 and success message + * @throws {404} If the Unit is not found + * @throws {403} If the user is not authorised to update this account + * @throws {400} If no update fields are provided + * @throws {500} If some error occurs + */ +router.put('/update/:userId', verifyToken, async function (req, res) { + try { + // Get the requesting user from the token + const requestingUser = await User.findById(req.user.id); + if (!requestingUser) return res.status(404).json({ error: 'Requesting user not found' }); + + // Get the target user by userID + const targetUser = await User.findById(req.params.userId); + if (!targetUser) return res.status(404).json({ error: 'Target user not found' }); + + // Check authorisation + const isSameUser = requestingUser._id.toString() === targetUser._id.toString(); + const isAdminUpdatingOther = requestingUser.admin && !isSameUser; + if (!isSameUser && !isAdminUpdatingOther) + return res.status(403).json({ error: 'You are not authorised to update user details' }); + + // Get the updated email and/or password from the request body + const { username, password } = req.body; + + // Validate that either username or password is provided + if (!username && !password) + return res.status(400).json({ error: 'Either username or password is required to update' }); + + // If usename is duplicated, return error + if (username) { + const existingUser = await User.findOne({ + username: username, + _id: { $ne: targetUser._id } // Exclude the current user + }); + if (existingUser) { + return res.status(400).json({ error: 'Username already exists' }); + } + } + + // Update fields if provided + if (username) { + targetUser.username = username; + } + + if (password) { + const hashedPassword = await bcrypt.hash(password, 10); + targetUser.password = hashedPassword; + } + + // Save the updated user + await targetUser.save(); + + // Return status 200 sending success message and updated user data + return res.status(200).json({ message: "User details successfully updated", username: targetUser.username }); + } + catch (error) { + // Handle general errors status 500 + return res.status(500).json({ error: `Error updating user details: ${error.message}` }); + } +}); + +/** + * ! GET Validates User + * + * Checks if the user has the access_token in their cookies to keep session. + * The payload also contains the user's data. + * + * @async + * @returns {JSON} Responds with status 200 and json containing message and decoded user data. + * @throws {401} If the user is not authenticated and has no access token + * @throws {403} If the given access_token is invalid + * @throws {404} If the user is not found + */ +router.get('/validate', async function (req, res) { + // Gets the access token from the user's cookies + const token = req.cookies.access_token; + + // Throw error if no access token + if (!token) + return res.status(401).json({ message: 'Not authenticated' }); + + try { + // Decode the token + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Find and store the user without storing the password + const user = await User.findById(decoded.id, 'email username isGoogleUser reviews admin profileImg likedReviews dislikedReviews notifications'); + // User not found error case + if (!user) + return res.status(404).json({ message: 'User not found' }); + + // Return status 200 success with decoded user data + return res.status(200).json({ message: 'Authenticated', data: user }); + } + catch (error) { + // Invalid access token error + return res.status(403).json({ message: 'Invalid token' }); + } +}); + +/** + * ! POST Upload Avatar + * + * Uploads the given avatar to cloudinary via middlware, and then assigns the + * avatar as user's profileImg + * + * @async + * @returns {JSON} Responds with status 200 and json containing the success message and profileImg URL + * @throws {404} If the user is not found + * @throws {500} Internal server errors + */ +router.post('/upload-avatar', verifyToken, upload.single('avatar'), async function (req, res) { + try { + // Get the user by email + const { email } = req.body; + const user = await User.findOne({ email }); + + if (!user) + return res.status(404).json({ error: 'User not found' }); + + // If the user already has a profile image, remove it from Cloudinary + if (user.profileImg) { + // Extract the public id from the existing cloudinary URL + const urlParts = user.profileImg.split('/'); + const fileName = urlParts[urlParts.length - 1].split('.')[0]; // Extract the file name without its extension. + const publicId = `user_avatars/${fileName}`; + + // Delete the old avatar + await cloudinary.uploader.destroy(publicId); + } + + // Save the Cloudinary URL as the user's avatar + user.profileImg = req.file.path; // Cloudinary URL for the uploaded image + await user.save(); + + // Respond with status 200 and json containing success message and profile image + return res.status(200).json({ message: 'Avatar uploaded successfully', profileImg: user.profileImg }); + } + catch (error) { + return res.status(500).json({ error: `Error uploading avatar: ${error.message}` }); + } +}); + + module.exports = router; \ No newline at end of file diff --git a/backend/routes/users.js b/backend/routes/users.js new file mode 100644 index 00000000..0d9cefdb --- /dev/null +++ b/backend/routes/users.js @@ -0,0 +1,61 @@ +// Module Imports +const express = require('express'); + +// Model Imports +const User = require('../models/user'); + +// Function Imports +const { verifyAdmin } = require('../utils/verify_token.js'); + +// Router instance +const router = express.Router(); + +/** + * ! GET Get User by Username + * + * Gets a user by their username + * + * @async + * @returns {JSON} Responds with the unit and its details in JSON format + * @throws {500} If an error occurs whilst getting the singular user from the database + */ +router.get('/:username', async function (req, res) { + try { + const user = await User.findOne({ username: req.params.username }); + + if (!user) + return res.status(404).json({ error: 'User not found'}); + + return res.status(200).json(user); + } + catch (error) { + return res.status(500).json({ error: `An error occured whilst getting the singular user: ${error.message}`}); + } +}); + +/** + * ! GET Get All Users + * + * Gets all users from the database. + * + * @async + * @returns {JSON} Responds with a list of all users in JSON format. + * @throws {500} If an error occurs whilst fetching users from the database. + */ +router.get('/', async function (req, res) { + try { + // Find all the users + const users = await User.find({}); + + // Respond 200 with JSON list containing all Users. + return res.status(200).json(users); + } + catch (error) { + // Handle general errors 500 + return res.status(500).json({ error: `An error occured while getting all Users: ${error.message}` }); + } +}); + + +// Export the router +module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 3799cfc0..9f5f1c79 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,135 +1,135 @@ -// Load environment variables -require("dotenv").config(); - -// Module Imports -const express = require("express"); -const mongoose = require("mongoose"); -const cron = require("node-cron"); -const cors = require("cors"); -const app = express(); -const cookieParser = require('cookie-parser'); -const tagManager = require('./services/tagManager.service'); -const aiOverviewService = require("./services/aiOverview.service"); -const { exec } = require('child_process'); -const path = require("path"); - -// Router Imports -const UnitRouter = require('./routes/units'); -const ReviewRouter = require('./routes/reviews'); -const AuthRouter = require('./routes/auth'); -const NotificationRouter = require('./routes/notifications'); -const GitHubRouter = require("./routes/github"); -const SetuRouter = require("./routes/setus"); - -// === Environment Configuration === -const isDevelopment = process.env.DEVELOPMENT === 'true'; -console.log(`Running in ${isDevelopment ? 'DEVELOPMENT' : 'PRODUCTION'} mode`); - -// === Middleware === -if (isDevelopment) { - app.use( - cors({ - origin: "http://localhost:4200", - credentials: true, - }) - ); -} - -app.use(express.json({ limit: "50mb" })); // Increased payload limit for JSON requests. -app.use(express.urlencoded({ limit: "50mb", extended: true })); // Increased payload limit for URL-encoded requests. -app.use(cookieParser()); - -// Response handler middlware -app.use((obj, req, res, next) => { - const statusCode = obj.status || 500; - const message = obj.message || "Internal server error"; - return res.status(statusCode, { - success: [200, 201, 204].some((a) => a === obj.status) ? true : false, - status: statusCode, - message: message, - data: obj.data, - }); -}); - -// === Routes === -app.use('/api/v1/units', UnitRouter); -app.use('/api/v1/reviews', ReviewRouter); -app.use('/api/v1/auth', AuthRouter); -app.use('/api/v1/notifications', NotificationRouter); -app.use('/api/v1/github', GitHubRouter); -app.use('/api/v1/setus', SetuRouter); - -// === Serving Static Files (Production Mode) === -if (!isDevelopment) { - app.use(express.static(path.join(__dirname, '../frontend/dist/frontend/browser'))); -} - -// === Connect to MongoDB === -const url = process.env.MONGODB_CONN_STRING; -async function connect(url) { - await mongoose.connect(url); -} -connect(url) - .then(() => { - console.log("Connected to MongoDB Database"); - tagManager.updateMostReviewsTag(1); - }) - .catch((error) => console.log(error)); - -// === Services === -// Update the most reviews tag every hour -cron.schedule("0 * * * *", async function () { - await tagManager.updateMostReviewsTag(1); -}); - -// Generate sitemaps daily at 3:00 AM -cron.schedule("0 3 * * *", function () { - console.log("[Cron] Running daily sitemap generation..."); - - // Path to the sitemap generator script - const scriptPath = path.join(__dirname, "utils", "generate-sitemap.js"); - - // Use Node to execute the script - exec(`node ${scriptPath}`, (error, stdout, stderr) => { - if (error) { - console.error(`[Cron] Sitemap generation error: ${error.message}`); - return; - } - - if (stderr) { - console.error(`[Cron] Sitemap stderr: ${stderr}`); - return; - } - - console.log(`[Cron] Sitemap generation complete: ${stdout}`); - }); -}); - - -// Regenerate AI unit overviews ahead of each semester (Feb 1 & Jun 1 at 02:00) -cron.schedule('0 2 1 2 *', async function () { - console.log('[Cron] Running Semester 1 AI overview refresh'); - await aiOverviewService.generateOverviewsForAllUnits({ force: true, delayMs: 750 }); -}); - -cron.schedule('0 2 1 6 *', async function () { - console.log('[Cron] Running Semester 2 AI overview refresh'); - await aiOverviewService.generateOverviewsForAllUnits({ force: true, delayMs: 750 }); -}); - - -// === Catch all route (Production Mode) === -if (!isDevelopment) { - app.get('*', (req, res) => { - return res.sendFile(path.join(__dirname, '../frontend/dist/frontend/browser/index.html')); - }); -} - - -// === Start Server === -const PORT = process.env.PORT || 8080; // Default to 8080 if no port specified -app.listen(PORT, (error) => { - if (error) console.log(error); - - console.log(`Server running on port ${PORT}`); -}); +// Load environment variables +require("dotenv").config(); + +// Module Imports +const express = require("express"); +const mongoose = require("mongoose"); +const cron = require("node-cron"); +const cors = require("cors"); +const app = express(); +const cookieParser = require('cookie-parser'); +const tagManager = require('./services/tagManager.service'); +const aiOverviewService = require("./services/aiOverview.service"); +const { exec } = require('child_process'); +const path = require("path"); + +// Router Imports +const UnitRouter = require('./routes/units'); +const ReviewRouter = require('./routes/reviews'); +const AuthRouter = require('./routes/auth'); +const NotificationRouter = require('./routes/notifications'); +const GitHubRouter = require("./routes/github"); +const SetuRouter = require("./routes/setus"); + +// === Environment Configuration === +const isDevelopment = process.env.DEVELOPMENT === 'true'; +console.log(`Running in ${isDevelopment ? 'DEVELOPMENT' : 'PRODUCTION'} mode`); + +// === Middleware === +if (isDevelopment) { + app.use( + cors({ + origin: "http://localhost:4200", + credentials: true, + }) + ); +} + +app.use(express.json({ limit: "50mb" })); // Increased payload limit for JSON requests. +app.use(express.urlencoded({ limit: "50mb", extended: true })); // Increased payload limit for URL-encoded requests. +app.use(cookieParser()); + +// Response handler middlware +app.use((obj, req, res, next) => { + const statusCode = obj.status || 500; + const message = obj.message || "Internal server error"; + return res.status(statusCode, { + success: [200, 201, 204].some((a) => a === obj.status) ? true : false, + status: statusCode, + message: message, + data: obj.data, + }); +}); + +// === Routes === +app.use('/api/v1/units', UnitRouter); +app.use('/api/v1/reviews', ReviewRouter); +app.use('/api/v1/auth', AuthRouter); +app.use('/api/v1/notifications', NotificationRouter); +app.use('/api/v1/github', GitHubRouter); +app.use('/api/v1/setus', SetuRouter); + +// === Serving Static Files (Production Mode) === +if (!isDevelopment) { + app.use(express.static(path.join(__dirname, '../frontend/dist/frontend/browser'))); +} + +// === Connect to MongoDB === +const url = process.env.MONGODB_CONN_STRING; +async function connect(url) { + await mongoose.connect(url); +} +connect(url) + .then(() => { + console.log("Connected to MongoDB Database"); + tagManager.updateMostReviewsTag(1); + }) + .catch((error) => console.log(error)); + +// === Services === +// Update the most reviews tag every hour +cron.schedule("0 * * * *", async function () { + await tagManager.updateMostReviewsTag(1); +}); + +// Generate sitemaps daily at 3:00 AM +cron.schedule("0 3 * * *", function () { + console.log("[Cron] Running daily sitemap generation..."); + + // Path to the sitemap generator script + const scriptPath = path.join(__dirname, "utils", "generate-sitemap.js"); + + // Use Node to execute the script + exec(`node ${scriptPath}`, (error, stdout, stderr) => { + if (error) { + console.error(`[Cron] Sitemap generation error: ${error.message}`); + return; + } + + if (stderr) { + console.error(`[Cron] Sitemap stderr: ${stderr}`); + return; + } + + console.log(`[Cron] Sitemap generation complete: ${stdout}`); + }); +}); + + +// Regenerate AI unit overviews ahead of each semester (Feb 1 & Jun 1 at 02:00) +cron.schedule('0 2 1 2 *', async function () { + console.log('[Cron] Running Semester 1 AI overview refresh'); + await aiOverviewService.generateOverviewsForAllUnits({ force: true, delayMs: 750 }); +}); + +cron.schedule('0 2 1 6 *', async function () { + console.log('[Cron] Running Semester 2 AI overview refresh'); + await aiOverviewService.generateOverviewsForAllUnits({ force: true, delayMs: 750 }); +}); + + +// === Catch all route (Production Mode) === +if (!isDevelopment) { + app.get('*', (req, res) => { + return res.sendFile(path.join(__dirname, '../frontend/dist/frontend/browser/index.html')); + }); +} + + +// === Start Server === +const PORT = process.env.PORT || 8080; // Default to 8080 if no port specified +app.listen(PORT, (error) => { + if (error) console.log(error); + + console.log(`Server running on port ${PORT}`); +}); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index fb3c6487..06616fbd 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,37 +1,40 @@ -import { Routes } from '@angular/router'; - -// Component Imports -import { HomeComponent } from './routes/home/home.component'; -import { UnitListComponent } from './routes/unit-list/unit-list.component'; -import { UnitOverviewComponent } from './routes/unit-overview/unit-overview.component'; -import { VerifiedComponent } from './routes/verified/verified.component'; -import { NotFoundComponent } from './routes/not-found/not-found.component'; -import { ResetPasswordComponent } from './routes/reset-password/reset-password.component'; -import { UnitMapComponent } from './routes/unit-map/unit-map.component'; -import { TermsAndCondsComponent } from './routes/terms-and-conds/terms-and-conds.component'; -import { AboutComponent } from './routes/about/about.component'; -import { SetuOverviewComponent } from './routes/setu-overview/setu-overview.component'; - -export const routes: Routes = [ - // Homepage - { path: '', component: HomeComponent }, - // Unit List - { path: 'list', component: UnitListComponent }, - // Unit Overview - { path: 'unit/:unitcode', component: UnitOverviewComponent }, - // Unit Map - { path: 'map/:unitcode', component: UnitMapComponent }, - // SETU Data - { path: 'setu/:unitCode', component: SetuOverviewComponent }, - // Email Verification - { path: 'verify-email/:token', component: VerifiedComponent }, - // Reset Password - { path: 'reset-password/:token', component: ResetPasswordComponent }, - // Terms and Conditions Page - { path: 'terms-and-conditions', component: TermsAndCondsComponent }, - // About Page - { path: 'about', component: AboutComponent }, - - // 404 Not Found for all other routes - { path: '**', component: NotFoundComponent }, -]; +import { Routes } from '@angular/router'; + +// Component Imports +import { HomeComponent } from './routes/home/home.component'; +import { UnitListComponent } from './routes/unit-list/unit-list.component'; +import { UnitOverviewComponent } from './routes/unit-overview/unit-overview.component'; +import { UserOverviewComponent } from './routes/user-overview/user-overview.component'; +import { VerifiedComponent } from './routes/verified/verified.component'; +import { NotFoundComponent } from './routes/not-found/not-found.component'; +import { ResetPasswordComponent } from './routes/reset-password/reset-password.component'; +import { UnitMapComponent } from './routes/unit-map/unit-map.component'; +import { TermsAndCondsComponent } from './routes/terms-and-conds/terms-and-conds.component'; +import { AboutComponent } from './routes/about/about.component'; +import { SetuOverviewComponent } from './routes/setu-overview/setu-overview.component'; + +export const routes: Routes = [ + // Homepage + { path: '', component: HomeComponent }, + // Unit List + { path: 'list', component: UnitListComponent }, + // Unit Overview + { path: 'unit/:unitcode', component: UnitOverviewComponent }, + // User Overview + { path: 'user/:username', component: UserOverviewComponent }, + // Unit Map + { path: 'map/:unitcode', component: UnitMapComponent }, + // SETU Data + { path: 'setu/:unitCode', component: SetuOverviewComponent }, + // Email Verification + { path: 'verify-email/:token', component: VerifiedComponent }, + // Reset Password + { path: 'reset-password/:token', component: ResetPasswordComponent }, + // Terms and Conditions Page + { path: 'terms-and-conditions', component: TermsAndCondsComponent }, + // About Page + { path: 'about', component: AboutComponent }, + + // 404 Not Found for all other routes + { path: '**', component: NotFoundComponent }, +]; diff --git a/frontend/src/app/routes/user-overview/user-overview.component.html b/frontend/src/app/routes/user-overview/user-overview.component.html new file mode 100644 index 00000000..19814c3a --- /dev/null +++ b/frontend/src/app/routes/user-overview/user-overview.component.html @@ -0,0 +1,143 @@ + + + + +
+ +
+
+
+
+ {{ getInitials(user.username) }} +
+
+
+

{{ user.username }}

+

@{{ user.username }}

+
+
+ {{ user.reviews.length || 0 }} + Reviews +
+
+ {{ groupedReviews.length }} + Units +
+
+ Member + Status +
+
+
+
+ + + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + +
+
+

Reviews by {{ user?.username || 'User' }}

+ ({{ reviews.length }}) +
+ +
+
+ + + + + +
+
+ {{ group.unitCode | uppercase }} | + {{ group.unitName | uppercase }} +
+
+
+ {{ getStarRating(group.averageRating) }} + {{ group.averageRating.toFixed(1) }} +
+
+
+
+ + +
+
+ +
+
+
+
+ + +
+

That's all the reviews from {{ user?.username }}!

+
+
+ + + +
+
+ +
+

{{ user?.username }} hasn't written any reviews yet

+

Check back later to see what they think about their units!

+
+
+
+ + + +
+ +
+
+ + +
+
+
\ No newline at end of file diff --git a/frontend/src/app/routes/user-overview/user-overview.component.scss b/frontend/src/app/routes/user-overview/user-overview.component.scss new file mode 100644 index 00000000..105a6bc7 --- /dev/null +++ b/frontend/src/app/routes/user-overview/user-overview.component.scss @@ -0,0 +1,277 @@ +// Import variables - using same pattern as unit-overview +@import '../../_variables.scss'; + +:host { + background-color: $bg-dark-color; + display: block; + min-height: 100vh; +} + +.user-overview-container { + display: flex; + flex-direction: column; + min-height: calc(100vh - 57.2px); + padding: 0 1rem; + gap: 2rem; + + @media (min-width: 768px) { + padding: 0 2rem; + } + + @media (min-width: 1200px) { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; + } +} + +/* === USER PROFILE SECTION === */ +.user-profile-section { + padding: 2rem 0; + border-bottom: 2px solid $fg-dark-color; + + .profile-header { + display: flex; + align-items: center; + gap: 1.5rem; + + @media (max-width: 768px) { + flex-direction: column; + text-align: center; + gap: 1rem; + } + } + + .profile-avatar { + flex-shrink: 0; + + .avatar-circle { + width: 80px; + height: 80px; + border-radius: 50%; + background: linear-gradient(135deg, $primary-color, #6366f1); + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + font-weight: bold; + color: white; + text-transform: uppercase; + + @media (min-width: 768px) { + width: 100px; + height: 100px; + font-size: 2.5rem; + } + } + } + + .profile-info { + flex: 1; + + .user-name { + color: #ffffff; + font-size: 1.8rem; + font-weight: 700; + margin: 0 0 0.5rem 0; + + @media (min-width: 768px) { + font-size: 2.2rem; + } + } + + .username { + color: #a0a0a0; + font-size: 1rem; + margin: 0 0 1rem 0; + + @media (min-width: 768px) { + font-size: 1.1rem; + } + } + + .user-stats { + display: flex; + gap: 2rem; + + @media (max-width: 768px) { + justify-content: center; + } + + .stat-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + + @media (min-width: 768px) { + align-items: flex-start; + } + + .stat-number { + color: $primary-color; + font-size: 1.5rem; + font-weight: 700; + } + + .stat-label { + color: #a0a0a0; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + } + } + } +} + +/* === REVIEWS SECTION === */ +.reviews-section { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; // Important for flex child to shrink + + .section-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1.5rem; + + h2 { + color: #ffffff; + font-size: 1.5rem; + font-weight: 600; + margin: 0; + + @media (min-width: 768px) { + font-size: 1.8rem; + } + } + + .review-count { + color: #a0a0a0; + font-size: 1.2rem; + } + } + + .reviews-scroll { + flex: 1; + min-height: 400px; + + ::ng-deep .p-scrollpanel-content { + padding-right: 0.5rem; + } + } +} + +/* === REVIEWS CONTAINER === */ +.reviews-container { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-bottom: 2rem; +} + +.reviews-skeleton-container { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-bottom: 2rem; + + .review-skeleton { + margin-bottom: 0; + } +} + +/* === MESSAGES === */ +.no-reviews-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 3rem 1rem; + min-height: 300px; + + .no-reviews-icon { + margin-bottom: 1rem; + + i { + font-size: 3rem; + color: #a0a0a0; + } + } + + h3 { + color: #ffffff; + font-size: 1.3rem; + margin: 0 0 0.5rem 0; + font-weight: 600; + } + + p { + color: #a0a0a0; + font-size: 1rem; + margin: 0; + max-width: 400px; + } +} + +.end-message { + text-align: center; + padding: 2rem 1rem; + margin-top: 1rem; + + h3 { + color: #a0a0a0; + font-size: 1.1rem; + margin: 0; + font-weight: 500; + } +} + +/* === SPACER === */ +.spacer { + height: 3rem; +} + +/* === RESPONSIVE ADJUSTMENTS === */ +@media (max-width: 768px) { + .user-overview-container { + gap: 1.5rem; + } + + .user-profile-section { + padding: 1.5rem 0; + } + + .reviews-section .section-header { + margin-bottom: 1rem; + } +} + +/* === SKELETON SPECIFIC STYLES === */ +:host ::ng-deep { + .p-skeleton { + background: linear-gradient(90deg, $fg-dark-color 25%, lighten($fg-dark-color, 5%) 50%, $fg-dark-color 75%); + background-size: 200% 100%; + animation: loading 1.5s infinite; + } + + @keyframes loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } +} + +/* === REVIEW CARD ADJUSTMENTS === */ +:host ::ng-deep app-review-card { + .review-card-container { + margin-bottom: 0; // Remove default margins since we use gap in container + } +} \ No newline at end of file diff --git a/frontend/src/app/routes/user-overview/user-overview.component.ts b/frontend/src/app/routes/user-overview/user-overview.component.ts new file mode 100644 index 00000000..cddca910 --- /dev/null +++ b/frontend/src/app/routes/user-overview/user-overview.component.ts @@ -0,0 +1,432 @@ +import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Meta, Title } from '@angular/platform-browser'; +import { Subject, takeUntil, forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +// Constants +import { BASE_URL, NAVBAR_HEIGHT } from '../../shared/constants'; + +// Services +import { ApiService } from '../../shared/services/api.service'; +import { MessageService } from 'primeng/api'; +import { FooterService } from '../../shared/services/footer.service'; + +// Components +import { ReviewCardComponent } from "../../shared/components/review-card/review-card.component"; + +// Modules +import { ProgressSpinnerModule } from 'primeng/progressspinner'; +import { SkeletonModule } from 'primeng/skeleton'; +import { ScrollPanelModule } from 'primeng/scrollpanel'; +import { ToastModule } from 'primeng/toast'; +import { AccordionModule } from 'primeng/accordion'; + +// Models +import { Review } from '../../shared/models/review.model'; +import { User } from '../../shared/models/user.model'; +import { Unit } from '../../shared/models/unit.model'; + +// Interface for grouped reviews +interface UnitReviewGroup { + unit: Unit; + unitCode: string; + unitName: string; + reviews: Review[]; + averageRating: number; +} + +@Component({ + selector: 'app-user-overview', + standalone: true, + imports: [ + ReviewCardComponent, + ToastModule, + ProgressSpinnerModule, + SkeletonModule, + ScrollPanelModule, + AccordionModule, + CommonModule, + FormsModule, + ], + providers: [ + MessageService, + ], + templateUrl: './user-overview.component.html', + styleUrl: './user-overview.component.scss' +}) +export class UserOverviewComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild('userOverviewContainer') userOverviewContainer!: ElementRef; + + user: User | null = null; + reviews: Review[] = []; + groupedReviews: UnitReviewGroup[] = []; + reviewsLoading: boolean = true; + userLoading: boolean = true; + + private destroy$ = new Subject(); + private resizeHandlerBound = this.resizeHandler.bind(this); + + /** + * Constructor + */ + constructor ( + private apiService: ApiService, + private route: ActivatedRoute, + private router: Router, + private messageService: MessageService, + private meta: Meta, + private titleService: Title, + private footerService: FooterService + ) { } + + /** + * Runs on initialisation + */ + ngOnInit(): void { + // Hide the footer + this.footerService.hideFooter(); + + // Get username from the route parameters + const username = this.route.snapshot.paramMap.get('username'); + + if (!username || username.trim() === '') { + console.error('No username provided in route parameters'); + this.router.navigate(['/404']); + return; + } + + this.loadUserData(username); + } + + /** + * Runs after the view has been initialised + */ + ngAfterViewInit(): void { + // Add resize listener with bound context + window.addEventListener('resize', this.resizeHandlerBound); + + // Use setTimeout to ensure ViewChild is initialized + setTimeout(() => { + this.updateContainerHeight(); + }, 0); + } + + /** + * On Component Destruction + */ + ngOnDestroy(): void { + // Complete the subject to unsubscribe from all observables + this.destroy$.next(); + this.destroy$.complete(); + + // Remove the event listener with the same reference + window.removeEventListener('resize', this.resizeHandlerBound); + + // Reset height of the user overview container + if (this.userOverviewContainer?.nativeElement) { + this.userOverviewContainer.nativeElement.style.height = ''; + } + + // Show the footer again + this.footerService.showFooter(); + + // Reset title + this.titleService.setTitle('Unit Reviews'); + + // Proper meta tag cleanup + this.cleanupMetaTags(); + } + + /** + * Load user data and reviews simultaneously + */ + private loadUserData(username: string): void { + this.userLoading = true; + this.reviewsLoading = true; + + console.log(`Attempting to load data for username: ${username}`); + + // First get the user data + this.apiService.getUserByUsernameGET(username).pipe( + takeUntil(this.destroy$) + ).subscribe({ + next: (userData) => { + console.log('loadUserData: Received user data:', userData); + this.userLoading = false; + + if (!userData) { + console.log('loadUserData: No user data received, user probably does not exist'); + this.reviewsLoading = false; + this.handleUserNotFound(); + return; + } + + this.user = userData; + console.log('loadUserData: User found:', userData); + + // Now get reviews using the user ID + this.apiService.getUserReviewsGET(userData._id.toString()).pipe( + takeUntil(this.destroy$) + ).subscribe({ + next: (reviewsData) => { + console.log('loadUserData: Received reviews data:', reviewsData); + this.reviews = reviewsData || []; + this.groupReviewsByUnit(); + this.reviewsLoading = false; + + this.updateMetaTags(); + this.updateContainerHeight(); + + console.log('Successfully loaded all user data'); + }, + error: (error) => { + console.error('Error fetching user reviews:', error); + this.reviews = []; + this.groupedReviews = []; + this.reviewsLoading = false; + // Don't redirect on reviews error, just show empty reviews + } + }); + }, + error: (error) => { + console.error('Error fetching user:', error); + console.error('User API endpoint failed:', error.url); + this.userLoading = false; + this.reviewsLoading = false; + this.handleUserNotFound(); + } + }); + } + + /** + * Group reviews by unit + */ + private groupReviewsByUnit(): void { + const groupMap = new Map(); + + this.reviews.forEach(review => { + let unitCode = 'Unknown Unit'; + let unitName = 'Unit information not available'; + let unit: Unit | null = null; + + // Check if unit is populated + if (review.unit && typeof review.unit === 'object' && 'unitCode' in review.unit) { + unit = review.unit as Unit; + unitCode = unit.unitCode || 'Unknown Unit'; + unitName = unit.name || 'Unit information not available'; + } + + if (!groupMap.has(unitCode)) { + groupMap.set(unitCode, { + unit: unit!, + unitCode, + unitName, + reviews: [], + averageRating: 0 + }); + } + + groupMap.get(unitCode)!.reviews.push(review); + }); + + // Calculate average ratings and convert to array + this.groupedReviews = Array.from(groupMap.values()).map(group => { + const totalRating = group.reviews.reduce((sum, review) => sum + review.overallRating, 0); + group.averageRating = group.reviews.length > 0 ? totalRating / group.reviews.length : 0; + return group; + }); + + // Sort by unit code + this.groupedReviews.sort((a, b) => a.unitCode.localeCompare(b.unitCode)); + + console.log('Grouped reviews:', this.groupedReviews); + } + + /** + * Handle user not found scenario + */ + private handleUserNotFound(): void { + console.log('handleUserNotFound: User not found, showing error message'); + + this.messageService.add({ + severity: 'error', + summary: 'User Not Found', + detail: 'The requested user could not be found.' + }); + + // Redirect to 404 after showing error + console.log('handleUserNotFound: Redirecting to /404 in 2 seconds'); + setTimeout(() => { + console.log('handleUserNotFound: Executing redirect to /404'); + this.router.navigate(['/404']).then( + (success) => console.log('Navigation to /404 successful:', success), + (error) => console.error('Navigation to /404 failed:', error) + ); + }, 2000); + } + + /** + * Refreshes reviews after deletion + */ + refreshReviews(action?: string): void { + const username = this.route.snapshot.paramMap.get('username'); + if (!username || !this.user) return; + + this.reviewsLoading = true; + + // Use the user ID we already have to get reviews + this.apiService.getUserReviewsGET(this.user._id.toString()).pipe( + takeUntil(this.destroy$) + ).subscribe({ + next: (reviews: Review[]) => { + this.reviews = reviews; + this.groupReviewsByUnit(); + this.reviewsLoading = false; + this.updateContainerHeight(); + + if (action === 'delete') { + this.messageService.add({ + severity: 'success', + summary: 'Review Deleted', + detail: 'The review has been successfully deleted.' + }); + } + }, + error: (error: any) => { + console.error('Error refreshing user reviews:', error); + this.reviewsLoading = false; + this.reviews = []; + this.groupedReviews = []; + } + }); + } + + /** + * Gets user initials for avatar + */ + getInitials(name: string): string { + if (!name || typeof name !== 'string') return 'U'; + + const words = name.trim().split(/\s+/); + if (words.length === 0) return 'U'; + if (words.length === 1) { + return words[0].charAt(0).toUpperCase(); + } + + return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase(); + } + + /** + * Track by function for ngFor - unit groups + */ + trackByUnitCode(index: number, group: UnitReviewGroup): string { + return group.unitCode; + } + + /** + * Track by function for ngFor - reviews + */ + trackByReviewId(index: number, review: Review): string { + return review._id.toString(); + } + + /** + * Get star rating display for unit group + */ + getStarRating(rating: number): string { + const fullStars = Math.floor(rating); + const hasHalfStar = rating % 1 >= 0.5; + const emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0); + + return '★'.repeat(fullStars) + + (hasHalfStar ? '☆' : '') + + '☆'.repeat(emptyStars); + } + + /** + * Updates container height based on content + */ + private updateContainerHeight(): void { + if (!this.userOverviewContainer?.nativeElement) return; + + const container = this.userOverviewContainer.nativeElement; + + // Default height + container.style.height = `calc(100vh - ${NAVBAR_HEIGHT})`; + + // Adjust based on content if needed + if (this.groupedReviews.length > 2) { + container.style.height = 'auto'; + container.style.minHeight = `calc(100vh - ${NAVBAR_HEIGHT})`; + } + } + + /** + * Resize handler (properly bound) + */ + private resizeHandler(): void { + this.updateContainerHeight(); + } + + /** + * Updates Meta Tags + */ + private updateMetaTags(): void { + if (!this.user?.username) { + console.warn('Cannot update meta tags: User data is not available'); + return; + } + + const username = this.user.username; + const pageUrl = `${BASE_URL}/user/${username}`; + + // Basic meta tags + this.titleService.setTitle(`${username}'s Reviews | Unit Reviews`); + + this.meta.updateTag({ + name: 'description', + content: `View all reviews written by ${username}. See their thoughts and ratings on university units.` + }); + + this.meta.updateTag({ + name: 'keywords', + content: `${username}, reviews, university, units, ratings` + }); + + // Open Graph tags for social sharing + this.meta.updateTag({ property: 'og:title', content: `${username}'s Reviews` }); + this.meta.updateTag({ + property: 'og:description', + content: `View all reviews written by ${username} on Unit Reviews.` + }); + this.meta.updateTag({ property: 'og:url', content: pageUrl }); + this.meta.updateTag({ property: 'og:type', content: 'profile' }); + + // Twitter Card tags + this.meta.updateTag({ name: 'twitter:card', content: 'summary' }); + this.meta.updateTag({ name: 'twitter:title', content: `${username}'s Reviews` }); + this.meta.updateTag({ + name: 'twitter:description', + content: `View all reviews written by ${username}.` + }); + } + + /** + * Clean up meta tags properly + */ + private cleanupMetaTags(): void { + // Remove meta tags with proper selectors + this.meta.removeTag("name='description'"); + this.meta.removeTag("name='keywords'"); + this.meta.removeTag("property='og:title'"); + this.meta.removeTag("property='og:description'"); + this.meta.removeTag("property='og:url'"); + this.meta.removeTag("property='og:type'"); + this.meta.removeTag("name='twitter:card'"); + this.meta.removeTag("name='twitter:title'"); + this.meta.removeTag("name='twitter:description'"); + } +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/profile/profile.component.ts b/frontend/src/app/shared/components/profile/profile.component.ts index 8371ed51..1369ca57 100644 --- a/frontend/src/app/shared/components/profile/profile.component.ts +++ b/frontend/src/app/shared/components/profile/profile.component.ts @@ -576,7 +576,7 @@ export class ProfileComponent implements OnInit, OnDestroy { this.createToast.emit({ severity: 'error', summary: 'Error Updating Details', - detail: 'Some error occurred whilst updating details' + detail: error.error.error || 'There was an error whilst updating your details' }); // ? Debug log show error message diff --git a/frontend/src/app/shared/services/api.service.ts b/frontend/src/app/shared/services/api.service.ts index 4b10ceeb..c979d0d1 100644 --- a/frontend/src/app/shared/services/api.service.ts +++ b/frontend/src/app/shared/services/api.service.ts @@ -1,400 +1,420 @@ -import { Injectable, OnInit } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Review } from '../models/review.model'; -import { Observable, catchError, of, tap, throwError } from 'rxjs'; -import { AuthService } from './auth.service'; -import { User } from '../models/user.model'; -import { ObjectId, Types } from 'mongoose'; -import { Unit } from '../models/unit.model'; -import { environment } from '../../../environments/environment'; - -interface ReportPayload { - reportReason: string | null; - reportDescription: string | null; - reporterName: string | undefined; - review: Review | null; -} - -@Injectable({ - providedIn: 'root' -}) -export class ApiService { - // The URL of where the API Server is located - private url = environment.apiUrl; - - - // ! Inject HttpClient - constructor( - private http: HttpClient, - ) { } - - /** - * * GET Get All Reviews - * - * Retrieves all reviews or reviews for a specific unit if unit code is provided. - * - * If the unit parameter is provided, we get all reviews by unit, if not get all the reviews. - * - * @param {string} [unitcode] The unit code of the unit (optional) - * @returns {Observable} An observable containing the reviews data - */ - getAllReviewsGET(unitcode?: string): Observable { - return this.http.get( - unitcode ? `${this.url}/reviews/${unitcode}` : `${this.url}/reviews` - ).pipe( - tap({ - next: (response) => { - // ? Debug log - // console.log('ApiService | Successfully fetched reviews:', response); - }, - error: (error) => { - // ? Debug log - // console.log('ApiService | Error whilst fetching reviews:', error.error); - } - }) - ); - } - - /** - * * GET Gets the reviews written by a user - * - * Retrieves all reviews written by a specific user. - * - * @param {string} userId The ID of the user - * @returns {Observable} An observable containing the reviews data - */ - getUserReviewsGET(userId: string): Observable { - return this.http.get( - `${this.url}/reviews/user/${userId}` - ).pipe( - tap({ - next: (response) => { - // console.log('ApiService | Successfully fetched user reviews:', response); - }, - error: (error) => { - // console.log('ApiService | Error whilst fetching user reviews:', error.error); - } - }) - ); - } - - /** - * * GET Gets the notifications of a user - */ - getUserNotificationsGET(userID: string): Observable { - const url = `${this.url}/notifications/user/${userID}`; - return this.http.get(url); - } - - /** - * * DELETE Delete a notification by ID - * - * Deletes a notification by its ID. - * - * @param {string} notificationId The ID of the notification - * @returns {Observable} An observable containing the response from the server - */ - deleteNotificationByIdDELETE(notificationId: Types.ObjectId): Observable { - return this.http.delete( - `${this.url}/notifications/${notificationId}`, - { withCredentials: true } - ).pipe( - tap({ - next: (response) => { - // ? Debug log - // console.log('ApiService | Successfully deleted notification:', response); - }, - error: (error) => { - // ? Debug log - // console.log('ApiService | Error whilst deleting notification:', error.error); - } - }) - ); - } - - /** - * * PATCH Toggle a reaction (like or dislike) on a review - * - * @param reviewId - The ID of the review to react to - * @param userId - The ID of the user reacting - * @param reactionType - The type of reaction ('like' or 'dislike') - * @returns An observable of the updated review with reaction status - */ - toggleReactionPATCH(reviewId: string, userId: string, reactionType: 'like' | 'dislike'): Observable { - return this.http.patch( - `${this.url}/reviews/toggle-reaction/${reviewId}`, - { userId, reactionType }, - { withCredentials: true } - ).pipe( - tap({ - next: (response) => { - // console.log('ApiService | Successfully toggled like/dislike', response); - }, - error: (error) => { - // console.error('ApiService | Error whilst toggling like/dislike', error.error); - } - }) - ); - } - - /** - * * GET Get Unit by Unitcode - * - * Retrieves a unit by its unit code. - * - * @param {string} unitcode The unit code of the unit - * @returns {Observable} An observable containing the unit data - */ - getUnitByUnitcodeGET(unitcode: string): Observable { - return this.http.get( - `${this.url}/units/unit/${unitcode}` - ).pipe( - tap({ - next: (response) => { - // ? Debug log - // console.log('ApiService | Successfully fetched unit:', response); - }, - error: (error) => { - // ? Debug log - // console.log('ApiService | Error whilst fetching unit:', error.error); - } - }) - ); - } - - /** - * * GET Get All Units - * - * Retrieves all units. - * - * @returns {Observable} An observable containing an array of all units - */ - getAllUnits(): Observable { - return this.http.get( - `${this.url}/units` - ).pipe( - tap({ - next: (response) => { - // ? Debug log - // console.log('ApiService | Successfully fetched all units:', response); - }, - error: (error) => { - // ? Debug log - // console.log('ApiService | Error whilst fetching all units:', error.error); - } - }) - ); - } - - /** - * * GET Get Popular Units - * - * Retrieves the most popular units. - * - * @returns {Observable} An observable containing an array of popular units - */ - getPopularUnitsGET(): Observable { - return this.http.get( - `${this.url}/units/popular` - ).pipe( - tap({ - next: (response) => { - // ? Debug log - // console.log('ApiService | Successfully fetched popular units:', response); - }, - error: (error) => { - // ? Debug log - // console.log('ApiService | Error whilst fetching popular units:', error.error); - } - }) - ); - } - - /** - * * GET Get Units Filtered - * - * Retrieves units based on the provided filters. - * - * @param {number} offset The offset for pagination - * @param {number} limit The limit for pagination - * @param {string} [search=''] The search query for filtering units - * @param {string} [faculty] The faculty to filter by - * @returns {Observable} An observable containing an array of filtered units - */ - getUnitsFilteredGET(offset: number, limit: number, search: string = '', sort: string = 'Alphabetic', showReviewed?: boolean, showUnreviewed?: boolean, hideNoOfferings?: boolean, faculty?: string[], semesters?: string[], campuses?: string[]): Observable { - const params: { offset: string; limit: string; search: string; sort: string; showReviewed: string, showUnreviewed: string, hideNoOfferings: string, faculty: string[], semesters: string[], campuses: string[] } = { - offset: offset.toString(), - limit: limit.toString(), - search, - sort, - showReviewed: 'false', - showUnreviewed: 'false', - hideNoOfferings: 'false', - faculty: [], - semesters: [], - campuses: [] - } - - if (showReviewed) { params.showReviewed = showReviewed ? 'true' : 'false'; } - if (showUnreviewed) { params.showUnreviewed = showUnreviewed ? 'true' : 'false'; } - if (hideNoOfferings) { params.hideNoOfferings = hideNoOfferings ? 'true' : 'false'; } - if (faculty) { params.faculty = faculty; } - if (semesters) { params.semesters = semesters; } - if (campuses) { params.campuses = campuses; } - - return this.http.get( - `${this.url}/units/filter`, - { params } - ).pipe( - tap({ - next: (response) => { - // ? Debug log - // console.log('ApiService | Successfully fetched filtered units:', response); - }, - error: (error) => { - // ? Debug log - // console.log('ApiService | Error whilst fetching filtered units:', error.error); - } - }) - ); - } - - /** - * * POST Create a Review for a Unit - * - * Creates a new review for a unit. - * - * @param {string} unitcode The unit code of the unit - * @param {Review} review The review object containing review details - * @returns {Observable} An observable containing the response from the server - */ - createReviewForUnitPOST(unitcode: string, review: Review): Observable { - return this.http.post(`${this.url}/reviews/${unitcode}/create`, { - review_title: review.title, - review_semester: review.semester, - review_grade: review.grade, - review_year: review.year, - review_overall_rating: review.overallRating, - review_relevancy_rating: review.relevancyRating, - review_faculty_rating: review.facultyRating, - review_content_rating: review.contentRating, - review_description: review.description, - review_author: review.author - }, { withCredentials: true }).pipe( - tap({ - next: (response) => { - // ? Debug log - // console.log('AuthService | Successfully created review:', response); - }, - error: (error) => { - // ? Debug log - // console.log('AuthService | Error whilst creating review:', error.error); - } - }) - ); - } - - /** - * * DELETE Delete a Review by ID - * - * Deletes a review by its ID. - * - * @param {string} id The ID of the review - * @returns {Observable} An observable containing the response from the server - */ - deleteReviewByIdDELETE(id: string): Observable { - return this.http.delete( - `${this.url}/reviews/delete/${id}`, - { withCredentials: true } - ).pipe( - tap({ - next: (response) => { - // ? Debug log - // console.log('ApiService | Successfully deleted review:', response); - }, - error: (error) => { - // ? Debug log - // console.log('ApiService | Error whilst deleting review:', error.error); - } - }) - ); - } - - /** - * * PATCH Update a Review for a unit - * - * Updates a review by its ID. - * - * @param {Review} review The review object containing the updated review details - * @returns {Observable} An observable containing the response from the server - */ - editReviewPUT(review: Review): Observable { - return this.http.put( - `${this.url}/reviews/update/${review._id}`, { - title: review.title, - semester: review.semester, - grade: review.grade, - year: review.year, - overallRating: review.overallRating, - relevancyRating: review.relevancyRating, - facultyRating: review.facultyRating, - contentRating: review.contentRating, - description: review.description, - }, { withCredentials: true }).pipe( - tap({ - next: (response) => { - // ? Debug log - // console.log('ApiService | Successfully updated review:', response); - }, - error: (error) => { - // ? Debug log - // console.log('ApiService | Error whilst updating review:', error.error); - } - }) - ); - } - - /** - * * POST send a report for a review - * - * Sends a report for a given review - * - * @param {ReportPayload} reportPayload payload containing information for the report - */ - sendReviewReportPOST(reportPayload: ReportPayload): void { - this.http.post(`${this.url}/reviews/send-report`, - reportPayload, - { withCredentials: true }).subscribe({ - next: (response) => { - // console.log('ApiService | Successfully sent review report:', response) - }, - error: (error) => { - // console.log('ApiService | Error whilst sending review report:', error) - } - }); - } - - - - - /** - * * GET Units Requiring Unit - * - * Gets all units that have a specified unit as a prerequisite - * - * @param {string} unitCode The unit code to search for - * @returns {Observable} An observable containing an array of units - */ - getUnitsRequiringUnitGET(unitCode: string): Observable { - return this.http.get(`${this.url}/units/${unitCode}/required-by`).pipe( - tap({ - next: (units) => { - // console.log('ApiService | Sucessfully got units requiring unit:', units); - }, - error: (error) => { - // console.log('ApiService | Error whilst getting units requiring unit:', error.error); - } - }) - ) - } -} +import { Injectable, OnInit } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Review } from '../models/review.model'; +import { Observable, catchError, of, tap, throwError } from 'rxjs'; +import { AuthService } from './auth.service'; +import { User } from '../models/user.model'; +import { ObjectId, Types } from 'mongoose'; +import { Unit } from '../models/unit.model'; +import { environment } from '../../../environments/environment'; + +interface ReportPayload { + reportReason: string | null; + reportDescription: string | null; + reporterName: string | undefined; + review: Review | null; +} + +@Injectable({ + providedIn: 'root' +}) +export class ApiService { + // The URL of where the API Server is located + private url = environment.apiUrl; + + + // ! Inject HttpClient + constructor( + private http: HttpClient, + ) { } + + /** + * * GET Get All Reviews + * + * Retrieves all reviews or reviews for a specific unit if unit code is provided. + * + * If the unit parameter is provided, we get all reviews by unit, if not get all the reviews. + * + * @param {string} [unitcode] The unit code of the unit (optional) + * @returns {Observable} An observable containing the reviews data + */ + getAllReviewsGET(unitcode?: string): Observable { + return this.http.get( + unitcode ? `${this.url}/reviews/${unitcode}` : `${this.url}/reviews` + ).pipe( + tap({ + next: (response) => { + // ? Debug log + // console.log('ApiService | Successfully fetched reviews:', response); + }, + error: (error) => { + // ? Debug log + // console.log('ApiService | Error whilst fetching reviews:', error.error); + } + }) + ); + } + + /** + * * GET Gets the reviews written by a user + * + * Retrieves all reviews written by a specific user. + * + * @param {string} userId The ID of the user + * @returns {Observable} An observable containing the reviews data + */ + getUserReviewsGET(userId: string): Observable { + return this.http.get( + `${this.url}/reviews/user/${userId}` + ).pipe( + tap({ + next: (response) => { + // console.log('ApiService | Successfully fetched user reviews:', response); + }, + error: (error) => { + // console.log('ApiService | Error whilst fetching user reviews:', error.error); + } + }) + ); + } + + /** + * * GET Get User by Username + * + * Retrieves a user by their username. + * + * @param {string} username The username of the user + * @returns {Observable} An observable containing the user data + */ + getUserByUsernameGET(username: string): Observable { + return this.http.get( + `${this.url}/user/${username}` + ).pipe( + tap({ + next: (response) => { + console.log('ApiService | Successfully fetched user by username:', response); + }, + error: (error) => { + console.log('ApiService | Error whilst fetching user by username:', error.error); + } + }) + ); + } + + /** + * * GET Gets the notifications of a user + */ + getUserNotificationsGET(userID: string): Observable { + const url = `${this.url}/notifications/user/${userID}`; + return this.http.get(url); + } + + /** + * * DELETE Delete a notification by ID + * + * Deletes a notification by its ID. + * + * @param {string} notificationId The ID of the notification + * @returns {Observable} An observable containing the response from the server + */ + deleteNotificationByIdDELETE(notificationId: Types.ObjectId): Observable { + return this.http.delete( + `${this.url}/notifications/${notificationId}`, + { withCredentials: true } + ).pipe( + tap({ + next: (response) => { + // ? Debug log + // console.log('ApiService | Successfully deleted notification:', response); + }, + error: (error) => { + // ? Debug log + // console.log('ApiService | Error whilst deleting notification:', error.error); + } + }) + ); + } + + /** + * * PATCH Toggle a reaction (like or dislike) on a review + * + * @param reviewId - The ID of the review to react to + * @param userId - The ID of the user reacting + * @param reactionType - The type of reaction ('like' or 'dislike') + * @returns An observable of the updated review with reaction status + */ + toggleReactionPATCH(reviewId: string, userId: string, reactionType: 'like' | 'dislike'): Observable { + return this.http.patch( + `${this.url}/reviews/toggle-reaction/${reviewId}`, + { userId, reactionType }, + { withCredentials: true } + ).pipe( + tap({ + next: (response) => { + // console.log('ApiService | Successfully toggled like/dislike', response); + }, + error: (error) => { + // console.error('ApiService | Error whilst toggling like/dislike', error.error); + } + }) + ); + } + + /** + * * GET Get Unit by Unitcode + * + * Retrieves a unit by its unit code. + * + * @param {string} unitcode The unit code of the unit + * @returns {Observable} An observable containing the unit data + */ + getUnitByUnitcodeGET(unitcode: string): Observable { + return this.http.get( + `${this.url}/units/unit/${unitcode}` + ).pipe( + tap({ + next: (response) => { + // ? Debug log + // console.log('ApiService | Successfully fetched unit:', response); + }, + error: (error) => { + // ? Debug log + // console.log('ApiService | Error whilst fetching unit:', error.error); + } + }) + ); + } + + /** + * * GET Get All Units + * + * Retrieves all units. + * + * @returns {Observable} An observable containing an array of all units + */ + getAllUnits(): Observable { + return this.http.get( + `${this.url}/units` + ).pipe( + tap({ + next: (response) => { + // ? Debug log + // console.log('ApiService | Successfully fetched all units:', response); + }, + error: (error) => { + // ? Debug log + // console.log('ApiService | Error whilst fetching all units:', error.error); + } + }) + ); + } + + /** + * * GET Get Popular Units + * + * Retrieves the most popular units. + * + * @returns {Observable} An observable containing an array of popular units + */ + getPopularUnitsGET(): Observable { + return this.http.get( + `${this.url}/units/popular` + ).pipe( + tap({ + next: (response) => { + // ? Debug log + // console.log('ApiService | Successfully fetched popular units:', response); + }, + error: (error) => { + // ? Debug log + // console.log('ApiService | Error whilst fetching popular units:', error.error); + } + }) + ); + } + + /** + * * GET Get Units Filtered + * + * Retrieves units based on the provided filters. + * + * @param {number} offset The offset for pagination + * @param {number} limit The limit for pagination + * @param {string} [search=''] The search query for filtering units + * @param {string} [faculty] The faculty to filter by + * @returns {Observable} An observable containing an array of filtered units + */ + getUnitsFilteredGET(offset: number, limit: number, search: string = '', sort: string = 'Alphabetic', showReviewed?: boolean, showUnreviewed?: boolean, hideNoOfferings?: boolean, faculty?: string[], semesters?: string[], campuses?: string[]): Observable { + const params: { offset: string; limit: string; search: string; sort: string; showReviewed: string, showUnreviewed: string, hideNoOfferings: string, faculty: string[], semesters: string[], campuses: string[] } = { + offset: offset.toString(), + limit: limit.toString(), + search, + sort, + showReviewed: 'false', + showUnreviewed: 'false', + hideNoOfferings: 'false', + faculty: [], + semesters: [], + campuses: [] + } + + if (showReviewed) { params.showReviewed = showReviewed ? 'true' : 'false'; } + if (showUnreviewed) { params.showUnreviewed = showUnreviewed ? 'true' : 'false'; } + if (hideNoOfferings) { params.hideNoOfferings = hideNoOfferings ? 'true' : 'false'; } + if (faculty) { params.faculty = faculty; } + if (semesters) { params.semesters = semesters; } + if (campuses) { params.campuses = campuses; } + + return this.http.get( + `${this.url}/units/filter`, + { params } + ).pipe( + tap({ + next: (response) => { + // ? Debug log + // console.log('ApiService | Successfully fetched filtered units:', response); + }, + error: (error) => { + // ? Debug log + // console.log('ApiService | Error whilst fetching filtered units:', error.error); + } + }) + ); + } + + /** + * * POST Create a Review for a Unit + * + * Creates a new review for a unit. + * + * @param {string} unitcode The unit code of the unit + * @param {Review} review The review object containing review details + * @returns {Observable} An observable containing the response from the server + */ + createReviewForUnitPOST(unitcode: string, review: Review): Observable { + return this.http.post(`${this.url}/reviews/${unitcode}/create`, { + review_title: review.title, + review_semester: review.semester, + review_grade: review.grade, + review_year: review.year, + review_overall_rating: review.overallRating, + review_relevancy_rating: review.relevancyRating, + review_faculty_rating: review.facultyRating, + review_content_rating: review.contentRating, + review_description: review.description, + review_author: review.author + }, { withCredentials: true }).pipe( + tap({ + next: (response) => { + // ? Debug log + // console.log('AuthService | Successfully created review:', response); + }, + error: (error) => { + // ? Debug log + // console.log('AuthService | Error whilst creating review:', error.error); + } + }) + ); + } + + /** + * * DELETE Delete a Review by ID + * + * Deletes a review by its ID. + * + * @param {string} id The ID of the review + * @returns {Observable} An observable containing the response from the server + */ + deleteReviewByIdDELETE(id: string): Observable { + return this.http.delete( + `${this.url}/reviews/delete/${id}`, + { withCredentials: true } + ).pipe( + tap({ + next: (response) => { + // ? Debug log + // console.log('ApiService | Successfully deleted review:', response); + }, + error: (error) => { + // ? Debug log + // console.log('ApiService | Error whilst deleting review:', error.error); + } + }) + ); + } + + /** + * * PATCH Update a Review for a unit + * + * Updates a review by its ID. + * + * @param {Review} review The review object containing the updated review details + * @returns {Observable} An observable containing the response from the server + */ + editReviewPUT(review: Review): Observable { + return this.http.put( + `${this.url}/reviews/update/${review._id}`, { + title: review.title, + semester: review.semester, + grade: review.grade, + year: review.year, + overallRating: review.overallRating, + relevancyRating: review.relevancyRating, + facultyRating: review.facultyRating, + contentRating: review.contentRating, + description: review.description, + }, { withCredentials: true }).pipe( + tap({ + next: (response) => { + // ? Debug log + // console.log('ApiService | Successfully updated review:', response); + }, + error: (error) => { + // ? Debug log + // console.log('ApiService | Error whilst updating review:', error.error); + } + }) + ); + } + + /** + * * POST send a report for a review + * + * Sends a report for a given review + * + * @param {ReportPayload} reportPayload payload containing information for the report + */ + sendReviewReportPOST(reportPayload: ReportPayload): void { + this.http.post(`${this.url}/reviews/send-report`, + reportPayload, + { withCredentials: true }).subscribe({ + next: (response) => { + // console.log('ApiService | Successfully sent review report:', response) + }, + error: (error) => { + // console.log('ApiService | Error whilst sending review report:', error) + } + }); + } + + /** + * * GET Units Requiring Unit + * + * Gets all units that have a specified unit as a prerequisite + * + * @param {string} unitCode The unit code to search for + * @returns {Observable} An observable containing an array of units + */ + getUnitsRequiringUnitGET(unitCode: string): Observable { + return this.http.get(`${this.url}/units/${unitCode}/required-by`).pipe( + tap({ + next: (units) => { + // console.log('ApiService | Sucessfully got units requiring unit:', units); + }, + error: (error) => { + // console.log('ApiService | Error whilst getting units requiring unit:', error.error); + } + }) + ) + } +}