diff --git a/functions/package-lock.json b/functions/package-lock.json index b0acd38..0192d2c 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -13,25 +13,28 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "firebase-admin": "^12.7.0", - "firebase-functions": "^6.3.2", + "firebase-functions": "^6.4.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", + "luxon": "^3.7.1", "nodemailer": "^7.0.3", "validator": "^13.15.0" }, "devDependencies": { "@faker-js/faker": "^9.6.0", "@types/busboy": "^1.5.4", - "@types/cookie-parser": "^1.4.8", + "@types/cookie-parser": "^1.4.9", "@types/cors": "^2.8.18", "@types/csurf": "^1.11.5", "@types/express": "^5.0.1", "@types/jsonwebtoken": "^9.0.9", "@types/lodash": "^4.17.16", + "@types/luxon": "^3.6.2", "@types/nodemailer": "^6.4.17", "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", + "concurrently": "^9.2.0", "eslint": "^8.9.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.1.1", @@ -1687,9 +1690,9 @@ } }, "node_modules/@types/cookie-parser": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.8.tgz", - "integrity": "sha512-l37JqFrOJ9yQfRQkljb41l0xVphc7kg5JTjjr+pLRZ0IyZ49V4BQ8vbF4Ut2C2e+WH4al3xD3ZwYwIUfnbT4NQ==", + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1825,6 +1828,13 @@ "license": "MIT", "optional": true }, + "node_modules/@types/luxon": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz", + "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3055,6 +3065,48 @@ "dev": true, "license": "MIT" }, + "node_modules/concurrently": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", + "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -4379,9 +4431,9 @@ } }, "node_modules/firebase-functions": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-6.3.2.tgz", - "integrity": "sha512-FC3A1/nhqt1ZzxRnj5HZLScQaozAcFSD/vSR8khqSoFNOfxuXgwJS6ZABTB7+v+iMD5z6Mmxw6OfqITUBuI7OQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-6.4.0.tgz", + "integrity": "sha512-Q/LGhJrmJEhT0dbV60J4hCkVSeOM6/r7xJS/ccmkXzTWMjo+UPAYX9zlQmGlEjotstZ0U9GtQSJSgbB2Z+TJDg==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.5", @@ -6774,6 +6826,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -7911,6 +7972,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -8157,6 +8228,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -8656,6 +8740,16 @@ "license": "MIT", "optional": true }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-deepmerge": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-2.0.7.tgz", diff --git a/functions/package.json b/functions/package.json index 55b50ae..92fad9f 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,7 @@ "lint": "eslint --ext .js,.ts .", "build": "tsc", "build:watch": "tsc --watch --preserveWatchOutput", - "serve": "npm run build:watch | firebase emulators:start", + "serve": "concurrently \"npm:build:watch\" \"firebase emulators:start\"", "shell": "npm run build && firebase functions:shell", "start": "npm run shell", "deploy": "firebase deploy --only functions", @@ -23,25 +23,28 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "firebase-admin": "^12.7.0", - "firebase-functions": "^6.3.2", + "firebase-functions": "^6.4.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", + "luxon": "^3.7.1", "nodemailer": "^7.0.3", "validator": "^13.15.0" }, "devDependencies": { "@faker-js/faker": "^9.6.0", "@types/busboy": "^1.5.4", - "@types/cookie-parser": "^1.4.8", + "@types/cookie-parser": "^1.4.9", "@types/cors": "^2.8.18", "@types/csurf": "^1.11.5", "@types/express": "^5.0.1", "@types/jsonwebtoken": "^9.0.9", "@types/lodash": "^4.17.16", + "@types/luxon": "^3.6.2", "@types/nodemailer": "^6.4.17", "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", + "concurrently": "^9.2.0", "eslint": "^8.9.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.1.1", diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index aa87dd9..b10cb38 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -715,3 +715,49 @@ export const verifyAccount = async ( }); } }; + +export const getCurrentUserRole = async ( + req: Request, + res: Response +): Promise => { + try { + /** + * Check request: + * 1. Cookie must be present in header. + */ + const uid = req.user?.uid + if (req.user === undefined) { + res.status(401).json({ + status: 401, + error: "Unauthorized" + }) + return; + } + if (!uid || uid === undefined) { + res.status(401).json({ + status: 401, + error: "Unauthorized" + }) + return; + } + + /** + * Process request: + * 1. Get claims of a user. If none, then default to hacker. + */ + if (req.user.mentor === true) { + res.status(200).json({ + status: 200, + role: "mentor" + }) + return; + } + + res.status(200).json({ + status: 200, + role: "hacker" + }) + } catch (error) { + res.status(500).json({ error: (error as Error).message }) + } +} \ No newline at end of file diff --git a/functions/src/controllers/mentorship_controller.ts b/functions/src/controllers/mentorship_controller.ts new file mode 100644 index 0000000..21634e5 --- /dev/null +++ b/functions/src/controllers/mentorship_controller.ts @@ -0,0 +1,841 @@ +import { db } from "../config/firebase" +import { FirestoreMentor, MentorshipAppointment, MentorshipAppointmentResponseAsHacker, MentorshipAppointmentResponseAsMentor } from "../models/mentorship"; +import { Request, Response } from "express"; +import { DateTime } from 'luxon'; +import { CollectionReference, DocumentData, FieldPath, FieldValue } from "firebase-admin/firestore"; +import { MentorshipConfig } from "../types/config"; +import * as functions from "firebase-functions"; +import nodemailer from "nodemailer"; +import { epochToStringDate } from "../utils/date"; + + +const CONFIG = "config"; +const MENTORSHIP_CONIFG = "mentorshipConfig"; +const MENTORSHIPS = "mentorships"; +const MENTOR_ID = "mentorId"; +const HACKER_ID = "hackerId"; +const USERS = "users"; +const START_TIME = "startTime"; + +const transporter = nodemailer.createTransport({ + host: "live.smtp.mailtrap.io", + port: 587, + auth: { + user: process.env.MAILTRAP_USER, + pass: process.env.MAILTRAP_PASS, + }, +}); + +interface MailOptions { + from: string | { name: string; address: string }; + to: string; + subject: string; + html: string; + text: string; +} + +const createMentorshipCancelMailOptions = ( + mentorEmail: string, + mentorName: string, + teamName: string, + hackerName: string, + startDate: string, + endDate: string, + portalLink: string, + duration: number +): MailOptions => ({ + from: { + name: "Garuda Hacks", + address: "no-reply@garudahacks.com" + }, + to: mentorEmail, + subject: `Team ${teamName} Just Canceled A Mentorship Session`, + html: ` + + + + + + + Mentorship Booking Canceled + + + + +
+

Hi, ${mentorName}

+

The booking for team ${teamName} has been canceled. +

+
+
+

Team Name: ${teamName}

+

Hacker Name: ${hackerName}

+
+ +
+

${startDate} - ${endDate} (${duration} minutes)

+
+ +
+ View + In Portal +
+
+
+ The cancelation is permissible up to 45 minutes before the scheduled time. +
+
+
+

© ${new Date().getFullYear()} Garuda Hacks. All rights reserved.

+

+ Visit our website | + Contact Support +

+
+ + +`, + text: `Team ${teamName} Just Canceled A Mentorship Session.` +}) + +const createMentorshipBookingMailOptions = ( + mentorEmail: string, + mentorName: string, + teamName: string, + hackerName: string, + startDate: string, + endDate: string, + portalLink: string, + duration: number, +): MailOptions => ({ + from: { + name: "Garuda Hacks", + address: "no-reply@garudahacks.com" + }, + to: mentorEmail, + subject: `Team ${teamName} Just Booked A Mentorship Session`, + html: ` + + + + + + Mentorship Booking + + + + +
+

Hi, ${mentorName}

+

A team just booked a mentorship session with you.

+ +
+
+

Team Name ${teamName}

+

Hacker Name ${hackerName}

+
+ +
+

${startDate} - ${endDate} (${duration})

+
+ +
+

Click here to view portal.

+ View + In Portal +
+
+ +
+
+

© ${new Date().getFullYear()} Garuda Hacks. All rights reserved.

+

+ Visit our website | + Contact Support +

+
+ + + `, + text: `Team ${teamName} Just Booked A Mentorship Session.` +}) + +const sendMentorshipBookingEmail = async ( + mentorEmail: string, + mentorName: string, + teamName: string, + hackerName: string, + startDate: string, + endDate: string, + portalLink: string, + duration: number, +): Promise => { + const mailOptions = createMentorshipBookingMailOptions(mentorEmail, mentorName, teamName, hackerName, startDate, endDate, portalLink, duration) + await transporter.sendMail(mailOptions) + functions.logger.info("Booking email sent successfuly to:", mentorEmail) + functions.logger.info("Booking email sent successfuly to:", mentorEmail) +} + +const sendMentorshipCancelEmail = async ( + mentorEmail: string, + mentorName: string, + teamName: string, + hackerName: string, + startDate: string, + endDate: string, + portalLink: string, + duration: number, +): Promise => { + const mailOptions = createMentorshipCancelMailOptions(mentorEmail, mentorName, teamName, hackerName, startDate, endDate, portalLink, duration) + await transporter.sendMail(mailOptions) + functions.logger.info("Cancel email sent successfuly to:", mentorEmail) +} + +/** + * Get mentorship config. + */ +export const getMentorshipConfig = async ( + req: Request, + res: Response +) => { + try { + const mentorshipConfigSnapshot = await db.collection("config").doc("mentorshipConfig").get() + const mentorshipConfigData = mentorshipConfigSnapshot.data() as MentorshipConfig + + if (!mentorshipConfigSnapshot.exists || mentorshipConfigData === undefined) { + return res.status(400).json({ + status: 400, + error: "Config not found" + }) + } + return res.status(200).json({ + data: mentorshipConfigData + }) + } catch (error) { + return res.status(500).json({ error: (error as Error).message }) + } +} + + +/** ****************** + * MENTOR ENDPOINTS * + ********************/ +export const mentorGetMyMentorships = async ( + req: Request, + res: Response +) => { + try { + const uid = req.user?.uid + if (!uid) { + return res.status(401).send('Unauthorized: User ID not found.'); + } + + // available query params + const { + limit, + upcomingOnly, + recentOnly, + isBooked, + isAvailable, + } = req.query; + + let query: CollectionReference | DocumentData = db.collection(MENTORSHIPS); + + query = query.where(MENTOR_ID, "==", uid); + + const currentTimeSeconds = Math.floor(DateTime.now().setZone('Asia/Jakarta').toUnixInteger()); + if (upcomingOnly === 'true') { + query = query.where(START_TIME, ">=", currentTimeSeconds); + } else if (recentOnly === 'true') { + query = query.where(START_TIME, "<=", currentTimeSeconds); + } + + if (limit) { + const numericLimit = parseInt(limit as string, 10); + if (!isNaN(numericLimit) && numericLimit > 0) { + query = query.limit(numericLimit); + } + } + + const snapshot = await query.orderBy("startTime", "asc").get(); + + let mentorships = snapshot.docs.map((doc: any) => ({ + id: doc.id, + ...doc.data(), + })) as MentorshipAppointment[]; + + if (isBooked === 'true') { + mentorships = mentorships.filter(m => m.hackerId != null); + } else if (isAvailable === 'true') { + mentorships = mentorships.filter(m => m.hackerId == null); + } + + return res.status(200).json({ + data: mentorships, + }); + } catch (error) { + functions.logger.error(`Error when trying mentorGetMyMentorships: ${(error as Error).message} `) + return res.status(500).json({ error: (error as Error).message }) + } +} + +export const mentorGetMyMentorship = async ( + req: Request, + res: Response +) => { + try { + const { id } = req.params + + // 1. Validate id is in param + if (!id) { + res.status(400).json({ + error: "id is required" + }) + } + + // 2. Get a mentroship appointment + const snapshot = await db.collection(MENTORSHIPS).doc(id).get() + if (!snapshot.exists) { + return res.status(400).json({ + error: "Cannot find mentorship" + }) + } + + return res.status(200).json({ + data: snapshot.data() + }) + } catch (error) { + functions.logger.error(`Error when trying mentorGetMyMentorship: ${(error as Error).message} `) + return res.status(500).json({ error: (error as Error).message }) + } +} + +/** + * Updates a mentorship + * @param req.mentorNotes + * @param req.mentorMarkAsDone + * @param req.mentorMarkAsAfk + * @param res + * @returns + */ +export const mentorPutMyMentorship = async ( + req: Request, + res: Response +) => { + try { + const { id } = req.params + + // 1. Validate id is in param + if (!id) { + return res.status(400).json({ + error: "id is required" + }) + } + + const { + mentorNotes, + mentorMarkAsDone, + mentorMarkAsAfk + } = req.body + + const payload: { [key: string]: any } = {}; + if (mentorNotes !== undefined) { + payload.mentorNotes = mentorNotes; + } + if (mentorMarkAsDone !== undefined) { + payload.mentorMarkAsDone = mentorMarkAsDone; + } + if (mentorMarkAsAfk !== undefined) { + payload.mentorMarkAsAfk = mentorMarkAsAfk; + } + + if (Object.keys(payload).length === 0) { + return res.status(400).json({ error: "No fields to update were provided." }); + } + + await db.collection(MENTORSHIPS).doc(id).update({ + mentorNotes: mentorNotes, + mentorMarkAsDone: mentorMarkAsDone, + mentorMarkAsAfk: mentorMarkAsAfk + }) + + return res.status(200).json({ + message: "Success updated" + }); + } catch (error: any) { + if (error.code === 5) { + return res.status(404).json({ error: "Mentorship with that ID was not found." }); + } + return res.status(500).json({ error: "An unexpected error occurred." }); + } +} + + +/** ****************** + * HACKER ENDPOINTS * + ********************/ +export const hackerGetMentors = async ( + req: Request, + res: Response +) => { + try { + const { limit } = req.query + + let query = db.collection('users') + .where("mentor", "==", true) + + if (limit) { + const numericLimit = parseInt(limit as string, 10); + if (!isNaN(numericLimit) && numericLimit > 0) { + query = query.limit(numericLimit); + } + } + + const snapshot = await query.get() + const allMentors: { + id?: string; + email: string; + name: string; + mentor: boolean; + specialization: string; + discordUsername: string; + intro: string; // introduction given by mentor + + }[] = []; + + await Promise.all( + snapshot.docs.map(async (mentor) => { + const mentorData = mentor.data(); + + allMentors.push({ + id: mentor.id, + email: mentorData.email, + name: mentorData.name, + mentor: mentorData.mentor, + specialization: mentorData.specialization, + discordUsername: mentorData.discordUsername, + intro: mentorData.intro, + }); + + }) + ); + return res.status(200).json({ data: allMentors }) + } catch (error: any) { + functions.logger.error(`Error when trying hackerGetMentors: ${(error as Error).message} `) + return res.status(500).json({ error: "An unexpected error occurred." }); + } +} + +export const hackerGetMentor = async ( + req: Request, + res: Response +) => { + try { + const { id } = req.params; + + // 1. Validate id + const snapshot = await db.collection(USERS).doc(id).get() + if (!snapshot.exists) { + return res.status(400).json({ + error: "Cannot find mentor" + }) + } + + const data = snapshot.data() + + if (!data) { + return res.status(400).json({ + error: "Cannot find mentor" + }) + } + + return res.status(200).json({ + data: { + id: data.id, + email: data.email, + name: data.name, + mentor: data.mentor, + specialization: data.specialization, + discordUsername: data.discordUsername, + intro: data.intro + } + }) + } catch (error) { + functions.logger.error(`Error when trying hackerGetMentor: ${(error as Error).message} `) + return res.status(500).json({ error: "An unexpected error occurred." }); + } +} + +export const hackerGetMentorSchedules = async ( + req: Request, + res: Response +) => { + try { + const { mentorId, limit } = req.query + + // Check if mentorship is open + const configSnapshot = await db.collection(CONFIG).doc(MENTORSHIP_CONIFG).get() + const configData = configSnapshot.data() + if (configData && !configData.isMentorshipOpen) { + return res.status(400).json({ error: "Mentorship is currently closed" }) + } + + if (!mentorId) { + return res.status(400).json({ error: "mentorId is required as argument" }) + } + + let query = db.collection(MENTORSHIPS) + .where(MENTOR_ID, "==", mentorId) + + if (limit) { + const numericLimit = parseInt(limit as string, 10); + if (!isNaN(numericLimit) && numericLimit > 0) { + query = query.limit(numericLimit); + } + } + + const snapshot = await query.get() + + const allSchedules = snapshot.docs.map((doc) => ({ + id: doc.id, + startTime: doc.data().startTime, + endTime: doc.data().endTime, + mentorId: doc.data().mentorId, + hackerId: doc.data().hackerId, + location: doc.data().location, + })) as MentorshipAppointmentResponseAsHacker[]; + + return res.status(200).json({ data: allSchedules }); + } catch (error) { + functions.logger.error(`Error when trying hackerGetMentorSchedules: ${(error as Error).message} `) + return res.status(500).json({ error: "An unexpected error occurred." }); + } +} + +export const hackerGetMentorSchedule = async ( + req: Request, + res: Response +) => { + try { + const { id } = req.params + + // 1. Validate id is in param + if (!id) { + return res.status(400).json({ + error: "id is required" + }) + } + + const snapshot = await db.collection(MENTORSHIPS).doc(id).get() + const data = snapshot.data() + if (!snapshot.exists || !data) { + return res.status(404).json({ error: "Cannot find mentorship" }) + } + + const mentorshipAppointmentResponseAsHacker: MentorshipAppointmentResponseAsHacker = { + id: data.id, + startTime: data.startTime, + endTime: data.endTime, + mentorId: data.mentorId, + hackerId: data.hackerId, + location: data.location, + } + return res.status(200).json({ data: mentorshipAppointmentResponseAsHacker }) + } catch (error) { + functions.logger.error(`Error when trying hackerGetMentorSchedules: ${(error as Error).message} `) + return res.status(500).json({ error: "An unexpected error occurred." }); + } +} + +interface BookMentorshipRequest { + id: string; + hackerName: string; + teamName: string; + hackerDescription: string; + offlineLocation?: string; +} + +export const hackerBookMentorships = async ( + req: Request, + res: Response +) => { + const MAX_CONCURRENT_BOOKINGS = 2 + try { + const uid = req.user?.uid + if (!uid) { + return res.status(401).json({ error: "Unauthorized" }) + } + + // Check if mentorship is open + const configSnapshot = await db.collection(CONFIG).doc(MENTORSHIP_CONIFG).get() + const configData = configSnapshot.data() + if (configData && !configData.isMentorshipOpen) { + return res.status(400).json({ error: "Mentorship is currently closed" }) + } + + const { mentorships }: { mentorships: BookMentorshipRequest[] } = req.body; + + if (!mentorships || !Array.isArray(mentorships) || mentorships.length === 0) { + return res.status(400).json({ error: 'Mentorships must be a non-empty array.' }); + } + + if (mentorships.length > MAX_CONCURRENT_BOOKINGS) { + return res.status(400).json({ error: `Cannot book more than ${MAX_CONCURRENT_BOOKINGS} slots at a time.` }); + } + + for (const mentorship of mentorships) { + if (!mentorship.id || !mentorship.hackerName || !mentorship.teamName || !mentorship.hackerDescription) { + return res.status(400).json({ error: 'Each mentorship must include id, hackerId, hackerName, teamName, and hackerDescription.' }); + } + } + + const mentorshipsCollection = db.collection(MENTORSHIPS); + const currentTimeSeconds = Math.floor(DateTime.now().setZone('Asia/Jakarta').toUnixInteger()); + const existingBookingsQuery = mentorshipsCollection + .where(HACKER_ID, '==', uid) + .where(START_TIME, '>', currentTimeSeconds); + + const existingBookingsSnapshot = await existingBookingsQuery.get(); + + if (existingBookingsSnapshot.size + mentorships.length > MAX_CONCURRENT_BOOKINGS) { + return res.status(400).json({ error: `This request would exceed the maximum of ${MAX_CONCURRENT_BOOKINGS} active bookings.` }); + } + + await db.runTransaction(async (transaction) => { + const mentorshipIds = mentorships.map((m) => m.id); + + const requestedMentorshipsRef = mentorshipsCollection.where(FieldPath.documentId(), 'in', mentorshipIds); + const requestedMentorshipsSnapshot = await transaction.get(requestedMentorshipsRef); + + if (requestedMentorshipsSnapshot.size !== mentorships.length) { + throw new Error("One or more mentorship slots could not be found."); + } + + const thirtyMinsFromNow = Math.floor(DateTime.now().setZone('Asia/Jakarta').toUnixInteger()) + (30 * 60); + + for (const doc of requestedMentorshipsSnapshot.docs) { + const data = doc.data(); + + if (data.hackerId) { + throw new Error(`Mentorship slot ${doc.id} is already booked.`); + } + + if (data.startTime < thirtyMinsFromNow) { + throw new Error(`Mentorship slot ${doc.id} is starting too soon to book.`); + } + } + + for (const mentorshipRequest of mentorships) { + const docRef = mentorshipsCollection.doc(mentorshipRequest.id); + transaction.update(docRef, { + hackerId: uid, + hackerName: mentorshipRequest.hackerName, + teamName: mentorshipRequest.teamName, + hackerDescription: mentorshipRequest.hackerDescription, + offlineLocation: mentorshipRequest.offlineLocation || null, + }); + } + }); + + for (const mentorship of mentorships) { + try { + const mentorshipSnap = await db.collection(MENTORSHIPS).doc(mentorship.id).get() + if (!mentorshipSnap.exists) { + functions.logger.error("Mentorship document not found:", mentorship.id) + continue + } + + const mentorshipData = mentorshipSnap.data() as MentorshipAppointment + + if (!mentorshipData.mentorId) { + functions.logger.error("No mentor ID in mentorship data") + continue + } + + const mentorSnap = await db.collection(USERS).doc(mentorshipData.mentorId).get() + if (!mentorSnap.exists) { + functions.logger.error("Mentor not found:", mentorshipData.id) + continue + } + + const mentorData = mentorSnap.data() as FirestoreMentor + + await sendMentorshipBookingEmail( + mentorData.email, + mentorData.name, + mentorship.teamName, + mentorship.hackerName, + `${epochToStringDate(mentorshipData.startTime)}`, + `${epochToStringDate(mentorshipData.endTime)}`, + "https://portal.garudahacks.com", + (mentorshipData.endTime - mentorshipData.startTime) / 60 + ) + functions.logger.info(`Email sent successfully for mentor ${mentorData.email}:`) + } catch (error) { + functions.logger.error(`Error when trying to send email for mentorship ${mentorship.id}: ${(error as Error).message}`) + } + } + + return res.status(200).json({ success: true, message: 'Mentorships booked successfully.' }); + } catch (error) { + const err = error as Error + + if (err.message.includes('mentorship slots could not be found')) { + return res.status(400).json({ error: "Mentorship slot(s) could not be found" }) + } else if (err.message.includes('already booked')) { + return res.status(400).json({ error: "Mentorship slot(s) are already booked" }) + } else if (err.message.includes('too soon to book')) { + return res.status(400).json({ error: "Cannot book less than 30 mins before the mentoring schedule. Please choose another mentorship slot!" }) + } + + functions.logger.error(`Error when trying hackerBookMentorships: ${(error as Error).message} `) + return res.status(500).json({ error: "An unexpected error occurred." }); + } +} + +export const hackerCancelMentorship = async ( + req: Request, res: Response +) => { + try { + const uid = req.user?.uid + if (!uid) { + return res.status(401).json({ error: "Unauthorized" }) + } + + const { id } = req.body; + if (!id) { + return res.status(400).json({ error: "id must be in the argument" }) + } + + // Validate + // 1. If mentorship does not exist + // 2. If mentorship does not belong to the hacker + const mentorshipSnapshot = await db.collection(MENTORSHIPS).doc(id).get() + const mentorshipData = mentorshipSnapshot.data() as MentorshipAppointmentResponseAsMentor + if (!mentorshipSnapshot.exists || !mentorshipData) { + return res.status(404).json({ error: "Cannot find mentorship with the given id" }) + } + + if (mentorshipData.hackerId !== uid) { + return res.status(401).json({ error: "Unauthorized" }) + } + + // handle if booking is aleady 45 mins away + const fortyFiveMinsFromNow = Math.floor(DateTime.now().setZone('Asia/Jakarta').toUnixInteger()) + (45 * 60); + if (mentorshipData.startTime < fortyFiveMinsFromNow) { + return res.status(400).json({ error: "Mentorship cannot be canceled less than 45 minutes before schedule." }) + } + + + // get mentor data + const mentorSnapshot = await db.collection(USERS).doc(mentorshipData.mentorId).get() + const mentorData = mentorSnapshot.data() + + if (mentorData && mentorshipData.teamName && mentorshipData.hackerName) { + // sendEmail + await sendMentorshipCancelEmail( + mentorData.email, + mentorData.name, + mentorshipData.teamName, + mentorshipData.hackerName, + `${epochToStringDate(mentorshipData.startTime)}`, + `${epochToStringDate(mentorshipData.endTime)}`, + "https://portal.garudahacks.com", + (mentorshipData.endTime - mentorshipData.startTime) / 60 + ) + } + + await db.collection(MENTORSHIPS).doc(id).update({ + hackerId: FieldValue.delete(), + hackerName: FieldValue.delete(), + teamName: FieldValue.delete(), + hackerDescription: FieldValue.delete(), + offlineLocation: FieldValue.delete(), + }) + + return res.status(200).json({ message: "Mentorship has been canceled." }) + } catch (error) { + functions.logger.error(`Error when trying hackerCancelMentorship: ${(error as Error).message} `) + return res.status(500).json({ error: "An unexpected error occurred." }); + } +} + + +export const hackerGetMyMentorships = async ( + req: Request, res: Response +) => { + try { + const uid = req.user?.uid + if (!uid) { + return res.status(401).json({ error: "Unauthorized" }) + } + + // available query params + const { + upcomingOnly, + recentOnly + } = req.query; + + let query: CollectionReference | DocumentData = db.collection(MENTORSHIPS); + + query = query.where(HACKER_ID, "==", uid); + + const currentTimeSeconds = Math.floor(DateTime.now().setZone('Asia/Jakarta').toUnixInteger()); + if (upcomingOnly === 'true') { + query = query.where(START_TIME, ">=", currentTimeSeconds); + } else if (recentOnly === 'true') { + query = query.where(START_TIME, "<=", currentTimeSeconds); + } + + const snapshot = await query.orderBy(START_TIME, "asc").get(); + + const mentorships = snapshot.docs.map((doc: any) => ({ + id: doc.id, + ...doc.data(), + })) as MentorshipAppointment[]; + + return res.status(200).json({ + data: mentorships, + }); + } catch (error) { + functions.logger.error(`Error when trying hackerGetMyMentorships: ${(error as Error).message} `) + return res.status(500).json({ error: (error as Error).message }) + } +} + +export const hackerGetMyMentorship = async ( + req: Request, res: Response +) => { + try { + const uid = req.user?.uid + if (!uid) { + return res.status(401).json({ error: "Unauthorized" }) + } + + const { id } = req.params + if (!id) { + return res.status(400).json({ error: "id is required as argument" }) + } + + const snapshot = await db.collection(MENTORSHIPS).doc(id).get() + const data = snapshot.data() + if (!snapshot.exists || !data) { + return res.status(404).json({ error: "Cannot find mentorship" }) + } + + if (data.mentorId !== uid) { + return res.status(401).json({ error: "Unauthorized" }) + } + + return res.status(200).json({ data: data }) + } catch (error) { + functions.logger.error(`Error when trying hackerGetMyMentorship: ${(error as Error).message} `) + return res.status(500).json({ error: (error as Error).message }) + } +} \ No newline at end of file diff --git a/functions/src/data/dummy/mentorship.ts b/functions/src/data/dummy/mentorship.ts new file mode 100644 index 0000000..9e9825f --- /dev/null +++ b/functions/src/data/dummy/mentorship.ts @@ -0,0 +1,28 @@ +import { MentorshipAppointment } from "../../models/mentorship"; + +export const dummyMentorships: MentorshipAppointment[] = [ + { + "mentorId": "-OVQFbrn-Ct6u6SSB47I", + "startTime": 1752804000, + "endTime": 1752804900, + "location": "online", + }, + { + "mentorId": "-OVQFbrn-Ct6u6SSB47I", + "startTime": 1752804900, + "endTime": 1752805800, + "location": "online", + }, + { + "mentorId": "-OVQFrFPecnWerr4SwXn", + "startTime": 1752629400, + "endTime": 1752628500, + "location": "offline", + }, + { + "mentorId": "-OVQFrFPecnWerr4SwXn", + "startTime": 1752629400, + "endTime": 1752630300, + "location": "offline", + }, +] \ No newline at end of file diff --git a/functions/src/data/dummy/user.ts b/functions/src/data/dummy/user.ts new file mode 100644 index 0000000..f4720a4 --- /dev/null +++ b/functions/src/data/dummy/user.ts @@ -0,0 +1,22 @@ +import { FirestoreMentor } from "../../models/mentorship"; + +export const dummyMentors: FirestoreMentor[] = [ + { + "id": "-OVQFbrn-Ct6u6SSB47I", + mentor: true, + "email": "dummy@mentor.com", + "name": "Lorem Ipsum", + "specialization": "backend,frontend", + "discordUsername": "lole", + "intro": "Hello fwens" + }, + { + "id": "-OVQFrFPecnWerr4SwXn", + mentor: true, + "email": "dummy2@mentor.com", + "name": "John Pork", + "specialization": "product manager,designer", + "discordUsername": "hehei", + "intro": "Hi guuyss" + } +] \ No newline at end of file diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index 46ae735..5432cd9 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -32,16 +32,13 @@ export const validateSessionCookie = async ( return next(); } - functions.logger.log( - "Checking if request is authorized with session cookies" - ); - const sessionCookie = extractSessionCookieFromCookie(req); // Check for session cookie if (!sessionCookie) { + functions.logger.error("No session cookie found:", req) res.status(401).json({ status: 401, - error: "No session cookie found", + error: "Unauthorized", }); return; } @@ -50,16 +47,12 @@ export const validateSessionCookie = async ( sessionCookie, true ); - functions.logger.log( - "Session cookie correctly decoded", - decodedSessionCookie - ); req.user = decodedSessionCookie; return next(); } catch (error) { functions.logger.error("Error while verifying session cookie:", error); - res.status(401).json({ - status: 401, + res.status(500).json({ + status: 500, error: "Error while verifying session cookie", }); } diff --git a/functions/src/middlewares/role_middleware.ts b/functions/src/middlewares/role_middleware.ts index c4a5b74..f978d82 100644 --- a/functions/src/middlewares/role_middleware.ts +++ b/functions/src/middlewares/role_middleware.ts @@ -1,35 +1,29 @@ -import { NextFunction, Request, Response } from "express"; +import { NextFunction, Response } from "express"; import { auth } from "../config/firebase"; -import { RoleType } from "../models/role"; +import * as functions from "firebase-functions"; -export const restrictToRole = async ( +export const isMentor = async ( req: Request, res: Response, next: NextFunction, - allowedRoles: string[] ) => { try { const sessionCookie = req.cookies.__session; - - // Verify session cookie const decodedClaims = await auth.verifySessionCookie(sessionCookie, true); - - // Check if the user's role is in the allowed roles - const userRole = decodedClaims.role || RoleType.User; - if (!allowedRoles.includes(userRole)) { + const userIsMentor = decodedClaims.mentor === true; + if (!userIsMentor) { return res.status(403).json({ status: 403, error: "Forbidden: Insufficient permissions", }); } - req.user = decodedClaims; return next(); } catch (error) { - console.error("Error verifying session cookie:", error); - return res.status(401).json({ - status: 401, - error: "Unauthorized: Invalid or missing session cookie", + functions.logger.error("Error while verifying user:", error); + return res.status(500).json({ + status: 500, + error: "Something went wrong", }); } -}; +} \ No newline at end of file diff --git a/functions/src/models/mentorship.ts b/functions/src/models/mentorship.ts new file mode 100644 index 0000000..5080fa9 --- /dev/null +++ b/functions/src/models/mentorship.ts @@ -0,0 +1,56 @@ +export interface FirestoreMentor { + id?: string; + email: string; + name: string; + mentor: boolean; + specialization: string; + discordUsername: string; + intro: string; // introduction given by mentor + + available?: number // to represent how many slots available +} + +export interface MentorshipAppointment { + id?: string; + startTime: number; + endTime: number; + mentorId: string; + hackerId?: string; // a hacker book for the whole team + teamName: string; + hackerDescription?: string; // desc given needed by hacker + location: string; + offlineLocation?: string; // to be filled if the location is offline + mentorMarkAsDone: boolean; + mentorMarkAsAfk: boolean; // mark if this team is AFK + mentorNotes: string // to give this appointment a note + hackerMarkAsDone: boolean; +} + +export interface MentorshipAppointmentResponseAsMentor { + id?: string; + startTime: number; + endTime: number; + mentorId: string; + hackerId?: string; + hackerName?: string; + teamName?: string; + hackerDescription?: string; // desc given needed by hacker + location: string; // offline or online + offlineLocation?: string; // to be filled if the location is offline + mentorMarkAsDone?: boolean; + mentorMarkAsAfk?: boolean; // mark if this team is AFK + mentorNotes?: string // to give this appointment a note +} + +export interface MentorshipAppointmentResponseAsHacker { + id?: string; + startTime: number; + endTime: number; + mentorId: string; + hackerId?: string; + hackerName?: string; + teamName?: string; + hackerDescription?: string; // desc given needed by hacker + location: string; // offline or online + offlineLocation?: string; // to be filled if the location is offline +} diff --git a/functions/src/models/user.ts b/functions/src/models/user.ts index 267e782..3692710 100644 --- a/functions/src/models/user.ts +++ b/functions/src/models/user.ts @@ -1,4 +1,7 @@ export interface User { + id?: string; // linked to uid in firebase doc (not in the field) + + displayName: string; firstName: string; lastName: string; email: string; @@ -15,6 +18,7 @@ export interface User { } export const formatUser = (data: Partial): User => ({ + displayName: data.displayName || "", firstName: data.firstName || "", lastName: data.lastName || "", email: data.email || "", diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts index c9cbc75..6941c56 100644 --- a/functions/src/routes/auth.ts +++ b/functions/src/routes/auth.ts @@ -1,5 +1,6 @@ import express, { Request, Response } from "express"; import { + getCurrentUserRole, login, logout, register, @@ -11,6 +12,7 @@ import { const router = express.Router(); +router.get("/role", (req: Request, res: Response) => getCurrentUserRole(req, res)) router.post("/login", (req: Request, res: Response) => login(req, res)); router.post("/register", (req: Request, res: Response) => register(req, res)); router.post("/reset-password", requestPasswordReset); diff --git a/functions/src/routes/index.ts b/functions/src/routes/index.ts index 8c9f1ad..3f5ee4a 100644 --- a/functions/src/routes/index.ts +++ b/functions/src/routes/index.ts @@ -3,6 +3,7 @@ import authRoutes from "./auth"; import applicationRoutes from "./application"; import userRoutes from "./user"; import ticketRoutes from "./ticket"; +import mentorshipRoutes from "./mentorship"; const router: Router = express.Router(); @@ -10,5 +11,6 @@ router.use("/auth", authRoutes); router.use("/users", userRoutes); router.use("/application", applicationRoutes) router.use("/tickets", ticketRoutes); +router.use("/mentorship", mentorshipRoutes) export default router; diff --git a/functions/src/routes/mentorship.ts b/functions/src/routes/mentorship.ts new file mode 100644 index 0000000..8191a0c --- /dev/null +++ b/functions/src/routes/mentorship.ts @@ -0,0 +1,50 @@ +import express, { Request, Response } from "express"; +import { getMentorshipConfig, hackerBookMentorships, hackerCancelMentorship, hackerGetMentor, hackerGetMentors, hackerGetMentorSchedule, hackerGetMentorSchedules, hackerGetMyMentorship, hackerGetMyMentorships, mentorGetMyMentorship, mentorGetMyMentorships, mentorPutMyMentorship } from "../controllers/mentorship_controller"; +import { isMentor } from "../middlewares/role_middleware"; + +const router = express.Router(); + +router.get("/config", async (req: Request, res: Response) => { + await getMentorshipConfig(req, res); +}); + +// ****FOR MENTORS ONLY**** +router.get("/mentor/my-mentorships", isMentor, (req: Request, res: Response) => + mentorGetMyMentorships(req, res) +); + +router.get("/mentor/my-mentorships/:id", isMentor, async (req: Request, res: Response) => { + await mentorGetMyMentorship(req, res) +}); + +router.post("/mentor/my-mentorships/:id", isMentor, (req: Request, res: Response) => + mentorPutMyMentorship(req, res) +); + +// ****FOR HACKERS ONLY**** +router.get("/hacker/mentors", async (req: Request, res: Response) => { + await hackerGetMentors(req, res) +}); +router.get("/hacker/mentors/:id", async (req: Request, res: Response) => { + await hackerGetMentor(req, res) +}); +router.get("/hacker/mentorships", async (req: Request, res: Response) => { + await hackerGetMentorSchedules(req, res) +}); +router.get("/hacker/mentorships/:id", async (req: Request, res: Response) => { + await hackerGetMentorSchedule(req, res) +}); +router.post("/hacker/mentorships/book", async (req: Request, res: Response) => { + await hackerBookMentorships(req, res) +}) +router.post("/hacker/mentorships/cancel", async (req: Request, res: Response) => { + await hackerCancelMentorship(req, res) +}) +router.get("/hacker/my-mentorships", async (req: Request, res: Response) => { + await hackerGetMyMentorships(req, res) +}) +router.get("/hacker/my-mentorships/:id", async (req: Request, res: Response) => { + await hackerGetMyMentorship(req, res) +}) + +export default router; \ No newline at end of file diff --git a/functions/src/server.ts b/functions/src/server.ts index 3cdb290..99dfb38 100644 --- a/functions/src/server.ts +++ b/functions/src/server.ts @@ -51,6 +51,8 @@ app.use((req: Request, res: Response, next: NextFunction) => { if (req.method === "OPTIONS") { return next(); } + + /** Uncomment to disable session validation */ validateSessionCookie(req, res, next); }); @@ -102,7 +104,7 @@ app.use((req: Request, res: Response, next: NextFunction) => { } const timestamp = new Date().toISOString(); - functions.logger.info( + functions.logger.debug( `[${timestamp}] Incoming Request Details: ${JSON.stringify( logData, null, diff --git a/functions/src/types/config.ts b/functions/src/types/config.ts new file mode 100644 index 0000000..39614ff --- /dev/null +++ b/functions/src/types/config.ts @@ -0,0 +1,7 @@ +import { Timestamp } from "firebase-admin/firestore"; + +export interface MentorshipConfig { + isMentorshipOpen: boolean; // whether or not participant can start to book mentorship slots + mentorshipStartDate: Timestamp; + mentorshipEndDate: Timestamp; +} \ No newline at end of file diff --git a/functions/src/utils/date.ts b/functions/src/utils/date.ts new file mode 100644 index 0000000..a71c17c --- /dev/null +++ b/functions/src/utils/date.ts @@ -0,0 +1,21 @@ +/** + * Convert date to time + * @param date + * @returns + */ +export function dateToStringTime(date: Date) { + return date.toLocaleString('en-US', { timeStyle: 'short' }) +} + +/** + * Convert date to readable string + * @param epochSecond + * @returns + */ +export function epochToStringDate(epochSecond: number) { + const startDate = new Date(epochSecond * 1000) + const startDay = startDate.toLocaleDateString() + const startTimestamp = startDate.toLocaleString('en-US', { timeStyle: 'short' }) + const start = `${startDay} ${startTimestamp}` + return start +} \ No newline at end of file diff --git a/functions/src/utils/fake_data_populator.ts b/functions/src/utils/fake_data_populator.ts index ce6836d..d0db596 100644 --- a/functions/src/utils/fake_data_populator.ts +++ b/functions/src/utils/fake_data_populator.ts @@ -6,6 +6,9 @@ import { Question, QUESTION_TYPE, } from "../types/application_types"; +import { FirestoreMentor, MentorshipAppointment } from "../models/mentorship"; +import { dummyMentors } from "../data/dummy/user"; +import { dummyMentorships } from "../data/dummy/mentorship"; /** * Logs a message with a specific prefix. @@ -45,6 +48,8 @@ export class FakeDataPopulator { await this.generateUsers(); await this.generateQuestions(); + await this.generateMentorshipAppointments(); + await this.generateMentors() } } @@ -192,6 +197,28 @@ export class FakeDataPopulator { await this.createQuestionDocument(q); } + /** + * Generates mentorship appointments from the given dummy data. + */ + private async generateMentorshipAppointments(): Promise { + log("generateMentorshipAppointments"); + + dummyMentorships.map(async (dM: MentorshipAppointment) => { + await this.createMentorshipAppointmentDocument(dM) + }) + } + + /** + * Generate fake mentors. + */ + private async generateMentors(): Promise { + log("generateMentors") + + dummyMentors.map(async (mentor: FirestoreMentor) => { + await this.createMentorDocument(mentor) + }) + } + /** * Gets the document reference for the generate document. * @returns {firestore.DocumentReference} The document reference. @@ -228,4 +255,19 @@ export class FakeDataPopulator { private async createQuestionDocument(q: Question): Promise { await this.firestoreDatabase.collection("questions").add(q); } + + /** + * Create a mentorship appointment. + * @param mA mentorship appointment + */ + private async createMentorshipAppointmentDocument(mA: MentorshipAppointment): Promise { + await this.firestoreDatabase.collection("mentorships").add(mA) + } + + /** + * Create a mentor in user collection. + */ + private async createMentorDocument(mentor: FirestoreMentor): Promise { + await this.firestoreDatabase.collection("users").add(mentor) + } } diff --git a/functions/tsconfig.json b/functions/tsconfig.json index 9705366..c6af828 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -13,6 +13,7 @@ }, "compileOnSave": true, "include": [ - "src" - ] + "src/**/*" + ], + "exclude": ["node_modules"] }