From 14cdc3ea865a9c0fd9eaf0867294c724aca2735e Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Mon, 13 Oct 2025 12:31:52 +0100 Subject: [PATCH 1/8] chore: update package.json and package-lock.json to add ts-jest and new dependencies --- App/package-lock.json | 86 ++++++++++++++++++++++++++++++++++++++++++- App/package.json | 3 +- 2 files changed, 86 insertions(+), 3 deletions(-) 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" } } From 8dfabfd96fdedc7443e62222514305b6fb913e26 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Mon, 13 Oct 2025 12:33:39 +0100 Subject: [PATCH 2/8] feat: enhance WhatsApp status handling and add webhook verify token to GlobalConfig --- .../Notification/Services/WhatsAppService.ts | 9 +++++++-- Common/Models/DatabaseModels/GlobalConfig.ts | 19 +++++++++++++++++++ Common/Types/WhatsAppStatus.ts | 8 ++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/App/FeatureSet/Notification/Services/WhatsAppService.ts b/App/FeatureSet/Notification/Services/WhatsAppService.ts index 9bff079ace..a8f95658c0 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"; @@ -475,7 +475,12 @@ export default class WhatsAppService { id: options.userOnCallLogTimelineId, data: { status: - whatsAppLog.status === WhatsAppStatus.Success + [ + WhatsAppStatus.Success, + WhatsAppStatus.Sent, + WhatsAppStatus.Delivered, + WhatsAppStatus.Read, + ].includes(whatsAppLog.status || WhatsAppStatus.Error) ? UserNotificationStatus.Sent : UserNotificationStatus.Error, statusMessage: whatsAppLog.statusMessage, 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/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; From 9f72a8e554095b679be11af98de02a7a8645e28e Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Mon, 13 Oct 2025 12:44:48 +0100 Subject: [PATCH 3/8] feat: add webhook verification and enhance WhatsApp status handling --- .../src/Pages/Settings/WhatsApp/Index.tsx | 42 ++- App/FeatureSet/Notification/API/WhatsApp.ts | 322 +++++++++++++++++- 2 files changed, 361 insertions(+), 3 deletions(-) diff --git a/AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx b/AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx index a554abe362..243d686d11 100644 --- a/AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx +++ b/AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx @@ -68,6 +68,10 @@ const buildWhatsAppSetupMarkdown: BuildWhatsAppSetupMarkdown = (): string => { WhatsAppTemplateIds, ) as Array; + const appApiBaseUrl: string = APP_API_URL.toString().replace(/\/$/, ""); + const primaryWebhookUrl: string = `${appApiBaseUrl}/notification/whatsapp/webhook`; + const shorthandWebhookUrl: string = `${appApiBaseUrl.replace(/\/api$/, "")}/whatsapp/webhook`; + const description: string = "Follow these steps to connect Meta WhatsApp with OneUptime so notifications can be delivered via WhatsApp."; @@ -75,14 +79,15 @@ 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 = [ "Sign in to the [Meta Business Manager](https://business.facebook.com/) with admin access to your WhatsApp Business Account.", "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.", + "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, 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 +174,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 - Primary (recommended): \`${primaryWebhookUrl}\`\n - Alternate (short path): \`${shorthandWebhookUrl}\` (both routes hit the same listener).`, + "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 +289,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 +354,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..856c2f182e 100644 --- a/App/FeatureSet/Notification/API/WhatsApp.ts +++ b/App/FeatureSet/Notification/API/WhatsApp.ts @@ -1,6 +1,6 @@ import WhatsAppService from "../Services/WhatsAppService"; import BadDataException from "Common/Types/Exception/BadDataException"; -import { JSONObject } from "Common/Types/JSON"; +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 +9,215 @@ 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 = 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 = 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) => { From 34aaa34fb3f601ad6b8708fab2912752dcf42e69 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Mon, 13 Oct 2025 13:14:10 +0100 Subject: [PATCH 4/8] refactor: remove shorthand webhook URL and simplify callback URL instructions in WhatsApp setup markdown --- AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx b/AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx index 243d686d11..c655ea00c9 100644 --- a/AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx +++ b/AdminDashboard/src/Pages/Settings/WhatsApp/Index.tsx @@ -70,7 +70,6 @@ const buildWhatsAppSetupMarkdown: BuildWhatsAppSetupMarkdown = (): string => { const appApiBaseUrl: string = APP_API_URL.toString().replace(/\/$/, ""); const primaryWebhookUrl: string = `${appApiBaseUrl}/notification/whatsapp/webhook`; - const shorthandWebhookUrl: string = `${appApiBaseUrl.replace(/\/api$/, "")}/whatsapp/webhook`; const description: string = "Follow these steps to connect Meta WhatsApp with OneUptime so notifications can be delivered via WhatsApp."; @@ -86,8 +85,8 @@ const buildWhatsAppSetupMarkdown: BuildWhatsAppSetupMarkdown = (): string => { "Sign in to the [Meta Business Manager](https://business.facebook.com/) with admin access to your WhatsApp Business Account.", "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, phone number ID, and webhook verify token into the **Meta WhatsApp Settings** card above, then save.", + "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, 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.", @@ -179,7 +178,7 @@ const buildWhatsAppSetupMarkdown: BuildWhatsAppSetupMarkdown = (): string => { "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 - Primary (recommended): \`${primaryWebhookUrl}\`\n - Alternate (short path): \`${shorthandWebhookUrl}\` (both routes hit the same listener).`, + `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.", ] From e2639001150f2cb52a892d1e6e9ac1a388abc9bf Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Mon, 13 Oct 2025 13:15:57 +0100 Subject: [PATCH 5/8] feat: add migration for metaWhatsAppWebhookVerifyToken in GlobalConfig --- .../1760357680881-MigrationName.ts | 16 ++++++++++++++++ .../Postgres/SchemaMigrations/Index.ts | 2 ++ 2 files changed, 18 insertions(+) create mode 100644 Common/Server/Infrastructure/Postgres/SchemaMigrations/1760357680881-MigrationName.ts 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..62d5ee32cc --- /dev/null +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1760357680881-MigrationName.ts @@ -0,0 +1,16 @@ +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..f24da1bfbf 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 ]; From 4de4ad802247de5c2b0677c2ed4470c9750297c5 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Mon, 13 Oct 2025 13:26:19 +0100 Subject: [PATCH 6/8] feat: enhance status color handling in WhatsAppLogsTable component --- .../NotificationLogs/WhatsAppLogsTable.tsx | 67 ++++++++++++++++--- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx b/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx index d56836e0a8..49f7f9ea5b 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,37 @@ const WhatsAppLogsTable: FunctionComponent = ( const [modalText, setModalText] = useState(""); const [modalTitle, setModalTitle] = useState(""); + const getStatusColor = (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 => { + if (!status) { + return undefined; + } + + return (Object.values(WhatsAppStatus) as Array).includes(status) + ? (status as WhatsAppStatus) + : undefined; + }; + const defaultColumns: Columns = [ { field: { toNumber: true }, @@ -61,17 +100,25 @@ 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 = getStatusColor(normalizedStatus); + + return ( + + ); }, }, ]; From 6bf45f6f3142f123915cfc7f777b0e13e5c778de Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Mon, 13 Oct 2025 13:26:57 +0100 Subject: [PATCH 7/8] refactor: streamline code formatting and improve readability in WhatsApp components and services --- App/FeatureSet/Notification/API/WhatsApp.ts | 96 +++++++++---------- .../Notification/Services/WhatsAppService.ts | 19 ++-- .../1760357680881-MigrationName.ts | 27 ++++-- .../Postgres/SchemaMigrations/Index.ts | 2 +- .../NotificationLogs/WhatsAppLogsTable.tsx | 8 +- 5 files changed, 75 insertions(+), 77 deletions(-) diff --git a/App/FeatureSet/Notification/API/WhatsApp.ts b/App/FeatureSet/Notification/API/WhatsApp.ts index 856c2f182e..c15600190b 100644 --- a/App/FeatureSet/Notification/API/WhatsApp.ts +++ b/App/FeatureSet/Notification/API/WhatsApp.ts @@ -188,9 +188,8 @@ const updateWhatsAppLogStatus = async ( ? String(statusPayload["status"]) : undefined; - const derivedStatus: WhatsAppStatus = mapWebhookStatusToWhatsAppStatus( - rawStatus, - ); + const derivedStatus: WhatsAppStatus = + mapWebhookStatusToWhatsAppStatus(rawStatus); const statusMessage: string | undefined = buildStatusMessage(statusPayload); @@ -317,59 +316,56 @@ 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; +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 = await GlobalConfigService.findOneBy({ - query: { - _id: ObjectID.getZeroObjectID().toString(), - }, - props: { - isRoot: true, - }, - select: { - metaWhatsAppWebhookVerifyToken: true, - }, - }); + if (mode === "subscribe" && challenge) { + const globalConfigTokenResponse = await GlobalConfigService.findOneBy({ + query: { + _id: ObjectID.getZeroObjectID().toString(), + }, + props: { + isRoot: true, + }, + select: { + metaWhatsAppWebhookVerifyToken: true, + }, + }); - const configuredVerifyToken: string | undefined = - globalConfigTokenResponse?.metaWhatsAppWebhookVerifyToken?.trim() || - undefined; + 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.", + if (!configuredVerifyToken) { + logger.error( + "Meta WhatsApp webhook verify token is not configured. Rejecting verification request.", ); res.sendStatus(403); return; } - res.sendStatus(400); - }, -); + 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", @@ -377,7 +373,9 @@ router.post( try { const body: JSONObject = req.body as JSONObject; - if ((body["object"] as string | undefined) !== "whatsapp_business_account") { + if ( + (body["object"] as string | undefined) !== "whatsapp_business_account" + ) { logger.debug( `[Meta WhatsApp Webhook] Received event for unsupported object: ${JSON.stringify(body)}`, ); diff --git a/App/FeatureSet/Notification/Services/WhatsAppService.ts b/App/FeatureSet/Notification/Services/WhatsAppService.ts index a8f95658c0..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.Sent; + whatsAppLog.status = WhatsAppStatus.Sent; whatsAppLog.statusMessage = messageId ? `Message ID: ${messageId}` : "WhatsApp message sent successfully"; @@ -474,15 +474,14 @@ export default class WhatsAppService { await UserOnCallLogTimelineService.updateOneById({ id: options.userOnCallLogTimelineId, data: { - status: - [ - WhatsAppStatus.Success, - WhatsAppStatus.Sent, - WhatsAppStatus.Delivered, - WhatsAppStatus.Read, - ].includes(whatsAppLog.status || WhatsAppStatus.Error) - ? 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/Common/Server/Infrastructure/Postgres/SchemaMigrations/1760357680881-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1760357680881-MigrationName.ts index 62d5ee32cc..c46f27898e 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1760357680881-MigrationName.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1760357680881-MigrationName.ts @@ -1,16 +1,23 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class MigrationName1760357680881 implements MigrationInterface { - public name = 'MigrationName1760357680881' + 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"`); - } + 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 f24da1bfbf..0b7bf88c93 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts @@ -357,5 +357,5 @@ export default [ RenameUserTwoFactorAuthToUserTotpAuth1759234532998, MigrationName1759943124812, MigrationName1760345757975, - MigrationName1760357680881 + MigrationName1760357680881, ]; diff --git a/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx b/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx index 49f7f9ea5b..57338339df 100644 --- a/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx +++ b/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx @@ -112,13 +112,7 @@ const WhatsAppLogsTable: FunctionComponent = ( const pillColor = getStatusColor(normalizedStatus); - return ( - - ); + return ; }, }, ]; From 74c3dde7f10ed5d403ab76356953cef363772b9b Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Mon, 13 Oct 2025 13:31:56 +0100 Subject: [PATCH 8/8] refactor: improve type annotations and enhance readability in WhatsApp API and LogsTable components --- App/FeatureSet/Notification/API/WhatsApp.ts | 28 ++++++++++--------- .../NotificationLogs/WhatsAppLogsTable.tsx | 10 +++++-- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/App/FeatureSet/Notification/API/WhatsApp.ts b/App/FeatureSet/Notification/API/WhatsApp.ts index c15600190b..faae1b6d7f 100644 --- a/App/FeatureSet/Notification/API/WhatsApp.ts +++ b/App/FeatureSet/Notification/API/WhatsApp.ts @@ -1,5 +1,6 @@ import WhatsAppService from "../Services/WhatsAppService"; import BadDataException from "Common/Types/Exception/BadDataException"; +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"; @@ -170,9 +171,9 @@ export const buildStatusMessage: (payload: JSONObject) => string | undefined = ( return `${statusMessage.substring(0, MAX_STATUS_MESSAGE_LENGTH - 3)}...`; }; -const updateWhatsAppLogStatus = async ( +const updateWhatsAppLogStatus: ( statusPayload: JSONObject, -): Promise => { +) => Promise = async (statusPayload: JSONObject): Promise => { const messageId: string | undefined = statusPayload["id"] ? String(statusPayload["id"]) : undefined; @@ -328,17 +329,18 @@ router.get("/webhook", async (req: ExpressRequest, res: ExpressResponse) => { : undefined; if (mode === "subscribe" && challenge) { - const globalConfigTokenResponse = await GlobalConfigService.findOneBy({ - query: { - _id: ObjectID.getZeroObjectID().toString(), - }, - props: { - isRoot: true, - }, - select: { - metaWhatsAppWebhookVerifyToken: true, - }, - }); + 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() || diff --git a/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx b/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx index 57338339df..e83787c729 100644 --- a/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx +++ b/Dashboard/src/Components/NotificationLogs/WhatsAppLogsTable.tsx @@ -36,7 +36,9 @@ const WhatsAppLogsTable: FunctionComponent = ( const [modalText, setModalText] = useState(""); const [modalTitle, setModalTitle] = useState(""); - const getStatusColor = (status?: WhatsAppStatus): Color => { + const getStatusColor: (status?: WhatsAppStatus) => Color = ( + status?: WhatsAppStatus, + ): Color => { switch (status) { case WhatsAppStatus.Success: case WhatsAppStatus.Delivered: @@ -57,7 +59,9 @@ const WhatsAppLogsTable: FunctionComponent = ( } }; - const parseStatus = (status?: string): WhatsAppStatus | undefined => { + const parseStatus: (status?: string) => WhatsAppStatus | undefined = ( + status?: string, + ): WhatsAppStatus | undefined => { if (!status) { return undefined; } @@ -110,7 +114,7 @@ const WhatsAppLogsTable: FunctionComponent = ( const normalizedStatus: WhatsAppStatus | undefined = parseStatus(statusValue); - const pillColor = getStatusColor(normalizedStatus); + const pillColor: Color = getStatusColor(normalizedStatus); return ; },