diff --git a/apps/server/package.json b/apps/server/package.json index fe7e7c1ad..4a52d5739 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -36,6 +36,7 @@ "csv-parse": "^5.3.9", "geo-tz": "^7.0.7", "hash-wasm": "^4.9.0", + "libphonenumber-js": "^1.10.56", "mapbox-gl": "1.13", "maplibre-gl": "^3.1.0", "next": "^13.5.4", diff --git a/apps/server/src/Interfaces/NotificationParameters.ts b/apps/server/src/Interfaces/NotificationParameters.ts index 5af5c0ca1..76c4bac37 100644 --- a/apps/server/src/Interfaces/NotificationParameters.ts +++ b/apps/server/src/Interfaces/NotificationParameters.ts @@ -1,10 +1,13 @@ import type DataRecord from './DataRecord'; export interface NotificationParameters { - id: string; - message: string; - subject: string; + id?: string; + message?: string; + subject?: string; url?: string; + authenticationMessage?: boolean; + otp?: string, + siteName?: string, alert?: { id: string; type: string; diff --git a/apps/server/src/Services/Notifier/Notifier/WhatsAppNotifier.ts b/apps/server/src/Services/Notifier/Notifier/WhatsAppNotifier.ts index d337eb29f..55a16d5d5 100644 --- a/apps/server/src/Services/Notifier/Notifier/WhatsAppNotifier.ts +++ b/apps/server/src/Services/Notifier/Notifier/WhatsAppNotifier.ts @@ -1,44 +1,87 @@ +import {prisma} from '../../../server/db'; +import {logger} from '../../../server/logger'; import {type NotificationParameters} from '../../../Interfaces/NotificationParameters'; import type Notifier from '../Notifier'; import {NOTIFICATION_METHOD} from '../methodConstants'; -import twilio from 'twilio'; import {env} from '../../../env.mjs'; -import {logger} from '../../../../src/server/logger'; class WhatsAppNotifier implements Notifier { getSupportedMethods(): Array<string> { return [NOTIFICATION_METHOD.WHATSAPP]; } - notify( + async deleteNotificationDisableAndUnverifyWhatsApp(destination: string, notificationId: string): Promise<void> { + try { + // Delete the notification + await prisma.notification.delete({ + where: { + id: notificationId, + }, + }); + // Unverify and disable the alertMethod + await prisma.alertMethod.updateMany({ + where: { + destination: destination, + method: NOTIFICATION_METHOD.WHATSAPP, + }, + data: { + isVerified: false, + isEnabled: false, + }, + }); + logger(`Notification with ID: ${notificationId} deleted and alertMethod for destination: ${destination} has been unverified and disabled.`, "info"); + } catch (error) { + logger(`Database Error: Couldn't modify the alertMethod or delete the notification: ${error}`, "error"); + } + } + + async notify( destination: string, parameters: NotificationParameters, ): Promise<boolean> { - const {message, url} = parameters; - logger(`Sending WhatsApp message ${message} to ${destination}`, 'info'); - - // Twilio Credentials - const accountSid = env.TWILIO_ACCOUNT_SID; - const authToken = env.TWILIO_AUTH_TOKEN; - const whatsappNumber = env.TWILIO_WHATSAPP_NUMBER; - const client = twilio(accountSid, authToken); + // logger(`Sending message ${message} to ${destination}`, "info"); + + // construct the payload for Webhook + const payload = { + ...parameters + }; - // Define message body and send message - const messageBody = `${message} ${url ? url : ''}`; + const WHATSAPP_ENDPOINT_URL = `${env.WHATSAPP_ENDPOINT_URL}?whatsAppId=${destination}`; + + // call WehHook to send the notification + const response = await fetch(WHATSAPP_ENDPOINT_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-token-api': env.WHATSAPP_ENDPOINT_AUTH_TOKEN + }, + body: JSON.stringify(payload), + }); - return client.messages - .create({ - body: messageBody, - from: 'whatsapp:' + whatsappNumber, - to: 'whatsapp:' + destination, - }) - .then(() => { - return true; - }) - .catch(error => { - logger(`Failed to send WhatsApp message. Error: ${error}`, 'error'); - return false; - }); + if (!response.ok) { + logger( + `Failed to send webhook notification. Error: ${response.statusText} for ${parameters.id}.`, + 'error', + ); + // Specific status code handling + if (response.status === 404) { + // Webhook URL Not Found - Token not found + await this.deleteNotificationDisableAndUnverifyWhatsApp(destination, parameters.id as string); + } else if (response.status === 401){ + // Unauthorized + await this.deleteNotificationDisableAndUnverifyWhatsApp(destination, parameters.id as string); + } else if (response.status === 403){ + // Forbidden + await this.deleteNotificationDisableAndUnverifyWhatsApp(destination, parameters.id as string); + } else { + logger( + `Failed to send webhook notification. Something went wrong. Try again in next run.`, + 'error', + ); + } + return false; + } + return true; } } diff --git a/apps/server/src/env.mjs b/apps/server/src/env.mjs index e10f2e4a5..b58bbce16 100644 --- a/apps/server/src/env.mjs +++ b/apps/server/src/env.mjs @@ -36,7 +36,9 @@ const server = z.object({ PLANET_API_URL: z.string(), NEXT_PUBLIC_SENTRY_DSN: z.string().optional(), CRON_KEY: z.string().optional(), - NEXT_PUBLIC_LOGTAIL_SOURCE_TOKEN: z.string().optional() + NEXT_PUBLIC_LOGTAIL_SOURCE_TOKEN: z.string().optional(), + WHATSAPP_ENDPOINT_URL: z.string(), + WHATSAPP_ENDPOINT_AUTH_TOKEN: z.string() }); /** @@ -75,7 +77,9 @@ const processEnv = { PLANET_API_URL: process.env.PLANET_API_URL, NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, CRON_KEY: process.env.CRON_KEY, - NEXT_PUBLIC_LOGTAIL_SOURCE_TOKEN: process.env.NEXT_PUBLIC_LOGTAIL_SOURCE_TOKEN + NEXT_PUBLIC_LOGTAIL_SOURCE_TOKEN: process.env.NEXT_PUBLIC_LOGTAIL_SOURCE_TOKEN, + WHATSAPP_ENDPOINT_URL: process.env.WHATSAPP_ENDPOINT_URL, + WHATSAPP_ENDPOINT_AUTH_TOKEN: process.env.WHATSAPP_ENDPOINT_AUTH_TOKEN }; diff --git a/apps/server/src/pages/api/cron/text-message-callback-handler.ts b/apps/server/src/pages/api/cron/text-message-callback-handler.ts new file mode 100644 index 000000000..0214ced59 --- /dev/null +++ b/apps/server/src/pages/api/cron/text-message-callback-handler.ts @@ -0,0 +1,89 @@ +//To reach this endpoint call this URL (POST): http://localhost:3000/api/cron/text-message-callback-handler +import type { NextApiRequest, NextApiResponse } from 'next'; +import { prisma } from '../../../server/db'; +// import NotifierRegistry from '../../../Services/Notifier/NotifierRegistry'; +import { logger } from '../../../server/logger'; +// import { NotificationParameters } from '../../../Interfaces/NotificationParameters'; +import { parsePhoneNumberFromString } from 'libphonenumber-js'; +import {env} from "../../../env.mjs"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + res.status(405).end(`Method ${req.method} Not Allowed`); + return; + } + // Verify the 'cron_key' in the request headers before proceeding + if (env.CRON_KEY) { + // Verify the 'cron_key' in the request headers + const cronKey = req.query['cron_key']; + if (!cronKey || cronKey !== env.CRON_KEY) { + res.status(403).json({message: "Unauthorized: Invalid Cron Key"}); + return; + } + } + const { alertMethodMethod, action, destination } = req.body; + + if (!alertMethodMethod || typeof alertMethodMethod !== 'string') { + res.status(400).json({ message: 'alertMethodMethod must be a string.', status: '400' }); + return; + } + + if (!['sms', 'whatsapp'].includes(alertMethodMethod)) { + res.status(400).json({ message: 'Invalid alertMethodMethod provided.', status: '400' }); + return; + } + + if (typeof destination !== 'string') { + res.status(400).json({ message: 'Destination must be a string.', status: '400' }); + return; + } + + const formattedPhoneNumber = destination.startsWith('+') ? destination : '+' + destination; + const parsedPhoneNumber = parsePhoneNumberFromString(formattedPhoneNumber); + let phoneNumberE164 = ''; + + if (parsedPhoneNumber && parsedPhoneNumber.isValid()) { + phoneNumberE164 = parsedPhoneNumber.format('E.164'); + } else { + res.status(400).json({ message: 'Invalid destination phone number.', status: '400' }); + return; + } + + if (action === 'STOP') { + try { + const unverifyAlertMethod = await prisma.alertMethod.updateMany({ + where: { + destination: phoneNumberE164, + method: alertMethodMethod, + }, + data: { + isVerified: false, + }, + }); + + if (unverifyAlertMethod.count > 0) { + // const notificationParameters: NotificationParameters = { + // message: `Your FireAlert notifications for ${alertMethodMethod} have been stopped, and your number has been unverified. If this is an error, please verify your number again from our app.`, + // subject: 'FireAlert Notification STOP', + // }; + + // const notifier = NotifierRegistry.get(alertMethodMethod); + // const isDelivered = await notifier.notify(phoneNumberE164, notificationParameters); + + // if (isDelivered) { + // res.status(200).json({ message: `Notification sent successfully via ${alertMethodMethod}.`, status: '200' }); + // } else { + // res.status(500).json({ message: `Failed to send notification via ${alertMethodMethod}.`, status: '500' }); + // } + res.status(200).json({ message: `Successfully handled the WhatsApp callback action.`, status: '200' }); + } else { + res.status(404).json({ message: `No ${alertMethodMethod} alertMethods associated with that phonenumber`, status: '404' }); + } + } catch (error) { + logger(`Error in ${alertMethodMethod} service handler: ${error}`, 'error'); + res.status(500).json({ message: `Internal Server Error`, status: '500' }); + } + } else { + res.status(400).json({ message: 'Invalid action provided.', status: '400' }); + } +} diff --git a/apps/server/src/pages/api/tests/notify.ts b/apps/server/src/pages/api/tests/notify.ts index a9881a458..f6259618c 100644 --- a/apps/server/src/pages/api/tests/notify.ts +++ b/apps/server/src/pages/api/tests/notify.ts @@ -1,13 +1,31 @@ // to execute, point your browser to: http://localhost:3000/api/tests/notify -import {type NextApiResponse} from 'next'; +import {NextApiRequest, type NextApiResponse} from 'next'; import {PrismaClient} from '@prisma/client'; import NotifierRegistry from '../../../Services/Notifier/NotifierRegistry'; import {logger} from '../../../../src/server/logger'; +import {env} from "../../../../src/env.mjs"; export default async function notify( - res: NextApiResponse, + req: NextApiRequest, + res: NextApiResponse ) { + + if(env.NODE_ENV !== 'development'){ + return res.status(401).json({ + message: "Unauthorized for Production. Only use this endpoint for development.", + status: 401, + }); + } + if (env.CRON_KEY) { + // Verify the 'cron_key' in the request headers + const cronKey = req.query['cron_key']; + if (!cronKey || cronKey !== env.CRON_KEY) { + res.status(403).json({message: "Unauthorized: Invalid Cron Key"}); + return; + } + } + const prisma = new PrismaClient({ log: ['query', 'info', 'warn', 'error'], }); diff --git a/apps/server/src/pages/api/tests/sms.ts b/apps/server/src/pages/api/tests/sms.ts index 2afde717f..d0959d5b2 100644 --- a/apps/server/src/pages/api/tests/sms.ts +++ b/apps/server/src/pages/api/tests/sms.ts @@ -3,10 +3,26 @@ import { type NextApiRequest, type NextApiResponse } from "next"; import { logger } from "../../../../src/server/logger"; import NotifierRegistry from "../../../Services/Notifier/NotifierRegistry"; import { NotificationParameters } from "../../../Interfaces/NotificationParameters"; // Adjust this import path if necessary +import {env} from "../../../../src/env.mjs"; export default async function testSms(req: NextApiRequest, res: NextApiResponse) { logger(`Running Test SMS Sender.`, "info"); + if(env.NODE_ENV !== 'development'){ + return res.status(401).json({ + message: "Unauthorized for production. Only use this endpoint for development.", + status: 401, + }); + } + if (env.CRON_KEY) { + // Verify the 'cron_key' in the request headers + const cronKey = req.query['cron_key']; + if (!cronKey || cronKey !== env.CRON_KEY) { + res.status(403).json({message: "Unauthorized: Invalid Cron Key"}); + return; + } + } + // Extract the phone number from the query parameters const destination = req.query['phoneNumber'] as string; if (!destination) { diff --git a/apps/server/src/pages/api/tests/whatsapp.ts b/apps/server/src/pages/api/tests/whatsapp.ts new file mode 100644 index 000000000..6635639ba --- /dev/null +++ b/apps/server/src/pages/api/tests/whatsapp.ts @@ -0,0 +1,86 @@ +// Call this api to run this page: http://localhost:3000/api/tests/whatsapp?phoneNumber="E.164-phone-number" +import { type NextApiRequest, type NextApiResponse } from "next"; +import { logger } from "../../../../src/server/logger"; +import NotifierRegistry from "../../../Services/Notifier/NotifierRegistry"; +import { NotificationParameters } from "../../../Interfaces/NotificationParameters"; // Adjust this import path if necessary +import {env} from "../../../../src/env.mjs"; + +export default async function testWhatsApp(req: NextApiRequest, res: NextApiResponse) { + logger(`Running Test WhatsApp Sender.`, "info"); + + if(env.NODE_ENV !== 'development'){ + return res.status(401).json({ + message: "Unauthorized for production. Only use this endpoint for development.", + status: 401, + }); + } + if (env.CRON_KEY) { + // Verify the 'cron_key' in the request headers + const cronKey = req.query['cron_key']; + if (!cronKey || cronKey !== env.CRON_KEY) { + res.status(403).json({message: "Unauthorized: Invalid Cron Key"}); + return; + } + } + + // Extract the phone number from the query parameters + const destination = req.query['phoneNumber'] as string; + + if (!destination) { + return res.status(400).json({ + message: "Error: Phone number is required.", + status: 400, + }); + } + + // URL encode the phone number + const encodedPhoneNumber: string = encodeURIComponent(destination); + + // Create the notification parameters for an alert + const notificationParameters_alert: NotificationParameters = { + message: "Fire detected inside Las Americas 7A", + subject: "FireAlert", + url: "https://firealert.plant-for-the-planet.org/alert/ed1cf199-6c3a-4406-bac0-eb5519391e2e", + id: "notificationId", + authenticationMessage: true, + otp: "12345", + siteName: 'Las Americas', + alert:{ + id: "ed1cf199-6c3a-4406-bac0-eb5519391e2e", + type: 'fire', + confidence: 'high', + source: "TEST", + date: new Date(), + longitude: 80.45728, + latitude: 66.66537, + distance: 0, + siteId: "siteId1", + siteName: "SiteName", + data: {}, + } + }; + + try { + // Use the NotifierRegistry to get the WhatsApp notifier + const notifier = NotifierRegistry.get('whatsapp'); + const isDelivered = await notifier.notify(encodedPhoneNumber, notificationParameters_alert); + + if (isDelivered) { + res.status(200).json({ + message: "WhatsApp Message Sent Successfully!", + status: 200, + }); + } else { + res.status(500).json({ + message: "Failed to send WhatsApp Message.", + status: 500, + }); + } + } catch (error) { + logger(`Error sending test WhatsApp: ${error}`, "error"); + res.status(500).json({ + message: `Error sending WhatsApp Message`, + status: 500, + }); + } +} diff --git a/apps/server/src/server/api/routers/alertMethod.ts b/apps/server/src/server/api/routers/alertMethod.ts index 951ecd610..1e6ec0c13 100644 --- a/apps/server/src/server/api/routers/alertMethod.ts +++ b/apps/server/src/server/api/routers/alertMethod.ts @@ -1,4 +1,5 @@ import {TRPCError} from "@trpc/server"; +import { parsePhoneNumberFromString } from 'libphonenumber-js'; import { createAlertMethodSchema, params, @@ -225,6 +226,21 @@ export const alertMethodRouter = createTRPCRouter({ }); } } + // If Method is WhatsApp, + if (input.method === 'whatsapp') { + const phoneNumber = parsePhoneNumberFromString(input.destination); + + if (phoneNumber && phoneNumber.isValid()) { + // If the phone number is valid, update destination to the normalized international format + input.destination = phoneNumber.format('E.164'); + } else { + // If the phone number is not valid, throw an error + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Invalid WhatsApp Phone Number.`, + }); + } + } // For all other alertMethod methods, create alertMethod, then sendVerification code. try { const alertMethod = await ctx.prisma.alertMethod.create({ diff --git a/apps/server/src/utils/routers/alertMethod.ts b/apps/server/src/utils/routers/alertMethod.ts index d09031eae..a02e9755b 100644 --- a/apps/server/src/utils/routers/alertMethod.ts +++ b/apps/server/src/utils/routers/alertMethod.ts @@ -332,6 +332,13 @@ export const handlePendingVerification = async ( otp, url, ); + } else if (alertMethod.method === 'whatsapp') { + const notifier = NotifierRegistry.get(alertMethod.method); + const params = { + authenticationMessage: true, + otp: otp + } + await notifier.notify(alertMethod.destination, params); } else { // Use NotifierRegistry to send the verification code const notifier = NotifierRegistry.get(alertMethod.method); diff --git a/yarn.lock b/yarn.lock index a9ecc2405..74345c497 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7165,6 +7165,11 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +libphonenumber-js@^1.10.56: + version "1.10.56" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.56.tgz#96d9d64c31888ec363ed99fbf77cf4ad12117aa4" + integrity sha512-d0GdKshNnyfl5gM7kZ9rXjGiAbxT/zCXp0k+EAzh8H4zrb2R7GXtMCrULrX7UQxtfx6CLy/vz/lomvW79FAFdA== + lie@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"