add whatsapp modules

This commit is contained in:
ItsMalma
2025-12-01 18:22:37 +07:00
parent 1068ad9246
commit 393b65830c
16 changed files with 297 additions and 15 deletions

View File

@@ -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=

View File

@@ -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() {}

View File

@@ -0,0 +1,5 @@
export class WhatsAppError extends Error {
public constructor(message: string) {
super(message);
}
}

View File

@@ -0,0 +1,3 @@
export abstract class AbstractWhatsAppService {
public abstract sendMessage(to: string, message: string): Promise<void>;
}

View File

@@ -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<void> {
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");
}
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<Package>;
@Property({
type: "timestamp",
onCreate: () => new Date(),
})
createdAt!: Date;
@Property({
type: "timestamp",
onCreate: () => new Date(),
onUpdate: () => new Date(),
})
updatedAt!: Date;
}

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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<PackageResponse>);
}
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<PackageConsultResponse>);
}
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(

View File

@@ -33,6 +33,10 @@ export type PackageResponse = {
updated_at: Date;
};
export type PackageConsultResponse = {
session_code: string;
};
export type PackageItineraryWidgetResponse =
| {
type: "transport";

View File

@@ -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(),

View File

@@ -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;
}
}

View File

@@ -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";
}[];
}[];
};