diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 1dd79ad..f06dab1 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,7 +1,14 @@ +BASE_URL = APP_PORT = 3000 DATABASE_URL = ACCESS_TOKEN_SECRET = REFRESH_TOKEN_SECRET = -COOKIE_SECRET = \ No newline at end of file +RESET_PASSWORD_TOKEN_SECRET = +COOKIE_SECRET = + +SMTP_USERNAME = +SMTP_PASSWORD = +SMTP_HOST = +SMTP_PORT = \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index a923b14..21a8d3b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -19,6 +19,7 @@ "hono": "^4.4.6", "jsonwebtoken": "^9.0.2", "moment": "^2.30.1", + "nodemailer": "^6.9.14", "postgres": "^3.4.4", "sharp": "^0.33.4", "zod": "^3.23.8" @@ -27,6 +28,7 @@ "@types/bcrypt": "^5.0.2", "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.14.2", + "@types/nodemailer": "^6.4.15", "drizzle-kit": "^0.22.7", "pg": "^8.12.0", "tsx": "^4.15.5", diff --git a/apps/backend/src/appEnv.ts b/apps/backend/src/appEnv.ts index bedf2a1..9d4672a 100644 --- a/apps/backend/src/appEnv.ts +++ b/apps/backend/src/appEnv.ts @@ -4,11 +4,17 @@ import { z } from "zod"; dotenv.config(); const envSchema = z.object({ + BASE_URL: z.string(), APP_PORT: z.coerce.number().int(), DATABASE_URL: z.string(), ACCESS_TOKEN_SECRET: z.string(), REFRESH_TOKEN_SECRET: z.string(), + RESET_PASSWORD_TOKEN_SECRET: z.string(), COOKIE_SECRET: z.string(), + SMTP_USERNAME: z.string(), + SMTP_PASSWORD: z.string(), + SMTP_HOST: z.string(), + SMTP_PORT: z.coerce.number().int(), }); const parsedEnv = envSchema.safeParse(process.env); diff --git a/apps/backend/src/data/permissions.ts b/apps/backend/src/data/permissions.ts index d80bbac..d0a7c76 100644 --- a/apps/backend/src/data/permissions.ts +++ b/apps/backend/src/data/permissions.ts @@ -74,7 +74,36 @@ const permissionsData = [ { code: "assessmentResult.create", }, - + { + code: "assessmentRequest.read", + }, + { + code: "assessmentRequest.create", + }, + { + code: "assessments.readAssessmentScore", + }, + { + code: "assessments.readAllQuestions", + }, + { + code: "assessments.readAnswers", + }, + { + code: "assessments.toggleFlag", + }, + { + code: "assessments.checkAnswer", + }, + { + code: "assessments.uploadFile", + }, + { + code: "assessments.submitAnswer", + }, + { + code: "assessments.updateAnswer", + }, ] as const; export type SpecificPermissionCode = (typeof permissionsData)[number]["code"]; diff --git a/apps/backend/src/drizzle/schema/assessments.ts b/apps/backend/src/drizzle/schema/assessments.ts index 44eb4ed..75b9ccd 100644 --- a/apps/backend/src/drizzle/schema/assessments.ts +++ b/apps/backend/src/drizzle/schema/assessments.ts @@ -4,7 +4,7 @@ import { relations } from "drizzle-orm"; import { respondents } from "./respondents"; import { users } from "./users"; -const statusEnum = pgEnum("status", ["tertunda", "disetujui", "ditolak", "selesai"]); +const statusEnum = pgEnum("status", ["menunggu konfirmasi", "disetujui", "ditolak", "selesai"]); export const assessments = pgTable("assessments", { id: varchar("id", { length: 50 }) @@ -14,10 +14,9 @@ export const assessments = pgTable("assessments", { status: statusEnum("status"), reviewedBy: varchar("reviewedBy"), reviewedAt: timestamp("reviewedAt", { mode: "date" }), - validatedBy: varchar("validatedBy").notNull(), + validatedBy: varchar("validatedBy"), validatedAt: timestamp("validatedAt", { mode: "date" }), createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), - }); // Query Tools in PosgreSQL -// CREATE TYPE status AS ENUM ('tertunda', 'disetujui', 'ditolak', 'selesai'); \ No newline at end of file +// CREATE TYPE status AS ENUM ('menunggu konfirmasi', 'disetujui', 'ditolak', 'selesai'); \ No newline at end of file diff --git a/apps/backend/src/drizzle/schema/users.ts b/apps/backend/src/drizzle/schema/users.ts index 0574af0..ff5fa03 100644 --- a/apps/backend/src/drizzle/schema/users.ts +++ b/apps/backend/src/drizzle/schema/users.ts @@ -20,6 +20,7 @@ export const users = pgTable("users", { email: varchar("email"), password: text("password").notNull(), isEnabled: boolean("isEnabled").default(true), + resetPasswordToken: varchar("resetPasswordToken"), createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(), deletedAt: timestamp("deletedAt", { mode: "date" }), diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 4bc545d..7133f40 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -19,6 +19,9 @@ import devRoutes from "./routes/dev/route"; import appEnv from "./appEnv"; import questionsRoute from "./routes/questions/route"; import assessmentResultRoute from "./routes/assessmentResult/route"; +import assessmentRequestRoute from "./routes/assessmentRequest/route"; +import forgotPasswordRoutes from "./routes/forgotPassword/route"; +import assessmentsRoute from "./routes/assessments/route"; configDotenv(); @@ -86,6 +89,9 @@ const routes = app .route("/management-aspect", managementAspectsRoute) .route("/register", respondentsRoute) .route("/assessmentResult", assessmentResultRoute) + .route("/assessmentRequest", assessmentRequestRoute) + .route("/forgot-password", forgotPasswordRoutes) + .route("/assessments", assessmentsRoute) .onError((err, c) => { if (err instanceof DashboardError) { return c.json( diff --git a/apps/backend/src/routes/assessmentRequest/route.ts b/apps/backend/src/routes/assessmentRequest/route.ts new file mode 100644 index 0000000..3f189a9 --- /dev/null +++ b/apps/backend/src/routes/assessmentRequest/route.ts @@ -0,0 +1,105 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import db from "../../drizzle"; +import { respondents } from "../../drizzle/schema/respondents"; +import { assessments } from "../../drizzle/schema/assessments"; +import { users } from "../../drizzle/schema/users"; +import { rolesToUsers } from "../../drizzle/schema/rolesToUsers"; +import { rolesSchema } from "../../drizzle/schema/roles"; +import HonoEnv from "../../types/HonoEnv"; +import authInfo from "../../middlewares/authInfo"; +import { notFound } from "../../errors/DashboardError"; +import checkPermission from "../../middlewares/checkPermission"; +import requestValidator from "../../utils/requestValidator"; +import { HTTPException } from "hono/http-exception"; +import { createId } from "@paralleldrive/cuid2"; + +const assessmentRequestRoute = new Hono() + .use(authInfo) + + // Get assessment request by user ID + .get( + "/:id", + checkPermission("assessmentRequest.read"), + requestValidator( + "query", + z.object({ + includeTrashed: z.string().default("false"), + }) + ), + async (c) => { + const userId = c.req.param("id"); + + const queryResult = await db + .select({ + userId: users.id, + createdAt: assessments.createdAt, + name: users.name, + code: rolesSchema.code, + status: assessments.status, + }) + .from(users) + .leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId)) + .leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id)) + .leftJoin(respondents, eq(users.id, respondents.userId)) + .leftJoin(assessments, eq(respondents.id, assessments.respondentId)) + .where(eq(users.id, userId)); + + if (!queryResult[0]) throw notFound(); + + const assessmentRequestData = { + ...queryResult, + }; + + return c.json(assessmentRequestData); + } + ) + + // Post assessment request by user ID + .post( + "/:id", + checkPermission("assessmentRequest.create"), + requestValidator( + "json", + z.object({ + respondentId: z.string().min(1), + }) + ), + async (c) => { + const { respondentId } = c.req.valid("json"); + const userId = c.req.param("id"); + + // Make sure the userId exists + if (!userId) { + throw new HTTPException(400, { message: "User ID is required." }); + } + + // Validate if respondent exists + const respondent = await db + .select() + .from(respondents) + .where(eq(respondents.id, respondentId)); + + if (!respondent.length) { + throw new HTTPException(404, { message: "Respondent not found." }); + } + + // Create the assessment request + const newAssessment = await db + .insert(assessments) + .values({ + id: createId(), + respondentId, + status: "menunggu konfirmasi", + validatedBy: null, + validatedAt: null, + createdAt: new Date(), + }) + .returning(); + + return c.json({ message: "Successfully submitted the assessment request" }, 201); + } + ); + +export default assessmentRequestRoute; \ No newline at end of file diff --git a/apps/backend/src/routes/assessments/route.ts b/apps/backend/src/routes/assessments/route.ts new file mode 100644 index 0000000..604c903 --- /dev/null +++ b/apps/backend/src/routes/assessments/route.ts @@ -0,0 +1,536 @@ +import { and, eq, ilike, or, sql } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import db from "../../drizzle"; +import { answers } from "../../drizzle/schema/answers"; +import { options } from "../../drizzle/schema/options"; +import { questions } from "../../drizzle/schema/questions"; +import { subAspects } from "../../drizzle/schema/subAspects"; +import { aspects } from "../../drizzle/schema/aspects"; +import { assessments } from "../../drizzle/schema/assessments"; +import HonoEnv from "../../types/HonoEnv"; +import requestValidator from "../../utils/requestValidator"; +import authInfo from "../../middlewares/authInfo"; +import checkPermission from "../../middlewares/checkPermission"; +import path from "path"; +import fs from 'fs'; +import { notFound } from "../../errors/DashboardError"; + +export const answerFormSchema = z.object({ + optionId: z.string().min(1), + assessmentId: z.string().min(1), + isFlagged: z.boolean().optional().default(false), + filename: z.string().optional(), + validationInformation: z.string().min(1), +}); + +export const answerUpdateSchema = answerFormSchema.partial(); + +// Helper function to save the file +async function saveFile(filePath: string, fileBuffer: Buffer): Promise { + await fs.promises.writeFile(filePath, fileBuffer); +} + +// Function to update the filename in the database +async function updateFilenameInDatabase(answerId: string, filename: string): Promise { + + await db.update(answers) + .set({ filename }) + .where(eq(answers.id, answerId)); +} + +const assessmentsRoute = new Hono() + .use(authInfo) + + // Get data for current Assessment Score from submitted options By Assessment Id + .get( + "/getCurrentAssessmentScore", + checkPermission("assessments.readAssessmentScore"), + requestValidator( + "query", + z.object({ + assessmentId: z.string(), + }) + ), + async (c) => { + const { assessmentId } = c.req.valid("query"); + + // Query to sum the scores of selected options for the current assessment + const result = await db + .select({ + totalScore: sql`SUM(${options.score})`, + }) + .from(answers) + .leftJoin(options, eq(answers.optionId, options.id)) + .where(eq(answers.assessmentId, assessmentId)) + .execute(); + + return c.json({ + assessmentId, + totalScore: result[0]?.totalScore ?? 0, // Return 0 if no answers are found + }); + } + ) + + // Get all Questions and Options that relate to Sub Aspects and Aspects + .get( + "/getAllQuestions", + checkPermission("assessments.readAllQuestions"), + async (c) => { + const totalCountQuery = + sql`(SELECT count(*) + FROM ${options} + LEFT JOIN ${questions} ON ${options.questionId} = ${questions.id} + LEFT JOIN ${subAspects} ON ${questions.subAspectId} = ${subAspects.id} + LEFT JOIN ${aspects} ON ${subAspects.aspectId} = ${aspects.id} + WHERE ${questions.deletedAt} IS NULL + )`; + + const result = await db + .select({ + optionId: options.id, + aspectsId: aspects.id, + aspectsName: aspects.name, + subAspectId: subAspects.id, + subAspectName: subAspects.name, + questionId: questions.id, + questionText: questions.question, + optionText: options.text, + optionScore: options.score, + fullCount: totalCountQuery, + }) + .from(options) + .leftJoin(questions, eq(options.questionId, questions.id)) + .leftJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .leftJoin(aspects, eq(subAspects.aspectId, aspects.id)) + .where(sql`${questions.deletedAt} IS NULL`) + + return c.json({ + data: result.map((d) => ( + { + ...d, + fullCount: undefined + } + )), + }); + } + ) + + // Get all Answers Data by Assessment Id + .get( + "/getAnswers", + checkPermission("assessments.readAnswers"), + requestValidator( + "query", + z.object({ + assessmentId: z.string(), // Require assessmentId as a query parameter + withMetadata: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + page: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(1000).default(1000), + q: z.string().default(""), + }) + ), + async (c) => { + const { assessmentId, page, limit, q } = c.req.valid("query"); + + // Query to count total answers for the specific assessmentId + const totalCountQuery = + sql`(SELECT count(*) + FROM ${answers} + WHERE ${answers.assessmentId} = ${assessmentId})`; + + // Query to retrieve answers for the specific assessmentId + const result = await db + .select({ + id: answers.id, + assessmentId: answers.assessmentId, + optionId: answers.optionId, + isFlagged: answers.isFlagged, + filename: answers.filename, + validationInformation: answers.validationInformation, + fullCount: totalCountQuery, + }) + .from(answers) + .where( + and( + eq(answers.assessmentId, assessmentId), // Filter by assessmentId + q + ? or( + ilike(answers.filename, q), + ilike(answers.validationInformation, q), + eq(answers.id, q) + ) + : undefined + ) + ) + .offset(page * limit) + .limit(limit); + + return c.json({ + data: result.map((d) => ({ ...d, fullCount: undefined })), + _metadata: { + currentPage: page, + totalPages: Math.ceil( + (Number(result[0]?.fullCount) ?? 0) / limit + ), + totalItems: Number(result[0]?.fullCount) ?? 0, + perPage: limit, + }, + }); + } + ) + + // Toggles the isFlagged field between true and false + .patch( + "/:id/toggleFlag", + checkPermission("assessments.toggleFlag"), + async (c) => { + const answerId = c.req.param("id"); + + // Retrieve the current state of isFlagged + const currentAnswer = await db + .select({ + isFlagged: answers.isFlagged, + }) + .from(answers) + .where(eq(answers.id, answerId)) + .limit(1); + + if (!currentAnswer.length) { + throw notFound( + { + message: "Answer not found", + } + ) + } + + // Toggle the isFlagged value + const newIsFlaggedValue = !currentAnswer[0].isFlagged; + + // Update the answer with the toggled value + const updatedAnswer = await db + .update(answers) + .set({ + isFlagged: newIsFlaggedValue, + }) + .where(eq(answers.id, answerId)) + .returning(); + + if (!updatedAnswer.length) { + throw notFound( + { + message: "Failed to update answer", + } + ) + } + + return c.json( + { + message: "Answer flag toggled successfully", + answer: updatedAnswer[0], + }, + 200 + ); + } + ) + + // Get data answers from table answers by optionId and assessmentId + .post( + "/checkDataAnswer", + checkPermission("assessments.checkAnswer"), + async (c) => { + const { optionId, assessmentId } = await c.req.json(); + + const result = await db + .select() + .from(answers) + .where( + and(eq(answers.optionId, optionId), eq(answers.assessmentId, assessmentId)) + ) + .execute(); + + const existingAnswer = result[0]; + let response; + + if (existingAnswer) { + response = { + exists: true, + answerId: existingAnswer.id + }; + } else { + response = { + exists: false + }; + } + + return c.json(response); + } + ) + + // Upload filename to the table answers and save the file on the local storage + .post( + "/uploadFile", + checkPermission("assessments.uploadFile"), + async (c) => { + // Get the Content-Type header + const contentType = c.req.header('content-type'); + if (!contentType || !contentType.includes('multipart/form-data')) { + throw notFound({ + message: "Invalid Content-Type", + }); + } + + // Extract boundary + const boundary = contentType.split('boundary=')[1]; + if (!boundary) { + throw notFound({ + message: "Boundary not found", + }); + } + + // Get the raw body + const body = await c.req.arrayBuffer(); + const bodyString = Buffer.from(body).toString(); + + // Split the body by the boundary + const parts = bodyString.split(`--${boundary}`); + + let fileUrl = null; + + for (const part of parts) { + if (part.includes('Content-Disposition: form-data;')) { + // Extract file name + const match = /filename="(.+?)"/.exec(part); + if (match) { + const fileName = match[1]; + const fileContentStart = part.indexOf('\r\n\r\n') + 4; + const fileContentEnd = part.lastIndexOf('\r\n'); + + // Extract file content as Buffer + const fileBuffer = Buffer.from(part.slice(fileContentStart, fileContentEnd), 'binary'); + + // Define file path and save the file + const filePath = path.join('images', Date.now() + '-' + fileName); + await saveFile(filePath, fileBuffer); + + // Assuming answerId is passed as a query parameter or in the form-data + const answerId = c.req.query('answerId'); + if (!answerId) { + throw notFound({ + message: "answerId is required", + }); + } + + await updateFilenameInDatabase(answerId, path.basename(filePath)); + + // Set the file URL for the final response + fileUrl = `/images/${path.basename(filePath)}`; + } + } + } + + if (!fileUrl) { + throw notFound({ + message: 'No file uploaded', + }); + } + + return c.json( + { + success: true, + imageUrl: fileUrl + } + ); + } + ) + + // Submit option to table answers from use-form in frontend + .post( + "/submitAnswer", + checkPermission("assessments.submitAnswer"), + requestValidator("json", answerFormSchema), + async (c) => { + const answerData = c.req.valid("json"); + + const answer = await db + .insert(answers) + .values({ + optionId: answerData.optionId, + assessmentId: answerData.assessmentId, + isFlagged: answerData.isFlagged, + filename: answerData.filename, + validationInformation: answerData.validationInformation, + }) + .returning(); + + return c.json( + { + message: "Answer created successfully", + answer: answer[0], + }, + 201 + ); + } + ) + + // Update answer in table answers if answer changes + .patch( + "/:id/updateAnswer", + checkPermission("assessments.updateAnswer"), + requestValidator("json", answerUpdateSchema), + async (c) => { + const answerId = c.req.param("id"); + const answerData = c.req.valid("json"); + + const updatedAnswer = await db + .update(answers) + .set({ + optionId: answerData.optionId, + }) + .where(eq(answers.id, answerId)) + .returning(); + + if (!updatedAnswer.length) { + throw notFound({ + message: "Answer not found or update failed" + }) + } + + return c.json({ + message: "Answer updated successfully", + answer: updatedAnswer[0], + }); + } + ) + + // Get data for One Sub Aspect average score By Sub Aspect Id and Assessment Id + .get( + '/average-score/sub-aspects/:subAspectId/assessments/:assessmentId', + // checkPermission("assessments.readAssessmentScore"), + async (c) => { + const { subAspectId, assessmentId } = c.req.param(); + + const averageScore = await db + .select({ + subAspectName: subAspects.name, + average: sql`AVG(options.score)` + }) + .from(answers) + .innerJoin(options, eq(answers.optionId, options.id)) + .innerJoin(questions, eq(options.questionId, questions.id)) + .innerJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) + .where( + sql`sub_aspects.id = ${subAspectId} AND assessments.id = ${assessmentId}` + ) + .groupBy(subAspects.id); + + return c.json({ + subAspectId, + subAspectName: averageScore[0].subAspectName, + assessmentId, + averageScore: averageScore.length > 0 ? averageScore[0].average : 0 + }); + } + ) + + // Get data for All Sub Aspects average score By Assessment Id + .get( + '/average-score/sub-aspects/assessments/:assessmentId', + // checkPermission("assessments.readAssessmentScore"), + async (c) => { + const { assessmentId } = c.req.param(); + + const averageScores = await db + .select({ + subAspectId: subAspects.id, + subAspectName: subAspects.name, + average: sql`AVG(options.score)` + }) + .from(answers) + .innerJoin(options, eq(answers.optionId, options.id)) + .innerJoin(questions, eq(options.questionId, questions.id)) + .innerJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) + .where(eq(assessments.id, assessmentId)) + .groupBy(subAspects.id); + + return c.json({ + assessmentId, + subAspects: averageScores.map(score => ({ + subAspectId: score.subAspectId, + subAspectName: score.subAspectName, + averageScore: score.average + })) + }); + } + ) + + // Get data for One Aspect average score By Aspect Id and Assessment Id + .get( + "/average-score/aspects/:aspectId/assessments/:assessmentId", + async (c) => { + const { aspectId, assessmentId } = c.req.param(); + + const averageScore = await db + .select({ + aspectName: aspects.name, + average: sql`AVG(options.score)` + }) + .from(answers) + .innerJoin(options, eq(answers.optionId, options.id)) + .innerJoin(questions, eq(options.questionId, questions.id)) + .innerJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .innerJoin(aspects, eq(subAspects.aspectId, aspects.id)) + .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) + .where( + sql`aspects.id = ${aspectId} AND assessments.id = ${assessmentId}` + ) + .groupBy(aspects.id); + + return c.json({ + aspectId, + aspectName: averageScore[0].aspectName, + assessmentId, + averageScore: averageScore.length > 0 ? averageScore[0].average : 0 + }); + } + ) + + // Get data for All Aspects average score By Assessment Id + .get( + '/average-score/aspects/assessments/:assessmentId', + // checkPermission("assessments.readAssessmentScore"), + async (c) => { + const { assessmentId } = c.req.param(); + + const averageScores = await db + .select({ + AspectId: aspects.id, + AspectName: aspects.name, + average: sql`AVG(options.score)` + }) + .from(answers) + .innerJoin(options, eq(answers.optionId, options.id)) + .innerJoin(questions, eq(options.questionId, questions.id)) + .innerJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .innerJoin(aspects, eq(subAspects.aspectId, aspects.id)) + .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) + .where(eq(assessments.id, assessmentId)) + .groupBy(aspects.id); + + return c.json({ + assessmentId, + aspects: averageScores.map(score => ({ + AspectId: score.AspectId, + AspectName: score.AspectName, + averageScore: score.average + })) + }); + } + ) + +export default assessmentsRoute; diff --git a/apps/backend/src/routes/forgotPassword/route.ts b/apps/backend/src/routes/forgotPassword/route.ts new file mode 100644 index 0000000..8c3ddb0 --- /dev/null +++ b/apps/backend/src/routes/forgotPassword/route.ts @@ -0,0 +1,111 @@ +import { zValidator } from "@hono/zod-validator"; +import HonoEnv from "../../types/HonoEnv"; +import { z } from "zod"; +import { and, eq, isNull } from "drizzle-orm"; +import { Hono } from "hono"; +import db from "../../drizzle"; +import { users } from "../../drizzle/schema/users"; +import { notFound, unauthorized } from "../../errors/DashboardError"; +import { generateResetPasswordToken, verifyResetPasswordToken } from "../../utils/authUtils"; +import { sendResetPasswordEmail } from "../../utils/mailerUtils"; +import { hashPassword } from "../../utils/passwordUtils"; + +const forgotPasswordRoutes = new Hono() + /** + * Forgot Password + * + * Checking emails in the database, generating tokens, and sending emails occurs. + */ + .post( + '/', + zValidator( + 'json', + z.object({ + email: z.string().email(), + }) + ), + async (c) => { + const { email } = c.req.valid('json'); + + const user = await db + .select() + .from(users) + .where( + and( + isNull(users.deletedAt), + eq(users.email, email) + ) + ); + + if (!user.length) throw notFound(); + + // Generate reset password token + const resetPasswordToken = await generateResetPasswordToken({ + uid: user[0].id, + }); + + await db + .update(users) + .set({ + resetPasswordToken: resetPasswordToken + }) + .where(eq(users.email, email)); + + // Send email with reset password token + await sendResetPasswordEmail(email, resetPasswordToken); + + return c.json({ + message: 'Email has been sent successfully', + }); + } + ) + /** + * Reset Password + */ + .patch( + '/verify', + zValidator( + 'json', + z.object({ + password: z.string(), + confirm_password: z.string() + }) + ), + async (c) => { + const formData = c.req.valid('json'); + const token = c.req.query('token') + + // Token validation + if (!token) { + return c.json({ message: 'Token is required' }, 400); + } + + // Password validation + if (formData.password !== formData.confirm_password) { + return c.json({ message: 'Passwords do not match' }, 400); + } + + const decoded = await verifyResetPasswordToken(token); + if (!decoded) { + return c.json({ message: 'Invalid or expired token' }, 401); + } + + if (!decoded) throw unauthorized(); + + // Hash the password + const hashedPassword = await hashPassword(formData.password); + + await db + .update(users) + .set({ + password: hashedPassword, + updatedAt: new Date(), + }) + .where(eq(users.id, decoded.uid)); + + return c.json({ + message: 'Password has been reset successfully' + }); + }); + +export default forgotPasswordRoutes; \ No newline at end of file diff --git a/apps/backend/src/utils/authUtils.ts b/apps/backend/src/utils/authUtils.ts index 99019f4..09f19b3 100644 --- a/apps/backend/src/utils/authUtils.ts +++ b/apps/backend/src/utils/authUtils.ts @@ -4,6 +4,7 @@ import appEnv from "../appEnv"; // Environment variables for secrets, defaulting to a random secret if not set. const accessTokenSecret = appEnv.ACCESS_TOKEN_SECRET; const refreshTokenSecret = appEnv.REFRESH_TOKEN_SECRET; +const resetPasswordTokenSecret = appEnv.RESET_PASSWORD_TOKEN_SECRET; // Algorithm to be used for JWT encoding. const algorithm: jwt.Algorithm = "HS256"; @@ -11,6 +12,7 @@ const algorithm: jwt.Algorithm = "HS256"; // Expiry settings for tokens. 'null' signifies no expiry. export const accessTokenExpiry: number | string | null = null; export const refreshTokenExpiry: number | string | null = "30d"; +export const resetPasswordTokenExpiry: number | string | null = null; // Interfaces to describe the payload structure for access and refresh tokens. interface AccessTokenPayload { @@ -21,6 +23,10 @@ interface RefreshTokenPayload { uid: string; } +interface ResetPasswordTokenPayload { + uid: string; +} + /** * Generates a JSON Web Token (JWT) for access control using a specified payload. * @@ -84,3 +90,35 @@ export const verifyRefreshToken = async (token: string) => { return null; } }; + +/** + * Generates a JSON Web Token (JWT) for reset password using a specified payload. + * + * @param payload - The payload containing user-specific data for the token. + * @returns A promise that resolves to the generated JWT string. + */ +export const generateResetPasswordToken = async (payload: ResetPasswordTokenPayload) => { + const token = jwt.sign(payload, resetPasswordTokenSecret, { + algorithm, + ...(resetPasswordTokenExpiry ? { expiresIn: resetPasswordTokenExpiry } : {}), + }); + return token; +}; + +/** + * Verifies a given reset password token and decodes the payload if the token is valid. + * + * @param token - The JWT string to verify. + * @returns A promise that resolves to the decoded payload or null if verification fails. + */ +export const verifyResetPasswordToken = async (token: string) => { + try { + const payload = jwt.verify( + token, + resetPasswordTokenSecret + ) as ResetPasswordTokenPayload; + return payload; + } catch { + return null; + } +}; \ No newline at end of file diff --git a/apps/backend/src/utils/mailerUtils.ts b/apps/backend/src/utils/mailerUtils.ts new file mode 100644 index 0000000..4f5d660 --- /dev/null +++ b/apps/backend/src/utils/mailerUtils.ts @@ -0,0 +1,33 @@ +import nodemailer from 'nodemailer'; +import appEnv from '../appEnv'; + +/** + * Nodemailer configuration + */ +const transporter = nodemailer.createTransport({ + host: appEnv.SMTP_HOST, + port: appEnv.SMTP_PORT, + secure: false, + auth: { + user: appEnv.SMTP_USERNAME, + pass: appEnv.SMTP_PASSWORD, + }, + tls: { + rejectUnauthorized: false, + }, +}); + +export async function sendResetPasswordEmail(to: string, token: string) { + const resetUrl = appEnv.BASE_URL + '/forgot-password/verify?token=' + token; + + const info = await transporter.sendMail({ + from: `"Your App" <${appEnv.SMTP_USERNAME}>`, + to, + subject: 'Password Reset Request', + text: `You requested a password reset. Click this link to reset your password: ${resetUrl}`, + html: `

You requested a password reset. Click this link to reset your password:
${resetUrl}

`, + }); + + console.log('Email sent: %s', info.messageId); + return info; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16570cb..1659c27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: moment: specifier: ^2.30.1 version: 2.30.1 + nodemailer: + specifier: ^6.9.14 + version: 6.9.14 postgres: specifier: ^3.4.4 version: 3.4.4 @@ -69,6 +72,9 @@ importers: '@types/node': specifier: ^20.14.2 version: 20.14.2 + '@types/nodemailer': + specifier: ^6.4.15 + version: 6.4.15 drizzle-kit: specifier: ^0.22.7 version: 0.22.7 @@ -1124,6 +1130,7 @@ packages: '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -1131,6 +1138,7 @@ packages: '@humanwhocodes/object-schema@2.0.2': resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + deprecated: Use @eslint/object-schema instead '@humanwhocodes/retry@0.3.0': resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} @@ -1759,6 +1767,9 @@ packages: '@types/node@20.14.2': resolution: {integrity: sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==} + '@types/nodemailer@6.4.15': + resolution: {integrity: sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -3226,6 +3237,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -3377,6 +3389,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4047,6 +4060,10 @@ packages: node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + nodemailer@6.9.14: + resolution: {integrity: sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==} + engines: {node: '>=6.0.0'} + nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -4561,6 +4578,7 @@ packages: rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rollup@4.18.0: @@ -6760,7 +6778,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 20.11.24 + '@types/node': 20.14.2 '@types/graceful-fs@4.1.9': dependencies: @@ -6805,6 +6823,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/nodemailer@6.4.15': + dependencies: + '@types/node': 20.14.2 + '@types/normalize-package-data@2.4.4': {} '@types/parse-json@4.0.2': {} @@ -6841,7 +6863,7 @@ snapshots: '@types/through@0.0.30': dependencies: - '@types/node': 20.11.24 + '@types/node': 20.14.2 '@types/tinycolor2@1.4.6': {} @@ -9717,6 +9739,8 @@ snapshots: node-releases@2.0.14: {} + nodemailer@6.9.14: {} + nopt@5.0.0: dependencies: abbrev: 1.1.1