diff --git a/AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx b/AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx index a554abe362..c655ea00c9 100644 --- a/AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx +++ b/AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx @@ -68,6 +68,9 @@ const buildWhatsAppSetupMarkdown: BuildWhatsAppSetupMarkdown = (): string => { WhatsAppTemplateIds, ) as Array; + const appApiBaseUrl: string = APP_API_URL.toString().replace(/\/$/, ""); + const primaryWebhookUrl: string = `${appApiBaseUrl}/notification/whatsapp/webhook`; + const description: string = "Follow these steps to connect Meta WhatsApp with OneUptime so notifications can be delivered via WhatsApp."; @@ -75,6 +78,7 @@ const buildWhatsAppSetupMarkdown: BuildWhatsAppSetupMarkdown = (): string => { "Meta Business Manager admin access for the WhatsApp Business Account.", "A WhatsApp Business phone number approved for API messaging.", "Admin access to OneUptime with permission to edit global notification settings.", + "A webhook verify token string that you'll configure identically in Meta and OneUptime.", ]; const setupStepsList: Array = [ @@ -82,7 +86,7 @@ const buildWhatsAppSetupMarkdown: BuildWhatsAppSetupMarkdown = (): string => { "From **Business Settings → Accounts → WhatsApp Accounts**, create or select the account that owns your sender phone number.", "In Buisness Portfolio, create a system user and assign it to the WhatsApp Business Account with the role of **Admin**.", "Generate a token for this system user and this will be your long-lived access token. Make sure to select the **whatsapp_business_management** and **whatsapp_business_messaging** permissions when generating the token.", - "Paste the access token and phone number ID into the **Meta WhatsApp Settings** card above, then save.", + "Paste the access token, phone number ID, and webhook verify token into the **Meta WhatsApp Settings** card above, then save.", "For the **Business Account ID**, go to **Business Settings → Business Info** (or **Business Settings → WhatsApp Accounts → Settings**) and copy the **WhatsApp Business Account ID** value.", "To locate the **App ID** and **App Secret**, open [Meta for Developers](https://developers.facebook.com/apps/), select your WhatsApp app, then navigate to **Settings → Basic**. The App ID is shown at the top; click **Show** next to **App Secret** to reveal and copy it.", "Create each template listed below in the Meta WhatsApp Manager. Make sure the template name, language, and variables match exactly. You can however change the content to your preference. Please make sure it's approved by Meta.", @@ -169,12 +173,25 @@ const buildWhatsAppSetupMarkdown: BuildWhatsAppSetupMarkdown = (): string => { .filter(Boolean) .join("\n"); + const webhookSection: string = [ + "### Configure Meta Webhook Subscription", + "1. In the OneUptime Admin Dashboard, open **Settings → WhatsApp → Meta WhatsApp Settings** and enter a strong value in **Webhook Verify Token**. Save the form so the encrypted token is stored in Global Config.", + "2. Keep that verify token handy—Meta does not generate one for you. You'll paste the exact same value when configuring the callback.", + "3. In [Meta for Developers](https://developers.facebook.com/apps/), select your WhatsApp app and navigate to **WhatsApp → Configuration → Webhooks**.", + `4. Click **Configure**, then supply one of the following callback URLs when Meta asks for your endpoint:\n - \`${primaryWebhookUrl}\`\n `, + "5. Paste the verify token from step 1 into Meta's **Verify Token** field and submit. Meta will call the callback URL and expect that value to match before it approves the subscription.", + "6. After verification succeeds, subscribe to the **messages** field (and any other WhatsApp webhook categories you need) so delivery status updates are forwarded to OneUptime.", + ] + .filter(Boolean) + .join("\n\n"); + return [ description, "### Prerequisites", prerequisitesMarkdown, "### Setup Steps", setupStepsMarkdown, + webhookSection, "### Required WhatsApp Templates", templateSummaryTable, "### Template Bodies", @@ -271,6 +288,18 @@ const SettingsWhatsApp: FunctionComponent = (): ReactElement => { "Optional Business Account ID that owns the WhatsApp templates.", placeholder: "123456789012345", }, + { + field: { + metaWhatsAppWebhookVerifyToken: true, + }, + title: "Webhook Verify Token", + stepId: "meta-credentials", + fieldType: FormFieldSchemaType.EncryptedText, + required: false, + description: + "Secret token configured in Meta to validate webhook subscription requests.", + placeholder: "Webhook verify token", + }, { field: { metaWhatsAppAppId: true, @@ -324,6 +353,14 @@ const SettingsWhatsApp: FunctionComponent = (): ReactElement => { fieldType: FieldType.Text, placeholder: "Not Configured", }, + { + field: { + metaWhatsAppWebhookVerifyToken: true, + }, + title: "Webhook Verify Token", + fieldType: FieldType.HiddenText, + placeholder: "Not Configured", + }, { field: { metaWhatsAppAppId: true, diff --git a/App/FeatureSet/Notification/API/WhatsApp.ts b/App/FeatureSet/Notification/API/WhatsApp.ts index e1b1bc579b..faae1b6d7f 100644 --- a/App/FeatureSet/Notification/API/WhatsApp.ts +++ b/App/FeatureSet/Notification/API/WhatsApp.ts @@ -1,6 +1,7 @@ import WhatsAppService from "../Services/WhatsAppService"; import BadDataException from "Common/Types/Exception/BadDataException"; -import { JSONObject } from "Common/Types/JSON"; +import GlobalConfig from "Common/Models/DatabaseModels/GlobalConfig"; +import { JSONArray, JSONObject } from "Common/Types/JSON"; import ObjectID from "Common/Types/ObjectID"; import Phone from "Common/Types/Phone"; import WhatsAppMessage from "Common/Types/WhatsApp/WhatsAppMessage"; @@ -9,17 +10,214 @@ import { WhatsAppTemplateIds, WhatsAppTemplateLanguage, } from "Common/Types/WhatsApp/WhatsAppTemplates"; +import WhatsAppStatus from "Common/Types/WhatsAppStatus"; import ClusterKeyAuthorization from "Common/Server/Middleware/ClusterKeyAuthorization"; +import WhatsAppLogService from "Common/Server/Services/WhatsAppLogService"; +import GlobalConfigService from "Common/Server/Services/GlobalConfigService"; import Express, { ExpressRequest, ExpressResponse, ExpressRouter, NextFunction, } from "Common/Server/Utils/Express"; +import logger from "Common/Server/Utils/Logger"; import Response from "Common/Server/Utils/Response"; const router: ExpressRouter = Express.getRouter(); +const MAX_STATUS_MESSAGE_LENGTH: number = 500; + +export const mapWebhookStatusToWhatsAppStatus: ( + status?: string, +) => WhatsAppStatus = (status?: string): WhatsAppStatus => { + switch ((status || "").toLowerCase()) { + case "sent": + return WhatsAppStatus.Sent; + case "delivered": + return WhatsAppStatus.Delivered; + case "read": + return WhatsAppStatus.Read; + case "failed": + return WhatsAppStatus.Failed; + case "deleted": + case "removed": + return WhatsAppStatus.Deleted; + case "warning": + return WhatsAppStatus.Warning; + case "queued": + case "pending": + return WhatsAppStatus.Queued; + case "error": + return WhatsAppStatus.Error; + case "success": + return WhatsAppStatus.Success; + default: + return WhatsAppStatus.Unknown; + } +}; + +export const buildStatusMessage: (payload: JSONObject) => string | undefined = ( + payload: JSONObject, +): string | undefined => { + const messageParts: Array = []; + const rawStatus: string | undefined = payload["status"] + ? String(payload["status"]) + : undefined; + + if (rawStatus) { + messageParts.push(`Status: ${rawStatus}`); + } + + const timestamp: string | undefined = payload["timestamp"] + ? String(payload["timestamp"]) + : undefined; + + if (timestamp) { + const numericTimestamp: number = Number(timestamp); + if (!isNaN(numericTimestamp)) { + messageParts.push( + `Timestamp: ${new Date(numericTimestamp * 1000).toISOString()}`, + ); + } else { + messageParts.push(`Timestamp: ${timestamp}`); + } + } + + const conversation: JSONObject | undefined = + (payload["conversation"] as JSONObject | undefined) || undefined; + + if (conversation) { + if (conversation["id"]) { + messageParts.push(`Conversation: ${conversation["id"]}`); + } + + const origin: JSONObject | undefined = + (conversation["origin"] as JSONObject | undefined) || undefined; + + if (origin?.["type"]) { + messageParts.push(`Origin: ${origin["type"]}`); + } + + if (conversation["expiration_timestamp"]) { + const expirationTimestamp: number = Number( + conversation["expiration_timestamp"], + ); + + if (!isNaN(expirationTimestamp)) { + messageParts.push( + `Conversation expires: ${new Date(expirationTimestamp * 1000).toISOString()}`, + ); + } + } + } + + const pricing: JSONObject | undefined = + (payload["pricing"] as JSONObject | undefined) || undefined; + + if (pricing) { + const pricingParts: Array = []; + + if (pricing["billable"] !== undefined) { + pricingParts.push(`billable=${pricing["billable"]}`); + } + + if (pricing["category"]) { + pricingParts.push(`category=${pricing["category"]}`); + } + + if (pricing["pricing_model"]) { + pricingParts.push(`model=${pricing["pricing_model"]}`); + } + + if (pricingParts.length > 0) { + messageParts.push(`Pricing: ${pricingParts.join(", ")}`); + } + } + + const errors: JSONArray | undefined = + (payload["errors"] as JSONArray | undefined) || undefined; + + if (Array.isArray(errors) && errors.length > 0) { + const firstError: JSONObject = errors[0] as JSONObject; + const errorParts: Array = []; + + if (firstError["title"]) { + errorParts.push(String(firstError["title"])); + } + + if (firstError["code"]) { + errorParts.push(`code=${firstError["code"]}`); + } + + if (firstError["detail"]) { + errorParts.push(String(firstError["detail"])); + } + + if (errorParts.length > 0) { + messageParts.push(`Error: ${errorParts.join(" - ")}`); + } + } + + if (messageParts.length === 0) { + return undefined; + } + + const statusMessage: string = messageParts.join(" | "); + + if (statusMessage.length <= MAX_STATUS_MESSAGE_LENGTH) { + return statusMessage; + } + + return `${statusMessage.substring(0, MAX_STATUS_MESSAGE_LENGTH - 3)}...`; +}; + +const updateWhatsAppLogStatus: ( + statusPayload: JSONObject, +) => Promise = async (statusPayload: JSONObject): Promise => { + const messageId: string | undefined = statusPayload["id"] + ? String(statusPayload["id"]) + : undefined; + + if (!messageId) { + logger.warn( + `[Meta WhatsApp Webhook] Received status payload without message id. Payload: ${JSON.stringify(statusPayload)}`, + ); + return; + } + + const rawStatus: string | undefined = statusPayload["status"] + ? String(statusPayload["status"]) + : undefined; + + const derivedStatus: WhatsAppStatus = + mapWebhookStatusToWhatsAppStatus(rawStatus); + + const statusMessage: string | undefined = buildStatusMessage(statusPayload); + + const updateResult: number = await WhatsAppLogService.updateOneBy({ + query: { + whatsAppMessageId: messageId, + }, + data: { + status: derivedStatus, + ...(statusMessage ? { statusMessage } : {}), + }, + props: { + isRoot: true, + }, + }); + + if (updateResult === 0) { + logger.warn( + `[Meta WhatsApp Webhook] No WhatsApp log found for message id ${messageId}. Payload: ${JSON.stringify(statusPayload)}`, + ); + } else { + logger.debug( + `[Meta WhatsApp Webhook] Updated WhatsApp log for message id ${messageId} with status ${derivedStatus}.`, + ); + } +}; + const toTemplateVariables: ( rawVariables: JSONObject | undefined, ) => Record | undefined = ( @@ -119,6 +317,128 @@ router.post( }, ); +router.get("/webhook", async (req: ExpressRequest, res: ExpressResponse) => { + const mode: string | undefined = req.query["hub.mode"] + ? String(req.query["hub.mode"]) + : undefined; + const verifyToken: string | undefined = req.query["hub.verify_token"] + ? String(req.query["hub.verify_token"]) + : undefined; + const challenge: string | undefined = req.query["hub.challenge"] + ? String(req.query["hub.challenge"]) + : undefined; + + if (mode === "subscribe" && challenge) { + const globalConfigTokenResponse: GlobalConfig | null = + await GlobalConfigService.findOneBy({ + query: { + _id: ObjectID.getZeroObjectID().toString(), + }, + props: { + isRoot: true, + }, + select: { + metaWhatsAppWebhookVerifyToken: true, + }, + }); + + const configuredVerifyToken: string | undefined = + globalConfigTokenResponse?.metaWhatsAppWebhookVerifyToken?.trim() || + undefined; + + if (!configuredVerifyToken) { + logger.error( + "Meta WhatsApp webhook verify token is not configured. Rejecting verification request.", + ); + res.sendStatus(403); + return; + } + + if (verifyToken === configuredVerifyToken) { + res.status(200).send(challenge); + return; + } + + logger.warn( + "Meta WhatsApp webhook verification failed due to token mismatch.", + ); + res.sendStatus(403); + return; + } + + res.sendStatus(400); +}); + +router.post( + "/webhook", + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + const body: JSONObject = req.body as JSONObject; + + if ( + (body["object"] as string | undefined) !== "whatsapp_business_account" + ) { + logger.debug( + `[Meta WhatsApp Webhook] Received event for unsupported object: ${JSON.stringify(body)}`, + ); + return Response.sendEmptySuccessResponse(req, res); + } + + const entries: JSONArray | undefined = body["entry"] as + | JSONArray + | undefined; + + if (!Array.isArray(entries)) { + logger.warn( + `[Meta WhatsApp Webhook] Payload did not include entries array. Payload: ${JSON.stringify(body)}`, + ); + return Response.sendEmptySuccessResponse(req, res); + } + + const statusUpdatePromises: Array> = []; + + for (const entry of entries) { + const entryObject: JSONObject = entry as JSONObject; + const changes: JSONArray | undefined = + (entryObject["changes"] as JSONArray | undefined) || undefined; + + if (!Array.isArray(changes)) { + continue; + } + + for (const change of changes) { + const changeObject: JSONObject = change as JSONObject; + const value: JSONObject | undefined = + (changeObject["value"] as JSONObject | undefined) || undefined; + + if (!value) { + continue; + } + + const statuses: JSONArray | undefined = + (value["statuses"] as JSONArray | undefined) || undefined; + + if (Array.isArray(statuses)) { + for (const statusItem of statuses) { + statusUpdatePromises.push( + updateWhatsAppLogStatus(statusItem as JSONObject), + ); + } + } + } + } + + if (statusUpdatePromises.length > 0) { + await Promise.allSettled(statusUpdatePromises); + } + + return Response.sendEmptySuccessResponse(req, res); + } catch (err) { + return next(err); + } + }, +); + router.post( "/test", async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { diff --git a/App/FeatureSet/Notification/Services/WhatsAppService.ts b/App/FeatureSet/Notification/Services/WhatsAppService.ts index 9bff079ace..4c40dd7d4d 100644 --- a/App/FeatureSet/Notification/Services/WhatsAppService.ts +++ b/App/FeatureSet/Notification/Services/WhatsAppService.ts @@ -421,7 +421,7 @@ export default class WhatsAppService { whatsAppLog.whatsAppMessageId = messageId; } - whatsAppLog.status = WhatsAppStatus.Success; + whatsAppLog.status = WhatsAppStatus.Sent; whatsAppLog.statusMessage = messageId ? `Message ID: ${messageId}` : "WhatsApp message sent successfully"; @@ -474,10 +474,14 @@ export default class WhatsAppService { await UserOnCallLogTimelineService.updateOneById({ id: options.userOnCallLogTimelineId, data: { - status: - whatsAppLog.status === WhatsAppStatus.Success - ? UserNotificationStatus.Sent - : UserNotificationStatus.Error, + status: [ + WhatsAppStatus.Success, + WhatsAppStatus.Sent, + WhatsAppStatus.Delivered, + WhatsAppStatus.Read, + ].includes(whatsAppLog.status || WhatsAppStatus.Error) + ? UserNotificationStatus.Sent + : UserNotificationStatus.Error, statusMessage: whatsAppLog.statusMessage, }, props: { diff --git a/App/package-lock.json b/App/package-lock.json index 286da63065..7252bca500 100644 --- a/App/package-lock.json +++ b/App/package-lock.json @@ -27,7 +27,8 @@ "@types/xml2js": "^0.4.14", "@types/xmldom": "^0.1.34", "jest": "^28.1.0", - "nodemon": "^2.0.20" + "nodemon": "^2.0.20", + "ts-jest": "^28.0.8" } }, "../Common": { @@ -59,7 +60,9 @@ "@opentelemetry/sdk-trace-web": "^1.25.1", "@opentelemetry/semantic-conventions": "^1.26.0", "@remixicon/react": "^4.2.0", + "@simplewebauthn/server": "^13.2.2", "@tippyjs/react": "^4.2.6", + "@types/archiver": "^6.0.3", "@types/crypto-js": "^4.2.2", "@types/qrcode": "^1.5.5", "@types/react-highlight": "^0.12.8", @@ -68,7 +71,9 @@ "@types/web-push": "^3.6.4", "acme-client": "^5.3.0", "airtable": "^0.12.2", - "axios": "^1.7.2", + "archiver": "^7.0.1", + "axios": "^1.12.0", + "botbuilder": "^4.23.3", "bullmq": "^5.3.3", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -1915,6 +1920,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -3704,6 +3722,13 @@ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -4660,6 +4685,63 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/ts-jest": { + "version": "28.0.8", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.8.tgz", + "integrity": "sha512-5FaG0lXmRPzApix8oFG8RKjAz4ehtm8yMKOTy5HX3fY6W8kmvOrmcY0hKDElW52FJov+clhUbrKAqofnj4mXTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^28.0.0", + "json5": "^2.2.1", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "7.x", + "yargs-parser": "^21.0.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/types": "^28.0.0", + "babel-jest": "^28.0.0", + "jest": "^28.0.0", + "typescript": ">=4.3" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", diff --git a/App/package.json b/App/package.json index c7aac79512..477daf908a 100644 --- a/App/package.json +++ b/App/package.json @@ -35,7 +35,8 @@ "@types/nodemailer": "^6.4.14", "@types/xml2js": "^0.4.14", "@types/xmldom": "^0.1.34", - "jest": "^28.1.0", + "jest": "^28.1.0", + "ts-jest": "^28.0.8", "nodemon": "^2.0.20" } } diff --git a/Common/Models/DatabaseModels/GlobalConfig.ts b/Common/Models/DatabaseModels/GlobalConfig.ts index 120972ea11..573e6e01da 100644 --- a/Common/Models/DatabaseModels/GlobalConfig.ts +++ b/Common/Models/DatabaseModels/GlobalConfig.ts @@ -352,6 +352,25 @@ export default class GlobalConfig extends GlobalConfigModel { }) public metaWhatsAppAppSecret?: string = undefined; + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.ShortText, + title: "Meta WhatsApp Webhook Verify Token", + description: + "Verify token configured in Meta to validate webhook subscriptions.", + }) + @Column({ + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + nullable: true, + unique: true, + }) + public metaWhatsAppWebhookVerifyToken?: string = undefined; + @ColumnAccessControl({ create: [], read: [], diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1760357680881-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1760357680881-MigrationName.ts new file mode 100644 index 0000000000..c46f27898e --- /dev/null +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1760357680881-MigrationName.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MigrationName1760357680881 implements MigrationInterface { + public name = "MigrationName1760357680881"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "GlobalConfig" ADD "metaWhatsAppWebhookVerifyToken" character varying(100)`, + ); + await queryRunner.query( + `ALTER TABLE "GlobalConfig" ADD CONSTRAINT "UQ_afe98d53b718f485d3d64b383b8" UNIQUE ("metaWhatsAppWebhookVerifyToken")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "GlobalConfig" DROP CONSTRAINT "UQ_afe98d53b718f485d3d64b383b8"`, + ); + await queryRunner.query( + `ALTER TABLE "GlobalConfig" DROP COLUMN "metaWhatsAppWebhookVerifyToken"`, + ); + } +} diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts index 9f567f43d7..0b7bf88c93 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts @@ -176,6 +176,7 @@ import { MigrationName1759232954703 } from "./1759232954703-MigrationName"; import { RenameUserTwoFactorAuthToUserTotpAuth1759234532998 } from "./1759234532998-MigrationName"; import { MigrationName1759943124812 } from "./1759943124812-MigrationName"; import { MigrationName1760345757975 } from "./1760345757975-MigrationName"; +import { MigrationName1760357680881 } from "./1760357680881-MigrationName"; export default [ InitialMigration, @@ -356,4 +357,5 @@ export default [ RenameUserTwoFactorAuthToUserTotpAuth1759234532998, MigrationName1759943124812, MigrationName1760345757975, + MigrationName1760357680881, ]; diff --git a/Common/Types/WhatsAppStatus.ts b/Common/Types/WhatsAppStatus.ts index 416ef50fde..a98f4a1e58 100644 --- a/Common/Types/WhatsAppStatus.ts +++ b/Common/Types/WhatsAppStatus.ts @@ -1,8 +1,16 @@ enum WhatsAppStatus { Success = "Success", + Sent = "Sent", + Delivered = "Delivered", + Read = "Read", + Failed = "Failed", + Deleted = "Deleted", + Warning = "Warning", + Queued = "Queued", Error = "Error", LowBalance = "Low Balance", NotVerified = "Not Verified", + Unknown = "Unknown", } export default WhatsAppStatus; diff --git a/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx b/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx index d56836e0a8..e83787c729 100644 --- a/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx +++ b/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx @@ -4,7 +4,14 @@ import WhatsAppLog from "Common/Models/DatabaseModels/WhatsAppLog"; import FieldType from "Common/UI/Components/Types/FieldType"; import Columns from "Common/UI/Components/ModelTable/Columns"; import Pill from "Common/UI/Components/Pill/Pill"; -import { Green, Red } from "Common/Types/BrandColors"; +import { + Blue, + Green, + Grey, + Orange, + Red, + Yellow, +} from "Common/Types/BrandColors"; import WhatsAppStatus from "Common/Types/WhatsAppStatus"; import ProjectUtil from "Common/UI/Utils/Project"; import Filter from "Common/UI/Components/ModelFilter/Filter"; @@ -15,6 +22,7 @@ import Query from "Common/Types/BaseDatabase/Query"; import BaseModel from "Common/Types/Workflow/Components/BaseModel"; import UserElement from "../User/User"; import User from "Common/Models/DatabaseModels/User"; +import Color from "Common/Types/Color"; export interface WhatsAppLogsTableProps { query?: Query; @@ -28,6 +36,41 @@ const WhatsAppLogsTable: FunctionComponent = ( const [modalText, setModalText] = useState(""); const [modalTitle, setModalTitle] = useState(""); + const getStatusColor: (status?: WhatsAppStatus) => Color = ( + status?: WhatsAppStatus, + ): Color => { + switch (status) { + case WhatsAppStatus.Success: + case WhatsAppStatus.Delivered: + case WhatsAppStatus.Read: + return Green; + case WhatsAppStatus.Sent: + case WhatsAppStatus.Queued: + return Blue; + case WhatsAppStatus.Warning: + return Yellow; + case WhatsAppStatus.LowBalance: + return Orange; + case WhatsAppStatus.NotVerified: + case WhatsAppStatus.Unknown: + return Grey; + default: + return Red; + } + }; + + const parseStatus: (status?: string) => WhatsAppStatus | undefined = ( + status?: string, + ): WhatsAppStatus | undefined => { + if (!status) { + return undefined; + } + + return (Object.values(WhatsAppStatus) as Array).includes(status) + ? (status as WhatsAppStatus) + : undefined; + }; + const defaultColumns: Columns = [ { field: { toNumber: true }, @@ -61,17 +104,19 @@ const WhatsAppLogsTable: FunctionComponent = ( title: "Status", type: FieldType.Text, getElement: (item: WhatsAppLog): ReactElement => { - if (item["status"]) { - return ( - - ); + const statusValue: string | undefined = + (item["status"] as string | undefined) || undefined; + + if (!statusValue) { + return <>; } - return <>; + const normalizedStatus: WhatsAppStatus | undefined = + parseStatus(statusValue); + + const pillColor: Color = getStatusColor(normalizedStatus); + + return ; }, }, ];