diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseEmail.java b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseEmail.java new file mode 100644 index 000000000..978e02bbd --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseEmail.java @@ -0,0 +1,74 @@ +package com.objectcomputing.checkins.services.pulse; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; +import com.objectcomputing.checkins.notifications.email.EmailSender; +import com.objectcomputing.checkins.notifications.email.MailJetFactory; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; + +import jakarta.inject.Named; + +import java.util.stream.Collectors; +import java.util.List; + +class PulseEmail { + private final EmailSender emailSender; + private final CheckInsConfiguration checkInsConfiguration; + private final MemberProfileServices memberProfileServices; + + private final String SUBJECT = "Check Out the Pulse Survey!"; + + public PulseEmail(@Named(MailJetFactory.HTML_FORMAT) EmailSender emailSender, + CheckInsConfiguration checkInsConfiguration, + MemberProfileServices memberProfileServices) { + this.emailSender = emailSender; + this.checkInsConfiguration = checkInsConfiguration; + this.memberProfileServices = memberProfileServices; + } + + private List getActiveTeamMembers() { + List profiles = memberProfileServices.findAll().stream() + .filter(p -> p.getTerminationDate() == null) + .map(p -> p.getWorkEmail()) + .collect(Collectors.toList()); + return profiles; + } + + private String getEmailContent() { +/* + + + + + + Please fill out your Pulse survey, if you haven't already done so. We want to know how you're doing! + Click here to begin. + + + + +*/ + return String.format(""" + + +
+

+

+
+ Please fill out your Pulse survey, if you haven't already done so. We want to know how you're doing!
+

+
+ Click here to begin.
+
+ + + +""", checkInsConfiguration.getWebAddress()); + } + + public void send() { + final List recipients = getActiveTeamMembers(); + final String content = getEmailContent(); + emailSender.sendEmail(null, null, SUBJECT, content, + recipients.toArray(new String[recipients.size()])); + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServices.java b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServices.java new file mode 100644 index 000000000..2170d8ae2 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServices.java @@ -0,0 +1,7 @@ +package com.objectcomputing.checkins.services.pulse; + +import java.time.LocalDate; + +public interface PulseServices { + public void sendPendingEmail(LocalDate now); +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java new file mode 100644 index 000000000..79e040443 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java @@ -0,0 +1,119 @@ +package com.objectcomputing.checkins.services.pulse; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; +import com.objectcomputing.checkins.notifications.email.EmailSender; +import com.objectcomputing.checkins.notifications.email.MailJetFactory; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; +import com.objectcomputing.checkins.services.settings.SettingsServices; +import com.objectcomputing.checkins.services.settings.Setting; +import com.objectcomputing.checkins.exceptions.NotFoundException; + +import lombok.Getter; +import lombok.AllArgsConstructor; + +import jakarta.inject.Named; +import jakarta.inject.Singleton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.HashMap; +import java.time.LocalDate; +import java.time.DayOfWeek; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; + +@Singleton +public class PulseServicesImpl implements PulseServices { + @Getter + @AllArgsConstructor + private class Frequency { + private final int count; + private final ChronoUnit units; + } + + private static final Logger LOG = LoggerFactory.getLogger(PulseServicesImpl.class); + private final EmailSender emailSender; + private final CheckInsConfiguration checkInsConfiguration; + private final MemberProfileServices memberProfileServices; + private final SettingsServices settingsServices; + private final Map sent = new HashMap(); + + private final DayOfWeek emailDay = DayOfWeek.MONDAY; + + private String setting = "bi-weekly"; + private final Map frequency = Map.of( + "weekly", new Frequency(1, ChronoUnit.WEEKS), + "bi-weekly", new Frequency(2, ChronoUnit.WEEKS), + "monthly", new Frequency(1, ChronoUnit.MONTHS) + ); + + public PulseServicesImpl( + @Named(MailJetFactory.HTML_FORMAT) EmailSender emailSender, + CheckInsConfiguration checkInsConfiguration, + MemberProfileServices memberProfileServices, + SettingsServices settingsServices) { + this.emailSender = emailSender; + this.checkInsConfiguration = checkInsConfiguration; + this.memberProfileServices = memberProfileServices; + this.settingsServices = settingsServices; + } + + public void sendPendingEmail(LocalDate check) { + if (check.getDayOfWeek() == emailDay) { + LOG.info("Checking for pending Pulse email"); + // Start from the first of the year and move forward to ensure that we + // are sending email during the correct week. + LocalDate start = check.with(TemporalAdjusters.firstDayOfYear()) + .with(TemporalAdjusters.firstInMonth(emailDay)); + + try { + Setting freq = settingsServices.findByName("PULSE_EMAIL_FREQUENCY"); + if (frequency.containsKey(freq.getValue())) { + setting = freq.getValue(); + } else { + LOG.error("Invalid Pulse Email Frequency Setting: " + freq.getValue()); + } + } catch(NotFoundException ex) { + // Use the default setting. + LOG.error("Pulse Frequency Error: " + ex.toString()); + } + + LOG.info("Using Pulse Frequency: " + setting); + final Frequency freq = frequency.get(setting); + do { + if (start.getDayOfMonth() == check.getDayOfMonth()) { + LOG.info("Check day of month matches frequency day"); + final String key = new StringBuilder(start.getMonth().toString()) + .append("_") + .append(String.valueOf(start.getDayOfMonth())) + .toString(); + if (sent.containsKey(key)) { + LOG.info("The Pulse Email has already been sent today"); + } else { + LOG.info("Sending Pulse Email"); + send(); + sent.put(key, true); + } + break; + } + start = start.plus(freq.getCount(), freq.getUnits()); + + // Apply firstInMonth(emailDay) to support adding one month to the start + // date. When adding weeks, it remains on the original day. But, when + // adding months, it can move away from the first of the month and we + // need the day specified by emailDay. + if (freq.getUnits() == ChronoUnit.MONTHS) { + start = start.with(TemporalAdjusters.firstInMonth(emailDay)); + } + } while(start.isBefore(check) || start.isEqual(check)); + } + } + + private void send() { + PulseEmail email = new PulseEmail(emailSender, checkInsConfiguration, + memberProfileServices); + email.send(); + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImpl.java index b9a573840..4ca8627c6 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImpl.java @@ -3,6 +3,7 @@ import com.objectcomputing.checkins.services.feedback_request.FeedbackRequest; import com.objectcomputing.checkins.services.feedback_request.FeedbackRequestRepository; import com.objectcomputing.checkins.services.feedback_request.FeedbackRequestServicesImpl; +import com.objectcomputing.checkins.services.pulse.PulseServices; import jakarta.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,11 +17,14 @@ public class CheckServicesImpl implements CheckServices { private static final Logger LOG = LoggerFactory.getLogger(CheckServicesImpl.class); private final FeedbackRequestServicesImpl feedbackRequestServices; private final FeedbackRequestRepository feedbackRequestRepository; + private final PulseServices pulseServices; public CheckServicesImpl(FeedbackRequestServicesImpl feedbackRequestServices, - FeedbackRequestRepository feedbackRequestRepository) { + FeedbackRequestRepository feedbackRequestRepository, + PulseServices pulseServices) { this.feedbackRequestServices = feedbackRequestServices; this.feedbackRequestRepository = feedbackRequestRepository; + this.pulseServices = pulseServices; } @Override @@ -33,6 +37,7 @@ public boolean sendScheduledEmails() { req.setStatus("sent"); feedbackRequestRepository.update(req); } + pulseServices.sendPendingEmail(today); return true; } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/settings/SettingOption.java b/server/src/main/java/com/objectcomputing/checkins/services/settings/SettingOption.java index 7428254db..d1e1baf6a 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/settings/SettingOption.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/settings/SettingOption.java @@ -15,7 +15,8 @@ @JsonSerialize(using = SettingOptionSerializer.class) @JsonDeserialize(using = SettingOptionDeserializer.class) public enum SettingOption { - LOGO_URL("The logo url", Category.THEME, Type.FILE); + LOGO_URL("The logo url", Category.THEME, Type.FILE), + PULSE_EMAIL_FREQUENCY("The Pulse Email Frequency (weekly, bi-weekly, monthly)", Category.CHECK_INS, Type.STRING); private final String description; private final Category category; @@ -27,6 +28,18 @@ public enum SettingOption { this.type = type; } + public String getDescription() { + return description; + } + + public Category getCategory() { + return category; + } + + public Type getType() { + return type; + } + public static List getOptions(){ return Arrays.asList(SettingOption.values()); } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/settings/SettingsController.java b/server/src/main/java/com/objectcomputing/checkins/services/settings/SettingsController.java index c35dbd9b7..84be66928 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/settings/SettingsController.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/settings/SettingsController.java @@ -2,6 +2,7 @@ import com.objectcomputing.checkins.services.permissions.Permission; import com.objectcomputing.checkins.services.permissions.RequiredPermission; +import com.objectcomputing.checkins.exceptions.NotFoundException; import io.micronaut.http.HttpResponse; import io.micronaut.http.HttpStatus; import io.micronaut.http.annotation.*; @@ -18,6 +19,7 @@ import java.net.URI; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Controller(SettingsController.PATH) @ExecuteOn(TaskExecutors.BLOCKING) @@ -66,8 +68,24 @@ public SettingsResponseDTO findByName(@PathVariable @NotNull String name) { */ @Get("/options") @RequiredPermission(Permission.CAN_VIEW_SETTINGS) - public List getOptions() { - return SettingOption.getOptions(); + public List getOptions() { + List options = SettingOption.getOptions(); + return options.stream().map(option -> { + // Default to an empty value and "invalid" UUID. + // This can be used by the client to determine pre-existance. + String value = ""; + UUID uuid = new UUID(0, 0); + try { + Setting s = settingsServices.findByName(option.name()); + uuid = s.getId(); + value = s.getValue(); + } catch(NotFoundException ex) { + } + return new SettingsResponseDTO( + uuid, option.name(), option.getDescription(), + option.getCategory(), option.getType(), + value); + }).collect(Collectors.toList()); } /** diff --git a/server/src/main/java/com/objectcomputing/checkins/services/settings/SettingsResponseDTO.java b/server/src/main/java/com/objectcomputing/checkins/services/settings/SettingsResponseDTO.java index 1668ef9d9..1cefb2e59 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/settings/SettingsResponseDTO.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/settings/SettingsResponseDTO.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; @@ -12,6 +13,7 @@ @Setter @Getter @Introspected +@AllArgsConstructor public class SettingsResponseDTO { @NotNull @@ -38,4 +40,6 @@ public class SettingsResponseDTO { @Schema(description = "value of the setting") private String value; + public SettingsResponseDTO() { + } } diff --git a/server/src/test/java/com/objectcomputing/checkins/services/pulse/PulseServicesTest.java b/server/src/test/java/com/objectcomputing/checkins/services/pulse/PulseServicesTest.java new file mode 100644 index 000000000..03f606bce --- /dev/null +++ b/server/src/test/java/com/objectcomputing/checkins/services/pulse/PulseServicesTest.java @@ -0,0 +1,157 @@ +package com.objectcomputing.checkins.services.pulse; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; +import com.objectcomputing.checkins.notifications.email.MailJetFactory; +import com.objectcomputing.checkins.services.MailJetFactoryReplacement; +import com.objectcomputing.checkins.services.TestContainersSuite; +import com.objectcomputing.checkins.services.fixture.TeamFixture; +import com.objectcomputing.checkins.services.fixture.RoleFixture; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; +import com.objectcomputing.checkins.services.settings.SettingsServices; +import com.objectcomputing.checkins.services.settings.Setting; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; + +import io.micronaut.core.util.StringUtils; +import io.micronaut.context.annotation.Property; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Arrays; +import java.util.stream.Collectors; +import java.time.DayOfWeek; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; + +import static com.objectcomputing.checkins.services.role.RoleType.Constants.ADMIN_ROLE; +import static com.objectcomputing.checkins.services.role.RoleType.Constants.MEMBER_ROLE; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Property(name = "replace.mailjet.factory", value = StringUtils.TRUE) +class PulseServicesTest extends TestContainersSuite implements TeamFixture, RoleFixture { + @Inject + @Named(MailJetFactory.HTML_FORMAT) + private MailJetFactoryReplacement.MockEmailSender emailSender; + + @Inject + CheckInsConfiguration checkInsConfiguration; + + @Inject + MemberProfileServices memberProfileServices; + + @Inject + SettingsServices settingsServices; + + @Inject + PulseServices pulseServices; + + private MemberProfile member; + private MemberProfile other; + private MemberProfile admin; + private String recipients; + + private LocalDate weeklyDate; + private LocalDate biWeeklyDate; + private LocalDate monthlyDate; + + private final String pulseSettingName = "PULSE_EMAIL_FREQUENCY"; + private final String pulseWeekly = "weekly"; + private final String pulseBiWeekly = "bi-weekly"; + private final String pulseMonthly = "monthly"; + + @BeforeEach + void setUp() { + createAndAssignRoles(); + + member = createADefaultMemberProfile(); + other = createASecondDefaultMemberProfile(); + + admin = createAThirdDefaultMemberProfile(); + assignAdminRole(admin); + + List recipientsEmail = List.of(member.getWorkEmail(), + other.getWorkEmail(), + admin.getWorkEmail()); + recipients = String.join(",", recipientsEmail); + emailSender.reset(); + + final LocalDate start = + LocalDate.now() + .with(TemporalAdjusters.firstDayOfYear()) + .with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY)); + + weeklyDate = start.plus(23, ChronoUnit.WEEKS); + biWeeklyDate = start.plus(42, ChronoUnit.WEEKS); + monthlyDate = start.plus(2, ChronoUnit.MONTHS) + .with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY)); + } + + @Test + void testBiWeeklySendEmail() { + final Setting setting = new Setting(pulseSettingName, pulseBiWeekly); + settingsServices.save(setting); + + pulseServices.sendPendingEmail(biWeeklyDate); + assertEquals(1, emailSender.events.size()); + + final List event = emailSender.events.get(0); + assertEquals(6, event.size()); + assertEquals(recipients, event.get(5)); + } + + @Test + void testWeeklySendEmail() { + final Setting setting = new Setting(pulseSettingName, pulseWeekly); + settingsServices.save(setting); + + pulseServices.sendPendingEmail(weeklyDate); + assertEquals(1, emailSender.events.size()); + + final List event = emailSender.events.get(0); + assertEquals(6, event.size()); + assertEquals(recipients, event.get(5)); + } + + @Test + void testMonthlySendEmail() { + final Setting setting = new Setting(pulseSettingName, pulseMonthly); + settingsServices.save(setting); + + pulseServices.sendPendingEmail(monthlyDate); + assertEquals(1, emailSender.events.size()); + + final List event = emailSender.events.get(0); + assertEquals(6, event.size()); + assertEquals(recipients, event.get(5)); + } + + @Test + void testDuplicateSendEmail() { + final Setting setting = new Setting(pulseSettingName, pulseMonthly); + settingsServices.save(setting); + + pulseServices.sendPendingEmail(monthlyDate); + // This should be zero because email was already sent on this date. + assertEquals(0, emailSender.events.size()); + } + + @Test + void testNoSendEmail() { + final Setting setting = new Setting(pulseSettingName, pulseBiWeekly); + settingsServices.save(setting); + + pulseServices.sendPendingEmail(weeklyDate); + // This should be zero because, when set to bi-weekly, email is sent on + // the first, third, and fifth Monday of the month. + assertEquals(0, emailSender.events.size()); + + final LocalDate nonMonday = weeklyDate.plus(1, ChronoUnit.DAYS); + pulseServices.sendPendingEmail(nonMonday); + // This should be zero because the date is not a Monday. + assertEquals(0, emailSender.events.size()); + } +} diff --git a/server/src/test/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImplTest.java b/server/src/test/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImplTest.java index 3c3a8a81b..db095bf37 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImplTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImplTest.java @@ -4,6 +4,7 @@ import com.objectcomputing.checkins.services.feedback_request.FeedbackRequest; import com.objectcomputing.checkins.services.feedback_request.FeedbackRequestRepository; import com.objectcomputing.checkins.services.feedback_request.FeedbackRequestServicesImpl; +import com.objectcomputing.checkins.services.pulse.PulseServices; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -27,6 +28,9 @@ class CheckServicesImplTest extends TestContainersSuite { @Mock private FeedbackRequestRepository feedbackRequestRepository; + @Mock + private PulseServices pulseServices; + @InjectMocks private CheckServicesImpl checkServices; @@ -54,4 +58,4 @@ void sendScheduledEmails() { verify(feedbackRequestRepository).update(retrievedRequest); } -} \ No newline at end of file +} diff --git a/web-ui/src/api/settings.js b/web-ui/src/api/settings.js index 95bbd1ff2..ba46645b6 100644 --- a/web-ui/src/api/settings.js +++ b/web-ui/src/api/settings.js @@ -13,3 +13,29 @@ export const getAllOptions = async cookie => { } }); }; + +export const putOption = async (option, cookie) => { + return resolve({ + method: 'PUT', + url: settingsURL, + headers: { + 'X-CSRF-Header': cookie, + Accept: 'application/json', + 'Content-Type': 'application/json;charset=UTF-8' + }, + data: option, + }); +}; + +export const postOption = async (option, cookie) => { + return resolve({ + method: 'POST', + url: settingsURL, + headers: { + 'X-CSRF-Header': cookie, + Accept: 'application/json', + 'Content-Type': 'application/json;charset=UTF-8' + }, + data: option, + }); +}; diff --git a/web-ui/src/pages/PulsePage.jsx b/web-ui/src/pages/PulsePage.jsx index f4b12316f..4137a4494 100644 --- a/web-ui/src/pages/PulsePage.jsx +++ b/web-ui/src/pages/PulsePage.jsx @@ -124,7 +124,7 @@ const PulsePage = () => { score={externalScore} setComment={setExternalComment} setScore={setExternalScore} - title="How you feeling about life outside of work?" + title="How are you feeling about life outside of work?" /> + ); }; diff --git a/web-ui/src/pages/__snapshots__/PulsePage.test.jsx.snap b/web-ui/src/pages/__snapshots__/PulsePage.test.jsx.snap index d9288265b..4d308c868 100644 --- a/web-ui/src/pages/__snapshots__/PulsePage.test.jsx.snap +++ b/web-ui/src/pages/__snapshots__/PulsePage.test.jsx.snap @@ -236,7 +236,7 @@ exports[`renders correctly 1`] = `
- How you feeling about life outside of work? + How are you feeling about life outside of work?