diff --git a/App/FeatureSet/Notification/API/WhatsApp.ts b/App/FeatureSet/Notification/API/WhatsApp.ts new file mode 100644 index 0000000000..9a93509662 --- /dev/null +++ b/App/FeatureSet/Notification/API/WhatsApp.ts @@ -0,0 +1,148 @@ +import WhatsAppService from "../Services/WhatsAppService"; +import BadDataException from "Common/Types/Exception/BadDataException"; +import { JSONObject } from "Common/Types/JSON"; +import ObjectID from "Common/Types/ObjectID"; +import Phone from "Common/Types/Phone"; +import WhatsAppMessage from "Common/Types/WhatsApp/WhatsAppMessage"; +import ClusterKeyAuthorization from "Common/Server/Middleware/ClusterKeyAuthorization"; +import Express, { + ExpressRequest, + ExpressResponse, + ExpressRouter, +} from "Common/Server/Utils/Express"; +import Response from "Common/Server/Utils/Response"; + +const router: ExpressRouter = Express.getRouter(); + +const toTemplateVariables = ( + rawVariables: JSONObject | undefined, +): Record | undefined => { + if (!rawVariables) { + return undefined; + } + + const result: Record = {}; + + for (const key of Object.keys(rawVariables)) { + const value: unknown = rawVariables[key]; + if (value !== null && value !== undefined) { + result[key] = String(value); + } + } + + return Object.keys(result).length > 0 ? result : undefined; +}; + +router.post( + "/send", + ClusterKeyAuthorization.isAuthorizedServiceMiddleware, + async (req: ExpressRequest, res: ExpressResponse) => { + const body: JSONObject = req.body as JSONObject; + + if (!body["to"]) { + throw new BadDataException("`to` phone number is required"); + } + + const toPhone: Phone = new Phone(body["to"] as string); + + const message: WhatsAppMessage = { + to: toPhone, + body: (body["body"] as string) || "", + templateKey: (body["templateKey"] as string) || undefined, + templateVariables: toTemplateVariables( + body["templateVariables"] as JSONObject | undefined, + ), + templateLanguageCode: + (body["templateLanguageCode"] as string) || undefined, + }; + + await WhatsAppService.sendWhatsApp(message, { + projectId: body["projectId"] ? new ObjectID(body["projectId"] as string) : undefined, + isSensitive: (body["isSensitive"] as boolean) || false, + userOnCallLogTimelineId: body["userOnCallLogTimelineId"] + ? new ObjectID(body["userOnCallLogTimelineId"] as string) + : undefined, + incidentId: body["incidentId"] + ? new ObjectID(body["incidentId"] as string) + : undefined, + alertId: body["alertId"] + ? new ObjectID(body["alertId"] as string) + : undefined, + scheduledMaintenanceId: body["scheduledMaintenanceId"] + ? new ObjectID(body["scheduledMaintenanceId"] as string) + : undefined, + statusPageId: body["statusPageId"] + ? new ObjectID(body["statusPageId"] as string) + : undefined, + statusPageAnnouncementId: body["statusPageAnnouncementId"] + ? new ObjectID(body["statusPageAnnouncementId"] as string) + : undefined, + userId: body["userId"] ? new ObjectID(body["userId"] as string) : undefined, + onCallPolicyId: body["onCallPolicyId"] + ? new ObjectID(body["onCallPolicyId"] as string) + : undefined, + onCallPolicyEscalationRuleId: body["onCallPolicyEscalationRuleId"] + ? new ObjectID(body["onCallPolicyEscalationRuleId"] as string) + : undefined, + onCallDutyPolicyExecutionLogTimelineId: + body["onCallDutyPolicyExecutionLogTimelineId"] + ? new ObjectID( + body["onCallDutyPolicyExecutionLogTimelineId"] as string, + ) + : undefined, + onCallScheduleId: body["onCallScheduleId"] + ? new ObjectID(body["onCallScheduleId"] as string) + : undefined, + teamId: body["teamId"] ? new ObjectID(body["teamId"] as string) : undefined, + }); + + return Response.sendEmptySuccessResponse(req, res); + }, +); + +router.post("/test", async (req: ExpressRequest, res: ExpressResponse) => { + const body: JSONObject = req.body as JSONObject; + + if (!body["toPhone"]) { + throw new BadDataException("toPhone is required"); + } + + const toPhone: Phone = new Phone(body["toPhone"] as string); + + const message: WhatsAppMessage = { + to: toPhone, + body: + (body["message"] as string) || + "This is a test WhatsApp message from OneUptime.", + templateKey: (body["templateKey"] as string) || undefined, + templateVariables: toTemplateVariables( + body["templateVariables"] as JSONObject | undefined, + ), + templateLanguageCode: + (body["templateLanguageCode"] as string) || undefined, + }; + + try { + await WhatsAppService.sendWhatsApp(message, { + projectId: body["projectId"] + ? new ObjectID(body["projectId"] as string) + : undefined, + isSensitive: false, + }); + } catch (err) { + const errorMsg: string = + err instanceof Error && err.message + ? err.message + : "Failed to send test WhatsApp message."; + + return Response.sendErrorResponse( + req, + res, + new BadDataException(errorMsg), + ); + } + + return Response.sendEmptySuccessResponse(req, res); +}); + +export default router; diff --git a/App/FeatureSet/Notification/Services/WhatsAppService.ts b/App/FeatureSet/Notification/Services/WhatsAppService.ts new file mode 100644 index 0000000000..aada78991c --- /dev/null +++ b/App/FeatureSet/Notification/Services/WhatsAppService.ts @@ -0,0 +1,399 @@ +import { + WhatsAppTextDefaultCostInCents, + WhatsAppTextHighRiskCostInCents, + getMetaWhatsAppConfig, + MetaWhatsAppConfig, +} from "../Config"; +import { isHighRiskPhoneNumber } from "Common/Types/Call/CallRequest"; +import BadDataException from "Common/Types/Exception/BadDataException"; +import ObjectID from "Common/Types/ObjectID"; +import UserNotificationStatus from "Common/Types/UserNotification/UserNotificationStatus"; +import WhatsAppMessage from "Common/Types/WhatsApp/WhatsAppMessage"; +import WhatsAppStatus from "Common/Types/WhatsAppStatus"; +import { JSONArray, JSONObject } from "Common/Types/JSON"; +import { IsBillingEnabled } from "Common/Server/EnvironmentConfig"; +import NotificationService from "Common/Server/Services/NotificationService"; +import ProjectService from "Common/Server/Services/ProjectService"; +import UserOnCallLogTimelineService from "Common/Server/Services/UserOnCallLogTimelineService"; +import WhatsAppLogService from "Common/Server/Services/WhatsAppLogService"; +import logger from "Common/Server/Utils/Logger"; +import Project from "Common/Models/DatabaseModels/Project"; +import WhatsAppLog from "Common/Models/DatabaseModels/WhatsAppLog"; +import API from "Common/Utils/API"; +import Protocol from "Common/Types/API/Protocol"; +import Route from "Common/Types/API/Route"; +import URL from "Common/Types/API/URL"; + +const DEFAULT_META_WHATSAPP_API_VERSION: string = "v18.0"; +const SENSITIVE_MESSAGE_PLACEHOLDER: string = + "This message is sensitive and is not logged"; + +export default class WhatsAppService { + public static async sendWhatsApp( + message: WhatsAppMessage, + options: { + projectId?: ObjectID | undefined; + isSensitive?: boolean | undefined; + userOnCallLogTimelineId?: ObjectID | undefined; + incidentId?: ObjectID | undefined; + alertId?: ObjectID | undefined; + scheduledMaintenanceId?: ObjectID | undefined; + statusPageId?: ObjectID | undefined; + statusPageAnnouncementId?: ObjectID | undefined; + userId?: ObjectID | undefined; + onCallPolicyId?: ObjectID | undefined; + onCallPolicyEscalationRuleId?: ObjectID | undefined; + onCallDutyPolicyExecutionLogTimelineId?: ObjectID | undefined; + onCallScheduleId?: ObjectID | undefined; + teamId?: ObjectID | undefined; + } = {}, + ): Promise { + let sendError: Error | null = null; + const whatsAppLog: WhatsAppLog = new WhatsAppLog(); + + try { + if (!message.to) { + throw new BadDataException("WhatsApp recipient phone number is required"); + } + + if (!message.body && !message.templateKey) { + throw new BadDataException( + "Either WhatsApp message body or template key must be provided", + ); + } + + const config: MetaWhatsAppConfig = await getMetaWhatsAppConfig(); + + const isSensitiveMessage: boolean = Boolean(options.isSensitive); + const messageSummary: string = isSensitiveMessage + ? SENSITIVE_MESSAGE_PLACEHOLDER + : message.body || + (message.templateKey + ? `Template: ${message.templateKey}${ + message.templateVariables + ? " Variables: " + JSON.stringify(message.templateVariables) + : "" + }` + : ""); + + whatsAppLog.toNumber = message.to; + whatsAppLog.messageText = messageSummary; + whatsAppLog.whatsAppCostInUSDCents = 0; + + if (options.projectId) { + whatsAppLog.projectId = options.projectId; + } + + if (options.incidentId) { + whatsAppLog.incidentId = options.incidentId; + } + + if (options.alertId) { + whatsAppLog.alertId = options.alertId; + } + + if (options.scheduledMaintenanceId) { + whatsAppLog.scheduledMaintenanceId = options.scheduledMaintenanceId; + } + + if (options.statusPageId) { + whatsAppLog.statusPageId = options.statusPageId; + } + + if (options.statusPageAnnouncementId) { + whatsAppLog.statusPageAnnouncementId = + options.statusPageAnnouncementId; + } + + if (options.userId) { + whatsAppLog.userId = options.userId; + } + + if (options.teamId) { + whatsAppLog.teamId = options.teamId; + } + + if (options.onCallPolicyId) { + whatsAppLog.onCallDutyPolicyId = options.onCallPolicyId; + } + + if (options.onCallPolicyEscalationRuleId) { + whatsAppLog.onCallDutyPolicyEscalationRuleId = + options.onCallPolicyEscalationRuleId; + } + + if (options.onCallScheduleId) { + whatsAppLog.onCallDutyPolicyScheduleId = options.onCallScheduleId; + } + + let messageCost: number = 0; + const shouldChargeForMessage: boolean = IsBillingEnabled; + + if (shouldChargeForMessage) { + messageCost = WhatsAppTextDefaultCostInCents / 100; + + if (isHighRiskPhoneNumber(message.to)) { + messageCost = WhatsAppTextHighRiskCostInCents / 100; + } + } + + let project: Project | null = null; + + if (options.projectId) { + project = await ProjectService.findOneById({ + id: options.projectId, + select: { + smsOrCallCurrentBalanceInUSDCents: true, + lowCallAndSMSBalanceNotificationSentToOwners: true, + name: true, + notEnabledSmsOrCallNotificationSentToOwners: true, + }, + props: { + isRoot: true, + }, + }); + + if (!project) { + whatsAppLog.status = WhatsAppStatus.Error; + whatsAppLog.statusMessage = `Project ${options.projectId.toString()} not found.`; + logger.error(whatsAppLog.statusMessage); + await WhatsAppLogService.create({ + data: whatsAppLog, + props: { + isRoot: true, + }, + }); + return; + } + + if (shouldChargeForMessage) { + let updatedBalance: number = + project.smsOrCallCurrentBalanceInUSDCents || 0; + + try { + updatedBalance = await NotificationService.rechargeIfBalanceIsLow( + project.id!, + ); + } catch (err) { + logger.error(err); + } + + project.smsOrCallCurrentBalanceInUSDCents = updatedBalance; + + if (!project.smsOrCallCurrentBalanceInUSDCents) { + whatsAppLog.status = WhatsAppStatus.LowBalance; + whatsAppLog.statusMessage = `Project ${options.projectId.toString()} does not have enough balance for WhatsApp messages.`; + logger.error(whatsAppLog.statusMessage); + + await WhatsAppLogService.create({ + data: whatsAppLog, + props: { + isRoot: true, + }, + }); + + if (!project.lowCallAndSMSBalanceNotificationSentToOwners) { + await ProjectService.updateOneById({ + id: project.id!, + data: { + lowCallAndSMSBalanceNotificationSentToOwners: true, + }, + props: { + isRoot: true, + }, + }); + + await ProjectService.sendEmailToProjectOwners( + project.id!, + `Low WhatsApp message balance for ${project.name || ""}`, + `We tried to send a WhatsApp message to ${message.to.toString()} with message:

${messageSummary}

The message was not sent because your project does not have enough balance for WhatsApp messages. Current balance is ${ + (project.smsOrCallCurrentBalanceInUSDCents || 0) / 100 + } USD. Required balance for this message is ${messageCost} USD. Please enable auto recharge or recharge manually.`, + ); + } + return; + } + + if ( + project.smsOrCallCurrentBalanceInUSDCents < messageCost * 100 + ) { + whatsAppLog.status = WhatsAppStatus.LowBalance; + whatsAppLog.statusMessage = `Project does not have enough balance to send WhatsApp message. Current balance is ${ + project.smsOrCallCurrentBalanceInUSDCents / 100 + } USD. Required balance is ${messageCost} USD.`; + logger.error(whatsAppLog.statusMessage); + + await WhatsAppLogService.create({ + data: whatsAppLog, + props: { + isRoot: true, + }, + }); + + if (!project.lowCallAndSMSBalanceNotificationSentToOwners) { + await ProjectService.updateOneById({ + id: project.id!, + data: { + lowCallAndSMSBalanceNotificationSentToOwners: true, + }, + props: { + isRoot: true, + }, + }); + + await ProjectService.sendEmailToProjectOwners( + project.id!, + `Low WhatsApp message balance for ${project.name || ""}`, + `We tried to send a WhatsApp message to ${message.to.toString()} with message:

${messageSummary}

The message was not sent because your project does not have enough balance for WhatsApp messages. Current balance is ${ + project.smsOrCallCurrentBalanceInUSDCents / 100 + } USD. Required balance is ${messageCost} USD. Please enable auto recharge or recharge manually.`, + ); + } + return; + } + } + } + + const payload: JSONObject = { + messaging_product: "whatsapp", + to: message.to.toString(), + } as JSONObject; + + if (message.templateKey) { + const template: JSONObject = { + name: message.templateKey, + language: { + code: message.templateLanguageCode || "en", + }, + } as JSONObject; + + if (message.templateVariables && + Object.keys(message.templateVariables).length > 0) { + const parameters: JSONArray = []; + + for (const value of Object.values(message.templateVariables)) { + parameters.push({ + type: "text", + text: value, + } as JSONObject); + } + + if (parameters.length > 0) { + template["components"] = [ + { + type: "body", + parameters, + }, + ] as JSONArray; + } + } + + payload["type"] = "template"; + payload["template"] = template; + } else { + payload["type"] = "text"; + payload["text"] = { + body: message.body || "", + } as JSONObject; + } + + const apiVersion: string = + config.apiVersion?.trim() || DEFAULT_META_WHATSAPP_API_VERSION; + + const url: URL = new URL( + Protocol.HTTPS, + "graph.facebook.com", + new Route(`${apiVersion}/${config.phoneNumberId}/messages`), + ); + + const response = await API.post({ + url, + data: payload, + headers: { + Authorization: `Bearer ${config.accessToken}`, + "Content-Type": "application/json", + }, + }); + + const responseData: JSONObject = (response.jsonData || {}) as JSONObject; + + let messageId: string | undefined = undefined; + const messagesArray: JSONArray | undefined = + (responseData["messages"] as JSONArray) || undefined; + + if (Array.isArray(messagesArray) && messagesArray.length > 0) { + const firstMessage = messagesArray[0] as JSONObject; + if (firstMessage["id"]) { + messageId = firstMessage["id"] as string; + } + } + + whatsAppLog.status = WhatsAppStatus.Success; + whatsAppLog.statusMessage = messageId + ? `Message ID: ${messageId}` + : "WhatsApp message sent successfully"; + + if (shouldChargeForMessage && project) { + const deduction: number = Math.floor(messageCost * 100); + whatsAppLog.whatsAppCostInUSDCents = deduction; + + project.smsOrCallCurrentBalanceInUSDCents = Math.max( + 0, + Math.floor( + (project.smsOrCallCurrentBalanceInUSDCents || 0) - deduction, + ), + ); + + await ProjectService.updateOneById({ + id: project.id!, + data: { + smsOrCallCurrentBalanceInUSDCents: + project.smsOrCallCurrentBalanceInUSDCents, + notEnabledSmsOrCallNotificationSentToOwners: false, + }, + props: { + isRoot: true, + }, + }); + } + } catch (error: any) { + whatsAppLog.whatsAppCostInUSDCents = 0; + whatsAppLog.status = WhatsAppStatus.Error; + const errorMessage: string = + error && error.message ? error.message.toString() : `${error}`; + whatsAppLog.statusMessage = errorMessage; + logger.error("Failed to send WhatsApp message."); + logger.error(errorMessage); + sendError = error; + } + + if (options.projectId) { + await WhatsAppLogService.create({ + data: whatsAppLog, + props: { + isRoot: true, + }, + }); + } + + if (options.userOnCallLogTimelineId) { + await UserOnCallLogTimelineService.updateOneById({ + id: options.userOnCallLogTimelineId, + data: { + status: + whatsAppLog.status === WhatsAppStatus.Success + ? UserNotificationStatus.Sent + : UserNotificationStatus.Error, + statusMessage: whatsAppLog.statusMessage, + }, + props: { + isRoot: true, + }, + }); + } + + if (sendError) { + throw sendError; + } + } +} diff --git a/Common/Models/DatabaseModels/UserWhatsApp.ts b/Common/Models/DatabaseModels/UserWhatsApp.ts new file mode 100644 index 0000000000..8847b8908c --- /dev/null +++ b/Common/Models/DatabaseModels/UserWhatsApp.ts @@ -0,0 +1,288 @@ +import Project from "./Project"; +import User from "./User"; +import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; +import Route from "../../Types/API/Route"; +import AllowAccessIfSubscriptionIsUnpaid from "../../Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid"; +import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl"; +import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl"; +import ColumnLength from "../../Types/Database/ColumnLength"; +import ColumnType from "../../Types/Database/ColumnType"; +import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint"; +import CurrentUserCanAccessRecordBy from "../../Types/Database/CurrentUserCanAccessRecordBy"; +import TableColumn from "../../Types/Database/TableColumn"; +import TableColumnType from "../../Types/Database/TableColumnType"; +import TableMetadata from "../../Types/Database/TableMetadata"; +import TenantColumn from "../../Types/Database/TenantColumn"; +import IconProp from "../../Types/Icon/IconProp"; +import ObjectID from "../../Types/ObjectID"; +import Permission from "../../Types/Permission"; +import Phone from "../../Types/Phone"; +import Text from "../../Types/Text"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +@TenantColumn("projectId") +@AllowAccessIfSubscriptionIsUnpaid() +@TableAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + delete: [Permission.CurrentUser], + update: [Permission.CurrentUser], +}) +@CrudApiEndpoint(new Route("/user-whatsapp")) +@Entity({ + name: "UserWhatsApp", +}) +@TableMetadata({ + tableName: "UserWhatsApp", + singularName: "WhatsApp Number", + pluralName: "WhatsApp Numbers", + icon: IconProp.WhatsApp, + tableDescription: "WhatsApp numbers used for WhatsApp notifications.", +}) +@CurrentUserCanAccessRecordBy("userId") +class UserWhatsApp extends BaseModel { + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "projectId", + type: TableColumnType.Entity, + modelType: Project, + title: "Project", + description: "Relation to Project Resource in which this object belongs", + }) + @ManyToOne( + () => { + return Project; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "projectId" }) + public project?: Project = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: true, + canReadOnRelationQuery: true, + title: "Project ID", + description: "ID of your OneUptime Project in which this object belongs", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: false, + transformer: ObjectID.getDatabaseTransformer(), + }) + public projectId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [], + }) + @TableColumn({ + title: "WhatsApp Number", + required: true, + unique: false, + type: TableColumnType.Phone, + canReadOnRelationQuery: true, + }) + @Column({ + type: ColumnType.Phone, + length: ColumnLength.Phone, + unique: false, + nullable: false, + transformer: Phone.getDatabaseTransformer(), + }) + public phone?: Phone = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "user", + type: TableColumnType.Entity, + modelType: User, + title: "User", + description: "Relation to User who this WhatsApp number belongs to", + }) + @ManyToOne( + () => { + return User; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "userId" }) + public user?: User = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "User ID", + description: "User ID who this WhatsApp number belongs to", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + @Index() + public userId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "createdByUserId", + type: TableColumnType.Entity, + modelType: User, + title: "Created by User", + description: + "Relation to User who created this object (if this object was created by a User)", + }) + @ManyToOne( + () => { + return User; + }, + { + eager: false, + nullable: true, + onDelete: "SET NULL", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "createdByUserId" }) + public createdByUser?: User = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Created by User ID", + description: + "User ID who created this object (if this object was created by a User)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public createdByUserId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "deletedByUserId", + type: TableColumnType.Entity, + title: "Deleted by User", + modelType: User, + description: + "Relation to User who deleted this object (if this object was deleted by a User)", + }) + @ManyToOne( + () => { + return User; + }, + { + cascade: false, + eager: false, + nullable: true, + onDelete: "SET NULL", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "deletedByUserId" }) + public deletedByUser?: User = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Deleted by User ID", + description: + "User ID who deleted this object (if this object was deleted by a User)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public deletedByUserId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.CurrentUser], + update: [], + }) + @TableColumn({ + title: "Is Verified", + description: "Is this WhatsApp number verified?", + isDefaultValueColumn: true, + type: TableColumnType.Boolean, + defaultValue: false, + }) + @Column({ + type: ColumnType.Boolean, + default: false, + }) + public isVerified?: boolean = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + title: "Verification Code", + description: "Temporary Verification Code", + isDefaultValueColumn: true, + computed: true, + required: true, + type: TableColumnType.ShortText, + forceGetDefaultValueOnCreate: () => { + return Text.generateRandomNumber(6); + }, + }) + @Column({ + type: ColumnType.ShortText, + nullable: false, + length: ColumnLength.ShortText, + }) + public verificationCode?: string = undefined; +} + +export default UserWhatsApp; diff --git a/Common/Models/DatabaseModels/WhatsAppLog.ts b/Common/Models/DatabaseModels/WhatsAppLog.ts new file mode 100644 index 0000000000..578fca41f1 --- /dev/null +++ b/Common/Models/DatabaseModels/WhatsAppLog.ts @@ -0,0 +1,883 @@ +import Project from "./Project"; +import Incident from "./Incident"; +import Alert from "./Alert"; +import ScheduledMaintenance from "./ScheduledMaintenance"; +import StatusPage from "./StatusPage"; +import StatusPageAnnouncement from "./StatusPageAnnouncement"; +import User from "./User"; +import OnCallDutyPolicy from "./OnCallDutyPolicy"; +import OnCallDutyPolicyEscalationRule from "./OnCallDutyPolicyEscalationRule"; +import OnCallDutyPolicySchedule from "./OnCallDutyPolicySchedule"; +import Team from "./Team"; +import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; +import Route from "../../Types/API/Route"; +import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl"; +import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl"; +import ColumnLength from "../../Types/Database/ColumnLength"; +import ColumnType from "../../Types/Database/ColumnType"; +import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint"; +import EnableDocumentation from "../../Types/Database/EnableDocumentation"; +import EnableWorkflow from "../../Types/Database/EnableWorkflow"; +import TableColumn from "../../Types/Database/TableColumn"; +import TableColumnType from "../../Types/Database/TableColumnType"; +import TableMetadata from "../../Types/Database/TableMetadata"; +import TenantColumn from "../../Types/Database/TenantColumn"; +import IconProp from "../../Types/Icon/IconProp"; +import ObjectID from "../../Types/ObjectID"; +import Permission from "../../Types/Permission"; +import Phone from "../../Types/Phone"; +import WhatsAppStatus from "../../Types/WhatsAppStatus"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +@EnableDocumentation() +@TenantColumn("projectId") +@TableAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + delete: [], + update: [], +}) +@CrudApiEndpoint(new Route("/whatsapp-log")) +@Entity({ + name: "WhatsAppLog", +}) +@EnableWorkflow({ + create: true, + delete: false, + update: false, +}) +@TableMetadata({ + tableName: "WhatsAppLog", + singularName: "WhatsApp Log", + pluralName: "WhatsApp Logs", + icon: IconProp.WhatsApp, + tableDescription: + "Logs of all the WhatsApp messages sent out to all users and subscribers for this project.", +}) +export default class WhatsAppLog extends BaseModel { + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "projectId", + type: TableColumnType.Entity, + modelType: Project, + title: "Project", + description: "Relation to Project Resource in which this object belongs", + }) + @ManyToOne( + () => { + return Project; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "projectId" }) + public project?: Project = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: true, + canReadOnRelationQuery: true, + title: "Project ID", + description: "ID of your OneUptime Project in which this object belongs", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: false, + transformer: ObjectID.getDatabaseTransformer(), + }) + public projectId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + required: true, + type: TableColumnType.Phone, + title: "To Number", + description: "Phone Number WhatsApp message was sent to", + canReadOnRelationQuery: false, + }) + @Column({ + nullable: false, + type: ColumnType.Phone, + length: ColumnLength.Phone, + transformer: Phone.getDatabaseTransformer(), + }) + public toNumber?: Phone = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + required: false, + type: TableColumnType.Phone, + title: "From Number", + description: + "Phone Number WhatsApp message was sent from (Business Number ID)", + canReadOnRelationQuery: false, + }) + @Column({ + nullable: true, + type: ColumnType.Phone, + length: ColumnLength.Phone, + transformer: Phone.getDatabaseTransformer(), + }) + public fromNumber?: Phone = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + required: false, + type: TableColumnType.VeryLongText, + title: "Message Text", + description: "Text content of the WhatsApp message", + canReadOnRelationQuery: false, + }) + @Column({ + nullable: true, + type: ColumnType.VeryLongText, + }) + public messageText?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + required: false, + type: TableColumnType.LongText, + title: "Status Message", + description: "Status Message (if any)", + canReadOnRelationQuery: false, + }) + @Column({ + nullable: true, + type: ColumnType.LongText, + length: ColumnLength.LongText, + }) + public statusMessage?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + required: true, + type: TableColumnType.ShortText, + title: "Status of the WhatsApp Message", + description: "Status of the WhatsApp message sent", + canReadOnRelationQuery: false, + }) + @Column({ + nullable: false, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + }) + public status?: WhatsAppStatus = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + required: true, + type: TableColumnType.Number, + title: "WhatsApp Cost", + description: "WhatsApp Message Cost in USD Cents", + canReadOnRelationQuery: false, + isDefaultValueColumn: true, + defaultValue: 0, + }) + @Column({ + nullable: false, + default: 0, + type: ColumnType.Number, + }) + public whatsAppCostInUSDCents?: number = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "incidentId", + type: TableColumnType.Entity, + modelType: Incident, + title: "Incident", + description: "Incident associated with this message (if any)", + }) + @ManyToOne( + () => { + return Incident; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "incidentId" }) + public incident?: Incident = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: false, + canReadOnRelationQuery: true, + title: "Incident ID", + description: "ID of Incident associated with this message (if any)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public incidentId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "userId", + type: TableColumnType.Entity, + modelType: User, + title: "User", + description: "User who initiated this message (if any)", + }) + @ManyToOne( + () => { + return User; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "userId" }) + public user?: User = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: false, + canReadOnRelationQuery: true, + title: "User ID", + description: "ID of User who initiated this message (if any)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public userId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "alertId", + type: TableColumnType.Entity, + modelType: Alert, + title: "Alert", + description: "Alert associated with this message (if any)", + }) + @ManyToOne( + () => { + return Alert; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "alertId" }) + public alert?: Alert = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: false, + canReadOnRelationQuery: true, + title: "Alert ID", + description: "ID of Alert associated with this message (if any)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public alertId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "scheduledMaintenanceId", + type: TableColumnType.Entity, + modelType: ScheduledMaintenance, + title: "Scheduled Maintenance", + description: "Scheduled Maintenance associated with this message (if any)", + }) + @ManyToOne( + () => { + return ScheduledMaintenance; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "scheduledMaintenanceId" }) + public scheduledMaintenance?: ScheduledMaintenance = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: false, + canReadOnRelationQuery: true, + title: "Scheduled Maintenance ID", + description: "ID of Scheduled Maintenance associated with this message (if any)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public scheduledMaintenanceId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "statusPageId", + type: TableColumnType.Entity, + modelType: StatusPage, + title: "Status Page", + description: "Status Page associated with this message (if any)", + }) + @ManyToOne( + () => { + return StatusPage; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "statusPageId" }) + public statusPage?: StatusPage = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: false, + canReadOnRelationQuery: true, + title: "Status Page ID", + description: "ID of Status Page associated with this message (if any)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public statusPageId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "statusPageAnnouncementId", + type: TableColumnType.Entity, + modelType: StatusPageAnnouncement, + title: "Status Page Announcement", + description: + "Status Page Announcement associated with this message (if any)", + }) + @ManyToOne( + () => { + return StatusPageAnnouncement; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "statusPageAnnouncementId" }) + public statusPageAnnouncement?: StatusPageAnnouncement = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: false, + canReadOnRelationQuery: true, + title: "Status Page Announcement ID", + description: + "ID of Status Page Announcement associated with this message (if any)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public statusPageAnnouncementId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "teamId", + type: TableColumnType.Entity, + modelType: Team, + title: "Team", + description: "Team associated with this message (if any)", + }) + @ManyToOne( + () => { + return Team; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "teamId" }) + public team?: Team = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: false, + canReadOnRelationQuery: true, + title: "Team ID", + description: "ID of Team associated with this message (if any)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public teamId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "onCallDutyPolicyId", + type: TableColumnType.Entity, + modelType: OnCallDutyPolicy, + title: "On-Call Duty Policy", + description: "On-Call Duty Policy associated with this message (if any)", + }) + @ManyToOne( + () => { + return OnCallDutyPolicy; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "onCallDutyPolicyId" }) + public onCallDutyPolicy?: OnCallDutyPolicy = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: false, + canReadOnRelationQuery: true, + title: "On-Call Duty Policy ID", + description: + "ID of On-Call Duty Policy associated with this message (if any)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public onCallDutyPolicyId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "onCallDutyPolicyEscalationRuleId", + type: TableColumnType.Entity, + modelType: OnCallDutyPolicyEscalationRule, + title: "On-Call Duty Policy Escalation Rule", + description: + "On-Call Duty Policy Escalation Rule associated with this message (if any)", + }) + @ManyToOne( + () => { + return OnCallDutyPolicyEscalationRule; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "onCallDutyPolicyEscalationRuleId" }) + public onCallDutyPolicyEscalationRule?: OnCallDutyPolicyEscalationRule = + undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: false, + canReadOnRelationQuery: true, + title: "On-Call Duty Policy Escalation Rule ID", + description: + "ID of On-Call Duty Policy Escalation Rule associated with this message (if any)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public onCallDutyPolicyEscalationRuleId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "onCallDutyPolicyScheduleId", + type: TableColumnType.Entity, + modelType: OnCallDutyPolicySchedule, + title: "On-Call Duty Policy Schedule", + description: + "On-Call Duty Policy Schedule associated with this message (if any)", + }) + @ManyToOne( + () => { + return OnCallDutyPolicySchedule; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "onCallDutyPolicyScheduleId" }) + public onCallDutyPolicySchedule?: OnCallDutyPolicySchedule = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadSmsLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: false, + canReadOnRelationQuery: true, + title: "On-Call Duty Policy Schedule ID", + description: + "ID of On-Call Duty Policy Schedule associated with this message (if any)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public onCallDutyPolicyScheduleId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "deletedByUserId", + type: TableColumnType.Entity, + title: "Deleted by User", + modelType: User, + description: + "Relation to User who deleted this object (if this object was deleted by a User)", + }) + @ManyToOne( + () => { + return User; + }, + { + cascade: false, + eager: false, + nullable: true, + onDelete: "SET NULL", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "deletedByUserId" }) + public deletedByUser?: User = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Deleted by User ID", + description: + "User ID who deleted this object (if this object was deleted by a User)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public deletedByUserId?: ObjectID = undefined; +} diff --git a/Common/Server/Services/WhatsAppLogService.ts b/Common/Server/Services/WhatsAppLogService.ts new file mode 100644 index 0000000000..b6c21d577b --- /dev/null +++ b/Common/Server/Services/WhatsAppLogService.ts @@ -0,0 +1,15 @@ +import { IsBillingEnabled } from "../EnvironmentConfig"; +import DatabaseService from "./DatabaseService"; +import Model from "../../Models/DatabaseModels/WhatsAppLog"; + +export class Service extends DatabaseService { + public constructor() { + super(Model); + + if (IsBillingEnabled) { + this.hardDeleteItemsOlderThanInDays("createdAt", 3); + } + } +} + +export default new Service(); diff --git a/Common/Server/Services/WhatsAppService.ts b/Common/Server/Services/WhatsAppService.ts new file mode 100644 index 0000000000..3a96da9fa9 --- /dev/null +++ b/Common/Server/Services/WhatsAppService.ts @@ -0,0 +1,141 @@ +import { AppApiHostname } from "../EnvironmentConfig"; +import ClusterKeyAuthorization from "../Middleware/ClusterKeyAuthorization"; +import BaseService from "./BaseService"; +import EmptyResponseData from "../../Types/API/EmptyResponse"; +import HTTPResponse from "../../Types/API/HTTPResponse"; +import Protocol from "../../Types/API/Protocol"; +import Route from "../../Types/API/Route"; +import URL from "../../Types/API/URL"; +import { JSONObject } from "../../Types/JSON"; +import ObjectID from "../../Types/ObjectID"; +import WhatsAppMessage from "../../Types/WhatsApp/WhatsAppMessage"; +import API from "../../Utils/API"; +import CaptureSpan from "../Utils/Telemetry/CaptureSpan"; + +export class WhatsAppService extends BaseService { + public constructor() { + super(); + } + + @CaptureSpan() + public async sendWhatsAppMessage( + message: WhatsAppMessage, + options: { + projectId?: ObjectID | undefined; + isSensitive?: boolean | undefined; + userOnCallLogTimelineId?: ObjectID | undefined; + incidentId?: ObjectID | undefined; + alertId?: ObjectID | undefined; + scheduledMaintenanceId?: ObjectID | undefined; + statusPageId?: ObjectID | undefined; + statusPageAnnouncementId?: ObjectID | undefined; + userId?: ObjectID | undefined; + onCallPolicyId?: ObjectID | undefined; + onCallPolicyEscalationRuleId?: ObjectID | undefined; + onCallDutyPolicyExecutionLogTimelineId?: ObjectID | undefined; + onCallScheduleId?: ObjectID | undefined; + teamId?: ObjectID | undefined; + } = {}, + ): Promise> { + const body: JSONObject = { + to: message.to.toString(), + }; + + if (message.body) { + body["body"] = message.body; + } + + if (message.templateKey) { + body["templateKey"] = message.templateKey; + } + + if (message.templateVariables) { + const templateVariables: JSONObject = {}; + + for (const [key, value] of Object.entries(message.templateVariables)) { + templateVariables[key] = value; + } + + body["templateVariables"] = templateVariables; + } + + if (message.templateLanguageCode) { + body["templateLanguageCode"] = message.templateLanguageCode; + } + + if (options.projectId) { + body["projectId"] = options.projectId.toString(); + } + + if (options.isSensitive !== undefined) { + body["isSensitive"] = options.isSensitive; + } + + if (options.userOnCallLogTimelineId) { + body["userOnCallLogTimelineId"] = + options.userOnCallLogTimelineId.toString(); + } + + if (options.incidentId) { + body["incidentId"] = options.incidentId.toString(); + } + + if (options.alertId) { + body["alertId"] = options.alertId.toString(); + } + + if (options.scheduledMaintenanceId) { + body["scheduledMaintenanceId"] = + options.scheduledMaintenanceId.toString(); + } + + if (options.statusPageId) { + body["statusPageId"] = options.statusPageId.toString(); + } + + if (options.statusPageAnnouncementId) { + body["statusPageAnnouncementId"] = + options.statusPageAnnouncementId.toString(); + } + + if (options.userId) { + body["userId"] = options.userId.toString(); + } + + if (options.onCallPolicyId) { + body["onCallPolicyId"] = options.onCallPolicyId.toString(); + } + + if (options.onCallPolicyEscalationRuleId) { + body["onCallPolicyEscalationRuleId"] = + options.onCallPolicyEscalationRuleId.toString(); + } + + if (options.onCallDutyPolicyExecutionLogTimelineId) { + body["onCallDutyPolicyExecutionLogTimelineId"] = + options.onCallDutyPolicyExecutionLogTimelineId.toString(); + } + + if (options.onCallScheduleId) { + body["onCallScheduleId"] = options.onCallScheduleId.toString(); + } + + if (options.teamId) { + body["teamId"] = options.teamId.toString(); + } + + return await API.post({ + url: new URL( + Protocol.HTTP, + AppApiHostname, + new Route("/api/notification/whatsapp/send"), + ), + data: body, + headers: { + ...ClusterKeyAuthorization.getClusterKeyHeaders(), + }, + }); + } +} + +export default new WhatsAppService();