From 393b65830c4876a6303005a8a06d30cec5c9aac7 Mon Sep 17 00:00:00 2001 From: ItsMalma Date: Mon, 1 Dec 2025 18:22:37 +0700 Subject: [PATCH] add whatsapp modules --- .env.example | 5 + src/application.ts | 12 ++- src/common/errors/whatsapp.error.ts | 5 + .../abstract.whatsapp-service.ts | 3 + .../whatsapp-service/meta.whatsapp-service.ts | 29 ++++++ src/common/utils.ts | 8 +- src/configs/_env.ts | 13 +++ src/configs/whatsapp-business.config.ts | 8 ++ .../package-consult-session.entity.ts | 35 +++++++ src/modules/admin/admin.controller.ts | 8 +- src/modules/order/order.controller.ts | 2 +- src/modules/package/package.controller.ts | 49 ++++++++++ src/modules/package/package.types.ts | 4 + src/modules/partner/partner.controller.ts | 8 +- src/modules/whatsapp/whatsapp.controller.ts | 92 +++++++++++++++++++ src/modules/whatsapp/whatsapp.types.ts | 31 +++++++ 16 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 src/common/errors/whatsapp.error.ts create mode 100644 src/common/services/whatsapp-service/abstract.whatsapp-service.ts create mode 100644 src/common/services/whatsapp-service/meta.whatsapp-service.ts create mode 100644 src/configs/whatsapp-business.config.ts create mode 100644 src/database/entities/package-consult-session.entity.ts create mode 100644 src/modules/whatsapp/whatsapp.controller.ts create mode 100644 src/modules/whatsapp/whatsapp.types.ts diff --git a/.env.example b/.env.example index ec61f37..da32f9b 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,8 @@ MAIL_HOST= MAIL_PORT= MAIL_USERNAME= MAIL_PASSWORD= + +WHATSAPP_BUSINESS_ACCESS_TOKEN= +WHATSAPP_BUSINESS_PHONE_NUMBER_ID= +WHATSAPP_BUSINESS_ACCOUNT_ID= +WHATSAPP_BUSINESS_WEBHOOK_VERIFY_TOKEN= diff --git a/src/application.ts b/src/application.ts index b3ade43..e445c7e 100644 --- a/src/application.ts +++ b/src/application.ts @@ -6,6 +6,8 @@ import type { AbstractJwtService } from "@/common/services/jwt-service/abstract. import { LibraryJwtService } from "@/common/services/jwt-service/library.jwt-service"; import type { AbstractPaymentService } from "@/common/services/payment-service/abstract.payment-service"; import { MidtransPaymentService } from "@/common/services/payment-service/midtrans.payment-service"; +import type { AbstractWhatsAppService } from "@/common/services/whatsapp-service/abstract.whatsapp-service"; +import { MetaWhatsAppService } from "@/common/services/whatsapp-service/meta.whatsapp-service"; import { serverConfig } from "@/configs/server.config"; import { AdminController } from "@/modules/admin/admin.controller"; import { AdminMapper } from "@/modules/admin/admin.mapper"; @@ -36,6 +38,7 @@ import { TransportationClassController } from "@/modules/transportation-class/tr import { TransportationClassMapper } from "@/modules/transportation-class/transportation-class.mapper"; import { TransportationController } from "@/modules/transportation/transportation.controller"; import { TransportationMapper } from "@/modules/transportation/transportation.mapper"; +import { WhatsAppController } from "@/modules/whatsapp/whatsapp.controller"; import compression from "compression"; import cors from "cors"; import express from "express"; @@ -49,6 +52,7 @@ export class Application { private _fileStorage!: AbstractFileStorage; private _jwtService!: AbstractJwtService; private _paymentService!: AbstractPaymentService; + private _whatsAppService!: AbstractWhatsAppService; public constructor() { this._app = express(); @@ -59,6 +63,7 @@ export class Application { this._fileStorage = new LocalFileStorage(); this._jwtService = new LibraryJwtService(); this._paymentService = new MidtransPaymentService(); + this._whatsAppService = new MetaWhatsAppService(); } public initializeMiddlewares() { @@ -82,13 +87,14 @@ export class Application { const transportationClassMapper = new TransportationClassMapper( transportationMapper, ); + const partnerMapper = new PartnerMapper(); const packageMapper = new PackageMapper( + partnerMapper, flightMapper, hotelMapper, transportationMapper, ); const adminMapper = new AdminMapper(); - const partnerMapper = new PartnerMapper(); const orderMapper = new OrderMapper(packageMapper, partnerMapper); const countryRouter = new CountryController( @@ -155,6 +161,9 @@ export class Application { this._jwtService, ).buildRouter(); const staticRouter = new StaticController(this._fileStorage).buildRouter(); + const whatsAppRouter = new WhatsAppController( + this._whatsAppService, + ).buildRouter(); this._app.use("/countries", countryRouter); this._app.use("/cities", cityRouter); @@ -171,6 +180,7 @@ export class Application { this._app.use("/partners", partnerRouter); this._app.use("/orders", orderRouter); this._app.use("/statics", staticRouter); + this._app.use("/whatsapp", whatsAppRouter); } public initializeErrorHandlers() {} diff --git a/src/common/errors/whatsapp.error.ts b/src/common/errors/whatsapp.error.ts new file mode 100644 index 0000000..ddb5dea --- /dev/null +++ b/src/common/errors/whatsapp.error.ts @@ -0,0 +1,5 @@ +export class WhatsAppError extends Error { + public constructor(message: string) { + super(message); + } +} diff --git a/src/common/services/whatsapp-service/abstract.whatsapp-service.ts b/src/common/services/whatsapp-service/abstract.whatsapp-service.ts new file mode 100644 index 0000000..a4dc1ef --- /dev/null +++ b/src/common/services/whatsapp-service/abstract.whatsapp-service.ts @@ -0,0 +1,3 @@ +export abstract class AbstractWhatsAppService { + public abstract sendMessage(to: string, message: string): Promise; +} diff --git a/src/common/services/whatsapp-service/meta.whatsapp-service.ts b/src/common/services/whatsapp-service/meta.whatsapp-service.ts new file mode 100644 index 0000000..95706d6 --- /dev/null +++ b/src/common/services/whatsapp-service/meta.whatsapp-service.ts @@ -0,0 +1,29 @@ +import { WhatsAppError } from "@/common/errors/whatsapp.error"; +import { AbstractWhatsAppService } from "@/common/services/whatsapp-service/abstract.whatsapp-service"; +import { whatsAppBusinessConfig } from "@/configs/whatsapp-business.config"; + +export class MetaWhatsAppService extends AbstractWhatsAppService { + public async sendMessage(to: string, message: string): Promise { + const response = await fetch( + `https://graph.facebook.com/v22.0/${whatsAppBusinessConfig.phoneNumberId}/messages`, + { + method: "POST", + headers: { + Authorization: `Bearer ${whatsAppBusinessConfig.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + messaging_product: "whatsapp", + to, + type: "text", + text: { body: message }, + }), + }, + ); + + if (!response.ok) { + console.error(await response.json()); + throw new WhatsAppError("Failed to send WhatsApp message"); + } + } +} diff --git a/src/common/utils.ts b/src/common/utils.ts index 744237d..560be49 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,10 +1,8 @@ -export function generateRandomCode(length: number): string { - const numbers = "0123456789"; - +export function generateRandomCode(length: number, characters: string): string { let result = ""; for (let i = 0; i < length; i++) { - const randomIndex = Math.floor(Math.random() * numbers.length); - result += numbers[randomIndex]; + const randomIndex = Math.floor(Math.random() * characters.length); + result += characters[randomIndex]; } return result; diff --git a/src/configs/_env.ts b/src/configs/_env.ts index aad8519..2da6193 100644 --- a/src/configs/_env.ts +++ b/src/configs/_env.ts @@ -52,5 +52,18 @@ export const _env = z .min(0, "Min 0."), MAIL_USERNAME: z.string("Must be string.").nonempty("Must not empty."), MAIL_PASSWORD: z.string("Must be string.").nonempty("Must not empty."), + + WHATSAPP_BUSINESS_ACCESS_TOKEN: z + .string("Must be string.") + .nonempty("Must not empty."), + WHATSAPP_BUSINESS_PHONE_NUMBER_ID: z + .string("Must be string.") + .nonempty("Must not empty."), + WHATSAPP_BUSINESS_ACCOUNT_ID: z + .string("Must be string.") + .nonempty("Must not empty."), + WHATSAPP_BUSINESS_WEBHOOK_VERIFY_TOKEN: z + .string("Must be string.") + .nonempty("Must not empty."), }) .parse(Bun.env); diff --git a/src/configs/whatsapp-business.config.ts b/src/configs/whatsapp-business.config.ts new file mode 100644 index 0000000..39460ef --- /dev/null +++ b/src/configs/whatsapp-business.config.ts @@ -0,0 +1,8 @@ +import { _env } from "@/configs/_env"; + +export const whatsAppBusinessConfig = { + accessToken: _env.WHATSAPP_BUSINESS_ACCESS_TOKEN, + phoneNumberId: _env.WHATSAPP_BUSINESS_PHONE_NUMBER_ID, + accountId: _env.WHATSAPP_BUSINESS_ACCOUNT_ID, + webhookVerifyToken: _env.WHATSAPP_BUSINESS_WEBHOOK_VERIFY_TOKEN, +} as const; diff --git a/src/database/entities/package-consult-session.entity.ts b/src/database/entities/package-consult-session.entity.ts new file mode 100644 index 0000000..7378503 --- /dev/null +++ b/src/database/entities/package-consult-session.entity.ts @@ -0,0 +1,35 @@ +import { Package } from "@/database/entities/package.entity"; +import { + Entity, + ManyToOne, + PrimaryKey, + Property, + Unique, + type Rel, +} from "@mikro-orm/core"; + +@Entity() +export class PackageConsultSession { + @PrimaryKey({ type: "varchar", length: 30 }) + id!: string; + + @Property({ type: "varchar", length: 6 }) + @Unique() + code!: string; + + @ManyToOne(() => Package) + package!: Rel; + + @Property({ + type: "timestamp", + onCreate: () => new Date(), + }) + createdAt!: Date; + + @Property({ + type: "timestamp", + onCreate: () => new Date(), + onUpdate: () => new Date(), + }) + updatedAt!: Date; +} diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index a163dad..6b7ed4c 100644 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -69,7 +69,7 @@ export class AdminController extends Controller { const verification = orm.em.create(Verification, { id: ulid(), - code: generateRandomCode(6), + code: generateRandomCode(6, "0123456789"), type: VerificationType.createAdmin, expiredAt: dateFns.addHours(new Date(), 1), createdAt: new Date(), @@ -294,7 +294,7 @@ export class AdminController extends Controller { admin.permissions = body.permissions; admin.verification = orm.em.create(Verification, { id: ulid(), - code: generateRandomCode(6), + code: generateRandomCode(6, "1234567890"), type: VerificationType.updateAdmin, expiredAt: dateFns.addHours(new Date(), 1), createdAt: new Date(), @@ -357,7 +357,7 @@ export class AdminController extends Controller { admin.email = body.new_email; admin.verification = orm.em.create(Verification, { id: ulid(), - code: generateRandomCode(6), + code: generateRandomCode(6, "1234567890"), type: VerificationType.changeEmailAdmin, expiredAt: dateFns.addHours(new Date(), 1), createdAt: new Date(), @@ -435,7 +435,7 @@ export class AdminController extends Controller { admin.password = await Bun.password.hash(body.new_password); admin.verification = orm.em.create(Verification, { id: ulid(), - code: generateRandomCode(6), + code: generateRandomCode(6, "1234567890"), type: VerificationType.changePasswordAdmin, expiredAt: dateFns.addHours(new Date(), 1), createdAt: new Date(), diff --git a/src/modules/order/order.controller.ts b/src/modules/order/order.controller.ts index 1f5d8dd..4690f33 100644 --- a/src/modules/order/order.controller.ts +++ b/src/modules/order/order.controller.ts @@ -65,7 +65,7 @@ export class OrderController extends Controller { const verification = orm.em.create(Verification, { id: ulid(), - code: generateRandomCode(6), + code: generateRandomCode(6, "0123456789"), type: VerificationType.createOrder, expiredAt: dateFns.addHours(new Date(), 1), createdAt: new Date(), diff --git a/src/modules/package/package.controller.ts b/src/modules/package/package.controller.ts index 0ad53e0..c451101 100644 --- a/src/modules/package/package.controller.ts +++ b/src/modules/package/package.controller.ts @@ -9,9 +9,11 @@ import type { ListResponse, SingleResponse, } from "@/common/types"; +import { generateRandomCode } from "@/common/utils"; import { FlightClass } from "@/database/entities/flight-class.entity"; import { FlightSchedule } from "@/database/entities/flight-schedule.entity"; import { Hotel } from "@/database/entities/hotel.entity"; +import { PackageConsultSession } from "@/database/entities/package-consult-session.entity"; import { PackageDetail } from "@/database/entities/package-detail.entity"; import { PackageItineraryDay } from "@/database/entities/package-itinerary-day.entity"; import { PackageItineraryImage } from "@/database/entities/package-itinerary-image.entity"; @@ -35,6 +37,7 @@ import { packageRequestSchema, } from "@/modules/package/package.schemas"; import type { + PackageConsultResponse, PackageDetailResponse, PackageResponse, } from "@/modules/package/package.types"; @@ -98,6 +101,47 @@ export class PackageController extends Controller { } satisfies SingleResponse); } + async consult(req: Request, res: Response) { + const parseParamsResult = packageParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const package_ = await orm.em.findOne( + Package, + { id: params.id }, + { populate: ["*"] }, + ); + if (!package_) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Package not found.", + }, + ], + } satisfies ErrorResponse); + } + + const consultSession = orm.em.create(PackageConsultSession, { + id: ulid(), + code: generateRandomCode(6, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"), + package: package_, + createdAt: new Date(), + updatedAt: new Date(), + }); + + return res.status(200).json({ + data: { + session_code: consultSession.code, + }, + errors: null, + } satisfies SingleResponse); + } + async list(req: Request, res: Response) { const parseQueryResult = paginationQuerySchema.safeParse(req.query); if (!parseQueryResult.success) { @@ -1325,6 +1369,11 @@ export class PackageController extends Controller { isAdminMiddleware(this.jwtService, [AdminPermission.createPackage]), this.create.bind(this), ); + router.post( + "/:id/consult", + createOrmContextMiddleware, + this.consult.bind(this), + ); router.get("/", createOrmContextMiddleware, this.list.bind(this)); router.get("/:id", createOrmContextMiddleware, this.view.bind(this)); router.put( diff --git a/src/modules/package/package.types.ts b/src/modules/package/package.types.ts index 77fa73b..814cc04 100644 --- a/src/modules/package/package.types.ts +++ b/src/modules/package/package.types.ts @@ -33,6 +33,10 @@ export type PackageResponse = { updated_at: Date; }; +export type PackageConsultResponse = { + session_code: string; +}; + export type PackageItineraryWidgetResponse = | { type: "transport"; diff --git a/src/modules/partner/partner.controller.ts b/src/modules/partner/partner.controller.ts index 198fe9a..8a5c9b4 100644 --- a/src/modules/partner/partner.controller.ts +++ b/src/modules/partner/partner.controller.ts @@ -67,7 +67,7 @@ export class PartnerController extends Controller { const verification = orm.em.create(Verification, { id: ulid(), - code: generateRandomCode(6), + code: generateRandomCode(6, "0123456789"), type: VerificationType.createPartner, expiredAt: dateFns.addHours(new Date(), 1), createdAt: new Date(), @@ -281,7 +281,7 @@ export class PartnerController extends Controller { partner.name = body.name; partner.verification = orm.em.create(Verification, { id: ulid(), - code: generateRandomCode(6), + code: generateRandomCode(6, "0123456789"), type: VerificationType.updatePartner, expiredAt: dateFns.addHours(new Date(), 1), createdAt: new Date(), @@ -344,7 +344,7 @@ export class PartnerController extends Controller { partner.email = body.new_email; partner.verification = orm.em.create(Verification, { id: ulid(), - code: generateRandomCode(6), + code: generateRandomCode(6, "0123456789"), type: VerificationType.changeEmailPartner, expiredAt: dateFns.addHours(new Date(), 1), createdAt: new Date(), @@ -422,7 +422,7 @@ export class PartnerController extends Controller { partner.password = await Bun.password.hash(body.new_password); partner.verification = orm.em.create(Verification, { id: ulid(), - code: generateRandomCode(6), + code: generateRandomCode(6, "0123456789"), type: VerificationType.changePasswordPartner, expiredAt: dateFns.addHours(new Date(), 1), createdAt: new Date(), diff --git a/src/modules/whatsapp/whatsapp.controller.ts b/src/modules/whatsapp/whatsapp.controller.ts new file mode 100644 index 0000000..ab8c2be --- /dev/null +++ b/src/modules/whatsapp/whatsapp.controller.ts @@ -0,0 +1,92 @@ +import { Controller } from "@/common/controller"; +import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware"; +import type { AbstractWhatsAppService } from "@/common/services/whatsapp-service/abstract.whatsapp-service"; +import { whatsAppBusinessConfig } from "@/configs/whatsapp-business.config"; +import { PackageConsultSession } from "@/database/entities/package-consult-session.entity"; +import { orm } from "@/database/orm"; +import type { WhatsAppWebhookPayload } from "@/modules/whatsapp/whatsapp.types"; +import { Router, type Request, type Response } from "express"; + +export class WhatsAppController extends Controller { + public constructor( + private readonly whatsAppService: AbstractWhatsAppService, + ) { + super(); + } + + async webhookVerification(req: Request, res: Response) { + const hubMode = req.query["hub.mode"]; + const hubVerifyToken = req.query["hub.verify_token"]; + const hubChallenge = req.query["hub.challenge"]; + + if ( + hubMode === "subscribe" && + hubVerifyToken === whatsAppBusinessConfig.webhookVerifyToken + ) { + res.status(200).send(hubChallenge); + } else { + res.status(403).send(); + } + } + + async webhook(req: Request, res: Response) { + const body = req.body as WhatsAppWebhookPayload; + console.log(JSON.stringify(body, null, 2)); + const messageText = body.entry[0].changes[0].value.messages[0].text.body; + + const sessionCode = messageText + .trim() + .match(/session\s*#?\s*([a-zA-Z0-9]{4,})/i)?.[0]; + if (!sessionCode) { + return res.status(200).send(); + } + + const packageConsultSession = await orm.em.findOne(PackageConsultSession, { + code: sessionCode, + }); + if (!packageConsultSession) { + return res.status(200).send(); + } + + const partner = packageConsultSession.package.partner; + + const timeOfDay = new Date().getHours(); + let salutation: string; + if (timeOfDay > 4 && timeOfDay < 12) { + salutation = "pagi"; + } else if (timeOfDay >= 12 && timeOfDay < 15) { + salutation = "siang"; + } else if (timeOfDay >= 15 && timeOfDay < 18) { + salutation = "sore"; + } else { + salutation = "malam"; + } + + console.log(this.whatsAppService); + + await this.whatsAppService.sendMessage( + body.entry[0].changes[0].value.messages[0].from, + `Selamat ${salutation}, +Anda akan terhubung dengan mitra kami untuk konsultasi. + +Saudara ${"Adam Akmal Madani"} +Nomor: ${"085218606125"} + +Terima kasih.`, + ); + + return res.status(200).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.get("/webhook", this.webhookVerification.bind(this)); + router.post( + "/webhook", + createOrmContextMiddleware, + this.webhook.bind(this), + ); + + return router; + } +} diff --git a/src/modules/whatsapp/whatsapp.types.ts b/src/modules/whatsapp/whatsapp.types.ts new file mode 100644 index 0000000..16570df --- /dev/null +++ b/src/modules/whatsapp/whatsapp.types.ts @@ -0,0 +1,31 @@ +export type WhatsAppWebhookPayload = { + object: "whatsapp_business_account"; + entry: { + id: string; + changes: { + value: { + messaging_product: "whatsapp"; + metadata: { + display_phone_number: string; + phone_number_id: string; + }; + contacts: { + profile: { + name: string; + }; + wa_id: string; + }[]; + messages: { + from: string; + id: string; + timestamp: string; + text: { + body: string; + }; + type: "text"; + }[]; + }; + field: "messages"; + }[]; + }[]; +};