diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..7cc6192 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": false, + "bracketSpacing": true, + "printWidth": 120 +} diff --git a/src/features/article/article-repository.ts b/src/features/article/article-repository.ts index a489b70..6fe349e 100644 --- a/src/features/article/article-repository.ts +++ b/src/features/article/article-repository.ts @@ -1,14 +1,8 @@ import Article from "./article-schema"; -import { - ArticleEntity, - ICreateArticleData, - IPatchArticleData, -} from "./article-types"; +import { ArticleEntity, ICreateArticleData, IPatchArticleData } from "./article-types"; import { IParsedQuery } from "./article-utils"; -export async function findArticlesByQuery( - parsedQuery: IParsedQuery -): Promise { +export async function findArticlesByQuery(parsedQuery: IParsedQuery): Promise { const articles = await Article.find(parsedQuery.find) .sort(parsedQuery.sort) .limit(parsedQuery.pagination.limit) @@ -23,9 +17,7 @@ export async function findArticlesByQuery( return articles.map((x) => new ArticleEntity(x)); } -export async function countDocumentsByQuery( - parsedQuery: IParsedQuery -): Promise { +export async function countDocumentsByQuery(parsedQuery: IParsedQuery): Promise { if (parsedQuery.find) { return Article.find(parsedQuery.find).countDocuments(); } @@ -33,15 +25,9 @@ export async function countDocumentsByQuery( return Article.find().estimatedDocumentCount(); } -export async function findArticleById( - id: string, - populateField?: any -): Promise { +export async function findArticleById(id: string, populateField?: any): Promise { // TODO: Make it more sensible - const article = await Article.findById(id).populate( - populateField.populate, - populateField.limitPopulate - ); + const article = await Article.findById(id).populate(populateField.populate, populateField.limitPopulate); if (!article) { return null; @@ -50,9 +36,7 @@ export async function findArticleById( return new ArticleEntity(article); } -export async function createArticle( - data: ICreateArticleData -): Promise { +export async function createArticle(data: ICreateArticleData): Promise { const result = new Article(data); await result.save(); @@ -67,14 +51,15 @@ export async function findAndUpdateArticleById( const article = await Article.findById(id); if (article === null) throw new Error("Article not found"); - if (article.owner.toString() !== userId) - throw new Error("Only owners can update article"); + if (article.owner.toString() !== userId) throw new Error("Only owners can update article"); for (const key of Object.keys(data)) { //@ts-ignore article[key] = data[key]; } + article.updatedAt = new Date(); + await article.save(); return new ArticleEntity(article); diff --git a/src/features/user/user-handlers.ts b/src/features/user/user-handlers.ts index 1dc8840..7944073 100644 --- a/src/features/user/user-handlers.ts +++ b/src/features/user/user-handlers.ts @@ -1,8 +1,9 @@ import { IJsonResponse } from "../../utils/json-response"; -import * as authService from "./user-service"; +import * as userService from "./user-service"; import { NextFunction, Request, Response } from "express"; import { AppError } from "../../utils/app-error"; import { HttpStatusCode } from "../../utils/http-status-code"; +import { IUserLocal, UserEntity } from "./user-types"; const authCookieName = "jwt"; const authCookieOptions = { @@ -13,23 +14,16 @@ const authCookieOptions = { }; export function signUp() { - return async ( - req: Request, - res: Response, - next: NextFunction - ) => { + return async (req: Request, res: Response, next: NextFunction) => { try { const userData = req.body; - const [token, user] = await authService.signUp(userData); - - return res - .cookie(authCookieName, token, authCookieOptions) - .status(HttpStatusCode.CREATED) - .json({ - code: HttpStatusCode.CREATED, - message: "Sign up successful", - data: user, - }); + const [token, user] = await userService.signUp(userData); + + return res.cookie(authCookieName, token, authCookieOptions).status(HttpStatusCode.CREATED).json({ + code: HttpStatusCode.CREATED, + message: "Sign up successful", + data: user, + }); } catch (err) { next(new AppError(HttpStatusCode.BAD_REQUEST, "Sign up failed")); } @@ -37,17 +31,10 @@ export function signUp() { } export function signIn() { - return async ( - req: Request, - res: Response, - next: NextFunction - ) => { + return async (req: Request, res: Response, next: NextFunction) => { try { const userData = req.body; - const [token, user] = await authService.signIn( - userData.username, - userData.password - ); + const [token, user] = await userService.signIn(userData.username, userData.password); return res.cookie(authCookieName, token, authCookieOptions).json({ code: HttpStatusCode.OK, @@ -69,3 +56,70 @@ export function signOut() { }); }; } + +export function getUserData() { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const user = res.locals.user as IUserLocal; + + const userData = await userService.getUser(user); + + return res.json({ + code: HttpStatusCode.OK, + message: "User fetched successfully", + data: userData, + }); + } catch (err) { + next(new AppError(HttpStatusCode.BAD_REQUEST, "Failed to fetch user data")); + } + }; +} + +export function updateUser() { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const requestUserId = req.params?.id; + const user = res.locals.user as IUserLocal; + + if (user.id !== requestUserId) { + throw new Error("Only owners can update user data"); + } + + const userData = req.body; + const updatedUser: UserEntity = await userService.updateUser(user.id, userData); + + return res.json({ + code: HttpStatusCode.OK, + message: "User updated successfully", + data: updatedUser, + }); + } catch (err) { + next(new AppError(HttpStatusCode.BAD_REQUEST, "Failed to patch user data")); + } + }; +} + +export function updatePassword() { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const newPassword = req.body.newPassword; + const oldPassword = req.body.oldPassword; + const user = res.locals.user; + const requestedUserId = req.params.id; + + if (user.id !== requestedUserId) { + throw new Error("Invalid user"); + } + + const updatedUser = await userService.updatePassword(user.id, oldPassword, newPassword); + + return res.json({ + code: HttpStatusCode.OK, + message: "Password updated successfully", + data: updatedUser, + }); + } catch (err) { + next(new AppError(HttpStatusCode.BAD_REQUEST, "Failed to update password")); + } + }; +} diff --git a/src/features/user/user-repository.ts b/src/features/user/user-repository.ts index a6032d7..8b63aa1 100644 --- a/src/features/user/user-repository.ts +++ b/src/features/user/user-repository.ts @@ -1,9 +1,7 @@ import User from "./user-schema"; -import { SignUpUserData, UserEntity } from "./user-types"; +import { ISignUpUserData, UserEntity } from "./user-types"; -export async function findOneByUsername( - username: string | undefined -): Promise { +export async function findOneByUsername(username: string | undefined) { if (!username) return null; const existing = await User.findOne({ @@ -12,13 +10,16 @@ export async function findOneByUsername( if (!existing) return null; - return new UserEntity(existing); + return existing; } -export async function findOneByPassword( - username: string | undefined, - password: string | undefined -): Promise { +export async function findOneById(id: string) { + const existing = await User.findById(id); + if (!existing) return null; + return existing; +} + +export async function findOneByPassword(username: string | undefined, password: string | undefined) { if (!username || !password) return null; const existing = await User.findOne({ @@ -31,14 +32,11 @@ export async function findOneByPassword( if (!matching) return null; - return new UserEntity(existing); + return existing; } -export async function createUser( - userData: SignUpUserData -): Promise { +export async function createUser(userData: ISignUpUserData) { const user = new User(userData); await user.save(); - - return new UserEntity(user); + return user; } diff --git a/src/features/user/user-router.ts b/src/features/user/user-router.ts index b98f121..1bf8c85 100644 --- a/src/features/user/user-router.ts +++ b/src/features/user/user-router.ts @@ -2,7 +2,7 @@ import express, { Request, Response } from "express"; import * as userHandlers from "./user-handlers"; import authenticateToken from "../../middleware/auth-middleware"; import { validateBody } from "../../middleware/validator-middleware"; -import { userSignInSchema, userSignUpSchema } from "./user-validators"; +import { userPasswordSchema, userSignInSchema, userSignUpSchema, userUpdateSchema } from "./user-validators"; import { IJsonResponse } from "../../utils/json-response"; import { HttpStatusCode } from "../../utils/http-status-code"; @@ -19,5 +19,8 @@ router.all("/", (req: Request, res: Response) => { router.post("/signup", validateBody(userSignUpSchema), userHandlers.signUp()); router.post("/signin", validateBody(userSignInSchema), userHandlers.signIn()); router.delete("/signout", authenticateToken(), userHandlers.signOut()); +router.get("/:id", authenticateToken(), userHandlers.getUserData()); +router.patch("/:id", authenticateToken(), validateBody(userUpdateSchema), userHandlers.updateUser()); +router.patch("/:id/password", authenticateToken(), validateBody(userPasswordSchema), userHandlers.updatePassword()); export default router; diff --git a/src/features/user/user-schema.ts b/src/features/user/user-schema.ts index 9886fcd..2f5881e 100644 --- a/src/features/user/user-schema.ts +++ b/src/features/user/user-schema.ts @@ -35,9 +35,7 @@ userSchema.pre("save", function (next) { userSchema.pre("save", async function (next) { if (this.isModified("password")) { if (!noBlacklistedChars(this.password)) - throw new Error( - "Password should not contain whitespace or special symbols" - ); + throw new Error("Password should not contain whitespace or special symbols"); this.password = await bcrypt.hash(this.password, 10); } diff --git a/src/features/user/user-service.ts b/src/features/user/user-service.ts index a532a5c..a969dc4 100644 --- a/src/features/user/user-service.ts +++ b/src/features/user/user-service.ts @@ -1,11 +1,9 @@ import jwt from "jsonwebtoken"; import getConfig from "../../config/get-config"; -import { SignUpUserData, UserEntity } from "./user-types"; +import { IUserLocal, ISignUpUserData, UserEntity, IUpdateUserData } from "./user-types"; import * as userRepository from "./user-repository"; -export async function signUp( - userData: SignUpUserData -): Promise<[string, UserEntity]> { +export async function signUp(userData: ISignUpUserData): Promise<[string, UserEntity]> { const existing = await userRepository.findOneByUsername(userData.username); if (existing) { @@ -15,13 +13,10 @@ export async function signUp( const user = await userRepository.createUser(userData); const token = await createToken(user.id); - return [token, user]; + return [token, new UserEntity(user)]; } -export async function signIn( - username: string, - password: string -): Promise<[string, UserEntity]> { +export async function signIn(username: string, password: string): Promise<[string, UserEntity]> { const user = await userRepository.findOneByPassword(username, password); if (!user) { @@ -30,7 +25,7 @@ export async function signIn( const token = await createToken(user.id); - return [token, user]; + return [token, new UserEntity(user)]; } async function createToken(id: string) { @@ -44,3 +39,54 @@ async function createToken(id: string) { expiresIn: "7d", }); } + +export async function getUser(userData: IUserLocal): Promise { + const user = await userRepository.findOneById(userData.id); + + if (!user) { + throw new Error("Not found"); + } + + return new UserEntity(user); +} + +const ALLOWED_UPDATE_FIELDS = ["firstName", "lastName"]; + +export async function updateUser(userId: string, userData: Partial): Promise { + const user = await userRepository.findOneById(userId); + + if (user === null || user._id.toString() !== userId) { + throw new Error("Failed to update user"); + } + + for (const key of ALLOWED_UPDATE_FIELDS) { + if (userData[key]) { + user[key] = userData[key]; + } + } + + // mongoose will auto-update updatedAt field + await user.save(); + + return new UserEntity(user); +} + +export async function updatePassword(userId: string, oldPassword: string, newPassword: string) { + const user = await userRepository.findOneById(userId); + + if (!user) { + throw new Error("User does not exist"); + } + + const match = await user.comparePassword(oldPassword); + + if (!match) { + throw new Error("Incorrect password"); + } + + user.password = newPassword; + + await user.save(); + + return user; +} diff --git a/src/features/user/user-types.ts b/src/features/user/user-types.ts index 6b73c82..717c2b3 100644 --- a/src/features/user/user-types.ts +++ b/src/features/user/user-types.ts @@ -11,13 +11,23 @@ export interface IUser { comparePassword: (password: string) => Promise; } -export interface SignUpUserData { +export interface IUserLocal { + id: string; +} + +export interface ISignUpUserData { username: string; password: string; firstName: string; lastName: string; } +export interface IUpdateUserData { + password: string; + firstName: string; + lastName: string; +} + /** * Represents a Public User with limited information. */ diff --git a/src/features/user/user-validators.ts b/src/features/user/user-validators.ts index 071bc5e..60212f4 100644 --- a/src/features/user/user-validators.ts +++ b/src/features/user/user-validators.ts @@ -1,12 +1,24 @@ import Joi from "joi"; -import { SignUpUserData } from "./user-types"; +import { ISignUpUserData } from "./user-types"; export const userSignInSchema = Joi.object().keys({ username: Joi.string().trim().required(), password: Joi.string().trim().required(), }); -export const userSignUpSchema = Joi.object().keys({ +export const userPasswordSchema = Joi.object().keys({ + oldPassword: Joi.string().min(3).max(30).trim().required(), + newPassword: Joi.string().min(3).max(30).trim().required(), +}); + +export const userUpdateSchema = Joi.object() + .keys({ + firstName: Joi.string().alphanum().trim(), + lastName: Joi.string().alphanum().trim(), + }) + .min(1); + +export const userSignUpSchema = Joi.object().keys({ username: Joi.string().min(3).max(30).alphanum().trim().required(), password: Joi.string().min(3).max(30).trim().required(), firstName: Joi.string().alphanum().trim().required(), diff --git a/src/middleware/auth-middleware.ts b/src/middleware/auth-middleware.ts index e89852f..df1b23e 100644 --- a/src/middleware/auth-middleware.ts +++ b/src/middleware/auth-middleware.ts @@ -3,37 +3,28 @@ import { Request, Response, NextFunction } from "express"; import { AppError } from "../utils/app-error"; import { HttpStatusCode } from "../utils/http-status-code"; import getConfig from "../config/get-config"; +import { IUserLocal } from "../features/user/user-types"; -const authenticateTokenMiddleware = - () => (req: Request, res: Response, next: NextFunction) => { - const accessToken = req.cookies.jwt; - const config = getConfig(); +const authenticateTokenMiddleware = () => (req: Request, res: Response, next: NextFunction) => { + const accessToken = req.cookies.jwt; + const config = getConfig(); - if (!accessToken) { - return next(new AppError(HttpStatusCode.UNAUTHORIZED, "Unauthorized")); - } + if (!accessToken) { + return next(new AppError(HttpStatusCode.UNAUTHORIZED, "Unauthorized")); + } - if (!config.JWT_SECRET) { - return next( - new AppError( - HttpStatusCode.INTERNAL_SERVER_ERROR, - "Something went wrong" - ) - ); - } + if (!config.JWT_SECRET) { + return next(new AppError(HttpStatusCode.INTERNAL_SERVER_ERROR, "Something went wrong")); + } - jwt.verify( - accessToken, - config.JWT_SECRET, - (err: jwt.VerifyErrors | null, user: any) => { - if (err) { - return next(new AppError(HttpStatusCode.FORBIDDEN, "Forbidden")); - } + jwt.verify(accessToken, config.JWT_SECRET, (err: jwt.VerifyErrors | null, user: IUserLocal) => { + if (err) { + return next(new AppError(HttpStatusCode.FORBIDDEN, "Forbidden")); + } - res.locals.user = user; - next(); - } - ); - }; + res.locals.user = user; + next(); + }); +}; export default authenticateTokenMiddleware; diff --git a/src/middleware/validator-middleware.ts b/src/middleware/validator-middleware.ts index c8d2e2f..2e7a8de 100644 --- a/src/middleware/validator-middleware.ts +++ b/src/middleware/validator-middleware.ts @@ -4,17 +4,14 @@ import Joi from "joi"; import { HttpStatusCode } from "../utils/http-status-code"; import { AppError } from "../utils/app-error"; -export const validateBody = - (schema: Joi.Schema) => (req: Request, res: Response, next: NextFunction) => { - const { error } = Joi.compile(schema).validate(req.body); +export const validateBody = (schema: Joi.Schema) => (req: Request, res: Response, next: NextFunction) => { + const { error } = Joi.compile(schema).validate(req.body); - if (error) { - const errorMessage = error.details - .map((details) => details.message) - .join(", "); + if (error) { + const errorMessage = error.details.map((details) => details.message).join(", "); - return next(new AppError(HttpStatusCode.BAD_REQUEST, errorMessage)); - } + return next(new AppError(HttpStatusCode.BAD_REQUEST, errorMessage)); + } - return next(); - }; + return next(); +};