diff --git a/src/application.ts b/src/application.ts index e445c7e..cff7f3d 100644 --- a/src/application.ts +++ b/src/application.ts @@ -15,6 +15,8 @@ import { AirlineController } from "@/modules/airline/airline.controller"; import { AirlineMapper } from "@/modules/airline/airline.mapper"; import { AirportController } from "@/modules/airport/airport.controller"; import { AirportMapper } from "@/modules/airport/airport.mapper"; +import { ArticleController } from "@/modules/article/article.controller"; +import { ArticleMapper } from "@/modules/article/article.mapper"; import { CityController } from "@/modules/city/city.controller"; import { CityMapper } from "@/modules/city/city.mapper"; import { CountryController } from "@/modules/country/country.controller"; @@ -34,6 +36,8 @@ import { PackageMapper } from "@/modules/package/package.mapper"; import { PartnerController } from "@/modules/partner/partner.controller"; import { PartnerMapper } from "@/modules/partner/partner.mapper"; import { StaticController } from "@/modules/static/static.controller"; +import { TagController } from "@/modules/tag/tag.controller"; +import { TagMapper } from "@/modules/tag/tag.mapper"; import { TransportationClassController } from "@/modules/transportation-class/transportation-class.controller"; import { TransportationClassMapper } from "@/modules/transportation-class/transportation-class.mapper"; import { TransportationController } from "@/modules/transportation/transportation.controller"; @@ -96,6 +100,8 @@ export class Application { ); const adminMapper = new AdminMapper(); const orderMapper = new OrderMapper(packageMapper, partnerMapper); + const tagMapper = new TagMapper(); + const articleMapper = new ArticleMapper(tagMapper); const countryRouter = new CountryController( countryMapper, @@ -164,6 +170,15 @@ export class Application { const whatsAppRouter = new WhatsAppController( this._whatsAppService, ).buildRouter(); + const tagRouter = new TagController( + tagMapper, + this._jwtService, + ).buildRouter(); + const articleRouter = new ArticleController( + articleMapper, + this._fileStorage, + this._jwtService, + ).buildRouter(); this._app.use("/countries", countryRouter); this._app.use("/cities", cityRouter); @@ -181,6 +196,8 @@ export class Application { this._app.use("/orders", orderRouter); this._app.use("/statics", staticRouter); this._app.use("/whatsapp", whatsAppRouter); + this._app.use("/tags", tagRouter); + this._app.use("/articles", articleRouter); } public initializeErrorHandlers() {} diff --git a/src/database/entities/article.entity.ts b/src/database/entities/article.entity.ts new file mode 100644 index 0000000..ca6a8fd --- /dev/null +++ b/src/database/entities/article.entity.ts @@ -0,0 +1,47 @@ +import { Tag } from "@/database/entities/tag.entity"; +import { + Collection, + Entity, + ManyToMany, + PrimaryKey, + Property, + Unique, +} from "@mikro-orm/core"; + +@Entity() +export class Article { + @PrimaryKey({ type: "varchar", length: 30 }) + id!: string; + + @Property({ type: "varchar", length: 100 }) + thumbnail!: string; + + @Property({ type: "varchar", length: 100 }) + @Unique() + slug!: string; + + @Property({ type: "varchar", length: 100 }) + title!: string; + + @ManyToMany(() => Tag) + tags = new Collection(this); + + @Property({ type: "text" }) + content!: string; + + @Property({ type: "integer" }) + views!: number; + + @Property({ + type: "timestamp", + onCreate: () => new Date(), + }) + createdAt!: Date; + + @Property({ + type: "timestamp", + onCreate: () => new Date(), + onUpdate: () => new Date(), + }) + updatedAt!: Date; +} diff --git a/src/database/entities/tag.entity.ts b/src/database/entities/tag.entity.ts new file mode 100644 index 0000000..16ba3e3 --- /dev/null +++ b/src/database/entities/tag.entity.ts @@ -0,0 +1,27 @@ +import { Entity, PrimaryKey, Property, Unique } from "@mikro-orm/core"; + +@Entity() +export class Tag { + @PrimaryKey({ type: "varchar", length: 30 }) + id!: string; + + @Property({ type: "varchar", length: 100 }) + @Unique() + slug!: string; + + @Property({ type: "varchar", length: 100 }) + name!: string; + + @Property({ + type: "timestamp", + onCreate: () => new Date(), + }) + createdAt!: Date; + + @Property({ + type: "timestamp", + onCreate: () => new Date(), + onUpdate: () => new Date(), + }) + updatedAt!: Date; +} diff --git a/src/database/enums/admin-permission.enum.ts b/src/database/enums/admin-permission.enum.ts index 9e5a439..7d91642 100644 --- a/src/database/enums/admin-permission.enum.ts +++ b/src/database/enums/admin-permission.enum.ts @@ -55,4 +55,12 @@ export enum AdminPermission { createPartner = "partner:create", updatePartner = "partner:update", deletePartner = "partner:delete", + // Tag permissions + createTag = "tag:create", + updateTag = "tag:update", + deleteTag = "tag:delete", + // Article permissions + createArticle = "article:create", + updateArticle = "article:update", + deleteArticle = "article:delete", } diff --git a/src/modules/article/article.controller.ts b/src/modules/article/article.controller.ts new file mode 100644 index 0000000..8b5a218 --- /dev/null +++ b/src/modules/article/article.controller.ts @@ -0,0 +1,272 @@ +import { Controller } from "@/common/controller"; +import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware"; +import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware"; +import { paginationQuerySchema } from "@/common/schemas"; +import type { AbstractFileStorage } from "@/common/services/file-storage/abstract.file-storage"; +import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service"; +import type { + ErrorResponse, + ListResponse, + SingleResponse, +} from "@/common/types"; +import { Article } from "@/database/entities/article.entity"; +import { Tag } from "@/database/entities/tag.entity"; +import { AdminPermission } from "@/database/enums/admin-permission.enum"; +import { orm } from "@/database/orm"; +import type { ArticleMapper } from "@/modules/article/article.mapper"; +import { + articleParamsSchema, + articleRequestSchema, +} from "@/modules/article/article.schemas"; +import type { ArticleResponse } from "@/modules/article/article.types"; +import { Router, type Request, type Response } from "express"; +import slugify from "slugify"; +import { ulid } from "ulid"; + +export class ArticleController extends Controller { + public constructor( + private readonly mapper: ArticleMapper, + private readonly fileStorage: AbstractFileStorage, + private readonly jwtService: AbstractJwtService, + ) { + super(); + } + + async create(req: Request, res: Response) { + const parseBodyResult = articleRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const thumbnailFile = await this.fileStorage.storeFile( + Buffer.from(body.thumbnail, "base64"), + ); + + const article = orm.em.create(Article, { + id: ulid(), + thumbnail: thumbnailFile.name, + slug: slugify(body.title, { lower: true }), + title: body.title, + content: body.content, + views: 0, + createdAt: new Date(), + updatedAt: new Date(), + }); + + for (const [index, tagId] of body.tag_ids.entries()) { + const tag = await orm.em.findOne(Tag, { + id: tagId, + }); + if (!tag) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `tag_ids.${index}`, + location: "body", + message: "Tag not found.", + }, + ], + } satisfies ErrorResponse); + } + + article.tags.add(tag); + } + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapEntityToResponse(article), + errors: null, + } satisfies SingleResponse); + } + + async list(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const count = await orm.em.count(Article); + + const articles = await orm.em.find( + Article, + {}, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + }, + ); + + return res.status(200).json({ + data: articles.map(this.mapper.mapEntityToResponse.bind(this.mapper)), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async view(req: Request, res: Response) { + const parseParamsResult = articleParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const article = await orm.em.findOne(Article, { slug: params.slug }); + if (!article) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "slug", + location: "params", + message: "Article not found.", + }, + ], + } satisfies ErrorResponse); + } + + article.views += 1; + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(article), + errors: null, + } satisfies SingleResponse); + } + + async update(req: Request, res: Response) { + const parseParamsResult = articleParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = articleRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const article = await orm.em.findOne(Article, { slug: params.slug }); + if (!article) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "slug", + location: "params", + message: "Article not found.", + }, + ], + } satisfies ErrorResponse); + } + + await this.fileStorage.storeFile( + Buffer.from(body.thumbnail, "base64"), + article.thumbnail, + ); + + article.slug = slugify(body.title, { lower: true }); + article.title = body.title; + article.content = body.content; + article.updatedAt = new Date(); + + article.tags.removeAll(); + for (const [index, tagId] of body.tag_ids.entries()) { + const tag = await orm.em.findOne(Tag, { + id: tagId, + }); + if (!tag) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `tag_ids.${index}`, + location: "body", + message: "Tag not found.", + }, + ], + } satisfies ErrorResponse); + } + + article.tags.add(tag); + } + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(article), + errors: null, + } satisfies SingleResponse); + } + + async delete(req: Request, res: Response) { + const parseParamsResult = articleParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const article = await orm.em.findOne( + Article, + { slug: params.slug }, + { + populate: ["*"], + }, + ); + if (!article) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "slug", + location: "params", + message: "Article not found.", + }, + ], + } satisfies ErrorResponse); + } + + await this.fileStorage.removeFile(article.thumbnail); + + await orm.em.removeAndFlush(article); + + return res.status(204).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.post( + "/", + createOrmContextMiddleware, + isAdminMiddleware(this.jwtService, [AdminPermission.createArticle]), + this.create.bind(this), + ); + router.get("/", createOrmContextMiddleware, this.list.bind(this)); + router.get("/:id", createOrmContextMiddleware, this.view.bind(this)); + router.put( + "/:id", + createOrmContextMiddleware, + isAdminMiddleware(this.jwtService, [AdminPermission.updateArticle]), + this.update.bind(this), + ); + router.delete( + "/:id", + createOrmContextMiddleware, + isAdminMiddleware(this.jwtService, [AdminPermission.deleteArticle]), + this.delete.bind(this), + ); + + return router; + } +} diff --git a/src/modules/article/article.mapper.ts b/src/modules/article/article.mapper.ts new file mode 100644 index 0000000..f36b9e7 --- /dev/null +++ b/src/modules/article/article.mapper.ts @@ -0,0 +1,23 @@ +import type { Article } from "@/database/entities/article.entity"; +import type { ArticleResponse } from "@/modules/article/article.types"; +import type { TagMapper } from "@/modules/tag/tag.mapper"; + +export class ArticleMapper { + public constructor(private readonly tagMapper: TagMapper) {} + + public mapEntityToResponse(article: Article): ArticleResponse { + return { + id: article.id, + thumbnail: article.thumbnail, + slug: article.slug, + title: article.title, + tags: article.tags.map( + this.tagMapper.mapEntityToResponse.bind(this.tagMapper), + ), + content: article.content, + views: article.views, + created_at: article.createdAt, + updated_at: article.updatedAt, + }; + } +} diff --git a/src/modules/article/article.schemas.ts b/src/modules/article/article.schemas.ts new file mode 100644 index 0000000..abb8f55 --- /dev/null +++ b/src/modules/article/article.schemas.ts @@ -0,0 +1,29 @@ +import z from "zod"; + +export const articleRequestSchema = z.object({ + thumbnail: z.base64("Must be base64 string.").nonempty("Must not empty."), + title: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(100, "Max 100 characters."), + tag_ids: z + .array( + z + .string("Must be string.") + .nonempty("Must not empty.") + .max(200, "Max 200 characters."), + "Must be array.", + ) + .nonempty("Must not empty."), + content: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(100, "Max 100 characters."), +}); + +export const articleParamsSchema = z.object({ + slug: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(100, "Max 100 characters."), +}); diff --git a/src/modules/article/article.types.ts b/src/modules/article/article.types.ts new file mode 100644 index 0000000..e7b36f5 --- /dev/null +++ b/src/modules/article/article.types.ts @@ -0,0 +1,22 @@ +import type { + articleParamsSchema, + articleRequestSchema, +} from "@/modules/article/article.schemas"; +import type { TagResponse } from "@/modules/tag/tag.types"; +import z from "zod"; + +export type ArticleRequest = z.infer; + +export type ArticleParams = z.infer; + +export type ArticleResponse = { + id: string; + thumbnail: string; + slug: string; + title: string; + tags: TagResponse[]; + content: string; + views: number; + created_at: Date; + updated_at: Date; +}; diff --git a/src/modules/package/package.controller.ts b/src/modules/package/package.controller.ts index c451101..cdfa8f3 100644 --- a/src/modules/package/package.controller.ts +++ b/src/modules/package/package.controller.ts @@ -82,7 +82,7 @@ export class PackageController extends Controller { const package_ = orm.em.create(Package, { id: ulid(), - slug: slugify(body.name), + slug: slugify(body.name, { lower: true }), name: body.name, type: body.type, class: body.class, @@ -261,7 +261,7 @@ export class PackageController extends Controller { package_.thumbnail, ); - package_.slug = slugify(body.name); + package_.slug = slugify(body.name, { lower: true }); package_.name = body.name; package_.type = body.type; package_.class = body.class; diff --git a/src/modules/tag/tag.controller.ts b/src/modules/tag/tag.controller.ts new file mode 100644 index 0000000..16a0092 --- /dev/null +++ b/src/modules/tag/tag.controller.ts @@ -0,0 +1,206 @@ +import { Controller } from "@/common/controller"; +import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware"; +import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware"; +import { paginationQuerySchema } from "@/common/schemas"; +import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service"; +import type { + ErrorResponse, + ListResponse, + SingleResponse, +} from "@/common/types"; +import { Tag } from "@/database/entities/tag.entity"; +import { AdminPermission } from "@/database/enums/admin-permission.enum"; +import { orm } from "@/database/orm"; +import type { TagMapper } from "@/modules/tag/tag.mapper"; +import { tagParamsSchema, tagRequestSchema } from "@/modules/tag/tag.schemas"; +import type { TagResponse } from "@/modules/tag/tag.types"; +import { Router, type Request, type Response } from "express"; +import slugify from "slugify"; +import { ulid } from "ulid"; + +export class TagController extends Controller { + public constructor( + private readonly mapper: TagMapper, + private readonly jwtService: AbstractJwtService, + ) { + super(); + } + + async create(req: Request, res: Response) { + const parseBodyResult = tagRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const tag = orm.em.create(Tag, { + id: ulid(), + slug: slugify(body.name, { lower: true }), + name: body.name, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapEntityToResponse(tag), + errors: null, + } satisfies SingleResponse); + } + + async list(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const count = await orm.em.count(Tag); + + const countries = await orm.em.find( + Tag, + {}, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + }, + ); + + return res.status(200).json({ + data: countries.map(this.mapper.mapEntityToResponse.bind(this.mapper)), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async view(req: Request, res: Response) { + const parseParamsResult = tagParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const tag = await orm.em.findOne(Tag, { id: params.id }); + if (!tag) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Tag not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(tag), + errors: null, + } satisfies SingleResponse); + } + + async update(req: Request, res: Response) { + const parseParamsResult = tagParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = tagRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const tag = await orm.em.findOne(Tag, { id: params.id }); + if (!tag) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Tag not found.", + }, + ], + } satisfies ErrorResponse); + } + + tag.slug = slugify(body.name, { lower: true }); + tag.name = body.name; + tag.updatedAt = new Date(); + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(tag), + errors: null, + } satisfies SingleResponse); + } + + async delete(req: Request, res: Response) { + const parseParamsResult = tagParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const tag = await orm.em.findOne( + Tag, + { id: params.id }, + { + populate: ["*"], + }, + ); + if (!tag) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Tag not found.", + }, + ], + } satisfies ErrorResponse); + } + + await orm.em.removeAndFlush(tag); + + return res.status(204).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.post( + "/", + createOrmContextMiddleware, + isAdminMiddleware(this.jwtService, [AdminPermission.createTag]), + this.create.bind(this), + ); + router.get("/", createOrmContextMiddleware, this.list.bind(this)); + router.get("/:id", createOrmContextMiddleware, this.view.bind(this)); + router.put( + "/:id", + createOrmContextMiddleware, + isAdminMiddleware(this.jwtService, [AdminPermission.updateTag]), + this.update.bind(this), + ); + router.delete( + "/:id", + createOrmContextMiddleware, + isAdminMiddleware(this.jwtService, [AdminPermission.deleteTag]), + this.delete.bind(this), + ); + + return router; + } +} diff --git a/src/modules/tag/tag.mapper.ts b/src/modules/tag/tag.mapper.ts new file mode 100644 index 0000000..8cbb820 --- /dev/null +++ b/src/modules/tag/tag.mapper.ts @@ -0,0 +1,14 @@ +import type { Tag } from "@/database/entities/tag.entity"; +import type { TagResponse } from "@/modules/tag/tag.types"; + +export class TagMapper { + public mapEntityToResponse(tag: Tag): TagResponse { + return { + id: tag.id, + slug: tag.slug, + name: tag.name, + created_at: tag.createdAt, + updated_at: tag.updatedAt, + }; + } +} diff --git a/src/modules/tag/tag.schemas.ts b/src/modules/tag/tag.schemas.ts new file mode 100644 index 0000000..1090d5c --- /dev/null +++ b/src/modules/tag/tag.schemas.ts @@ -0,0 +1,15 @@ +import z from "zod"; + +export const tagRequestSchema = z.object({ + name: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(100, "Max 100 characters."), +}); + +export const tagParamsSchema = z.object({ + id: z + .ulid("Must be ulid string.") + .nonempty("Must not empty.") + .max(30, "Max 30 characters."), +}); diff --git a/src/modules/tag/tag.types.ts b/src/modules/tag/tag.types.ts new file mode 100644 index 0000000..0715beb --- /dev/null +++ b/src/modules/tag/tag.types.ts @@ -0,0 +1,17 @@ +import type { + tagParamsSchema, + tagRequestSchema, +} from "@/modules/tag/tag.schemas"; +import z from "zod"; + +export type TagRequest = z.infer; + +export type TagParams = z.infer; + +export type TagResponse = { + id: string; + slug: string; + name: string; + created_at: Date; + updated_at: Date; +};