Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BAH-2180|sms-alerts-appointments-booking #89

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.openmrs.module.appointments.notification;

import java.util.Date;

public interface SmsSender {
void send(String recipientMobileNumber, String patientName, String smsType, String serviceName, Date startDateTime, String teleLink);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was hoping the interface is Channel vocabulary based and not Appointment vocabulary based. Something like:

MessageSender.send(String phoneNumber, String fullMessage).

Then SMS Sender, Whatsapp Sender, etc can easily implement this interface. They don't care if the message is goign to patient, doctor, customer, relative, etc. They just send a message over the appropriate channel to the number. Its the caller of these classes that constructs the message and delegates the "sending" to the channel implementation.

So.. Appointment ---delegates to ---> AppointmentMessage class ---delegates to--> SMSSender class ---> sends SMS

Similarly,
Appointment ---delegates to ---> AppointmentMessage class ---delegates to (based on config)--> WhatsappSender class ---> sends SMS

Then we write unit tests for AppointmentMessage class which checks if the construction of message is correct based on passed parameters like PatientName, Service Name, Date, Link.

With this logic, the MessageSender interface and its implementations are reusable in the context of different messages (Appointments, Registration, Teleconsult, Doctor-Doctor consult, etc).

For this JIRA ticket, we would only implement SMS (and not whatzapp classes).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building of the message text requires sms template identifier. The message template format is available in SMSSender class (DefaultSmsSender) which is needed as outgoing SMS has to be conforming to this format.
Either message template is available in config file or in global properties in openmrs.
Please advise

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we don't need different templates for SMS or Whatzapp. We just create a template called: appointmentMessageTemplate and reuse the same template for SMS or Whatzapp etc... Or, depending on the current configured settings (SMS, Whatsapp, etc) -- the loaded properties file by AppointmentMessage class is: load appointmentMessageTemplate-sms.properties and constructs the string message, and calls the SMSSender.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.openmrs.module.appointments.notification.impl;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.openmrs.Patient;
import org.openmrs.PersonAttribute;
import org.openmrs.api.context.Context;
import org.openmrs.module.appointments.model.Appointment;
import org.openmrs.module.appointments.notification.AppointmentEventNotifier;
import org.openmrs.module.appointments.notification.NotificationException;
import org.openmrs.module.appointments.notification.NotificationResult;
import org.openmrs.module.appointments.notification.SmsSender;

public class DefaultAppointmentPatientSmsNotifier implements AppointmentEventNotifier {
private Log log = LogFactory.getLog(this.getClass());

private final static String PROP_SEND_APPT_SMS = "bahmni.appointment.sendSms";

private static final String SMS_NOT_CONFIGURED = "SMS notification can not be sent to patient. Phone number not configured.";
private static final String SMS_SENT = "SMS sent to patient";
private static final String MEDIUM_SMS = "SMS";
private static final String SMS_FAILURE = "Failed to send sms to patient";
private static final String SMS_NOT_SENT = "SMS notification not configured to be sent to patient.";

private SmsSender smsSender;

public DefaultAppointmentPatientSmsNotifier() {}

public DefaultAppointmentPatientSmsNotifier(SmsSender smsSender) {
this.smsSender = smsSender;
}

@Override
public String getMedium() {
return MEDIUM_SMS;
}

@Override
public boolean isApplicable(final Appointment appointment) {
boolean sendSmsToPatient = shouldSendSmsToPatient();
if (!sendSmsToPatient) {
log.warn(SMS_NOT_SENT);
}
return sendSmsToPatient;
}

@Override
public NotificationResult sendNotification(final Appointment appointment) throws NotificationException {
Patient patient = appointment.getPatient();
PersonAttribute patientPhoneAttribute = patient.getPerson().getAttribute("phoneNumber");
if (patientPhoneAttribute != null) {
String patientPhoneNumber = patientPhoneAttribute.getValue();
String patientName = appointment.getPatient().getGivenName();
String apptServiceName = appointment.getService().getName();
String teleLink = StringUtils.isNotBlank(appointment.getTeleHealthVideoLink()) ? appointment.getTeleHealthVideoLink() : "";
String smsType = appointment.getAppointmentKind().toString();
try {
log.info("Sending sms through: " + smsSender.getClass());
smsSender.send(patientPhoneNumber, patientName, smsType, apptServiceName, appointment.getStartDateTime(), teleLink);
return new NotificationResult("", "SMS", 0, SMS_SENT);
} catch (Exception e) {
log.error(SMS_FAILURE, e);
throw new NotificationException(SMS_FAILURE, e);
}
} else {
log.warn(SMS_NOT_CONFIGURED);
return new NotificationResult(null, "SMS", 1, SMS_NOT_CONFIGURED);
}
}

private boolean shouldSendSmsToPatient() {
String shouldSendEmail = Context.getAdministrationService().getGlobalProperty(PROP_SEND_APPT_SMS, "false");
return Boolean.valueOf(shouldSendEmail);
}

public void setSmsSender(SmsSender smsSender) {
log.warn("Replacing default SmsSender: " + this.smsSender + ", with:" + smsSender);
this.smsSender = smsSender;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package org.openmrs.module.appointments.notification.impl;

import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.openmrs.api.AdministrationService;
import org.openmrs.module.appointments.notification.SmsSender;
import org.openmrs.util.OpenmrsUtil;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Properties;

public class DefaultSmsSender implements SmsSender {

private Log log = LogFactory.getLog(this.getClass());

private static final String SMS_PROPERTIES_FILENAME = "sms-config.properties";
private static Properties smsSessionProperties = null;

SimpleDateFormat fmtTime = new SimpleDateFormat("HH:mm a");
SimpleDateFormat fmtDateTele = new SimpleDateFormat("dd/MM/yyyy");

private AdministrationService administrationService;

public DefaultSmsSender(AdministrationService administrationService) {
this.administrationService = administrationService;
}

@Override
public void send(String recipientMobileNumber, String patientName, String smsType, String serviceName, Date startDateTime, String teleLink) {
HttpURLConnection uc = null;
try {
getSession();

String password = "";
String addOnParams = "";
String entityId = "";
String templateId = "";
String msgText = "";

recipientMobileNumber = recipientMobileNumber.replace("+", "");
int recipientPhoneLengthRequired = Integer.parseInt(smsSessionProperties.getProperty("sms.phonenumber.length"));
if (recipientMobileNumber.length()>recipientPhoneLengthRequired) {
int indexStart = recipientMobileNumber.length() - recipientPhoneLengthRequired;
recipientMobileNumber = recipientMobileNumber.substring(indexStart);
}
String dateSuffixText = getDateWithSuffix(startDateTime.getDate());
String dateApptPattern = String.format("MMMM '%s', yyyy", dateSuffixText);
SimpleDateFormat fmtDateAppt = new SimpleDateFormat(dateApptPattern);

String requestUrl = smsSessionProperties.getProperty("sms.url");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of logic for string concatenation. Needs tests. See the previous comment on how this could possibly be restructured into an AppointmentMessage class, which constructs the message string.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

String apiKey = smsSessionProperties.getProperty("sms.key.username") + "=" + URLEncoder.encode(smsSessionProperties.getProperty("sms.username"), "UTF-8");
String sender = "&" + smsSessionProperties.getProperty("sms.key.sender") + "=" + URLEncoder.encode(smsSessionProperties.getProperty("sms.sender"), "UTF-8");
String number = "&" + smsSessionProperties.getProperty("sms.key.phonenumber") + "=" + URLEncoder.encode(recipientMobileNumber, "UTF-8");

if (StringUtils.isNotBlank(smsSessionProperties.getProperty("sms.key.password"))) {
password = "&" + smsSessionProperties.getProperty("sms.key.password") + "=" + URLEncoder.encode(smsSessionProperties.getProperty("sms.password") , "UTF-8");
}
if (StringUtils.isNotBlank(smsSessionProperties.getProperty("sms.key.entity.identifier"))) {
entityId = "&" + smsSessionProperties.getProperty("sms.key.entity.identifier") + "=" + URLEncoder.encode(smsSessionProperties.getProperty("sms.entity.identifier"), "UTF-8");
}
if (StringUtils.isNotBlank(smsSessionProperties.getProperty("sms.key.template.identifier")) && "Virtual".equalsIgnoreCase(smsType)) {
templateId = "&" + smsSessionProperties.getProperty("sms.key.template.identifier") + "=" +
URLEncoder.encode(smsSessionProperties.getProperty("sms.appointment.teleconsultation.template.identifier"), "UTF-8");
// Dear %s,\nTele-Consultation booked for %s %s\nVideo link: %s Bahmni Hospital(IPLit)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the comment

msgText = String.format(smsSessionProperties.getProperty("sms.appointment.teleconsultation.message.template"), patientName,
fmtDateTele.format(startDateTime), fmtTime.format(startDateTime), teleLink);
}
if (StringUtils.isNotBlank(smsSessionProperties.getProperty("sms.key.template.identifier")) && "WalkIn".equalsIgnoreCase(smsType)) {
templateId = "&" + smsSessionProperties.getProperty("sms.key.template.identifier") + "=" +
URLEncoder.encode(smsSessionProperties.getProperty("sms.appointment.walkin.template.identifier"), "UTF-8");
// Dear %s,\n Your appointment is booked for %s on %s at %s by Bahmni Hospital (powered by IPLit)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remove the comment

msgText = String.format(smsSessionProperties.getProperty("sms.appointment.walkin.message.template"), patientName,
serviceName, fmtDateAppt.format(startDateTime), fmtTime.format(startDateTime));
}
if (StringUtils.isNotBlank(smsSessionProperties.getProperty("sms.params.addons"))) {
addOnParams = "&" + smsSessionProperties.getProperty("sms.params.addons");
}
String message = "&" + smsSessionProperties.getProperty("sms.key.message") + "=" + URLEncoder.encode(msgText, "UTF-8");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use a StringJoiner or a StringBuilder

requestUrl += apiKey + message + sender + number + password + entityId + templateId + addOnParams;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sending the msg itself through the URL is not a standard way of passing the sms. Can you check if you can pass through the body/payload ..
Also, its probably better to use a closeable HTTP Client, and you can use try with resources block


URL url = new URL(requestUrl);
uc = (HttpURLConnection)url.openConnection();
String smsResponse = uc.getResponseMessage();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you aren't logging the response, why assign to a variable?

} catch (Exception e) {
throw new RuntimeException("Error occurred while sending sms", e);
} finally {
if (uc!=null) {
uc.disconnect();
uc = null;
}
}
}

private Properties getSession() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this method called getSession()? all it does it load properties.
maybe call it loadProperties() and not return anything

if (smsSessionProperties == null) {
Properties sessionProperties = smsSessionPropertiesFromPath();
if (sessionProperties == null) {
log.warn("Could not load sms properties from application data directory file. Loading from OMRS settings.");
sessionProperties = smsSessionPropertiesFromOMRS();
}
smsSessionProperties = sessionProperties;
}
return smsSessionProperties;
}

/**
* To be used as fallback. SMS properties are visible in openmrs settings.
* @param as
* @return
*/
private Properties smsSessionPropertiesFromOMRS() {
Properties p = new Properties();
p.put("sms.key.username", administrationService.getGlobalProperty("sms.key.username", ""));
p.put("sms.key.password", administrationService.getGlobalProperty("sms.key.password", ""));
p.put("sms.key.phonenumber", administrationService.getGlobalProperty("sms.key.phonenumber", ""));
p.put("sms.key.message", administrationService.getGlobalProperty("sms.key.message", ""));
p.put("sms.key.sender", administrationService.getGlobalProperty("sms.key.sender", ""));
p.put("sms.key.entity.identifier", administrationService.getGlobalProperty("sms.key.entity.identifier", ""));
p.put("sms.key.template.identifier", administrationService.getGlobalProperty("sms.key.template.identifier", ""));

p.put("sms.url", administrationService.getGlobalProperty("sms.url", ""));
p.put("sms.username", administrationService.getGlobalProperty("sms.username", ""));
p.put("sms.password", administrationService.getGlobalProperty("sms.password", ""));
p.put("sms.sender", administrationService.getGlobalProperty("sms.sender", ""));
p.put("sms.entity.identifier", administrationService.getGlobalProperty("sms.entity.identifier", ""));
p.put("sms.appointment.walkin.template.identifier", administrationService.getGlobalProperty("sms.appointment.walkin.template.identifier", ""));
p.put("sms.appointment.teleconsultation.template.identifier", administrationService.getGlobalProperty("sms.appointment.teleconsultation.template.identifier", ""));
p.put("sms.appointment.walkin.message.template", administrationService.getGlobalProperty("sms.appointment.walkin.message.template", ""));
p.put("sms.appointment.teleconsultation.message.template", administrationService.getGlobalProperty("sms.appointment.teleconsultation.message.template", ""));
p.put("sms.params.addons", administrationService.getGlobalProperty("sms.params.addons", ""));
return p;
}

private Properties smsSessionPropertiesFromPath() {
Path propertyFilePath = Paths.get(OpenmrsUtil.getApplicationDataDirectory(), SMS_PROPERTIES_FILENAME);
if (Files.exists(propertyFilePath)) {
Properties properties = new Properties();
try {
log.info("Reading properties from: " + propertyFilePath);
properties.load(Files.newInputStream(propertyFilePath));
return properties;
} catch (IOException e) {
log.error("Could not load sms properties from: " + propertyFilePath, e);
}
} else {
log.warn("No sms configuration defined at " + propertyFilePath);
}
return null;
}

private String getDateWithSuffix(int date) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really need to hardcode st, nd etc .. why not just convert to string representation of date .. like 30 June 2022. its also not using the locale and using just english. You can leverage a date formatter

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure. Will incorporate all the comments.

switch (date) {
case 1:
case 21:
case 31:
return "" + date + "st";

case 2:
case 22:
return "" + date + "nd";

case 3:
case 23:
return "" + date + "rd";

default:
return "" + date + "th";
}
}

}
8 changes: 8 additions & 0 deletions api/src/main/resources/moduleApplicationContext.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,20 @@
<constructor-arg ref="defaultTCApptMailSender"/>
</bean>

<bean id="defaultApptSmsSender" class="org.openmrs.module.appointments.notification.impl.DefaultSmsSender">
<constructor-arg ref="adminService"/>
</bean>
<bean id="defaultPatientSmsNotifier" class="org.openmrs.module.appointments.notification.impl.DefaultAppointmentPatientSmsNotifier">
<constructor-arg ref="defaultApptSmsSender"/>
</bean>

<!-- <ref bean="defaultAppointmentMailSender"/>-->

<bean id="patientAppointmentNotifierService" class="org.openmrs.module.appointments.service.impl.PatientAppointmentNotifierService">
<property name="eventNotifiers">
<list value-type="org.openmrs.module.appointments.notification.AppointmentEventNotifier">
<ref bean="defaultPatientEmailNotifier"/>
<ref bean="defaultPatientSmsNotifier"/>
</list>
</property>
</bean>
Expand Down