add whatsapp modules
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
5
src/common/errors/whatsapp.error.ts
Normal file
5
src/common/errors/whatsapp.error.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class WhatsAppError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export abstract class AbstractWhatsAppService {
|
||||
public abstract sendMessage(to: string, message: string): Promise<void>;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
8
src/configs/whatsapp-business.config.ts
Normal file
8
src/configs/whatsapp-business.config.ts
Normal 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;
|
||||
35
src/database/entities/package-consult-session.entity.ts
Normal file
35
src/database/entities/package-consult-session.entity.ts
Normal 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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -33,6 +33,10 @@ export type PackageResponse = {
|
||||
updated_at: Date;
|
||||
};
|
||||
|
||||
export type PackageConsultResponse = {
|
||||
session_code: string;
|
||||
};
|
||||
|
||||
export type PackageItineraryWidgetResponse =
|
||||
| {
|
||||
type: "transport";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
92
src/modules/whatsapp/whatsapp.controller.ts
Normal file
92
src/modules/whatsapp/whatsapp.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/modules/whatsapp/whatsapp.types.ts
Normal file
31
src/modules/whatsapp/whatsapp.types.ts
Normal 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";
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
Reference in New Issue
Block a user