Skip to content

Commit

Permalink
Merge pull request #140 from Plant-for-the-Planet-org/feature/whatsap…
Browse files Browse the repository at this point in the history
…p-notifier

Implement WhatsApp Notification Service and Callback URL Feature
  • Loading branch information
dhakalaashish authored Feb 16, 2024
2 parents 2918576 + c58c5a4 commit e5e99b6
Show file tree
Hide file tree
Showing 11 changed files with 321 additions and 33 deletions.
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 6 additions & 3 deletions apps/server/src/Interfaces/NotificationParameters.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
95 changes: 69 additions & 26 deletions apps/server/src/Services/Notifier/Notifier/WhatsAppNotifier.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}

Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
});

/**
Expand Down Expand Up @@ -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
};


Expand Down
89 changes: 89 additions & 0 deletions apps/server/src/pages/api/cron/text-message-callback-handler.ts
Original file line number Diff line number Diff line change
@@ -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' });
}
}
22 changes: 20 additions & 2 deletions apps/server/src/pages/api/tests/notify.ts
Original file line number Diff line number Diff line change
@@ -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'],
});
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/pages/api/tests/sms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
86 changes: 86 additions & 0 deletions apps/server/src/pages/api/tests/whatsapp.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
Loading

0 comments on commit e5e99b6

Please sign in to comment.