From 0887b7c94be7add183f1fa6b1349fe3683024e47 Mon Sep 17 00:00:00 2001 From: ItsMalma Date: Mon, 8 Dec 2025 10:49:28 +0700 Subject: [PATCH] add testimony api --- src/application.ts | 8 + src/database/entities/testimony.entity.ts | 29 +++ src/database/enums/admin-permission.enum.ts | 4 + src/modules/testimony/testimony.controller.ts | 210 ++++++++++++++++++ src/modules/testimony/testimony.mapper.ts | 15 ++ src/modules/testimony/testimony.schemas.ts | 24 ++ src/modules/testimony/testimony.types.ts | 18 ++ 7 files changed, 308 insertions(+) create mode 100644 src/database/entities/testimony.entity.ts create mode 100644 src/modules/testimony/testimony.controller.ts create mode 100644 src/modules/testimony/testimony.mapper.ts create mode 100644 src/modules/testimony/testimony.schemas.ts create mode 100644 src/modules/testimony/testimony.types.ts diff --git a/src/application.ts b/src/application.ts index cff7f3d..5fcf3b6 100644 --- a/src/application.ts +++ b/src/application.ts @@ -38,6 +38,8 @@ 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 { TestimonyController } from "@/modules/testimony/testimony.controller"; +import { TestimonyMapper } from "@/modules/testimony/testimony.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"; @@ -100,6 +102,7 @@ export class Application { ); const adminMapper = new AdminMapper(); const orderMapper = new OrderMapper(packageMapper, partnerMapper); + const testimonyMapper = new TestimonyMapper(); const tagMapper = new TagMapper(); const articleMapper = new ArticleMapper(tagMapper); @@ -170,6 +173,10 @@ export class Application { const whatsAppRouter = new WhatsAppController( this._whatsAppService, ).buildRouter(); + const testimonyRouter = new TestimonyController( + testimonyMapper, + this._jwtService, + ).buildRouter(); const tagRouter = new TagController( tagMapper, this._jwtService, @@ -196,6 +203,7 @@ export class Application { this._app.use("/orders", orderRouter); this._app.use("/statics", staticRouter); this._app.use("/whatsapp", whatsAppRouter); + this._app.use("/testimonies", testimonyRouter); this._app.use("/tags", tagRouter); this._app.use("/articles", articleRouter); } diff --git a/src/database/entities/testimony.entity.ts b/src/database/entities/testimony.entity.ts new file mode 100644 index 0000000..6de3a0c --- /dev/null +++ b/src/database/entities/testimony.entity.ts @@ -0,0 +1,29 @@ +import { Entity, PrimaryKey, Property } from "@mikro-orm/core"; + +@Entity() +export class Testimony { + @PrimaryKey({ type: "varchar", length: 30 }) + id!: string; + + @Property({ type: "varchar", length: 200 }) + review!: string; + + @Property({ type: "varchar", length: 100 }) + reviewer!: string; + + @Property({ type: "int" }) + rating!: 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/enums/admin-permission.enum.ts b/src/database/enums/admin-permission.enum.ts index 7d91642..d1483b2 100644 --- a/src/database/enums/admin-permission.enum.ts +++ b/src/database/enums/admin-permission.enum.ts @@ -55,6 +55,10 @@ export enum AdminPermission { createPartner = "partner:create", updatePartner = "partner:update", deletePartner = "partner:delete", + // Testimony permissions + createTestimony = "testimony:create", + updateTestimony = "testimony:update", + deleteTestimony = "testimony:delete", // Tag permissions createTag = "tag:create", updateTag = "tag:update", diff --git a/src/modules/testimony/testimony.controller.ts b/src/modules/testimony/testimony.controller.ts new file mode 100644 index 0000000..07bab64 --- /dev/null +++ b/src/modules/testimony/testimony.controller.ts @@ -0,0 +1,210 @@ +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 { Testimony } from "@/database/entities/testimony.entity"; +import { AdminPermission } from "@/database/enums/admin-permission.enum"; +import { orm } from "@/database/orm"; +import type { TestimonyMapper } from "@/modules/testimony/testimony.mapper"; +import { + testimonyParamsSchema, + testimonyRequestSchema, +} from "@/modules/testimony/testimony.schemas"; +import type { TestimonyResponse } from "@/modules/testimony/testimony.types"; +import { Router, type Request, type Response } from "express"; +import { ulid } from "ulid"; + +export class TestimonyController extends Controller { + public constructor( + private readonly mapper: TestimonyMapper, + private readonly jwtService: AbstractJwtService, + ) { + super(); + } + + async create(req: Request, res: Response) { + const parseBodyResult = testimonyRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const testimony = orm.em.create(Testimony, { + id: ulid(), + review: body.review, + reviewer: body.reviewer, + rating: body.rating, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapEntityToResponse(testimony), + 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(Testimony); + + const testimonies = await orm.em.find( + Testimony, + {}, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + }, + ); + + return res.status(200).json({ + data: testimonies.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 = testimonyParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const testimony = await orm.em.findOne(Testimony, { id: params.id }); + if (!testimony) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Testimony not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(testimony), + errors: null, + } satisfies SingleResponse); + } + + async update(req: Request, res: Response) { + const parseParamsResult = testimonyParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = testimonyRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const testimony = await orm.em.findOne(Testimony, { id: params.id }); + if (!testimony) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Testimony not found.", + }, + ], + } satisfies ErrorResponse); + } + + testimony.review = body.review; + testimony.reviewer = body.reviewer; + testimony.rating = body.rating; + testimony.updatedAt = new Date(); + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(testimony), + errors: null, + } satisfies SingleResponse); + } + + async delete(req: Request, res: Response) { + const parseParamsResult = testimonyParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const testimony = await orm.em.findOne( + Testimony, + { id: params.id }, + { + populate: ["*"], + }, + ); + if (!testimony) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Testimony not found.", + }, + ], + } satisfies ErrorResponse); + } + + await orm.em.removeAndFlush(testimony); + + return res.status(204).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.post( + "/", + createOrmContextMiddleware, + isAdminMiddleware(this.jwtService, [AdminPermission.createTestimony]), + 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.updateTestimony]), + this.update.bind(this), + ); + router.delete( + "/:id", + createOrmContextMiddleware, + isAdminMiddleware(this.jwtService, [AdminPermission.deleteTestimony]), + this.delete.bind(this), + ); + + return router; + } +} diff --git a/src/modules/testimony/testimony.mapper.ts b/src/modules/testimony/testimony.mapper.ts new file mode 100644 index 0000000..bbda36e --- /dev/null +++ b/src/modules/testimony/testimony.mapper.ts @@ -0,0 +1,15 @@ +import type { Testimony } from "@/database/entities/testimony.entity"; +import type { TestimonyResponse } from "@/modules/testimony/testimony.types"; + +export class TestimonyMapper { + public mapEntityToResponse(testimony: Testimony): TestimonyResponse { + return { + id: testimony.id, + review: testimony.review, + reviewer: testimony.reviewer, + rating: testimony.rating, + created_at: testimony.createdAt, + updated_at: testimony.updatedAt, + }; + } +} diff --git a/src/modules/testimony/testimony.schemas.ts b/src/modules/testimony/testimony.schemas.ts new file mode 100644 index 0000000..94e9f71 --- /dev/null +++ b/src/modules/testimony/testimony.schemas.ts @@ -0,0 +1,24 @@ +import z from "zod"; + +export const testimonyRequestSchema = z.object({ + review: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(200, "Max 200 characters."), + reviewer: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(100, "Max 100 characters."), + rating: z + .number("Must be number.") + .int("Must be integer.") + .min(1, "Min 1.") + .max(5, "Max 5."), +}); + +export const testimonyParamsSchema = z.object({ + id: z + .ulid("Must be ulid string.") + .nonempty("Must not empty.") + .max(30, "Max 30 characters."), +}); diff --git a/src/modules/testimony/testimony.types.ts b/src/modules/testimony/testimony.types.ts new file mode 100644 index 0000000..2240f90 --- /dev/null +++ b/src/modules/testimony/testimony.types.ts @@ -0,0 +1,18 @@ +import type { + testimonyParamsSchema, + testimonyRequestSchema, +} from "@/modules/testimony/testimony.schemas"; +import z from "zod"; + +export type TestimonyRequest = z.infer; + +export type TestimonyParams = z.infer; + +export type TestimonyResponse = { + id: string; + review: string; + reviewer: string; + rating: number; + created_at: Date; + updated_at: Date; +};