diff --git a/backend/src/constants/response-message.constant.ts b/backend/src/constants/response-message.constant.ts index 91122e1..2d886a6 100644 --- a/backend/src/constants/response-message.constant.ts +++ b/backend/src/constants/response-message.constant.ts @@ -23,5 +23,12 @@ export const HttpResponse = { SAME_USERNAME: "Cannot change to old username", RESOURCE_FOUND: "Resource found.", RESOURCE_UPDATED: "Resource updated.", - PROFILE_PICTURE_CHANGED: "Profile picture changed successfully" + PROFILE_PICTURE_CHANGED: "Profile picture changed successfully", + + BLOG_NOT_FOUND: "Blog not found", + INVALID_ID: "Invalid ID format", + REQUIRED_AUTHOR_ID: "Author ID is required", + REQUIRED_AUTHOR_NAME: "Author name is required", + REQUIRED_TITLE: "Blog title is required", + REQUIRED_CONTENT: "Blog content is required", }; diff --git a/backend/src/controllers/implementation/blog.controller.ts b/backend/src/controllers/implementation/blog.controller.ts new file mode 100644 index 0000000..5034948 --- /dev/null +++ b/backend/src/controllers/implementation/blog.controller.ts @@ -0,0 +1,87 @@ +import { NextFunction, Request, Response } from "express"; +import { IBlogController } from "../interface/IBlogController"; +import { IBlogService } from "@/services/interface/IBlogService"; +import { Types } from "mongoose"; +import { HttpStatus } from "@/constants"; +import { CreateBlogRequestType } from "@/schema/create-blog.schema"; +import { EditBlogRequestType } from "@/schema"; + +export class BlogController implements IBlogController { + constructor(private blogService: IBlogService) {} + + async createBlog( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const blogData = req.body as CreateBlogRequestType; + const { id } = JSON.parse(req.headers["x-user-payload"] as string); + const createdBlog = await this.blogService.createBlog({ + ...blogData, + authorId: id, + }); + res.status(HttpStatus.CREATED).json(createdBlog); + } catch (error) { + next(error); + } + } + + async getBlogById( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const blogId = new Types.ObjectId(req.params.id); + const blog = await this.blogService.getBlogById(blogId); + res.status(HttpStatus.OK).json(blog); + } catch (error) { + next(error); + } + } + + async getAllBlogs( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const blogs = await this.blogService.getAllBlogs(); + res.status(HttpStatus.OK).json(blogs); + } catch (error) { + next(error); + } + } + + async updateBlog( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const blogId = new Types.ObjectId(req.params.id); + const updateData = req.body as EditBlogRequestType; + const updatedBlog = await this.blogService.updateBlog(blogId, updateData); + + res.status(HttpStatus.OK).json(updatedBlog); + } catch (error) { + next(error); + } + } + + async deleteBlog( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const blogId = new Types.ObjectId(req.params.id); + const deletedBlog = await this.blogService.deleteBlog(blogId); + + res.status(HttpStatus.OK).json(deletedBlog); + } catch (error) { + next(error); + } + } +} diff --git a/backend/src/controllers/interface/IBlogController.ts b/backend/src/controllers/interface/IBlogController.ts new file mode 100644 index 0000000..67b0570 --- /dev/null +++ b/backend/src/controllers/interface/IBlogController.ts @@ -0,0 +1,9 @@ +import { NextFunction, Request, Response } from "express"; + +export interface IBlogController { + createBlog(req: Request, res: Response, next: NextFunction): Promise; + getBlogById(req: Request, res: Response, next: NextFunction): Promise; + getAllBlogs(req: Request, res: Response, next: NextFunction): Promise; + updateBlog(req: Request, res: Response, next: NextFunction): Promise; + deleteBlog(req: Request, res: Response, next: NextFunction): Promise; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index ce421b3..a884200 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,8 +4,6 @@ import cors from "cors"; import dotenv from "dotenv"; import cookieParser from "cookie-parser"; - - dotenv.config(); //* validating all the env @@ -23,14 +21,17 @@ import { notFoundHandler } from "./middlewares/not-found.middleware"; import { errorHandler } from "./middlewares/error.middlware"; import { env } from "./configs/env.config"; import profileRouter from "./routers/profile.router"; +import blogRouter from "./routers/blog.router"; const app = express(); -app.use(cors({ +app.use( + cors({ origin: env.CLIENT_ORIGIN, credentials: true, methods: ["GET", "POST", "PUT", "DELETE"], allowedHeaders: ["Content-Type", "Authorization"], -})); + }) +); app.use(cookieParser()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -40,6 +41,7 @@ connectRedis(); app.use("/api/auth", authRouter); app.use("/api/profile", profileRouter); +app.use("/api/blog", blogRouter); app.use(notFoundHandler); app.use(errorHandler); diff --git a/backend/src/models/implementation/blog.model.ts b/backend/src/models/implementation/blog.model.ts new file mode 100644 index 0000000..143e161 --- /dev/null +++ b/backend/src/models/implementation/blog.model.ts @@ -0,0 +1,47 @@ +import { model, Schema, Document, Types } from "mongoose"; +import { IBlog } from "shared/types"; + +export interface IBlogModel extends Document, Omit { + authorId: Types.ObjectId; +}; +const blogSchema = new Schema( + { + authorId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + authorName: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + thumbnail: { + type: String, + }, + content: { + type: String, + required: true, + }, + tags: { + type: [String], + }, + likes: { + type: Number, + default: 0, + }, + comments: { + type: Number, + default: 0, + }, + }, + { + timestamps: { createdAt: "created_at", updatedAt: "updated_at" }, + } +); + +const Blog = model("Blog", blogSchema); +export default Blog; diff --git a/backend/src/repositories/implementation/blog.repository.ts b/backend/src/repositories/implementation/blog.repository.ts new file mode 100644 index 0000000..971a1e0 --- /dev/null +++ b/backend/src/repositories/implementation/blog.repository.ts @@ -0,0 +1,37 @@ +import { BaseRepository } from "../base.repository"; +import { IBlogRepository } from "../interface/IBlogRepository"; +import Blog, { IBlogModel } from "@/models/implementation/blog.model"; +import { Types } from "mongoose"; + +export class BlogRepository + extends BaseRepository + implements IBlogRepository +{ + constructor() { + super(Blog); + } + + async createBlog(blogData: Partial): Promise { + const newBlog = await this.create(blogData); + return newBlog; + } + + async findBlogById(blogId: Types.ObjectId): Promise { + return this.findById(blogId); + } + + async findAllBlogs(): Promise { + return this.findAll(); + } + + async updateBlog( + blogId: Types.ObjectId, + updateData: Partial + ): Promise { + return this.update(blogId, updateData); + } + + async deleteBlog(blogId: Types.ObjectId): Promise { + return this.delete(blogId); + } +} diff --git a/backend/src/repositories/interface/IBlogRepository.ts b/backend/src/repositories/interface/IBlogRepository.ts new file mode 100644 index 0000000..71d5f36 --- /dev/null +++ b/backend/src/repositories/interface/IBlogRepository.ts @@ -0,0 +1,13 @@ +import { IBlogModel } from "@/models/implementation/blog.model"; +import { Types } from "mongoose"; + +export interface IBlogRepository { + createBlog(blogData: Partial): Promise; + findBlogById(blogId: Types.ObjectId): Promise; + findAllBlogs(): Promise; + updateBlog( + blogId: Types.ObjectId, + updateData: Partial + ): Promise; + deleteBlog(blogId: Types.ObjectId): Promise; +} diff --git a/backend/src/routers/blog.router.ts b/backend/src/routers/blog.router.ts new file mode 100644 index 0000000..ae3baa3 --- /dev/null +++ b/backend/src/routers/blog.router.ts @@ -0,0 +1,49 @@ +import { Router } from "express"; +import { validate } from "@/middlewares/validate.middleware"; +import { BlogRepository } from "@/repositories/implementation/blog.repository"; +import { BlogService } from "@/services/implementation/blog.service"; +import { BlogController } from "@/controllers/implementation/blog.controller"; +import { UserRepository } from "@/repositories/implementation/user.repository"; +import { createBlogSchema, editBlogSchema } from "@/schema"; +import authenticate from "@/middlewares/verify-token.middleware"; + +const router = Router(); + +const blogRepository = new BlogRepository(); +const userRepository = new UserRepository(); +const blogService = new BlogService(blogRepository, userRepository); +const blogController = new BlogController(blogService); + +router.post( + "/", + validate(createBlogSchema), + authenticate("user"), + blogController.createBlog.bind(blogController) +); + +router.get( + "/", + authenticate("user"), + blogController.getAllBlogs.bind(blogController) +); + +router.get( + "/:id", + authenticate("user"), + blogController.getBlogById.bind(blogController) +); + +router.put( + "/:id", + validate(editBlogSchema), + authenticate("user"), + blogController.updateBlog.bind(blogController) +); + +router.delete( + "/:id", + authenticate("user"), + blogController.deleteBlog.bind(blogController) +); + +export default router; diff --git a/backend/src/schema/create-blog.schema.ts b/backend/src/schema/create-blog.schema.ts new file mode 100644 index 0000000..e054c61 --- /dev/null +++ b/backend/src/schema/create-blog.schema.ts @@ -0,0 +1,19 @@ +import { HttpResponse } from "@/constants/response-message.constant"; +import { z } from "zod"; + +export const createBlogSchema = z + .object({ + title: z + .string() + .min(1, HttpResponse.REQUIRED_TITLE) + .max(200, "Title must not exceed 200 characters"), + content: z + .string() + .min(1, HttpResponse.REQUIRED_CONTENT) + .max(5000, "Content must not exceed 5000 characters"), + thumbnail: z.string().url("Thumbnail must be a valid URL").optional(), + tags: z.array(z.string()).optional(), + }) + .strict(); + +export type CreateBlogRequestType = z.infer; diff --git a/backend/src/schema/edit-blog-schema.ts b/backend/src/schema/edit-blog-schema.ts new file mode 100644 index 0000000..0796935 --- /dev/null +++ b/backend/src/schema/edit-blog-schema.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; +import { createBlogSchema } from "./create-blog.schema"; + +export const editBlogSchema = createBlogSchema.partial(); + +export type EditBlogRequestType = z.infer; diff --git a/backend/src/schema/index.ts b/backend/src/schema/index.ts index 78b9a7f..13d905b 100644 --- a/backend/src/schema/index.ts +++ b/backend/src/schema/index.ts @@ -1,7 +1,9 @@ -export { verifyOtpSchema } from "./verify-otp.schema" -export { signinSchema } from "./signin.schema" -export { signupSchema } from "./signup-schema" -export { editUsernameSchema } from "./username.schema" -export { updateProfileSchema } from "./update-profile.schema" -export { resetPasswordSchema } from "./reset-pass.schema" -export { verifyEmailSchema } from "./forgot-pass.schema" \ No newline at end of file +export { verifyOtpSchema } from "./verify-otp.schema"; +export { signinSchema } from "./signin.schema"; +export { signupSchema } from "./signup-schema"; +export { editUsernameSchema } from "./username.schema"; +export { updateProfileSchema } from "./update-profile.schema"; +export { resetPasswordSchema } from "./reset-pass.schema"; +export { verifyEmailSchema } from "./forgot-pass.schema"; +export { CreateBlogRequestType, createBlogSchema } from "./create-blog.schema"; +export { EditBlogRequestType, editBlogSchema } from "./edit-blog-schema"; diff --git a/backend/src/schema/username.schema.ts b/backend/src/schema/username.schema.ts index f6f0b26..e13155a 100644 --- a/backend/src/schema/username.schema.ts +++ b/backend/src/schema/username.schema.ts @@ -1,7 +1,4 @@ - -import {z} from 'zod' +import { z } from "zod"; export const editUsernameSchema = z.object({ - username: z - .string() - .min(3, "Username must be at least 3 characters long") -}) + username: z.string().min(3, "Username must be at least 3 characters long"), +}); diff --git a/backend/src/services/implementation/blog.service.ts b/backend/src/services/implementation/blog.service.ts new file mode 100644 index 0000000..9b6b4b9 --- /dev/null +++ b/backend/src/services/implementation/blog.service.ts @@ -0,0 +1,70 @@ +import { HttpResponse, HttpStatus } from "@/constants"; +import { IBlogService } from "../interface/IBlogService"; +import { IBlogModel } from "@/models/implementation/blog.model"; +import { BlogRepository } from "@/repositories/implementation/blog.repository"; +import { createHttpError } from "@/utils"; +import { Types } from "mongoose"; +import { IUserRepository } from "@/repositories/interface/IUserRepository"; +import { IBlogRepository } from "@/repositories/interface/IBlogRepository"; + +export class BlogService implements IBlogService { + private blogRepository: IBlogRepository; + private userRepository: IUserRepository; + + constructor(blogRepository: BlogRepository, userRepository: IUserRepository) { + this.blogRepository = blogRepository; + this.userRepository = userRepository; + } + + async createBlog(blogData: Partial): Promise { + const authorId = blogData.authorId; + if (!authorId) { + throw createHttpError( + HttpStatus.NOT_FOUND, + HttpResponse.REQUIRED_AUTHOR_ID + ); + } + + const author = await this.userRepository.findUserById(authorId.toString()); + if (!author) { + throw createHttpError(HttpStatus.NOT_FOUND, HttpResponse.USER_NOT_FOUND); + } + const authorName = author.username; + return this.blogRepository.createBlog({ ...blogData, authorName }); + } + + async getBlogById(blogId: Types.ObjectId): Promise { + const blog = await this.blogRepository.findBlogById(blogId); + if (!blog) { + throw createHttpError(HttpStatus.NOT_FOUND, HttpResponse.BLOG_NOT_FOUND); + } + return blog; + } + + async getAllBlogs(): Promise { + return this.blogRepository.findAllBlogs(); + } + + async updateBlog( + blogId: Types.ObjectId, + updateData: Partial + ): Promise { + const updatedBlog = await this.blogRepository.updateBlog( + blogId, + updateData + ); + console.log("updateData", updateData); + if (!updatedBlog) { + throw createHttpError(HttpStatus.NOT_FOUND, HttpResponse.BLOG_NOT_FOUND); + } + return updatedBlog; + } + + async deleteBlog(blogId: Types.ObjectId): Promise { + const deletedBlog = await this.blogRepository.deleteBlog(blogId); + if (!deletedBlog) { + throw createHttpError(HttpStatus.NOT_FOUND, HttpResponse.BLOG_NOT_FOUND); + } + return deletedBlog; + } +} diff --git a/backend/src/services/interface/IBlogService.ts b/backend/src/services/interface/IBlogService.ts new file mode 100644 index 0000000..3b21251 --- /dev/null +++ b/backend/src/services/interface/IBlogService.ts @@ -0,0 +1,25 @@ +// import { IBlogModel } from "@/models/implementation/blog.model"; +// import { ICreateBlogRequestDTO } from "shared/types"; + +// export interface IBlogService { +// create(data: ICreateBlogRequestDTO): Promise; +// createBlog(blogData: any): Promise; +// getBlogById(blogId: string): Promise; +// getAllBlogs(): Promise; +// updateBlog(blogId: string, updateData: any): Promise; +// deleteBlog(blogId: string): Promise; +// } + +import { Types } from "mongoose"; +import { IBlogModel } from "@/models/implementation/blog.model"; + +export interface IBlogService { + createBlog(blogData: Partial): Promise; + getBlogById(blogId: Types.ObjectId): Promise; + getAllBlogs(): Promise; + updateBlog( + blogId: Types.ObjectId, + updateData: Partial + ): Promise; + deleteBlog(blogId: Types.ObjectId): Promise; +} diff --git a/backend/src/utils/cloudinary.util.ts b/backend/src/utils/cloudinary.util.ts index 8f6c465..a884e4d 100644 --- a/backend/src/utils/cloudinary.util.ts +++ b/backend/src/utils/cloudinary.util.ts @@ -83,9 +83,9 @@ export const isCloudinaryUrl = (url: string): boolean => { export const deleteFromCloudinary = (publicId: string): Promise => { return new Promise((resolve, reject) => { - cloudinary.uploader.destroy(publicId, (error, result) => { + cloudinary.uploader.destroy(publicId, (error) => { if (error) return reject(error); resolve(); }); }); -}; \ No newline at end of file +}; diff --git a/backend/src/utils/send-email.util.ts b/backend/src/utils/send-email.util.ts index 8056a29..f904ae9 100644 --- a/backend/src/utils/send-email.util.ts +++ b/backend/src/utils/send-email.util.ts @@ -1,6 +1,4 @@ - -import { transporter } from "@/configs/mail.config"; - +import { transporter } from "@/configs"; import { env } from "../configs/env.config"; export const sendOtpEmail = async (email: string, otp: string) => { @@ -42,16 +40,13 @@ export const sendResetPasswordEmail = async (email: string, token: string) => {

If you did not request this, you can ignore this email.


~ Inker

- ` + `, }; const info = await transporter.sendMail(mailOptions); console.log("Password reset email sent successfully", info.response); } catch (error) { - console.error("Error sendResetPasswordEmail",error); + console.error("Error sendResetPasswordEmail", error); throw new Error("Error sending reset pass email"); } }; - - - diff --git a/shared/types/index.ts b/shared/types/index.ts index ba83c2e..030a458 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -1,16 +1,38 @@ export interface IUser { - _id: string; - username: string; - name: string; - email: string; - password: string; - status: "active" | "blocked"; - role: "user" | "moderator"; - bio: string; - profilePicture?: string; - socialLinks: { type: string; url: string }[]; - resume?: string; - dateOfBirth?: Date; - createdAt: Date; - updatedAt: Date; -} \ No newline at end of file + _id: string; + username: string; + name: string; + email: string; + password: string; + status: "active" | "blocked"; + role: "user" | "moderator"; + bio: string; + profilePicture?: string; + socialLinks: { type: string; url: string }[]; + resume?: string; + dateOfBirth?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface IBlog { + _id: string; + authorId: string; + authorName: string; + title: string; + thumbnail: string; + content: string; + tags: string[]; + likes: number; + comments: number; + createdAt: Date; + updatedAt: Date; +} + +export interface ICreateBlogRequestDTO { + title: string; + thumbnail: string; + content: string; + authorName: string; + authorId: string; +} diff --git a/shared/types/reponse.ts b/shared/types/reponse.ts new file mode 100644 index 0000000..e69de29