diff --git a/.github/workflows/gradle-build-production.yml b/.github/workflows/gradle-build-production.yml index 9012c94109..e69a0babcb 100644 --- a/.github/workflows/gradle-build-production.yml +++ b/.github/workflows/gradle-build-production.yml @@ -87,6 +87,7 @@ jobs: --set-env-vars "^@^MICRONAUT_ENVIRONMENTS=cloud,google,gcp" \ --set-env-vars "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" \ --set-env-vars "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" \ + --set-env-vars "SLACK_KUDOS_CHANNEL_ID=${{ secrets.SLACK_KUDOS_CHANNEL_ID }}" \ --set-env-vars "SLACK_SIGNING_SECRET=${{ secrets.SLACK_PULSE_SIGNING_SECRET }}" \ --platform "managed" \ --max-instances 8 \ diff --git a/.github/workflows/gradle-deploy-develop.yml b/.github/workflows/gradle-deploy-develop.yml index 1994e1886f..a30f87d573 100644 --- a/.github/workflows/gradle-deploy-develop.yml +++ b/.github/workflows/gradle-deploy-develop.yml @@ -113,6 +113,7 @@ jobs: --set-env-vars "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" \ --set-env-vars "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" \ --set-env-vars "SLACK_SIGNING_SECRET=${{ secrets.SLACK_PULSE_SIGNING_SECRET }}" \ + --set-env-vars "SLACK_KUDOS_CHANNEL_ID=${{ secrets.SLACK_KUDOS_CHANNEL_ID }}" \ --platform "managed" \ --max-instances 2 \ --allow-unauthenticated diff --git a/.github/workflows/gradle-deploy-native-develop.yml b/.github/workflows/gradle-deploy-native-develop.yml index 0799a5c583..98105aaa74 100644 --- a/.github/workflows/gradle-deploy-native-develop.yml +++ b/.github/workflows/gradle-deploy-native-develop.yml @@ -111,6 +111,7 @@ jobs: --set-env-vars "^@^MICRONAUT_ENVIRONMENTS=dev,cloud,google,gcp" \ --set-env-vars "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" \ --set-env-vars "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" \ + --set-env-vars "SLACK_KUDOS_CHANNEL_ID=${{ secrets.SLACK_KUDOS_CHANNEL_ID }}" \ --set-env-vars "SLACK_SIGNING_SECRET=${{ secrets.SLACK_PULSE_SIGNING_SECRET }}" \ --platform "managed" \ --max-instances 2 \ diff --git a/docker-compose.yaml b/docker-compose.yaml index 5d4c56db0d..9dd01c2abd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,3 @@ -version: '3' services: postgresql: image: postgres:17.2 diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 5b49fd3792..1e38a64802 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -220,6 +220,7 @@ GEM rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) mercenary (0.3.6) + mini_portile2 (2.8.8) minima (2.5.1) jekyll (>= 3.5, < 5.0) jekyll-feed (~> 0.9) @@ -228,17 +229,18 @@ GEM mutex_m (0.2.0) net-http (0.4.1) uri - nokogiri (1.16.5-aarch64-linux) + nokogiri (1.18.3) + mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.16.5-arm-linux) + nokogiri (1.18.3-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.16.5-arm64-darwin) + nokogiri (1.18.3-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.16.5-x86-linux) + nokogiri (1.18.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.5-x86_64-darwin) + nokogiri (1.18.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.16.5-x86_64-linux) + nokogiri (1.18.3-x86_64-linux-gnu) racc (~> 1.4) octokit (4.25.1) faraday (>= 1, < 3) @@ -246,7 +248,7 @@ GEM pathutil (0.16.2) forwardable-extended (~> 2.6) public_suffix (5.0.4) - racc (1.7.3) + racc (1.8.1) rake (13.1.0) rb-fsevent (0.11.2) rb-inotify (0.10.1) @@ -275,7 +277,7 @@ GEM unf_ext unf_ext (0.0.9.1) unicode-display_width (1.8.0) - uri (0.13.0) + uri (0.13.2) webrick (1.8.2) PLATFORMS diff --git a/server/build.gradle b/server/build.gradle index 15d9912251..4aa56e47b2 100755 --- a/server/build.gradle +++ b/server/build.gradle @@ -7,7 +7,7 @@ plugins { id "jacoco" } -version "0.8.14" +version "0.8.15" group "com.objectcomputing.checkins" repositories { diff --git a/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java b/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java index d29dedbe8d..3e8327af45 100644 --- a/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java +++ b/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java @@ -82,6 +82,9 @@ public static class SlackConfig { @NotBlank private String signingSecret; + + @NotBlank + private String kudosChannel; } } } diff --git a/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSender.java b/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSender.java new file mode 100644 index 0000000000..1555638f27 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSender.java @@ -0,0 +1,124 @@ +package com.objectcomputing.checkins.notifications.social_media; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import com.slack.api.Slack; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import com.slack.api.methods.request.chat.ChatDeleteRequest; +import com.slack.api.methods.response.chat.ChatDeleteResponse; +import com.slack.api.methods.request.conversations.ConversationsOpenRequest; +import com.slack.api.methods.response.conversations.ConversationsOpenResponse; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +@Singleton +public class SlackSender { + private static final Logger LOG = LoggerFactory.getLogger(SlackSender.class); + + @Inject + private CheckInsConfiguration configuration; + + public boolean send(List userIds, String slackBlocks) { + // See if we have a token. + String token = configuration.getApplication() + .getSlack().getBotToken(); + if (token != null && !slackBlocks.isEmpty()) { + MethodsClient client = Slack.getInstance().methods(token); + + try { + ConversationsOpenResponse openResponse = + client.conversationsOpen(ConversationsOpenRequest.builder() + .users(userIds) + .returnIm(true) + .build()); + if (!openResponse.isOk()) { + LOG.error("Unable to open the conversation"); + return false; + } + + return send(openResponse.getChannel().getId(), slackBlocks); + } catch(Exception ex) { + LOG.error("SlackSender.send: " + ex.toString()); + return false; + } + } else { + LOG.error("Missing token or missing slack blocks"); + return false; + } + } + + public boolean send(String channelId, String slackBlocks) { + // See if we have a token. + String token = configuration.getApplication() + .getSlack().getBotToken(); + if (token != null && !slackBlocks.isEmpty()) { + MethodsClient client = Slack.getInstance().methods(token); + + try { + ChatPostMessageRequest request = ChatPostMessageRequest + .builder() + .channel(channelId) + .blocksAsString(slackBlocks) + .build(); + + // Send it to Slack + ChatPostMessageResponse response = client.chatPostMessage(request); + + if (!response.isOk()) { + LOG.error("Unable to send the chat message: " + + response.getError()); + } + + return response.isOk(); + } catch(Exception ex) { + LOG.error("SlackSender.send: " + ex.toString()); + return false; + } + } else { + LOG.error("Missing token or missing slack blocks"); + return false; + } + } + + public boolean delete(String channel, String ts) { + // See if we have a token. + String token = configuration.getApplication() + .getSlack().getBotToken(); + if (token != null) { + MethodsClient client = Slack.getInstance().methods(token); + + try { + ChatDeleteRequest request = ChatDeleteRequest + .builder() + .channel(channel) + .ts(ts) + .build(); + + // Send it to Slack + ChatDeleteResponse response = client.chatDelete(request); + + if (!response.isOk()) { + LOG.error("Unable to delete the chat message: " + + response.getError()); + } + + return response.isOk(); + } catch(Exception ex) { + LOG.error("SlackSender.delete: " + ex.toString()); + return false; + } + } else { + LOG.error("Missing token or missing slack blocks"); + return false; + } + } +} + diff --git a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosController.java b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosController.java index 72eead812a..2e81190115 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosController.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosController.java @@ -39,6 +39,11 @@ public Kudos create(@Body @Valid KudosCreateDTO kudos) { } @Put + public Kudos update(@Body @Valid KudosUpdateDTO kudos) { + return kudosServices.update(kudos); + } + + @Put("/approve") public Kudos approve(@Body @Valid Kudos kudos) { return kudosServices.approve(kudos); } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosConverter.java b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosConverter.java index 3587243bbe..8e4f969b9a 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosConverter.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosConverter.java @@ -1,6 +1,6 @@ package com.objectcomputing.checkins.services.kudos; -import com.objectcomputing.checkins.notifications.social_media.SlackSearch; +import com.objectcomputing.checkins.services.slack.SlackSearch; import com.objectcomputing.checkins.services.kudos.kudos_recipient.KudosRecipientServices; import com.objectcomputing.checkins.services.kudos.kudos_recipient.KudosRecipient; import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; @@ -22,10 +22,6 @@ @Singleton public class KudosConverter { - private record InternalBlock( - List blocks - ) {} - private final MemberProfileServices memberProfileServices; private final KudosRecipientServices kudosRecipientServices; private final SlackSearch slackSearch; @@ -61,9 +57,8 @@ public String toSlackBlock(Kudos kudos) { .elements(content).build(); RichTextBlock richTextBlock = RichTextBlock.builder() .elements(List.of(element)).build(); - InternalBlock block = new InternalBlock(List.of(richTextBlock)); Gson mapper = GsonFactory.createSnakeCase(); - return mapper.toJson(block); + return mapper.toJson(List.of(richTextBlock)); } private RichTextSectionElement.TextStyle boldItalic() { diff --git a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServices.java b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServices.java index 72cbf4f96d..e4695ece38 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServices.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServices.java @@ -9,8 +9,12 @@ public interface KudosServices { Kudos save(KudosCreateDTO kudos); + Kudos update(KudosUpdateDTO kudos); + Kudos approve(Kudos kudos); + Kudos savePreapproved(KudosCreateDTO kudos); + List getRecent(); KudosResponseDTO getById(UUID id); diff --git a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServicesImpl.java index 27de524e42..1489a1bf96 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServicesImpl.java @@ -1,11 +1,14 @@ package com.objectcomputing.checkins.services.kudos; +import com.objectcomputing.checkins.exceptions.PermissionException; import com.objectcomputing.checkins.services.permissions.Permission; import com.objectcomputing.checkins.services.permissions.RequiredPermission; import com.objectcomputing.checkins.configuration.CheckInsConfiguration; import com.objectcomputing.checkins.notifications.email.EmailSender; import com.objectcomputing.checkins.notifications.email.MailJetFactory; -import com.objectcomputing.checkins.notifications.social_media.SlackPoster; +import com.objectcomputing.checkins.notifications.social_media.SlackSender; +import com.objectcomputing.checkins.services.slack.SlackReader; +import com.objectcomputing.checkins.services.slack.kudos.BotSentKudosLocator; import com.objectcomputing.checkins.exceptions.BadArgException; import com.objectcomputing.checkins.exceptions.NotFoundException; import com.objectcomputing.checkins.exceptions.PermissionException; @@ -22,13 +25,15 @@ import com.objectcomputing.checkins.services.team.Team; import com.objectcomputing.checkins.services.team.TeamRepository; import com.objectcomputing.checkins.util.Util; +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + import io.micronaut.core.annotation.Nullable; import io.micronaut.transaction.annotation.Transactional; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.HttpStatus; import jakarta.inject.Named; import jakarta.inject.Singleton; +import jakarta.inject.Inject; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,6 +42,8 @@ import java.util.List; import java.util.UUID; import java.util.Set; +import java.util.HashSet; +import java.util.stream.Collectors; import static com.objectcomputing.checkins.services.validate.PermissionsValidation.NOT_AUTHORIZED_MSG; @@ -55,13 +62,17 @@ class KudosServicesImpl implements KudosServices { private final CheckInsConfiguration checkInsConfiguration; private final RoleServices roleServices; private final MemberProfileServices memberProfileServices; - private final SlackPoster slackPoster; + private final SlackSender slackSender; private final KudosConverter converter; + private final BotSentKudosLocator botSentKudosLocator; private enum NotificationType { creation, approval } + @Inject + private CheckInsConfiguration configuration; + KudosServicesImpl(KudosRepository kudosRepository, KudosRecipientServices kudosRecipientServices, KudosRecipientRepository kudosRecipientRepository, @@ -72,8 +83,9 @@ private enum NotificationType { MemberProfileServices memberProfileServices, @Named(MailJetFactory.HTML_FORMAT) EmailSender emailSender, CheckInsConfiguration checkInsConfiguration, - SlackPoster slackPoster, - KudosConverter converter + SlackSender slackSender, + KudosConverter converter, + BotSentKudosLocator botSentKudosLocator ) { this.kudosRepository = kudosRepository; this.kudosRecipientServices = kudosRecipientServices; @@ -85,39 +97,16 @@ private enum NotificationType { this.currentUserServices = currentUserServices; this.emailSender = emailSender; this.checkInsConfiguration = checkInsConfiguration; - this.slackPoster = slackPoster; + this.slackSender = slackSender; this.converter = converter; + this.botSentKudosLocator = botSentKudosLocator; } @Override @Transactional @RequiredPermission(Permission.CAN_CREATE_KUDOS) public Kudos save(KudosCreateDTO kudosDTO) { - UUID senderId = kudosDTO.getSenderId(); - if (memberProfileRetrievalServices.getById(senderId).isEmpty()) { - throw new BadArgException("Kudos sender %s does not exist".formatted(senderId)); - } - - if (kudosDTO.getTeamId() != null) { - UUID teamId = kudosDTO.getTeamId(); - if (teamRepository.findById(teamId).isEmpty()) { - throw new BadArgException("Team %s does not exist".formatted(teamId)); - } - } - - if (kudosDTO.getRecipientMembers() == null || kudosDTO.getRecipientMembers().isEmpty()) { - throw new BadArgException("Kudos must contain at least one recipient"); - } - - Kudos kudos = new Kudos(kudosDTO); - - Kudos savedKudos = kudosRepository.save(kudos); - - for (MemberProfile recipient : kudosDTO.getRecipientMembers()) { - KudosRecipient kudosRecipient = new KudosRecipient(savedKudos.getId(), recipient.getId()); - kudosRecipientServices.save(kudosRecipient); - } - + Kudos savedKudos = saveCommon(kudosDTO, true); sendNotification(savedKudos, NotificationType.creation); return savedKudos; } @@ -140,6 +129,91 @@ public Kudos approve(Kudos kudos) { return updated; } + @Override + public Kudos savePreapproved(KudosCreateDTO kudos) { + Kudos savedKudos = saveCommon(kudos, false); + savedKudos.setDateApproved(LocalDate.now()); + return kudosRepository.update(savedKudos); + } + + @Override + public Kudos update(KudosUpdateDTO kudos) { + // Find the corresponding kudos and make sure we have permission. + final UUID kudosId = kudos.getId(); + final Kudos existingKudos = + kudosRepository.findById(kudosId).orElseThrow(() -> + new BadArgException(KUDOS_DOES_NOT_EXIST_MSG.formatted(kudosId))); + + final MemberProfile currentUser = currentUserServices.getCurrentUser(); + if (!currentUser.getId().equals(existingKudos.getSenderId()) && + !hasAdministerKudosPermission()) { + throw new PermissionException(NOT_AUTHORIZED_MSG); + } + + if (kudos.getRecipientMembers() == null || + kudos.getRecipientMembers().isEmpty()) { + throw new BadArgException( + "Kudos must contain at least one recipient"); + } + + // Begin modifying the existing kudos to reflect desired changes. + final String originalMessage = existingKudos.getMessage(); + existingKudos.setMessage(kudos.getMessage()); + + boolean existingPublic = existingKudos.getPubliclyVisible(); + boolean proposedPublic = kudos.getPubliclyVisible(); + boolean removePublicSlack = false; + if (existingPublic && !proposedPublic) { + removePublicSlack = true; + existingKudos.setDateApproved(null); + } else if ((!existingPublic && proposedPublic) || + (proposedPublic && + !originalMessage.equals(existingKudos.getMessage()))) { + // Clear the date approved when going from private to public or + // if public and the text changed, require approval again. + existingKudos.setDateApproved(null); + } + + existingKudos.setPubliclyVisible(kudos.getPubliclyVisible()); + + List recipients = kudosRecipientRepository + .findByKudosId(kudosId); + Set proposed = kudos.getRecipientMembers() + .stream() + .map(r -> r.getId()) + .collect(Collectors.toSet()); + boolean different = (recipients.size() != proposed.size()); + if (!different) { + Set existing = recipients.stream() + .map(r -> r.getMemberId()) + .collect(Collectors.toSet()); + different = !existing.equals(proposed); + } + + // First, update the Kudos so that we only change recipients if they + // are different and we were able to update the Kudos. + final Kudos updated = kudosRepository.update(existingKudos); + + // Change recipients, if necessary. + if (different) { + updateRecipients(updated, recipients, proposed); + } + + // The kudos has been updated. Send notification to admin, if going + // from private to public. + if (!existingPublic && proposedPublic) { + sendNotification(updated, NotificationType.creation); + } + + if (removePublicSlack) { + // Search for and remove the Slack Kudos that the Check-Ins + // Integration posted. + removeSlackMessage(existingKudos); + } + + return updated; + } + @Override public KudosResponseDTO getById(UUID id) { @@ -172,16 +246,27 @@ public KudosResponseDTO getById(UUID id) { } @Override - @RequiredPermission(Permission.CAN_ADMINISTER_KUDOS) public void delete(UUID id) { Kudos kudos = kudosRepository.findById(id).orElseThrow(() -> new NotFoundException(KUDOS_DOES_NOT_EXIST_MSG.formatted(id))); + MemberProfile currentUser = currentUserServices.getCurrentUser(); + if (!currentUser.getId().equals(kudos.getSenderId()) && + !hasAdministerKudosPermission()) { + throw new PermissionException(NOT_AUTHORIZED_MSG); + } + // Delete all KudosRecipients associated with this kudos List recipients = kudosRecipientServices.getAllByKudosId(kudos.getId()); kudosRecipientRepository.deleteAll(recipients); kudosRepository.deleteById(id); + + if (kudos.getPubliclyVisible()) { + // Search for and remove the Slack Kudos that the Check-Ins + // Integration posted. + removeSlackMessage(kudos); + } } @Override @@ -248,7 +333,8 @@ private List findAllToMember(UUID memberId) { Kudos relatedKudos = kudosRepository.findById(kudosId).orElseThrow(() -> new NotFoundException(KUDOS_DOES_NOT_EXIST_MSG.formatted(kudosId))); - if (relatedKudos.getDateApproved() != null) { + if (!relatedKudos.getPubliclyVisible() || + relatedKudos.getDateApproved() != null) { kudosList.add(constructKudosResponseDTO(relatedKudos)); } }); @@ -377,14 +463,77 @@ private void sendNotification(Kudos kudos, NotificationType notificationType) { } private void slackApprovedKudos(Kudos kudos) { - HttpResponse httpResponse = - slackPoster.post(converter.toSlackBlock(kudos)); - if (httpResponse.status() != HttpStatus.OK) { - LOG.error("Unable to POST to Slack: " + httpResponse.reason()); - } + slackSender.send(configuration.getApplication() + .getSlack().getKudosChannel(), + converter.toSlackBlock(kudos)); } private boolean hasAdministerKudosPermission() { return currentUserServices.hasPermission(Permission.CAN_ADMINISTER_KUDOS); } + + private Kudos saveCommon(KudosCreateDTO kudosDTO, boolean verifyAndNotify) { + UUID senderId = kudosDTO.getSenderId(); + if (memberProfileRetrievalServices.getById(senderId).isEmpty()) { + throw new BadArgException("Kudos sender %s does not exist".formatted(senderId)); + } + + if (kudosDTO.getTeamId() != null) { + UUID teamId = kudosDTO.getTeamId(); + if (teamRepository.findById(teamId).isEmpty()) { + throw new BadArgException("Team %s does not exist".formatted(teamId)); + } + } + + if (kudosDTO.getRecipientMembers() == null || kudosDTO.getRecipientMembers().isEmpty()) { + throw new BadArgException("Kudos must contain at least one recipient"); + } + + Kudos savedKudos = kudosRepository.save(new Kudos(kudosDTO)); + + for (MemberProfile recipient : kudosDTO.getRecipientMembers()) { + KudosRecipient kudosRecipient = new KudosRecipient(savedKudos.getId(), recipient.getId()); + if (verifyAndNotify) { + // Going through the service verifies the sender and recipient + // and sends email notification after saving. + kudosRecipientServices.save(kudosRecipient); + } else { + // This does none of that and just stores it in the database. + kudosRecipientRepository.save(kudosRecipient); + } + } + + return savedKudos; + } + + private void updateRecipients(Kudos updated, + List recipients, + Set proposed) { + // Add the new recipients. + Set existing = recipients.stream() + .map(r -> r.getMemberId()) + .collect(Collectors.toSet()); + for (UUID id : proposed) { + if (!existing.contains(id)) { + kudosRecipientServices.save( + new KudosRecipient(updated.getId(), id)); + } + } + + // Remove any that are no longer designated as recipients. + for (KudosRecipient recipient : recipients) { + if (!proposed.contains(recipient.getMemberId())) { + kudosRecipientRepository.delete(recipient); + } + } + } + + private void removeSlackMessage(Kudos kudos) { + String ts = botSentKudosLocator.find(kudos); + if (ts != null) { + slackSender.delete(configuration.getApplication() + .getSlack().getKudosChannel(), + ts); + } + } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosUpdateDTO.java b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosUpdateDTO.java new file mode 100644 index 0000000000..38ea0a0eda --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosUpdateDTO.java @@ -0,0 +1,31 @@ +package com.objectcomputing.checkins.services.kudos; + +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@AllArgsConstructor +@Introspected +public class KudosUpdateDTO { + @NotNull + private UUID id; + + @NotBlank + private String message; + + @Nullable + private Boolean publiclyVisible; + + @NotNull + private List recipientMembers; +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/member_skill/MemberSkillRepository.java b/server/src/main/java/com/objectcomputing/checkins/services/member_skill/MemberSkillRepository.java index 08c9a63144..175e976304 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/member_skill/MemberSkillRepository.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/member_skill/MemberSkillRepository.java @@ -1,5 +1,6 @@ package com.objectcomputing.checkins.services.member_skill; +import io.micronaut.data.annotation.Query; import io.micronaut.data.jdbc.annotation.JdbcRepository; import io.micronaut.data.model.query.builder.sql.Dialect; import io.micronaut.data.repository.CrudRepository; @@ -19,6 +20,13 @@ public interface MemberSkillRepository extends CrudRepository List findBySkillid(UUID skillid); - Optional findByMemberidAndSkillid(UUID memberId,UUID skillid ); + Optional findByMemberidAndSkillid(UUID memberId, UUID skillid); + @Query(value = "SELECT member_skills.* FROM member_skills " + + "INNER JOIN member_profile " + + "ON member_skills.memberid = member_profile.id " + + "WHERE :targetSkillId = member_skills.skillid " + + "AND member_profile.terminationdate IS NULL", + nativeQuery = true) + List activeMemberSkills(String targetSkillId); } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/member_skill/skillsreport/SkillsReportServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/member_skill/skillsreport/SkillsReportServicesImpl.java index ef2c12ac63..f6321f0fae 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/member_skill/skillsreport/SkillsReportServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/member_skill/skillsreport/SkillsReportServicesImpl.java @@ -92,7 +92,8 @@ private List getPotentialQualifyingMembers(List temp = memberSkillRepo.findBySkillid(skill.getId()); + final List temp = + memberSkillRepo.activeMemberSkills(skill.getId().toString()); if (skill.getLevel() != null && !temp.isEmpty()) { for (MemberSkill memSkill : temp) { if (memSkill.getSkilllevel() != null && isSkillLevelSatisfied(memSkill.getSkilllevel(), skill.getLevel())) { diff --git a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfile.java b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfile.java index e1fc5efdbf..51a92cd0a6 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfile.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfile.java @@ -155,6 +155,11 @@ public class MemberProfile { @TypeDef(type = DataType.DATE, converter = LocalDateConverter.class) private LocalDate lastSeen; + @Column(name="ignore_birthday") + @Schema(description = "flag indicating the member would like to have their birthday ignored") + @Nullable + private Boolean ignoreBirthday; + public MemberProfile(@NotBlank String firstName, @Nullable String middleName, @NotBlank String lastName, @@ -171,9 +176,10 @@ public MemberProfile(@NotBlank String firstName, @Nullable LocalDate birthDate, @Nullable Boolean voluntary, @Nullable Boolean excluded, - @Nullable LocalDate lastSeen) { + @Nullable LocalDate lastSeen, + @Nullable Boolean ignoreBirthday) { this(null, firstName, middleName, lastName, suffix, title, pdlId, location, workEmail, - employeeId, startDate, bioText, supervisorid, terminationDate,birthDate, voluntary, excluded, lastSeen); + employeeId, startDate, bioText, supervisorid, terminationDate,birthDate, voluntary, excluded, lastSeen, ignoreBirthday); } public MemberProfile(UUID id, @@ -193,7 +199,8 @@ public MemberProfile(UUID id, @Nullable LocalDate birthDate, @Nullable Boolean voluntary, @Nullable Boolean excluded, - @Nullable LocalDate lastSeen) { + @Nullable LocalDate lastSeen, + @Nullable Boolean ignoreBirthday) { this.id = id; this.firstName = firstName; this.middleName = middleName; @@ -212,6 +219,7 @@ public MemberProfile(UUID id, this.voluntary = voluntary; this.excluded = excluded; this.lastSeen = lastSeen; + this.ignoreBirthday = ignoreBirthday; } public MemberProfile() { @@ -246,14 +254,15 @@ public boolean equals(Object o) { Objects.equals(birthDate, that.birthDate) && Objects.equals(voluntary, that.voluntary) && Objects.equals(excluded, that.excluded) && - Objects.equals(lastSeen, that.lastSeen); + Objects.equals(lastSeen, that.lastSeen) && + Objects.equals(ignoreBirthday, that.ignoreBirthday); } @Override public int hashCode() { return Objects.hash(id, firstName, middleName, lastName, suffix, title, pdlId, location, workEmail, employeeId, startDate, bioText, supervisorid, terminationDate,birthDate, - voluntary, excluded, lastSeen); + voluntary, excluded, lastSeen, ignoreBirthday); } @Override @@ -274,6 +283,7 @@ public String toString() { ", voluntary=" + voluntary + ", excluded=" + excluded + ", lastSeen=" + lastSeen + + ", ignoreBirthday=" + ignoreBirthday + '}'; } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileController.java b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileController.java index c79727b6ba..dab6c285a0 100755 --- a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileController.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileController.java @@ -58,7 +58,7 @@ public HttpResponse getById(UUID id) { public List getSupervisorsForId(UUID id) { return memberProfileServices.getSupervisorsForId(id) .stream() - .map(this::fromEntity) + .map(MemberProfileController::fromEntity) .toList(); } @@ -83,7 +83,7 @@ public List findByValue(@Nullable String firstName, @QueryValue(value = "terminated", defaultValue = "false") Boolean terminated) { return memberProfileServices.findByValues(firstName, lastName, title, pdlId, workEmail, supervisorId, terminated) .stream() - .map(this::fromEntity) + .map(MemberProfileController::fromEntity) .toList(); } @@ -128,7 +128,7 @@ protected URI location(UUID id) { return URI.create("/member-profiles/" + id); } - private MemberProfileResponseDTO fromEntity(MemberProfile entity) { + public static MemberProfileResponseDTO fromEntity(MemberProfile entity) { MemberProfileResponseDTO dto = new MemberProfileResponseDTO(); dto.setId(entity.getId()); dto.setFirstName(entity.getFirstName()); @@ -147,6 +147,7 @@ private MemberProfileResponseDTO fromEntity(MemberProfile entity) { dto.setTerminationDate(entity.getTerminationDate()); dto.setBirthDay(entity.getBirthDate()); dto.setLastSeen(entity.getLastSeen()); + dto.setIgnoreBirthday(entity.getIgnoreBirthday()); return dto; } @@ -154,13 +155,13 @@ private MemberProfile fromDTO(MemberProfileUpdateDTO dto) { return new MemberProfile(dto.getId(), dto.getFirstName(), dto.getMiddleName(), dto.getLastName(), dto.getSuffix(), dto.getTitle(), dto.getPdlId(), dto.getLocation(), dto.getWorkEmail(), dto.getEmployeeId(), dto.getStartDate(), dto.getBioText(), dto.getSupervisorid(), - dto.getTerminationDate(), dto.getBirthDay(), dto.getVoluntary(), dto.getExcluded(), dto.getLastSeen()); + dto.getTerminationDate(), dto.getBirthDay(), dto.getVoluntary(), dto.getExcluded(), dto.getLastSeen(), dto.getIgnoreBirthday()); } private MemberProfile fromDTO(MemberProfileCreateDTO dto) { return new MemberProfile(dto.getFirstName(), dto.getMiddleName(), dto.getLastName(), dto.getSuffix(), dto.getTitle(), dto.getPdlId(), dto.getLocation(), dto.getWorkEmail(), dto.getEmployeeId(), dto.getStartDate(), dto.getBioText(), dto.getSupervisorid(), dto.getTerminationDate(), dto.getBirthDay(), - dto.getVoluntary(), dto.getExcluded(), dto.getLastSeen()); + dto.getVoluntary(), dto.getExcluded(), dto.getLastSeen(), dto.getIgnoreBirthday()); } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileCreateDTO.java b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileCreateDTO.java index 4cc079033f..8c59002210 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileCreateDTO.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileCreateDTO.java @@ -85,6 +85,10 @@ public class MemberProfileCreateDTO { @Schema(description = "Last date employee logged in") private LocalDate lastSeen; + @Nullable + @Schema(description = "The employee would like their birthday to not be celebrated", nullable = true) + private Boolean ignoreBirthday; + @Override public boolean equals(Object o) { if (this == o) return true; @@ -106,7 +110,8 @@ public boolean equals(Object o) { Objects.equals(birthDay, that.birthDay) && Objects.equals(voluntary, that.voluntary) && Objects.equals(excluded, that.excluded) && - Objects.equals(lastSeen, that.lastSeen); + Objects.equals(lastSeen, that.lastSeen) && + Objects.equals(ignoreBirthday, that.ignoreBirthday); } @@ -114,6 +119,6 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(firstName, middleName, lastName, suffix, title, pdlId, location, workEmail, employeeId, startDate, bioText, supervisorid, terminationDate, birthDay, - voluntary, excluded, lastSeen); + voluntary, excluded, lastSeen, ignoreBirthday); } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileRepository.java b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileRepository.java index f9f448d29f..8d0420f23d 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileRepository.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileRepository.java @@ -27,7 +27,7 @@ public interface MemberProfileRepository extends CrudRepository findWorkEmailByIdIn(Set ids); @Query(value = "WITH RECURSIVE subordinate AS (SELECT " + - "id, firstname, middlename, lastname, suffix, title, pdlid, location, workemail, employeeid, startdate, biotext, supervisorid, terminationdate, birthdate, voluntary, excluded, last_seen, 0 as level " + + "id, firstname, middlename, lastname, suffix, title, pdlid, location, workemail, employeeid, startdate, biotext, supervisorid, terminationdate, birthdate, voluntary, excluded, last_seen, ignore_birthday, 0 as level " + "FROM member_profile " + "WHERE id = :id and terminationdate is NULL " + " UNION ALL " + "SELECT " + - "e.id, e.firstname, e.middlename, e.lastname, e.suffix, e.title, e.pdlid, e.location, e.workemail, e.employeeid, e.startdate, e.biotext, e.supervisorid, e.terminationdate, e.birthdate, e.voluntary, e.excluded, e.last_seen, level + 1 " + + "e.id, e.firstname, e.middlename, e.lastname, e.suffix, e.title, e.pdlid, e.location, e.workemail, e.employeeid, e.startdate, e.biotext, e.supervisorid, e.terminationdate, e.birthdate, e.voluntary, e.excluded, e.last_seen, e.ignore_birthday, level + 1 " + "FROM member_profile e " + "JOIN subordinate s " + "ON s.supervisorid = e.id " + @@ -96,7 +96,7 @@ WHERE mp.id IN (:ids)""", "PGP_SYM_DECRYPT(cast(s.workemail as bytea), '${aes.key}') as workemail, " + "s.employeeid, s.startdate, " + "PGP_SYM_DECRYPT(cast(s.biotext as bytea), '${aes.key}') as biotext, " + - "s.supervisorid, s.terminationdate, s.birthdate, s.voluntary, s.excluded, s.last_seen, " + + "s.supervisorid, s.terminationdate, s.birthdate, s.voluntary, s.excluded, s.last_seen, s.ignore_birthday, " + "s.level " + "FROM subordinate s " + "WHERE s.id <> :id " + @@ -106,11 +106,11 @@ WHERE mp.id IN (:ids)""", @Query( value = """ WITH RECURSIVE subordinate AS ( - SELECT id, firstname, middlename, lastname, suffix, title, pdlid, location, workemail, employeeid, startdate, biotext, supervisorid, terminationdate, birthdate, voluntary, excluded, last_seen, 0 as level + SELECT id, firstname, middlename, lastname, suffix, title, pdlid, location, workemail, employeeid, startdate, biotext, supervisorid, terminationdate, birthdate, voluntary, excluded, last_seen, ignore_birthday, 0 as level FROM member_profile WHERE id = :id and terminationdate is NULL UNION ALL - SELECT e.id, e.firstname, e.middlename, e.lastname, e.suffix, e.title, e.pdlid, e.location, e.workemail, e.employeeid, e.startdate, e.biotext, e.supervisorid, e.terminationdate, e.birthdate, e.voluntary, e.excluded, e.last_seen, level + 1 + SELECT e.id, e.firstname, e.middlename, e.lastname, e.suffix, e.title, e.pdlid, e.location, e.workemail, e.employeeid, e.startdate, e.biotext, e.supervisorid, e.terminationdate, e.birthdate, e.voluntary, e.excluded, e.last_seen, e.ignore_birthday, level + 1 FROM member_profile AS e JOIN subordinate AS s ON s.id = e.supervisorid WHERE e.terminationdate is NULL ) @@ -126,7 +126,7 @@ WITH RECURSIVE subordinate AS ( PGP_SYM_DECRYPT(cast(s.workemail as bytea), '${aes.key}') as workemail, s.employeeid, s.startdate, PGP_SYM_DECRYPT(cast(s.biotext as bytea), '${aes.key}') as biotext, - s.supervisorid, s.terminationdate, s.birthdate, s.voluntary, s.excluded, s.last_seen, + s.supervisorid, s.terminationdate, s.birthdate, s.voluntary, s.excluded, s.last_seen, s.ignore_birthday, s.level FROM subordinate s WHERE s.id <> :id diff --git a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileResponseDTO.java b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileResponseDTO.java index 40fe0bd670..36a6570740 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileResponseDTO.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileResponseDTO.java @@ -92,6 +92,10 @@ public class MemberProfileResponseDTO { @Schema(description = "Last date employee logged in") private LocalDate lastSeen; + @Nullable + @Schema(description = "The employee would like their birthday to not be celebrated", nullable = true) + private Boolean ignoreBirthday; + @Override public String toString() { return "MemberProfileResponseDTO{" + @@ -114,6 +118,7 @@ public String toString() { ", voluntary=" + voluntary + ", excluded=" + excluded + ", lastSeen=" + lastSeen + + ", ignoreBirthday=" + ignoreBirthday + '}'; } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileUpdateDTO.java b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileUpdateDTO.java index 4f17f003b6..95d74e45ce 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileUpdateDTO.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileUpdateDTO.java @@ -87,4 +87,8 @@ public class MemberProfileUpdateDTO { @Nullable @Schema(description = "Last date employee logged in", nullable = true) private LocalDate lastSeen; + + @Nullable + @Schema(description = "The employee would like their birthday to not be celebrated", nullable = true) + private Boolean ignoreBirthday; } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/birthday/BirthDayServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/birthday/BirthDayServicesImpl.java index 015bb88559..a71f7c9cf4 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/birthday/BirthDayServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/birthday/BirthDayServicesImpl.java @@ -38,7 +38,7 @@ public List findByValue(String[] months, Integer[] daysOfMo if (month != null) { memberProfileAll = memberProfileAll .stream() - .filter(member -> member.getBirthDate() != null && month.equalsIgnoreCase(member.getBirthDate().getMonth().name()) && member.getTerminationDate() == null) + .filter(member -> member.getBirthDate() != null && month.equalsIgnoreCase(member.getBirthDate().getMonth().name()) && member.getTerminationDate() == null && (member.getIgnoreBirthday() == null || member.getIgnoreBirthday() == Boolean.FALSE)) .toList(); } } @@ -48,7 +48,7 @@ public List findByValue(String[] months, Integer[] daysOfMo if (day != null) { memberProfileAll = memberProfiles .stream() - .filter(member -> member.getBirthDate() != null && day.equals(member.getBirthDate().getDayOfMonth()) && member.getTerminationDate() == null) + .filter(member -> member.getBirthDate() != null && day.equals(member.getBirthDate().getDayOfMonth()) && member.getTerminationDate() == null && (member.getIgnoreBirthday() == null || member.getIgnoreBirthday() == Boolean.FALSE)) .toList(); } } @@ -63,7 +63,7 @@ public List getTodaysBirthdays() { LocalDate today = LocalDate.now(); List results = memberProfiles .stream() - .filter(member -> member.getBirthDate() != null && today.getMonthValue() == member.getBirthDate().getMonthValue()) + .filter(member -> member.getBirthDate() != null && today.getMonthValue() == member.getBirthDate().getMonthValue() && (member.getIgnoreBirthday() == null || member.getIgnoreBirthday() == Boolean.FALSE)) .toList(); return profileToBirthDateResponseDto(results); } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/currentuser/CurrentUserController.java b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/currentuser/CurrentUserController.java index 68d185b64f..49ce695718 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/currentuser/CurrentUserController.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/currentuser/CurrentUserController.java @@ -1,6 +1,7 @@ package com.objectcomputing.checkins.services.memberprofile.currentuser; import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileController; import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; import com.objectcomputing.checkins.services.memberprofile.MemberProfileUtils; import com.objectcomputing.checkins.services.permissions.Permission; @@ -86,7 +87,7 @@ private CurrentUserDTO fromEntity(MemberProfile entity, String imageUrl, List auth = securityService.getAuthentication(); if (auth.isPresent() && auth.get().getAttributes().get("email") != null) { @@ -80,8 +80,15 @@ public MemberProfile getCurrentUser() { return memberProfileRepo.findByWorkEmail(workEmail).orElse(null); } } + return null; + } - throw new NotFoundException("No active members in the system"); + public MemberProfile getCurrentUser() { + MemberProfile profile = getCurrentUserImpl(); + if (profile == null) { + throw new NotFoundException("No active members in the system"); + } + return profile; } private MemberProfile saveNewUser(String firstName, String lastName, String workEmail) { @@ -91,7 +98,7 @@ private MemberProfile saveNewUser(String firstName, String lastName, String work } LocalDate lastSeen = LocalDate.now(); MemberProfile createdMember = memberProfileRepo.save(new MemberProfile(firstName, null, lastName, null, "", null, - "", workEmail, "", null, "", null, null, null, null, null, lastSeen)); + "", workEmail, "", null, "", null, null, null, null, null, lastSeen, false)); Optional role = roleServices.findByRole("MEMBER"); if(role.isPresent()){ diff --git a/server/src/main/java/com/objectcomputing/checkins/services/permissions/Permission.java b/server/src/main/java/com/objectcomputing/checkins/services/permissions/Permission.java index ba203ca9ae..00ddde2fb7 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/permissions/Permission.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/permissions/Permission.java @@ -17,6 +17,7 @@ public enum Permission { CAN_ADMINISTER_FEEDBACK_ANSWER("Administer feedback answers", "Feedback"), CAN_ADMINISTER_FEEDBACK_TEMPLATES("Administer feedback templates", "Feedback"), CAN_SEND_EMAIL("Send email", "Feedback"), + CAN_VIEW_TERMINATED_MEMBERS("Can view the profiles of terminated members", "User Management"), CAN_EDIT_ALL_ORGANIZATION_MEMBERS("Edit all member profiles", "User Management"), CAN_DELETE_ORGANIZATION_MEMBERS("Delete organization members", "User Management"), CAN_CREATE_ORGANIZATION_MEMBERS("Create organization members", "User Management"), diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java index 4fb5209de9..e2ff1a1d99 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java @@ -1,7 +1,7 @@ package com.objectcomputing.checkins.services.pulseresponse; import com.objectcomputing.checkins.exceptions.NotFoundException; -import com.objectcomputing.checkins.util.form.FormUrlEncodedDecoder; +import com.objectcomputing.checkins.services.slack.SlackSubmissionHandler; import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; import io.micronaut.http.MediaType; @@ -32,8 +32,6 @@ import java.time.LocalDate; import java.util.Set; import java.util.UUID; -import java.util.Map; -import java.nio.charset.StandardCharsets; @Controller("/services/pulse-responses") @ExecuteOn(TaskExecutors.BLOCKING) @@ -43,20 +41,14 @@ public class PulseResponseController { private final PulseResponseService pulseResponseServices; private final MemberProfileServices memberProfileServices; - private final SlackSignatureVerifier slackSignatureVerifier; - private final PulseSlackCommand pulseSlackCommand; - private final SlackPulseResponseConverter slackPulseResponseConverter; + private final SlackSubmissionHandler slackSubmissionHandler; public PulseResponseController(PulseResponseService pulseResponseServices, MemberProfileServices memberProfileServices, - SlackSignatureVerifier slackSignatureVerifier, - PulseSlackCommand pulseSlackCommand, - SlackPulseResponseConverter slackPulseResponseConverter) { + SlackSubmissionHandler slackSubmissionHandler) { this.pulseResponseServices = pulseResponseServices; this.memberProfileServices = memberProfileServices; - this.slackSignatureVerifier = slackSignatureVerifier; - this.pulseSlackCommand = pulseSlackCommand; - this.slackPulseResponseConverter = slackPulseResponseConverter; + this.slackSubmissionHandler = slackSubmissionHandler; } /** @@ -125,25 +117,8 @@ public HttpResponse commandPulseResponse( @Header("X-Slack-Signature") String signature, @Header("X-Slack-Request-Timestamp") String timestamp, @Body String requestBody) { - // Validate the request - if (slackSignatureVerifier.verifyRequest(signature, - timestamp, requestBody)) { - // Convert the request body to a map of values. - FormUrlEncodedDecoder formUrlEncodedDecoder = new FormUrlEncodedDecoder(); - Map body = - formUrlEncodedDecoder.decode(requestBody, - StandardCharsets.UTF_8); - - // Respond to the slack command. - String triggerId = (String)body.get("trigger_id"); - if (pulseSlackCommand.send(triggerId)) { - return HttpResponse.ok(); - } else { - return HttpResponse.status(HttpStatus.INTERNAL_SERVER_ERROR); - } - } else { - return HttpResponse.unauthorized(); - } + return slackSubmissionHandler.commandResponse(signature, + timestamp, requestBody); } @Secured(SecurityRule.IS_ANONYMOUS) @@ -153,56 +128,7 @@ public HttpResponse externalPulseResponse( @Header("X-Slack-Request-Timestamp") String timestamp, @Body String requestBody, HttpRequest request) { - // DEBUG Only - LOG.info(requestBody); - - // Validate the request - if (slackSignatureVerifier.verifyRequest(signature, - timestamp, requestBody)) { - // Convert the request body to a map of values. - FormUrlEncodedDecoder formUrlEncodedDecoder = - new FormUrlEncodedDecoder(); - Map body = - formUrlEncodedDecoder.decode(requestBody, - StandardCharsets.UTF_8); - - final String key = "payload"; - if (body.containsKey(key)) { - PulseResponseCreateDTO pulseResponseDTO = - slackPulseResponseConverter.get(memberProfileServices, - (String)body.get(key)); - - // If we receive a null DTO, that means that this is not the - // actual submission of the form. We can just return 200 so - // that Slack knows to continue without error. - if (pulseResponseDTO == null) { - return HttpResponse.ok(); - } - - // Create the pulse response - PulseResponse pulseResponse = - pulseResponseServices.unsecureSave( - new PulseResponse( - pulseResponseDTO.getInternalScore(), - pulseResponseDTO.getExternalScore(), - pulseResponseDTO.getSubmissionDate(), - pulseResponseDTO.getTeamMemberId(), - pulseResponseDTO.getInternalFeelings(), - pulseResponseDTO.getExternalFeelings() - ) - ); - - if (pulseResponse == null) { - return HttpResponse.status(HttpStatus.CONFLICT, - "Already submitted today"); - } else { - return HttpResponse.ok(); - } - } else { - return HttpResponse.unprocessableEntity(); - } - } else { - return HttpResponse.unauthorized(); - } + return slackSubmissionHandler.externalResponse(signature, timestamp, + requestBody, request); } } 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 baba523e35..70f76665af 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 @@ -5,6 +5,8 @@ import com.objectcomputing.checkins.services.feedback_request.FeedbackRequestServicesImpl; import com.objectcomputing.checkins.services.reviews.ReviewPeriodServices; import com.objectcomputing.checkins.services.pulse.PulseServices; +import com.objectcomputing.checkins.services.slack.kudos.KudosChannelReader; + import jakarta.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,15 +22,18 @@ public class CheckServicesImpl implements CheckServices { private final FeedbackRequestRepository feedbackRequestRepository; private final PulseServices pulseServices; private final ReviewPeriodServices reviewPeriodServices; + private final KudosChannelReader kudosChannelReader; public CheckServicesImpl(FeedbackRequestServicesImpl feedbackRequestServices, FeedbackRequestRepository feedbackRequestRepository, PulseServices pulseServices, - ReviewPeriodServices reviewPeriodServices) { + ReviewPeriodServices reviewPeriodServices, + KudosChannelReader kudosChannelReader) { this.feedbackRequestServices = feedbackRequestServices; this.feedbackRequestRepository = feedbackRequestRepository; this.pulseServices = pulseServices; this.reviewPeriodServices = reviewPeriodServices; + this.kudosChannelReader = kudosChannelReader; } @Override @@ -43,6 +48,7 @@ public boolean sendScheduledEmails() { } pulseServices.notifyUsers(today); reviewPeriodServices.sendNotifications(today); + kudosChannelReader.readChannel(); return true; } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackReader.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackReader.java new file mode 100644 index 0000000000..a880ed9afe --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackReader.java @@ -0,0 +1,66 @@ +package com.objectcomputing.checkins.services.slack; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import com.slack.api.Slack; +import com.slack.api.model.Message; +import com.slack.api.model.Conversation; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.conversations.ConversationsHistoryRequest; +import com.slack.api.methods.response.conversations.ConversationsHistoryResponse; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.ArrayList; +import java.io.IOException; +import java.time.ZoneOffset; +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Singleton +public class SlackReader { + private static final Logger LOG = LoggerFactory.getLogger(SlackReader.class); + + @Inject + private CheckInsConfiguration configuration; + + public List read(String channelId, LocalDateTime last) { + String token = configuration.getApplication().getSlack().getBotToken(); + if (token != null) { + try { + long ts = last.atZone(ZoneId.systemDefault()) + .toInstant().getEpochSecond(); + String timestamp = String.valueOf(ts); + MethodsClient client = Slack.getInstance().methods(token); + ConversationsHistoryResponse response = + client.conversationsHistory( + ConversationsHistoryRequest.builder() + .channel(channelId) + .oldest(timestamp) + .inclusive(true) + .build()); + + if (response.isOk()) { + return response.getMessages(); + } else { + LOG.error("Slack Response: " + response.getError() + + " - " + response.getNeeded()); + } + } catch(IOException e) { + LOG.error("SlackReader.read: " + e.toString()); + } catch(SlackApiException e) { + LOG.error("SlackReader.read: " + e.toString()); + } + } else { + LOG.error("Slack Token not available"); + } + return new ArrayList(); + } +} + diff --git a/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSearch.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSearch.java similarity index 78% rename from server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSearch.java rename to server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSearch.java index e7fbb6fed1..c0af370cba 100644 --- a/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSearch.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSearch.java @@ -1,4 +1,4 @@ -package com.objectcomputing.checkins.notifications.social_media; +package com.objectcomputing.checkins.services.slack; import com.objectcomputing.checkins.configuration.CheckInsConfiguration; import com.slack.api.model.block.LayoutBlock; @@ -58,6 +58,31 @@ public String findChannelId(String channelName) { return null; } + public String findChannelName(String channelId) { + String token = configuration.getApplication().getSlack().getBotToken(); + if (token != null) { + try { + MethodsClient client = Slack.getInstance().methods(token); + ConversationsListResponse response = client.conversationsList( + ConversationsListRequest.builder().build() + ); + + if (response.isOk()) { + for (Conversation conversation: response.getChannels()) { + if (conversation.getId().equals(channelId)) { + return conversation.getName(); + } + } + } + } catch(IOException e) { + LOG.error("SlackSearch.findChannelName: " + e.toString()); + } catch(SlackApiException e) { + LOG.error("SlackSearch.findChannelName: " + e.toString()); + } + } + return null; + } + public String findUserId(String userEmail) { String token = configuration.getApplication().getSlack().getBotToken(); if (token != null) { diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSignature.java similarity index 63% rename from server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java rename to server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSignature.java index 2d95c33bd8..22f36c2026 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSignature.java @@ -1,5 +1,6 @@ -package com.objectcomputing.checkins.services.pulseresponse; +package com.objectcomputing.checkins.services.slack; +import com.objectcomputing.checkins.exceptions.BadArgException; import com.objectcomputing.checkins.configuration.CheckInsConfiguration; import javax.crypto.Mac; @@ -12,11 +13,11 @@ import jakarta.inject.Singleton; @Singleton -public class SlackSignatureVerifier { +public class SlackSignature { @Inject private CheckInsConfiguration configuration; - public SlackSignatureVerifier() {} + public SlackSignature() {} public boolean verifyRequest(String slackSignature, String timestamp, String requestBody) { try { @@ -35,7 +36,7 @@ public boolean verifyRequest(String slackSignature, String timestamp, String req // Compare the computed signature with Slack's signature return computedSignature.equals(slackSignature); - } catch (Exception e) { + } catch (Exception ex) { return false; } } @@ -64,4 +65,30 @@ private String hmacSha256(String secret, String message) throws Exception { } return hexString.toString(); } + + public String generate(String timestamp, String rawBody) { + String baseString = "v0:" + timestamp + ":" + rawBody; + String secret = configuration.getApplication() + .getSlack().getSigningSecret(); + + try { + // Generate HMAC SHA-256 signature + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = + new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), + "HmacSHA256"); + mac.init(secretKeySpec); + byte[] hash = mac.doFinal( + baseString.getBytes(StandardCharsets.UTF_8)); + + // Convert hash to hex + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + hexString.append(String.format("%02x", b)); + } + return "v0=" + hexString.toString(); + } catch (Exception ex) { + throw new BadArgException(ex.toString()); + } + } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSubmissionHandler.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSubmissionHandler.java new file mode 100644 index 0000000000..5c21ece4d8 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSubmissionHandler.java @@ -0,0 +1,180 @@ +package com.objectcomputing.checkins.services.slack; + +import com.objectcomputing.checkins.services.slack.pulseresponse.PulseSlackCommand; +import com.objectcomputing.checkins.services.slack.pulseresponse.SlackPulseResponseConverter; +import com.objectcomputing.checkins.services.slack.kudos.SlackKudosResponseHandler; + +import com.objectcomputing.checkins.util.form.FormUrlEncodedDecoder; +import com.objectcomputing.checkins.services.pulseresponse.PulseResponse; +import com.objectcomputing.checkins.services.pulseresponse.PulseResponseService; +import com.objectcomputing.checkins.services.pulseresponse.PulseResponseCreateDTO; + +import io.micronaut.http.HttpStatus; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; + +import jakarta.inject.Singleton; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.core.JsonProcessingException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.nio.charset.StandardCharsets; + +@Singleton +public class SlackSubmissionHandler { + private static final Logger LOG = LoggerFactory.getLogger(SlackSubmissionHandler.class); + private static final String typeKey = "type"; + + private final PulseResponseService pulseResponseServices; + private final SlackSignature slackSignature; + private final PulseSlackCommand pulseSlackCommand; + private final SlackPulseResponseConverter slackPulseResponseConverter; + private final SlackKudosResponseHandler slackKudosResponseHandler; + + public SlackSubmissionHandler(PulseResponseService pulseResponseServices, + SlackSignature slackSignature, + PulseSlackCommand pulseSlackCommand, + SlackPulseResponseConverter slackPulseResponseConverter, + SlackKudosResponseHandler slackKudosResponseHandler) { + this.pulseResponseServices = pulseResponseServices; + this.slackSignature = slackSignature; + this.pulseSlackCommand = pulseSlackCommand; + this.slackPulseResponseConverter = slackPulseResponseConverter; + this.slackKudosResponseHandler = slackKudosResponseHandler; + } + + public HttpResponse commandResponse(String signature, + String timestamp, + String requestBody) { + // Validate the request + if (slackSignature.verifyRequest(signature, timestamp, requestBody)) { + // Convert the request body to a map of values. + FormUrlEncodedDecoder formUrlEncodedDecoder = new FormUrlEncodedDecoder(); + Map body = + formUrlEncodedDecoder.decode(requestBody, + StandardCharsets.UTF_8); + + // Respond to the slack command. + String triggerId = (String)body.get("trigger_id"); + if (pulseSlackCommand.send(triggerId)) { + return HttpResponse.ok(); + } else { + return HttpResponse.status(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return HttpResponse.unauthorized(); + } + } + + public HttpResponse externalResponse(String signature, + String timestamp, + String requestBody, + HttpRequest request) { + // Validate the request + if (slackSignature.verifyRequest(signature, timestamp, requestBody)) { + // Convert the request body to a map of values. + FormUrlEncodedDecoder formUrlEncodedDecoder = + new FormUrlEncodedDecoder(); + Map body = + formUrlEncodedDecoder.decode(requestBody, + StandardCharsets.UTF_8); + + final String key = "payload"; + if (body.containsKey(key)) { + try { + final ObjectMapper mapper = new ObjectMapper(); + final Map map = + mapper.readValue((String)body.get(key), + new TypeReference<>() {}); + if (isPulseSubmission(map)) { + return completePulse(map); + } else if (isKudosSubmission(map)) { + return completeKudos(map); + } + } catch(JsonProcessingException ex) { + // Fall through to the bottom... + LOG.error("externalResponse: " + ex.toString()); + } + } + } else { + return HttpResponse.unauthorized(); + } + + return HttpResponse.unprocessableEntity(); + } + + private boolean isPulseSubmission(Map map) { + if (map.containsKey(typeKey)) { + final String type = (String)map.get(typeKey); + if (type.equals("view_submission")) { + final String viewKey = "view"; + if (map.containsKey(viewKey)) { + final Map view = + (Map)map.get(viewKey); + final String callbackKey = "callback_id"; + if (view.containsKey(callbackKey)) { + return "pulseSubmission".equals(view.get(callbackKey)); + } + } + } + } + return false; + } + + private HttpResponse completePulse(Map map) { + PulseResponseCreateDTO pulseResponseDTO = + slackPulseResponseConverter.get(map); + + // If we receive a null DTO, that means that this is not the actual + // submission of the form. We can just return 200 so that Slack knows + // to continue without error. Realy, this should not happen. But, just + // in case... + if (pulseResponseDTO != null) { + // Create the pulse response + PulseResponse pulseResponse = + pulseResponseServices.unsecureSave( + new PulseResponse( + pulseResponseDTO.getInternalScore(), + pulseResponseDTO.getExternalScore(), + pulseResponseDTO.getSubmissionDate(), + pulseResponseDTO.getTeamMemberId(), + pulseResponseDTO.getInternalFeelings(), + pulseResponseDTO.getExternalFeelings() + ) + ); + + if (pulseResponse == null) { + // If pulse response is null, that means that this user has + // already submitted a response today. + return HttpResponse.status(HttpStatus.CONFLICT, + "Already submitted today"); + } + } + return HttpResponse.ok(); + } + + private boolean isKudosSubmission(Map map) { + if (map.containsKey(typeKey)) { + final String type = (String)map.get(typeKey); + if (type.equals("block_actions")) { + final String actionKey = "actions"; + return map.containsKey(actionKey); + } + } + return false; + } + + private HttpResponse completeKudos(Map map) { + if (slackKudosResponseHandler.handle(map)) { + return HttpResponse.ok(); + } else { + // Something was wrong and we were not able to handle this. + return HttpResponse.unprocessableEntity(); + } + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudos.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudos.java new file mode 100644 index 0000000000..e08dfc9411 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudos.java @@ -0,0 +1,78 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.data.annotation.AutoPopulated; +import io.micronaut.data.annotation.TypeDef; +import io.micronaut.data.annotation.sql.ColumnTransformer; +import io.micronaut.data.model.DataType; +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Entity +@Getter +@Setter +@Introspected +@Table(name = "automated_kudos") +public class AutomatedKudos { + @Id + @Column(name = "id") + @AutoPopulated + @TypeDef(type = DataType.STRING) + @Schema(description = "the id of the kudos") + private UUID id; + + @Column(name = "requested") + @NotNull + @Schema(description = "Has permission been requested of the poster") + private Boolean requested; + + @NotBlank + @Column(name = "message") + @ColumnTransformer(read = "pgp_sym_decrypt(message::bytea, '${aes.key}')", write = "pgp_sym_encrypt(?, '${aes.key}')") + @Schema(description = "message describing the kudos") + private String message; + + @NotBlank + @Column(name = "externalid") + @ColumnTransformer(read = "pgp_sym_decrypt(externalid::bytea, '${aes.key}')", write = "pgp_sym_encrypt(?, '${aes.key}')") + @Schema(description = "the external id of the sender") + private String externalId; + + @NotNull + @Column(name = "senderid") + @TypeDef(type = DataType.STRING) + @Schema(description = "id of the user who gave the kudos") + private UUID senderId; + + @Column(name = "recipientids") + @TypeDef(type = DataType.STRING_ARRAY) + @Schema(description = "UUIDs of the recipients") + private List recipientIds; + + // This is necessary for Micronaut to persist instances of this class. + AutomatedKudos() {} + + AutomatedKudos(AutomatedKudosDTO automatedKudosDTO) { + this.requested = false; + this.message = automatedKudosDTO.getMessage(); + this.externalId = automatedKudosDTO.getExternalId(); + this.senderId = automatedKudosDTO.getSenderId(); + this.recipientIds = automatedKudosDTO.getRecipientIds() + .stream() + .map(UUID::toString) + .collect(Collectors.toList()); + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudosDTO.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudosDTO.java new file mode 100644 index 0000000000..d8b80d0687 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudosDTO.java @@ -0,0 +1,31 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import io.micronaut.core.annotation.Introspected; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@AllArgsConstructor +@Introspected +public class AutomatedKudosDTO { + + @NotBlank + private String message; + + @NotNull + private String externalId; + + @NotNull + private UUID senderId; + + @NotNull + private List recipientIds; +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudosRepository.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudosRepository.java new file mode 100644 index 0000000000..224e0e9363 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudosRepository.java @@ -0,0 +1,22 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.repository.CrudRepository; +import io.micronaut.data.annotation.Query; + +import java.util.List; +import java.util.UUID; + +@JdbcRepository(dialect = Dialect.POSTGRES) +public interface AutomatedKudosRepository extends CrudRepository { + @Query(value = """ + SELECT + id, requested, + PGP_SYM_DECRYPT(cast(message as bytea), '${aes.key}') as message, + PGP_SYM_DECRYPT(cast(externalid as bytea), '${aes.key}') as externalid, + senderid, recipientids + FROM automated_kudos + WHERE requested IS FALSE""", nativeQuery = true) + List getUnrequested(); +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/BotSentKudosLocator.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/BotSentKudosLocator.java new file mode 100644 index 0000000000..0df297df9a --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/BotSentKudosLocator.java @@ -0,0 +1,55 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import com.objectcomputing.checkins.services.kudos.Kudos; +import com.objectcomputing.checkins.services.slack.SlackReader; +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import com.slack.api.model.Message; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import java.util.List; +import java.time.LocalDateTime; + +@Singleton +public class BotSentKudosLocator { + private static final Logger LOG = LoggerFactory.getLogger(BotSentKudosLocator.class); + + @Inject + private CheckInsConfiguration configuration; + + @Inject + private SlackReader slackReader; + + // The identifiers needed to identify a message is the channel id and the + // time stamp. We are always looking at a specific channel. So if we find + // a message, we will return the timestamp as a string. Otherwise, we will + // return null. + public String find(Kudos kudos) { + String channelId = configuration.getApplication() + .getSlack().getKudosChannel(); + List messages = + slackReader.read(channelId, kudos.getDateCreated().atStartOfDay()); + + String kudosText = kudos.getMessage().trim(); + for (Message message : messages) { + // We only care about messages sent by our bot. + if (message.getBotId() != null) { + // The first line is the "kudos from" line and is not part of + // the kudos message. + int cut = message.getText().indexOf("\n"); + if (cut >= 0) { + String actual = message.getText().substring(cut + 1).trim(); + if (actual.equals(kudosText)) { + return message.getTs(); + } + } + } + } + return null; + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReadTime.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReadTime.java new file mode 100644 index 0000000000..33307243ba --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReadTime.java @@ -0,0 +1,42 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import io.micronaut.core.annotation.Introspected; +import io.micronaut.data.annotation.TypeDef; +import io.micronaut.data.model.DataType; +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@Introspected +@Table(name = "automated_kudos_read_time") +public class KudosChannelReadTime { + static final public String key = "Singleton"; + + @Id + @Column(name = "id") + @Schema(description = "the id of the kudos channel read time") + private String id; + + @NotNull + @Column(name = "readtime") + @TypeDef(type = DataType.TIMESTAMP) + @Schema(description = "date the kudos were created") + private LocalDateTime readTime; + + public KudosChannelReadTime() { + id = key; + readTime = LocalDateTime.now(); + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReadTimeStore.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReadTimeStore.java new file mode 100644 index 0000000000..e2adbec19a --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReadTimeStore.java @@ -0,0 +1,9 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +@JdbcRepository(dialect = Dialect.POSTGRES) +public interface KudosChannelReadTimeStore extends CrudRepository { +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReader.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReader.java new file mode 100644 index 0000000000..faa62ebfb1 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReader.java @@ -0,0 +1,56 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import com.objectcomputing.checkins.services.slack.SlackReader; +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import com.slack.api.model.Message; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import java.util.List; +import java.util.Optional; +import java.time.LocalDateTime; + +@Singleton +public class KudosChannelReader { + private static final Logger LOG = LoggerFactory.getLogger(KudosChannelReader.class); + + @Inject + private KudosChannelReadTimeStore kudosChannelReadTimeStore; + + @Inject + private CheckInsConfiguration configuration; + + @Inject + private SlackReader slackReader; + + @Inject + private SlackKudosCreator slackKudosCreator; + + public void readChannel() { + Optional readTime = + kudosChannelReadTimeStore.findById(KudosChannelReadTime.key); + boolean present = readTime.isPresent(); + LocalDateTime lastImport = present ? readTime.get().getReadTime() + : LocalDateTime.now(); + + String channelId = configuration.getApplication() + .getSlack().getKudosChannel(); + LOG.info("Reading messages from " + channelId + + " as of " + lastImport.toString()); + List messages = slackReader.read(channelId, lastImport); + if (present) { + kudosChannelReadTimeStore.update(new KudosChannelReadTime()); + } else { + kudosChannelReadTimeStore.save(new KudosChannelReadTime()); + } + + if (!messages.isEmpty()) { + slackKudosCreator.store(messages); + } + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/SlackKudosCreator.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/SlackKudosCreator.java new file mode 100644 index 0000000000..254df1cad5 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/SlackKudosCreator.java @@ -0,0 +1,170 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import com.objectcomputing.checkins.services.slack.SlackSearch; +import com.objectcomputing.checkins.notifications.social_media.SlackSender; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileUtils; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.exceptions.NotFoundException; + +import org.apache.commons.lang3.StringEscapeUtils; + +import com.slack.api.model.Message; + +import io.micronaut.context.annotation.Value; +import io.micronaut.core.io.Readable; +import io.micronaut.core.io.IOUtils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import java.util.UUID; +import java.util.List; +import java.util.ArrayList; +import java.util.regex.Pattern; +import java.util.regex.Matcher; +import java.lang.StringBuffer; +import java.io.BufferedReader; + +@Singleton +public class SlackKudosCreator { + private static final Logger LOG = LoggerFactory.getLogger(SlackKudosCreator.class); + + @Inject + private SlackSearch slackSearch; + + @Inject + private SlackSender slackSender; + + @Inject + private AutomatedKudosRepository automatedKudosRepository; + + @Inject + private MemberProfileServices memberProfileServices; + + @Value("classpath:slack/kudos_slack_blocks.json") + private Readable kudosSlackBlocks; + + public void store(List messages) { + for (Message message : messages) { + // User messages do not have a sub-type. A bot user can send + // messages. They will not have a subtype, but they will have a bot + // id. We want to skip those too. + if (message.getSubtype() == null && message.getBotId() == null) { + try { + AutomatedKudosDTO kudosDTO = createFromMessage(message); + if (kudosDTO.getRecipientIds().size() == 0) { + LOG.warn("Unable to extract recipients from message"); + LOG.warn(message.getText()); + } else { + automatedKudosRepository.save( + new AutomatedKudos(kudosDTO)); + } + } catch (Exception ex) { + LOG.error("store: " + ex.toString()); + } + } else { + LOG.info("Skipping message: " + message.getText()); + } + } + + requestAction(); + } + + private AutomatedKudosDTO createFromMessage(Message message) { + String userId = message.getUser(); + MemberProfile sender = lookupUser(userId); + List recipients = new ArrayList<>(); + String text = processText(message.getText(), recipients); + return new AutomatedKudosDTO(text, userId, sender.getId(), recipients); + } + + private MemberProfile lookupUser(String userId) { + if (userId == null) { + throw new NotFoundException("User Id is not present"); + } + + String email = slackSearch.findUserEmail(userId); + if (email == null) { + throw new NotFoundException( + "Could not find an email address for " + userId); + } + return memberProfileServices.findByWorkEmail(email); + } + + private String processText(String text, List recipients) { + // First, process user references. + StringBuffer buffer = new StringBuffer(text.length()); + Pattern userRef = Pattern.compile("<@([^>]+)>"); + Matcher action = userRef.matcher(StringEscapeUtils.unescapeHtml4(text)); + while (action.find()) { + // Pull out the recipient user id, get the profile and add it to + // the list of recipients. + String userId = action.group(1); + MemberProfile profile = lookupUser(userId); + recipients.add(profile.getId()); + + // Replace the user reference with their full name. + action.appendReplacement(buffer, Matcher.quoteReplacement( + MemberProfileUtils.getFullName(profile))); + } + action.appendTail(buffer); + text = buffer.toString(); + + // Next, translate channel references to channel names. + Pattern channelRef = Pattern.compile("<#([^>]+)\\|>"); + buffer = new StringBuffer(text.length()); + action = channelRef.matcher(text); + while (action.find()) { + // Get the name of the channel. + String channelId = action.group(1); + String name = slackSearch.findChannelName(channelId); + if (name == null) { + name = "unknown_channel"; + } + name = "#" + name; + + // Replace the channel reference with the channel name. + action.appendReplacement(buffer, Matcher.quoteReplacement(name)); + } + action.appendTail(buffer); + return buffer.toString(); + } + + private void requestAction() { + for (AutomatedKudos kudos : automatedKudosRepository.getUnrequested()) { + try { + // Create the slack blocks, inserting the kudos UUID as the + // block id. + String blocks = getSlackBlocks(kudos.getId().toString(), + kudos.getMessage()); + + // Send the message to the sender of the kudos + List userIds = new ArrayList<>(); + userIds.add(kudos.getExternalId()); + if (slackSender.send(userIds, blocks)) { + // If the message was sent, set the requested flag and + // update the repository. + kudos.setRequested(true); + automatedKudosRepository.update(kudos); + } + } catch (Exception ex) { + LOG.error("requestAction: " + ex.toString()); + } + } + } + + private String getSlackBlocks(String kudosUUID, String contents) { + try { + return String.format(IOUtils.readText( + new BufferedReader(kudosSlackBlocks.asReader())), + kudosUUID, StringEscapeUtils.escapeJson(contents)); + } catch(Exception ex) { + LOG.error("SlackKudosCreator.getSlackBlocks: " + ex.toString()); + return ""; + } + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/SlackKudosResponseHandler.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/SlackKudosResponseHandler.java new file mode 100644 index 0000000000..179a969f60 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/SlackKudosResponseHandler.java @@ -0,0 +1,84 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.services.kudos.KudosCreateDTO; +import com.objectcomputing.checkins.services.kudos.KudosServices; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.UUID; +import java.util.List; +import java.util.ArrayList; +import java.util.Optional; + +@Singleton +public class SlackKudosResponseHandler { + private static final Logger LOG = LoggerFactory.getLogger(SlackKudosResponseHandler.class); + + @Inject + private KudosServices kudosServices; + + @Inject + private AutomatedKudosRepository automatedKudosRepository; + + @Inject + private MemberProfileServices memberProfileServices; + + public boolean handle(Map map) { + try { + // Get the blocks out of the message so that we can grab the + // automated kudos id. + Map message = + (Map)map.get("message"); + List blocks = (List)message.get("blocks"); + if (blocks.size() > 0) { + Map first = (Map)blocks.get(0); + String id = (String)first.get("block_id"); + UUID uuid = UUID.fromString(id); + + List actions = (List)map.get("actions"); + if (actions.size() > 0) { + Map entry = + (Map)actions.get(0); + String actionId = (String)entry.get("action_id"); + if (actionId.equals("yes_button")) { + store(uuid); + } else { + automatedKudosRepository.deleteById(uuid); + } + return true; + } + } + } catch (Exception ex) { + LOG.error("SlackKudosResponseHandler.handle: " + ex.toString()); + } + return false; + } + + private void store(UUID automatedKudosId) { + Optional found = + automatedKudosRepository.findById(automatedKudosId); + if (found.isPresent()) { + AutomatedKudos kudos = found.get(); + List recipients = new ArrayList<>(); + for (String recipientId : kudos.getRecipientIds()) { + recipients.add(memberProfileServices.getById( + UUID.fromString(recipientId))); + } + KudosCreateDTO dto = + new KudosCreateDTO(kudos.getMessage(), kudos.getSenderId(), + null, true, recipients); + kudosServices.savePreapproved(dto); + automatedKudosRepository.deleteById(automatedKudosId); + } else { + LOG.error("Unable to find automated kudos: " + + automatedKudosId.toString()); + } + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/pulseresponse/PulseSlackCommand.java similarity index 90% rename from server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java rename to server/src/main/java/com/objectcomputing/checkins/services/slack/pulseresponse/PulseSlackCommand.java index 1c48eb30ba..85f9d79fac 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/pulseresponse/PulseSlackCommand.java @@ -1,4 +1,4 @@ -package com.objectcomputing.checkins.services.pulseresponse; +package com.objectcomputing.checkins.services.slack.pulseresponse; import com.objectcomputing.checkins.configuration.CheckInsConfiguration; @@ -54,7 +54,7 @@ public boolean send(String triggerId) { return response.isOk(); } catch(Exception ex) { - LOG.error(ex.toString()); + LOG.error("PulseSlackCommand.send: " + ex.toString()); return false; } } else { @@ -68,7 +68,7 @@ private String getSlackBlocks() { return IOUtils.readText( new BufferedReader(pulseSlackBlocks.asReader())); } catch(Exception ex) { - LOG.error(ex.toString()); + LOG.error("PulseSlackCommand.getSlackBlocks: " + ex.toString()); return ""; } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/pulseresponse/SlackPulseResponseConverter.java similarity index 79% rename from server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java rename to server/src/main/java/com/objectcomputing/checkins/services/slack/pulseresponse/SlackPulseResponseConverter.java index 4e00287cb8..3205b6c663 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/pulseresponse/SlackPulseResponseConverter.java @@ -1,19 +1,16 @@ -package com.objectcomputing.checkins.services.pulseresponse; +package com.objectcomputing.checkins.services.slack.pulseresponse; import com.objectcomputing.checkins.exceptions.BadArgException; import com.objectcomputing.checkins.services.memberprofile.MemberProfile; import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; -import com.objectcomputing.checkins.notifications.social_media.SlackSearch; +import com.objectcomputing.checkins.services.slack.SlackSearch; +import com.objectcomputing.checkins.services.pulseresponse.PulseResponseCreateDTO; import jakarta.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.core.JsonProcessingException; - import java.util.Map; import java.util.UUID; import java.time.LocalDate; @@ -23,20 +20,17 @@ public class SlackPulseResponseConverter { private static final Logger LOG = LoggerFactory.getLogger(SlackPulseResponseConverter.class); private final SlackSearch slackSearch; + private final MemberProfileServices memberProfileServices; - public SlackPulseResponseConverter(SlackSearch slackSearch) { + public SlackPulseResponseConverter(SlackSearch slackSearch, + MemberProfileServices memberProfileServices) { this.slackSearch = slackSearch; + this.memberProfileServices = memberProfileServices; } - public PulseResponseCreateDTO get( - MemberProfileServices memberProfileServices, String body) { + public PulseResponseCreateDTO get(Map map) { try { - // Get the map of values from the string body - final ObjectMapper mapper = new ObjectMapper(); - final Map map = - mapper.readValue(body, new TypeReference<>() {}); final String type = (String)map.get("type"); - if (type.equals("view_submission")) { final Map view = (Map)map.get("view"); @@ -47,7 +41,7 @@ public PulseResponseCreateDTO get( // Create the pulse DTO and fill in the values. PulseResponseCreateDTO response = new PulseResponseCreateDTO(); - response.setTeamMemberId(lookupUser(memberProfileServices, map)); + response.setTeamMemberId(lookupUser(map)); response.setSubmissionDate(LocalDate.now()); // Internal Score @@ -78,11 +72,8 @@ public PulseResponseCreateDTO get( // response. return null; } - } catch(JsonProcessingException ex) { - LOG.error(ex.getMessage()); - throw new BadArgException(ex.getMessage()); } catch(NumberFormatException ex) { - LOG.error(ex.getMessage()); + LOG.error("SlackPulseResponseConverter.get: " + ex.getMessage()); throw new BadArgException("Pulse scores must be integers"); } } @@ -111,8 +102,7 @@ private String getMappedValue(Map map, String key1, } } - private UUID lookupUser(MemberProfileServices memberProfileServices, - Map map) { + private UUID lookupUser(Map map) { // Get the user's profile map. Map user = (Map)map.get("user"); diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 469d84a58d..23ea93a927 100755 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -101,6 +101,7 @@ check-ins: webhook-url: ${ SLACK_WEBHOOK_URL } bot-token: ${ SLACK_BOT_TOKEN } signing-secret: ${ SLACK_SIGNING_SECRET } + kudos-channel: ${ SLACK_KUDOS_CHANNEL_ID } web-address: ${ WEB_ADDRESS } --- flyway: diff --git a/server/src/main/resources/db/common/V121__automated_kudos_table.sql b/server/src/main/resources/db/common/V121__automated_kudos_table.sql new file mode 100644 index 0000000000..a29c362bbd --- /dev/null +++ b/server/src/main/resources/db/common/V121__automated_kudos_table.sql @@ -0,0 +1,19 @@ +DROP TABLE IF EXISTS automated_kudos; + +CREATE TABLE automated_kudos +( + id varchar PRIMARY KEY, + requested boolean, + message varchar, + externalid varchar, + senderid varchar REFERENCES member_profile (id), + recipientids varchar[] +); + +DROP TABLE IF EXISTS automated_kudos_read_time; + +CREATE TABLE automated_kudos_read_time +( + id varchar PRIMARY KEY, + readtime timestamp +); diff --git a/server/src/main/resources/db/common/V122__add_disable_bday_flag.sql b/server/src/main/resources/db/common/V122__add_disable_bday_flag.sql new file mode 100644 index 0000000000..0bb0927759 --- /dev/null +++ b/server/src/main/resources/db/common/V122__add_disable_bday_flag.sql @@ -0,0 +1 @@ +ALTER TABLE member_profile ADD column ignore_birthday boolean; diff --git a/server/src/main/resources/slack/kudos_slack_blocks.json b/server/src/main/resources/slack/kudos_slack_blocks.json new file mode 100644 index 0000000000..19558fc326 --- /dev/null +++ b/server/src/main/resources/slack/kudos_slack_blocks.json @@ -0,0 +1,43 @@ +[ + { + "type": "section", + "block_id": "%s", + "text": { + "type": "plain_text", + "text": "Would you like to automatically add this Kudos to Check-Ins?" + } + }, + { + "type": "section", + "text": { + "type": "plain_text", + "text": "%s" + } + }, + { + "type": "divider" + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "action_id": "yes_button", + "text": { + "type": "plain_text", + "text": "Yes" + }, + "value": "yes" + }, + { + "type": "button", + "action_id": "no_button", + "text": { + "type": "plain_text", + "text": "No" + }, + "value": "no" + } + ] + } +] diff --git a/server/src/main/resources/slack/pulse_slack_blocks.json b/server/src/main/resources/slack/pulse_slack_blocks.json index ad0f7342a8..6ef6b9d436 100644 --- a/server/src/main/resources/slack/pulse_slack_blocks.json +++ b/server/src/main/resources/slack/pulse_slack_blocks.json @@ -1,5 +1,6 @@ { "type": "modal", + "callback_id": "pulseSubmission", "title": { "type": "plain_text", "text": "Check-Ins Pulse", diff --git a/server/src/test/java/com/objectcomputing/checkins/services/SlackReaderReplacement.java b/server/src/test/java/com/objectcomputing/checkins/services/SlackReaderReplacement.java new file mode 100644 index 0000000000..6ac2035d41 --- /dev/null +++ b/server/src/test/java/com/objectcomputing/checkins/services/SlackReaderReplacement.java @@ -0,0 +1,58 @@ +package com.objectcomputing.checkins.services; + +import com.objectcomputing.checkins.services.slack.SlackReader; +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; + +import com.slack.api.model.Message; + +import jakarta.inject.Singleton; + +import java.util.List; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Singleton +@Replaces(SlackReader.class) +@Requires(property = "replace.slackreader", value = StringUtils.TRUE) +public class SlackReaderReplacement extends SlackReader { + public final Map> channelMessages = new HashMap<>(); + + @Override + public List read(String channelId, LocalDateTime last) { + List messages = new ArrayList<>(); + if (channelMessages.containsKey(channelId)) { + long ts = last.atZone(ZoneId.systemDefault()) + .toInstant().getEpochSecond(); + for (Message message : channelMessages.get(channelId)) { + long messageTime = Long.parseLong(message.getTs()); + if (messageTime >= ts) { + messages.add(message); + } + } + } + return messages; + } + + public void addMessage(String channelId, String userId, + String text, LocalDateTime sendTime) { + Message message = new Message(); + message.setTs(String.valueOf(sendTime.atZone(ZoneId.systemDefault()) + .toInstant().getEpochSecond())); + message.setText(text); + message.setUser(userId); + + if (!channelMessages.containsKey(channelId)) { + channelMessages.put(channelId, new ArrayList()); + } + channelMessages.get(channelId).add(message); + } +} diff --git a/server/src/test/java/com/objectcomputing/checkins/services/SlackSearchReplacement.java b/server/src/test/java/com/objectcomputing/checkins/services/SlackSearchReplacement.java index c239da699b..db0d493470 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/SlackSearchReplacement.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/SlackSearchReplacement.java @@ -1,6 +1,6 @@ package com.objectcomputing.checkins.services; -import com.objectcomputing.checkins.notifications.social_media.SlackSearch; +import com.objectcomputing.checkins.services.slack.SlackSearch; import com.objectcomputing.checkins.configuration.CheckInsConfiguration; import io.micronaut.context.annotation.Replaces; @@ -25,10 +25,20 @@ public SlackSearchReplacement(CheckInsConfiguration checkInsConfiguration) { super(checkInsConfiguration); } + @Override + public String findChannelName(String channelId) { + return channels.containsKey(channelId) ? + channels.get(channelId) : null; + } + @Override public String findChannelId(String channelName) { - return channels.containsKey(channelName) ? - channels.get(channelName) : null; + for (Map.Entry entry : channels.entrySet()) { + if (entry.getValue().equals(channelName)) { + return entry.getKey(); + } + } + return null; } @Override diff --git a/server/src/test/java/com/objectcomputing/checkins/services/SlackSenderReplacement.java b/server/src/test/java/com/objectcomputing/checkins/services/SlackSenderReplacement.java new file mode 100644 index 0000000000..84fba29842 --- /dev/null +++ b/server/src/test/java/com/objectcomputing/checkins/services/SlackSenderReplacement.java @@ -0,0 +1,46 @@ +package com.objectcomputing.checkins.services; + +import com.objectcomputing.checkins.notifications.social_media.SlackSender; + +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.StringUtils; + +import jakarta.inject.Singleton; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +@Singleton +@Replaces(SlackSender.class) +@Requires(property = "replace.slacksender", value = StringUtils.TRUE) +public class SlackSenderReplacement extends SlackSender { + public final Map> sent = new HashMap<>(); + + public void reset() { + sent.clear(); + } + + @Override + public boolean send(List userIds, String slackBlocks) { + for (String userId : userIds) { + if (!sent.containsKey(userId)) { + sent.put(userId, new ArrayList()); + } + sent.get(userId).add(slackBlocks); + } + return true; + } + + @Override + public boolean send(String channelId, String slackBlocks) { + if (!sent.containsKey(channelId)) { + sent.put(channelId, new ArrayList()); + } + sent.get(channelId).add(slackBlocks); + return true; + } +} + diff --git a/server/src/test/java/com/objectcomputing/checkins/services/email/EmailControllerTest.java b/server/src/test/java/com/objectcomputing/checkins/services/email/EmailControllerTest.java index eea14eccea..e1bd32cf26 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/email/EmailControllerTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/email/EmailControllerTest.java @@ -138,14 +138,16 @@ void testSendAndSaveTextEmail() { assertEquals(email.get("content"), firstEmailRes.getContents()); assertEquals(admin.getId(), firstEmailRes.getSentBy()); assertEquals(recipient1.getId(), firstEmailRes.getRecipient()); - assertTrue(firstEmailRes.getTransmissionDate().isAfter(firstEmailRes.getSendDate())); + assertTrue(firstEmailRes.getTransmissionDate().isAfter(firstEmailRes.getSendDate()) || + firstEmailRes.getTransmissionDate().isEqual(firstEmailRes.getSendDate())); Email secondEmailRes = response.getBody().get().get(1); assertEquals(email.get("subject"), secondEmailRes.getSubject()); assertEquals(email.get("content"), secondEmailRes.getContents()); assertEquals(admin.getId(), secondEmailRes.getSentBy()); assertEquals(recipient2.getId(), secondEmailRes.getRecipient()); - assertTrue(secondEmailRes.getTransmissionDate().isAfter(secondEmailRes.getSendDate())); + assertTrue(secondEmailRes.getTransmissionDate().isAfter(secondEmailRes.getSendDate()) || + secondEmailRes.getTransmissionDate().isEqual(secondEmailRes.getSendDate())); assertEquals(1, textEmailSender.events.size()); assertEquals( diff --git a/server/src/test/java/com/objectcomputing/checkins/services/fixture/AutomatedKudosFixture.java b/server/src/test/java/com/objectcomputing/checkins/services/fixture/AutomatedKudosFixture.java new file mode 100644 index 0000000000..a8c0c2a80d --- /dev/null +++ b/server/src/test/java/com/objectcomputing/checkins/services/fixture/AutomatedKudosFixture.java @@ -0,0 +1,15 @@ +package com.objectcomputing.checkins.services.fixture; + +import com.objectcomputing.checkins.services.slack.kudos.AutomatedKudos; +import com.objectcomputing.checkins.services.slack.kudos.AutomatedKudosRepository; + +import java.util.ArrayList; +import java.util.List; + +public interface AutomatedKudosFixture extends RepositoryFixture { + default List getAutomatedKudos() { + List list = new ArrayList<>(); + getAutomatedKudosRepository().findAll().forEach(list::add); + return list; + } +} diff --git a/server/src/test/java/com/objectcomputing/checkins/services/fixture/FeedbackRequestFixture.java b/server/src/test/java/com/objectcomputing/checkins/services/fixture/FeedbackRequestFixture.java index 29c427f94d..9ccf960943 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/fixture/FeedbackRequestFixture.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/fixture/FeedbackRequestFixture.java @@ -103,7 +103,7 @@ default MemberProfile createADefaultRecipient() { null, "Parks Director", null, "Pawnee, Indiana", "ron@objectcomputing.com", "mr-ron-swanson", LocalDate.now(), "enjoys woodworking, breakfast meats, and saxophone jazz", - null, null, null, false, false, null)); + null, null, null, false, false, null, false)); } default MemberProfile createASecondDefaultRecipient() { @@ -111,7 +111,7 @@ default MemberProfile createASecondDefaultRecipient() { null, "Parks Deputy Director", null, "Pawnee, Indiana", "leslie@objectcomputing.com", "ms-leslie-knope", LocalDate.now(), "proud member of numerous action committees", - null, null, null, false, false, null)); + null, null, null, false, false, null, false)); } default FeedbackRequest createFeedbackRequest(MemberProfile creator, MemberProfile requestee, MemberProfile recipient) { diff --git a/server/src/test/java/com/objectcomputing/checkins/services/fixture/MemberProfileFixture.java b/server/src/test/java/com/objectcomputing/checkins/services/fixture/MemberProfileFixture.java index b45f82ebfa..20258206f1 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/fixture/MemberProfileFixture.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/fixture/MemberProfileFixture.java @@ -12,7 +12,7 @@ default MemberProfile createADefaultMemberProfile() { null, "Comedic Relief", null, "New York, New York", "billm@objectcomputing.com", "mr-bill-employee", LocalDate.now().minusDays(3).minusYears(5), "is a clay figurine clown star of a parody of children's clay animation shows", - null, null, null, null, null, LocalDate.now())); + null, null, null, null, null, LocalDate.now(), false)); } default MemberProfile createADefaultMemberProfileWithBirthDayToday() { @@ -21,7 +21,7 @@ default MemberProfile createADefaultMemberProfileWithBirthDayToday() { null, "Comedic Relief", null, "New York, New York", "billm@objectcomputing.com", "mr-billy-employee-today", LocalDate.now().minusDays(3).minusMonths(1).minusYears(5), "is a clay figurine clown star of a parody of children's clay animation shows", - null, null,today.minusYears(30), null, null, LocalDate.now())); + null, null,today.minusYears(30), null, null, LocalDate.now(), false)); } default MemberProfile createADefaultMemberProfileWithBirthDayNotToday() { @@ -30,7 +30,7 @@ default MemberProfile createADefaultMemberProfileWithBirthDayNotToday() { null, "Comedic Relief", null, "New York, New York", "billm@objectcomputing.com", "mr-billy-employee-not-today", LocalDate.now().minusDays(3).minusYears(5), "is a clay figurine clown star of a parody of children's clay animation shows", - null, null, today.minusYears(30).minusDays(3), null, null, LocalDate.now())); + null, null, today.minusYears(30).minusDays(3), null, null, LocalDate.now(), false)); } default MemberProfile createADefaultMemberProfileWithAnniversaryToday() { @@ -39,7 +39,7 @@ default MemberProfile createADefaultMemberProfileWithAnniversaryToday() { null, "Comedic Relief", null, "New York, New York", "billm@objectcomputing.com", "mr-billish-employee", today.minusYears(10), "is a clay figurine clown star of a parody of children's clay animation shows", - null, null, null, null, null, LocalDate.now())); + null, null, null, null, null, LocalDate.now(), false)); } default MemberProfile createASecondDefaultMemberProfile() { @@ -47,7 +47,7 @@ default MemberProfile createASecondDefaultMemberProfile() { null, "Office Opossum", null, "New York, New York", "slimjim@objectcomputing.com", "slim-jim-employee", LocalDate.now().minusDays(3).minusYears(5), "A Virginia opossum, one of North America's only marsupials", - null, null, null, null, null, LocalDate.now())); + null, null, null, null, null, LocalDate.now(), false)); } default MemberProfile createAThirdDefaultMemberProfile() { @@ -55,7 +55,7 @@ default MemberProfile createAThirdDefaultMemberProfile() { null, "magic factory owner", null, "Chocolate Factory", "wonkaw@objectcomputing.com", "willy-wonka-employee", LocalDate.now().minusDays(3).minusYears(5), "questionable employer, but gives free golden tickets", - null, null, null, null, null, LocalDate.now())); + null, null, null, null, null, LocalDate.now(), false)); } default MemberProfile createADefaultMemberProfileForPdl(MemberProfile memberProfile) { @@ -63,7 +63,7 @@ default MemberProfile createADefaultMemberProfileForPdl(MemberProfile memberProf null, "Comedic Relief PDL", memberProfile.getId(), "New York, New York", "billmpdl@objectcomputing.com", "mr-bill-employee-pdl", LocalDate.now().minusDays(3).minusYears(5), "is a clay figurine clown star of a parody of children's clay animation shows", - memberProfile.getId(), null, null, null, null, LocalDate.now())); + memberProfile.getId(), null, null, null, null, LocalDate.now(), false)); } default MemberProfile createASecondDefaultMemberProfileForPdl(MemberProfile memberProfile) { @@ -71,7 +71,7 @@ default MemberProfile createASecondDefaultMemberProfileForPdl(MemberProfile memb null, "Bully Relief PDL", memberProfile.getId(), "New York, New York", "sluggopdl@objectcomputing.com", "sluggo-employee-pdl", LocalDate.now(), "is the bully in a clay figurine clown star of a parody of children's clay animation shows", - memberProfile.getId(), null, null, null, null, LocalDate.now())); + memberProfile.getId(), null, null, null, null, LocalDate.now(), false)); } default MemberProfile createAThirdDefaultMemberProfileForPdl(MemberProfile memberProfile) { @@ -79,7 +79,7 @@ default MemberProfile createAThirdDefaultMemberProfileForPdl(MemberProfile membe null, "local kaiju", memberProfile.getId(), "Tokyo, Japan", "godzilla@objectcomputing.com", "godzilla", LocalDate.now(), "is a destroyer of words", - null, null, null, null, null, LocalDate.now())); + null, null, null, null, null, LocalDate.now(), false)); } default MemberProfile createADefaultSupervisor() { @@ -87,7 +87,7 @@ default MemberProfile createADefaultSupervisor() { null, "Supervisor Man", null, "New York, New York", "dubebro@objectcomputing.com", "dude-bro-supervisor", LocalDate.now().minusDays(3).minusYears(5), "is such like a bro dude, you know?", - null, null, null, null, null, LocalDate.now())); + null, null, null, null, null, LocalDate.now(), false)); } default MemberProfile createAnotherSupervisor() { @@ -95,7 +95,7 @@ default MemberProfile createAnotherSupervisor() { null, "Supervisor Lady", null, "New York, New York", "dudettegal@objectcomputing.com", "dudette-gal-supervisor", LocalDate.now().minusDays(5).minusYears(7), "is such like a gal dudette, you know?", - null, null, null, null, null, LocalDate.now())); + null, null, null, null, null, LocalDate.now(), false)); } default MemberProfile createAProfileWithSupervisorAndPDL(MemberProfile supervisorProfile, MemberProfile pdlProfile) { @@ -103,21 +103,21 @@ default MemberProfile createAProfileWithSupervisorAndPDL(MemberProfile superviso null, "Local fire hazard", pdlProfile.getId(), "New York, New York", "charizard@objectcomputing.com", "local-kaiju", LocalDate.now().minusDays(3).minusYears(5), "Needs a lot of supervision due to building being ultra flammable", - supervisorProfile.getId(), null, null, null, null, LocalDate.now())); + supervisorProfile.getId(), null, null, null, null, LocalDate.now(), false)); } default MemberProfile createAnotherProfileWithSupervisorAndPDL(MemberProfile supervisorProfile, MemberProfile pdlProfile) { return getMemberProfileRepository().save(new MemberProfile("Pikachu", null, "Pika", null, "Local lightning hazard", pdlProfile.getId(), "New York, New York", "pikachu@objectcomputing.com", "local-lightning-kaiju", LocalDate.now().minusDays(5).minusYears(3), "Pretty sparky", - supervisorProfile.getId(), null, null, null, null, LocalDate.now())); + supervisorProfile.getId(), null, null, null, null, LocalDate.now(), false)); } default MemberProfile createYetAnotherProfileWithSupervisorAndPDL(MemberProfile supervisorProfile, MemberProfile pdlProfile) { return getMemberProfileRepository().save(new MemberProfile("Squirtle", null, "Squirt", null, "Local water hazard", pdlProfile.getId(), "New York, New York", "squirtle@objectcomputing.com", "local-water-kaiju", LocalDate.now().minusDays(4).minusYears(6), "Rather moist", - supervisorProfile.getId(), null, null, null, null, LocalDate.now())); + supervisorProfile.getId(), null, null, null, null, LocalDate.now(), false)); } // this user is not connected to other users in the system default MemberProfile createAnUnrelatedUser() { @@ -125,7 +125,7 @@ default MemberProfile createAnUnrelatedUser() { null, "Comedic Relief", null, "New York, New York", "nobody@objectcomputing.com", "mr-bill-employee-unrelated", LocalDate.now().minusDays(3).minusMonths(1).minusYears(5), "is a clay figurine clown star of a parody of children's clay animation shows", - null, null, null, null, null, LocalDate.now())); + null, null, null, null, null, LocalDate.now(), false)); } default MemberProfile createAPastTerminatedMemberProfile() { @@ -133,7 +133,7 @@ default MemberProfile createAPastTerminatedMemberProfile() { null, "Bully Relief PDL", null, "New York, New York", "sluggopdl@objectcomputing.com", "sluggo-employee-pdl-past-terminated", LocalDate.now().minusDays(3).minusYears(5), "is the bully in a clay figurine clown star of a parody of children's clay animation shows", - null, LocalDate.now().minusMonths(1), null, null, null, LocalDate.now().minusMonths(1))); + null, LocalDate.now().minusMonths(1), null, null, null, LocalDate.now().minusMonths(1), false)); } default MemberProfile createAFutureTerminatedMemberProfile() { @@ -141,7 +141,7 @@ default MemberProfile createAFutureTerminatedMemberProfile() { null, "Bully Relief PDL", null, "New York, New York", "sluggopdl@objectcomputing.com", "sluggo-employee-pdl-future terminated", LocalDate.now().minusDays(3).minusYears(5), "is the bully in a clay figurine clown star of a parody of children's clay animation shows", - null, LocalDate.now().plusDays(7), null, null, null, LocalDate.now().plusDays(7))); + null, LocalDate.now().plusDays(7), null, null, null, LocalDate.now().plusDays(7), false)); } @@ -150,7 +150,16 @@ default MemberProfile createADefaultMemberProfileWithBirthDay() { null, "Comedic Relief", null, "New York, New York", "billm@objectcomputing.com", "mr-bill-employee-birthday", LocalDate.now().minusDays(3).minusYears(5), "is a clay figurine clown star of a parody of children's clay animation shows", - null, null, LocalDate.now(), null, null, LocalDate.now())); + null, null, LocalDate.now(), null, null, LocalDate.now(), false)); + } + + // This user is disconnected and doesn't want their birthday celebrated. + default MemberProfile createUserWithIgnoredBirthday() { + return getMemberProfileRepository().save(new MemberProfile("Gone", null, " Girl", + null, "Video Vogue", null, "New York, New York", + "girlg@objectcomputing.com", "stay-away-from-me", + LocalDate.now().minusDays(3).minusYears(5), "is gone, girl!", + null, null, null, null, null, LocalDate.now(), true)); } default MemberProfile createAPastMemberProfile() { @@ -158,7 +167,7 @@ default MemberProfile createAPastMemberProfile() { null, "Comedic Relief", null, "New York, New York", "billm@objectcomputing.com", "mr-bill-employee-past", LocalDate.now().minusYears(2), "is a clay figurine clown star of a parody of children's clay animation shows", - null, null, null, null, null, LocalDate.now().minusYears(2))); + null, null, null, null, null, LocalDate.now().minusYears(2), false)); } default MemberProfile createANewHireProfile() { @@ -166,7 +175,7 @@ default MemberProfile createANewHireProfile() { null, "Comedic Relief", null, "New York, New York", "billm@objectcomputing.com", "mr-bill-employee-new", LocalDate.now().minusMonths(2), "is a clay figurine clown star of a parody of children's clay animation shows", - null, null, null, null, null, LocalDate.now())); + null, null, null, null, null, LocalDate.now(), false)); } default MemberProfile createATerminatedNewHireProfile() { @@ -174,7 +183,7 @@ default MemberProfile createATerminatedNewHireProfile() { null, "Comedic Relief", null, "New York, New York", "billm@objectcomputing.com", "mr-bill-employee-term-new", LocalDate.now().minusMonths(2), "is a clay figurine clown star of a parody of children's clay animation shows", - null, LocalDate.now().minusMonths(1), null, true, null, LocalDate.now().minusMonths(1))); + null, LocalDate.now().minusMonths(1), null, true, null, LocalDate.now().minusMonths(1), false)); } default MemberProfile memberWithoutBoss(String name) { @@ -204,7 +213,8 @@ default MemberProfile memberWithSupervisor(String name, MemberProfile supervisor null, null, null, - LocalDate.now() + LocalDate.now(), + false ) ); } diff --git a/server/src/test/java/com/objectcomputing/checkins/services/fixture/RepositoryFixture.java b/server/src/test/java/com/objectcomputing/checkins/services/fixture/RepositoryFixture.java index eba62d2b6e..648728e1d5 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/fixture/RepositoryFixture.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/fixture/RepositoryFixture.java @@ -43,6 +43,7 @@ import com.objectcomputing.checkins.services.volunteering.VolunteeringRelationshipRepository; import io.micronaut.runtime.server.EmbeddedServer; import com.objectcomputing.checkins.services.employee_hours.EmployeeHoursRepository; +import com.objectcomputing.checkins.services.slack.kudos.AutomatedKudosRepository; public interface RepositoryFixture { EmbeddedServer getEmbeddedServer(); @@ -213,4 +214,8 @@ default DocumentRepository getDocumentRepository() { default RoleDocumentationRepository getRoleDocumentationRepository() { return getEmbeddedServer().getApplicationContext().getBean(RoleDocumentationRepository.class); } + + default AutomatedKudosRepository getAutomatedKudosRepository() { + return getEmbeddedServer().getApplicationContext().getBean(AutomatedKudosRepository.class); + } } diff --git a/server/src/test/java/com/objectcomputing/checkins/services/kudos/KudosControllerTest.java b/server/src/test/java/com/objectcomputing/checkins/services/kudos/KudosControllerTest.java index e166954846..6e42c684bc 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/kudos/KudosControllerTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/kudos/KudosControllerTest.java @@ -3,7 +3,7 @@ import com.objectcomputing.checkins.configuration.CheckInsConfiguration; import com.objectcomputing.checkins.notifications.email.MailJetFactory; import com.objectcomputing.checkins.services.MailJetFactoryReplacement; -import com.objectcomputing.checkins.services.SlackPosterReplacement; +import com.objectcomputing.checkins.services.SlackSenderReplacement; import com.objectcomputing.checkins.services.TestContainersSuite; import com.objectcomputing.checkins.services.fixture.KudosFixture; import com.objectcomputing.checkins.services.fixture.TeamFixture; @@ -41,6 +41,7 @@ import java.time.LocalDate; import java.util.Collections; import java.util.List; +import java.util.ArrayList; import java.util.Optional; import java.util.UUID; @@ -59,14 +60,14 @@ // when attempting to post public Kudos to Slack. @DisabledInNativeImage @Property(name = "replace.mailjet.factory", value = StringUtils.TRUE) -@Property(name = "replace.slackposter", value = StringUtils.TRUE) +@Property(name = "replace.slacksender", value = StringUtils.TRUE) class KudosControllerTest extends TestContainersSuite implements KudosFixture, TeamFixture, RoleFixture { @Inject @Named(MailJetFactory.HTML_FORMAT) private MailJetFactoryReplacement.MockEmailSender emailSender; @Inject - private SlackPosterReplacement slackPoster; + private SlackSenderReplacement slackSender; @Inject @Client("/services/kudos") @@ -79,6 +80,8 @@ class KudosControllerTest extends TestContainersSuite implements KudosFixture, T private String message; private MemberProfile sender; + private MemberProfile recipient; + private MemberProfile other; private MemberProfile admin; private UUID senderId; private String senderWorkEmail; @@ -94,18 +97,20 @@ void setUp() { senderId = sender.getId(); senderWorkEmail = sender.getWorkEmail(); - MemberProfile recipient = createASecondDefaultMemberProfile(); + recipient = createASecondDefaultMemberProfile(); recipientMembers = List.of(recipient); admin = createAThirdDefaultMemberProfile(); assignAdminRole(admin); + other = createAnotherSupervisor(); + Team team = createDefaultTeam(); teamId = team.getId(); message = "Kudos!"; emailSender.reset(); - slackPoster.reset(); + slackSender.reset(); } @ParameterizedTest @@ -115,7 +120,7 @@ void setUp() { "true, false", "true, true" }) - void testCreateKudos(boolean supplyTeam, boolean publiclyVisible) { + Kudos testCreateKudos(boolean supplyTeam, boolean publiclyVisible) { KudosCreateDTO kudosCreateDTO = new KudosCreateDTO( message, senderId, @@ -127,7 +132,7 @@ void testCreateKudos(boolean supplyTeam, boolean publiclyVisible) { HttpRequest request = HttpRequest.POST("/", kudosCreateDTO).basicAuth(senderWorkEmail, MEMBER_ROLE); HttpResponse httpResponse = client.exchange(request, Kudos.class); - Kudos kudos = httpResponse.body(); + final Kudos kudos = httpResponse.body(); assertEquals(message, kudos.getMessage()); assertEquals(publiclyVisible, kudos.getPubliclyVisible()); assertEquals(senderId, kudos.getSenderId()); @@ -164,6 +169,7 @@ void testCreateKudos(boolean supplyTeam, boolean publiclyVisible) { emailSender.events.getFirst() ); } + return kudos; } @Test @@ -228,7 +234,7 @@ void testApproveKudos() throws JsonProcessingException { assertNull(kudos.getDateApproved()); KudosRecipient recipient = createKudosRecipient(kudos.getId(), recipientMembers.getFirst().getId()); - final HttpRequest request = HttpRequest.PUT("", kudos).basicAuth(admin.getWorkEmail(), ADMIN_ROLE); + final HttpRequest request = HttpRequest.PUT("/approve", kudos).basicAuth(admin.getWorkEmail(), ADMIN_ROLE); final HttpResponse response = client.exchange(request, Kudos.class); assertEquals(HttpStatus.OK, response.getStatus()); @@ -246,15 +252,16 @@ void testApproveKudos() throws JsonProcessingException { ); // Check the posted slack block - assertEquals(1, slackPoster.posted.size()); + assertEquals(1, slackSender.sent.size()); ObjectMapper mapper = new ObjectMapper(); - JsonNode posted = mapper.readTree(slackPoster.posted.get(0)); + String channelId = checkInsConfiguration.getApplication() + .getSlack().getKudosChannel(); + List sent = slackSender.sent.get(channelId); + JsonNode posted = mapper.readTree(sent.get(0)); - assertEquals(JsonNodeType.OBJECT, posted.getNodeType()); - JsonNode blocks = posted.get("blocks"); - assertEquals(JsonNodeType.ARRAY, blocks.getNodeType()); + assertEquals(JsonNodeType.ARRAY, posted.getNodeType()); - var iter = blocks.elements(); + var iter = posted.elements(); assertTrue(iter.hasNext()); JsonNode block = iter.next(); @@ -298,7 +305,7 @@ void testApproveNonExistentKudos() { UUID nonExistentKudosId = UUID.randomUUID(); kudos.setId(nonExistentKudosId); - final HttpRequest request = HttpRequest.PUT("", kudos).basicAuth(admin.getWorkEmail(), ADMIN_ROLE); + final HttpRequest request = HttpRequest.PUT("/approve", kudos).basicAuth(admin.getWorkEmail(), ADMIN_ROLE); HttpClientResponseException responseException = assertThrows(HttpClientResponseException.class, () -> client.exchange(request, Kudos.class)); assertEquals(HttpStatus.BAD_REQUEST, responseException.getStatus()); @@ -310,7 +317,7 @@ void testApproveNonExistentKudos() { void testApproveAlreadyApprovedKudos() { Kudos kudos = createApprovedKudos(senderId); - final HttpRequest request = HttpRequest.PUT("", kudos).basicAuth(admin.getWorkEmail(), ADMIN_ROLE); + final HttpRequest request = HttpRequest.PUT("/approve", kudos).basicAuth(admin.getWorkEmail(), ADMIN_ROLE); HttpClientResponseException responseException = assertThrows(HttpClientResponseException.class, () -> client.exchange(request, Kudos.class)); assertEquals(HttpStatus.BAD_REQUEST, responseException.getStatus()); @@ -322,11 +329,10 @@ void testApproveAlreadyApprovedKudos() { void testApproveKudosWithoutAdministerPermission() { Kudos kudos = createADefaultKudos(senderId); - final HttpRequest request = HttpRequest.PUT("", kudos).basicAuth(senderWorkEmail, MEMBER_ROLE); + final HttpRequest request = HttpRequest.PUT("/approve", kudos).basicAuth(senderWorkEmail, MEMBER_ROLE); HttpClientResponseException responseException = assertThrows(HttpClientResponseException.class, () -> client.exchange(request, Kudos.class)); assertEquals(HttpStatus.FORBIDDEN, responseException.getStatus()); - assertEquals("Forbidden", responseException.getMessage()); assertEquals(0, emailSender.events.size()); } @@ -562,10 +568,114 @@ void testDeleteKudosWithNonExistentKudosId() { void testDeleteKudosWithoutAdministerPermission() { Kudos kudos = createADefaultKudos(senderId); - HttpRequest request = HttpRequest.DELETE(String.format("/%s", kudos.getId())).basicAuth(senderWorkEmail, MEMBER_ROLE); + HttpRequest request = HttpRequest.DELETE(String.format("/%s", kudos.getId())).basicAuth(recipient.getWorkEmail(), MEMBER_ROLE); HttpClientResponseException responseException = assertThrows(HttpClientResponseException.class, () -> client.exchange(request)); assertEquals(HttpStatus.FORBIDDEN, responseException.getStatus()); - assertEquals("Forbidden", responseException.getMessage()); + } + + @ParameterizedTest + @CsvSource({ + "false, false", + "false, true", + "true, false", + "true, true" + }) + void testUpdateKudos(boolean supplyTeam, boolean publiclyVisible) { + // Create a kudos + final Kudos kudos = testCreateKudos(supplyTeam, publiclyVisible); + + // Set of changes to make. + final String message = "New kudos message"; + final boolean visible = !publiclyVisible; + final List members = new ArrayList<>(); + members.add(other); + if (!supplyTeam || publiclyVisible) { + // On some tests, retain the original recipient. + members.add(recipient); + } + + // Create the DTO + KudosUpdateDTO proposed = new KudosUpdateDTO(kudos.getId(), message, + visible, members); + + // Make the call + final HttpRequest request = + HttpRequest.PUT("", proposed) + .basicAuth(senderWorkEmail, MEMBER_ROLE); + final HttpResponse response = client.exchange(request, + Kudos.class); + assertEquals(HttpStatus.OK, response.getStatus()); + + final Kudos updated = response.body(); + assertEquals(message, updated.getMessage()); + assertEquals(visible, updated.getPubliclyVisible()); + assertNull(updated.getDateApproved()); + + final List kudosRecipients = + findKudosRecipientByKudosId(updated.getId()); + assertEquals(members.size(), kudosRecipients.size()); + for (MemberProfile member : members) { + boolean found = false; + for (KudosRecipient recipient : kudosRecipients) { + if (recipient.getMemberId().equals(member.getId())) { + found = true; + } + } + assertTrue(found); + } + } + + @Test + void testUpdateKudosNoPermission() { + // Create a kudos + final Kudos kudos = testCreateKudos(false, true); + + KudosUpdateDTO proposed = new KudosUpdateDTO(kudos.getId(), + kudos.getMessage(), + false, recipientMembers); + final HttpRequest request = + HttpRequest.PUT("", proposed).basicAuth(other.getWorkEmail(), + MEMBER_ROLE); + final HttpClientResponseException responseException = + assertThrows(HttpClientResponseException.class, + () -> client.exchange(request, Kudos.class)); + + assertEquals(HttpStatus.FORBIDDEN, responseException.getStatus()); + } + + @Test + void testUpdateKudosAdminPermission() { + // Create a kudos + final Kudos kudos = testCreateKudos(false, true); + + KudosUpdateDTO proposed = new KudosUpdateDTO(kudos.getId(), + kudos.getMessage(), + false, recipientMembers); + final HttpRequest request = + HttpRequest.PUT("", proposed).basicAuth(admin.getWorkEmail(), + ADMIN_ROLE); + final HttpResponse response = client.exchange(request, + Kudos.class); + assertEquals(HttpStatus.OK, response.getStatus()); + } + + @Test + void testUpdateKudosNoMembers() { + // Create a kudos + final Kudos kudos = testCreateKudos(false, true); + + final List members = new ArrayList<>(); + KudosUpdateDTO proposed = new KudosUpdateDTO(kudos.getId(), + kudos.getMessage(), + false, members); + final HttpRequest request = + HttpRequest.PUT("", proposed).basicAuth(senderWorkEmail, + MEMBER_ROLE); + final HttpClientResponseException responseException = + assertThrows(HttpClientResponseException.class, + () -> client.exchange(request, Kudos.class)); + + assertEquals(HttpStatus.BAD_REQUEST, responseException.getStatus()); } } diff --git a/server/src/test/java/com/objectcomputing/checkins/services/kudos/SlackKudosTest.java b/server/src/test/java/com/objectcomputing/checkins/services/kudos/SlackKudosTest.java new file mode 100644 index 0000000000..893b9cf368 --- /dev/null +++ b/server/src/test/java/com/objectcomputing/checkins/services/kudos/SlackKudosTest.java @@ -0,0 +1,188 @@ +package com.objectcomputing.checkins.services.kudos; + +import com.objectcomputing.checkins.services.slack.SlackSignature; +import com.objectcomputing.checkins.services.slack.kudos.KudosChannelReader; +import com.objectcomputing.checkins.services.slack.kudos.AutomatedKudos; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileUtils; +import com.objectcomputing.checkins.services.kudos.KudosResponseDTO; +import com.objectcomputing.checkins.services.role.RoleType; + +import com.objectcomputing.checkins.services.TestContainersSuite; +import com.objectcomputing.checkins.services.SlackSenderReplacement; +import com.objectcomputing.checkins.services.SlackReaderReplacement; +import com.objectcomputing.checkins.services.SlackSearchReplacement; +import com.objectcomputing.checkins.services.fixture.MemberProfileFixture; +import com.objectcomputing.checkins.services.fixture.AutomatedKudosFixture; +import com.objectcomputing.checkins.services.fixture.RoleFixture; + +import io.micronaut.core.type.Argument; +import io.micronaut.core.util.StringUtils; +import io.micronaut.context.annotation.Property; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.core.JsonProcessingException; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.time.Instant; +import java.net.URLEncoder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Property(name = "replace.slacksearch", value = StringUtils.TRUE) +@Property(name = "replace.slackreader", value = StringUtils.TRUE) +@Property(name = "replace.slacksender", value = StringUtils.TRUE) +class SlackKudosTest extends TestContainersSuite implements MemberProfileFixture, AutomatedKudosFixture, RoleFixture { + @Inject + private SlackReaderReplacement slackReader; + + @Inject + private SlackSearchReplacement slackSearch; + + @Inject + private SlackSenderReplacement slackSender; + + @Inject + private KudosChannelReader kudosChannelReader; + + @Inject + private SlackSignature slackSignature; + + @Inject + @Client("/services") + protected HttpClient client; + + MemberProfile sender; + MemberProfile recipient; + + @BeforeEach + void setUp() { + createAndAssignRoles(); + sender = createADefaultMemberProfile(); + recipient = createASecondDefaultMemberProfile(); + } + + @Test + void testCreateKudosFromSlackMessage() throws JsonProcessingException { + String slackSenderId = "senderId"; + slackSearch.users.put(slackSenderId, sender.getWorkEmail()); + String slackRecipient = "recipientId"; + slackSearch.users.put(slackRecipient, recipient.getWorkEmail()); + + // Post to "Slack" + final String beginning = "Kudos to "; + slackReader.addMessage("SLACK_KUDOS_CHANNEL_ID", slackSenderId, + beginning + "<@" + slackRecipient + ">", + LocalDateTime.now()); + + final String messageWithName = beginning + + MemberProfileUtils.getFullName(recipient); + + // Manually tell the reader to load messages from slack. This normally + // happens on an interval. + kudosChannelReader.readChannel(); + + // Get the automated kudos from the repository and validate. + List generatedKudos = getAutomatedKudos(); + assertEquals(1, generatedKudos.size()); + AutomatedKudos kudos = generatedKudos.get(0); + assertEquals(messageWithName, kudos.getMessage()); + assertEquals(slackSenderId, kudos.getExternalId()); + assertEquals(sender.getId(), kudos.getSenderId()); + assertEquals(1, kudos.getRecipientIds().size()); + assertEquals(recipient.getId().toString(), + kudos.getRecipientIds().get(0)); + + // A slack message should have been sent to the sender as well... + assertTrue(slackSender.sent.containsKey(slackSenderId)); + List messages = slackSender.sent.get(slackSenderId); + assertEquals(1, messages.size()); + + ObjectMapper mapper = new ObjectMapper(); + JsonNode sent = mapper.readTree(messages.get(0)); + assertEquals(JsonNodeType.ARRAY, sent.getNodeType()); + + var iter = sent.elements(); + assertTrue(iter.hasNext()); + JsonNode section = iter.next(); + + assertEquals(JsonNodeType.OBJECT, section.getNodeType()); + assertTrue(section.has("block_id")); + UUID automatedKudosUUID = + UUID.fromString(section.get("block_id").asText()); + + // Post to /external to say "yes, we want it in Check-Ins". + final String rawBody = getSlackPostPayload( + automatedKudosUUID.toString()); + long currentTime = Instant.now().getEpochSecond(); + String timestamp = String.valueOf(currentTime); + + HttpRequest request = + HttpRequest.POST("/pulse-responses/external", rawBody) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("X-Slack-Signature", slackSignature.generate(timestamp, rawBody)) + .header("X-Slack-Request-Timestamp", timestamp); + + HttpResponse response = client.toBlocking().exchange(request); + assertEquals(HttpStatus.OK, response.getStatus()); + + // Get list of kudos from kudos controller and verify. + request = HttpRequest.GET("/kudos/recent") + .basicAuth(sender.getWorkEmail(), + RoleType.Constants.MEMBER_ROLE); + HttpResponse> list = + client.toBlocking() + .exchange(request, Argument.listOf(KudosResponseDTO.class)); + assertEquals(HttpStatus.OK, list.getStatus()); + assertEquals(1, list.body().size()); + KudosResponseDTO element = list.body().getFirst(); + assertEquals(messageWithName, element.getMessage()); + assertEquals(sender.getId(), element.getSenderId()); + assertTrue(element.getPubliclyVisible()); + assertEquals(element.getRecipientMembers(), List.of(recipient)); + } + + @Test + void testNoSlackMessages() { + kudosChannelReader.readChannel(); + List generatedKudos = getAutomatedKudos(); + assertEquals(0, generatedKudos.size()); + } + + String getSlackPostPayload(String kudosId) { + return "payload=" + + URLEncoder.encode(String.format(""" +{ + "type": "block_actions", + "message": { + "blocks": [ + { + "block_id": "%s" + } + ] + }, + "actions": [ + { + "action_id": "yes_button" + } + ] +} +""", kudosId)); + } +} diff --git a/server/src/test/java/com/objectcomputing/checkins/services/member_skill/skillsreport/SkillsReportServicesImplTest.java b/server/src/test/java/com/objectcomputing/checkins/services/member_skill/skillsreport/SkillsReportServicesImplTest.java index b815befe0f..fd2d6b0d6a 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/member_skill/skillsreport/SkillsReportServicesImplTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/member_skill/skillsreport/SkillsReportServicesImplTest.java @@ -108,6 +108,7 @@ void testReport() { MemberProfile member2 = createASecondDefaultMemberProfile(); MemberProfile member3 = createAThirdDefaultMemberProfile(); MemberProfile member4 = createADefaultMemberProfileForPdl(member1); + MemberProfile member5 = createAPastTerminatedMemberProfile(); final UUID skillId1 = skill1.getId(); final UUID skillId2 = skill2.getId(); @@ -128,6 +129,12 @@ void testReport() { final MemberSkill ms8 = createMemberSkill(member4, skill2, SkillLevel.INTERMEDIATE_LEVEL, LocalDate.now()); final MemberSkill ms9 = createMemberSkill(member4, skill4, SkillLevel.EXPERT_LEVEL, LocalDate.now()); + // Skills for the terminated member + createMemberSkill(member5, skill1, SkillLevel.ADVANCED_LEVEL, LocalDate.now()); + createMemberSkill(member5, skill2, SkillLevel.ADVANCED_LEVEL, LocalDate.now()); + createMemberSkill(member5, skill3, SkillLevel.ADVANCED_LEVEL, LocalDate.now()); + createMemberSkill(member5, skill4, SkillLevel.ADVANCED_LEVEL, LocalDate.now()); + // List of skills required in first request final SkillLevelDTO dto1 = new SkillLevelDTO(); final SkillLevelDTO dto2 = new SkillLevelDTO(); diff --git a/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileTest.java b/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileTest.java index c74478a861..56e8e4b782 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileTest.java @@ -65,7 +65,7 @@ void testMemberProfileInstantiation() { MemberProfile memberProfile = new MemberProfile(firstName, null, lastName, null, null, null, null, workEmail, null, null, null, null, - null, null, null, null, null); + null, null, null, null, null, false); assertEquals(firstName, memberProfile.getFirstName()); assertEquals(lastName, memberProfile.getLastName()); assertEquals(workEmail, memberProfile.getWorkEmail()); @@ -79,7 +79,7 @@ void testConstraintViolation() { MemberProfile memberProfile = new MemberProfile(firstName, null, lastName, null, null, null, null, workEmail, null, null, null, null, - null, null, null, null, null); + null, null, null, null, null, false); assertEquals(firstName, memberProfile.getFirstName()); assertEquals(lastName, memberProfile.getLastName()); assertEquals(workEmail, memberProfile.getWorkEmail()); @@ -113,7 +113,7 @@ void testEquals() { LocalDate lastSeen = LocalDate.now(); MemberProfile memberProfile = new MemberProfile(id, firstName, middleName, lastName, suffix, title, pdlId, location, workEmail, - employeeId, startDate, bioText, supervisorId, terminationDate, birthDate, voluntary, excluded, lastSeen); + employeeId, startDate, bioText, supervisorId, terminationDate, birthDate, voluntary, excluded, lastSeen, false); assertEquals(id, memberProfile.getId()); assertEquals(firstName, memberProfile.getFirstName()); @@ -157,7 +157,7 @@ void testToString() { final LocalDate lastSeen = LocalDate.now(); MemberProfile memberProfile = new MemberProfile(id, firstName, middleName, lastName, suffix, title, pdlId, location, workEmail, - employeeId, startDate, bioText, supervisorId, terminationDate, birthDate, voluntary, excluded, lastSeen); + employeeId, startDate, bioText, supervisorId, terminationDate, birthDate, voluntary, excluded, lastSeen, false); String toString = memberProfile.toString(); assertTrue(toString.contains(id.toString())); @@ -184,7 +184,7 @@ void testToString() { void testSaveProfileWithExistingEmail() { MemberProfile existingProfile = createADefaultMemberProfile(); String workEmail = existingProfile.getWorkEmail(); - MemberProfile newProfile = new MemberProfile(UUID.randomUUID(), "John", null, "Smith", null, null, null, null, workEmail, null, null, null, null, null, null, null, null, null); + MemberProfile newProfile = new MemberProfile(UUID.randomUUID(), "John", null, "Smith", null, null, null, null, workEmail, null, null, null, null, null, null, null, null, null, false); // The current user must have permission to create new members. currentUserServices.currentUser = existingProfile; @@ -203,7 +203,7 @@ void testSaveProfileWithNewEmail() { LocalDate.now().minusDays(3).minusYears(5), "Needs supervision due to building being ultra flammable", supervisorProfile.getId(), null, null, null, null, - LocalDate.now()); + LocalDate.now(), false); // Need permission to create new profiles. currentUserServices.currentUser = supervisorProfile; @@ -239,7 +239,7 @@ void testUpdateProfileWithChangedPDL() { currentUserServices.currentUser = existingProfile; - MemberProfile updatedProfile = new MemberProfile(id, existingProfile.getFirstName(), null, existingProfile.getLastName(), null, null, pdlId, null, existingProfile.getWorkEmail(), null, null, null, null, null, null, null, null, null); + MemberProfile updatedProfile = new MemberProfile(id, existingProfile.getFirstName(), null, existingProfile.getLastName(), null, null, pdlId, null, existingProfile.getWorkEmail(), null, null, null, null, null, null, null, null, null, false); MemberProfile result = memberProfileServices.updateProfile(updatedProfile); @@ -264,7 +264,7 @@ void testUpdateProfileWithChangedSupervisor() { currentUserServices.currentUser = existingProfile; - MemberProfile updatedProfile = new MemberProfile(id, existingProfile.getFirstName(), null, existingProfile.getLastName(), null, null, null, null, existingProfile.getWorkEmail(), null, null, null, supervisorId, null, null, null, null, null); + MemberProfile updatedProfile = new MemberProfile(id, existingProfile.getFirstName(), null, existingProfile.getLastName(), null, null, null, null, existingProfile.getWorkEmail(), null, null, null, supervisorId, null, null, null, null, null, false); MemberProfile result = memberProfileServices.updateProfile(updatedProfile); @@ -356,7 +356,7 @@ void testEmailAssignmentWithValidPdlAndSupervisor() { void testEmailAssignmentWithInvalidPDL() { MemberProfile existingProfile = createADefaultMemberProfile(); UUID pdlId = UUID.randomUUID(); - MemberProfile member = new MemberProfile(existingProfile.getId(), existingProfile.getFirstName(), null, existingProfile.getLastName(), null, null, pdlId, null, existingProfile.getWorkEmail(), null, null, null, null, null, null, null, null, null); + MemberProfile member = new MemberProfile(existingProfile.getId(), existingProfile.getFirstName(), null, existingProfile.getLastName(), null, null, pdlId, null, existingProfile.getWorkEmail(), null, null, null, null, null, null, null, null, null, false); memberProfileServices.emailAssignment(member, true); @@ -367,7 +367,7 @@ void testEmailAssignmentWithInvalidPDL() { void testEmailAssignmentWithInvalidSupervisor() { MemberProfile existingProfile = createADefaultMemberProfile(); UUID supervisorId = UUID.randomUUID(); - MemberProfile member = new MemberProfile(existingProfile.getId(), existingProfile.getFirstName(), null, existingProfile.getLastName(), null, null, null, null, existingProfile.getWorkEmail(), null, null, null, supervisorId, null, null, null, null, null); + MemberProfile member = new MemberProfile(existingProfile.getId(), existingProfile.getFirstName(), null, existingProfile.getLastName(), null, null, null, null, existingProfile.getWorkEmail(), null, null, null, supervisorId, null, null, null, null, null, false); memberProfileServices.emailAssignment(member, true); @@ -377,7 +377,7 @@ void testEmailAssignmentWithInvalidSupervisor() { @Test void testEmailAssignmentWithInvalidMember() { MemberProfile member = new MemberProfile(UUID.randomUUID(), "John", null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, false); memberProfileServices.emailAssignment(member, true); @@ -387,7 +387,7 @@ void testEmailAssignmentWithInvalidMember() { @Test void testEmailAssignmentWithNullRoleId() { MemberProfile member = new MemberProfile(UUID.randomUUID(), "John", null, "Smith", null, null, null, null, "john.smith@example.com", - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, false); memberProfileServices.emailAssignment(member, true); diff --git a/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileTestUtil.java b/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileTestUtil.java index 8a86defce3..c436bea2d4 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileTestUtil.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/MemberProfileTestUtil.java @@ -51,7 +51,7 @@ public static MemberProfile mkMemberProfile(String seed) { LocalDate.of(2019, 1, 1), "TestBio" + seed, null, - null,null, null, null, LocalDate.now()); + null,null, null, null, LocalDate.now(), false); } public static MemberProfile mkMemberProfile() { diff --git a/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/birthday/BirthDayControllerTest.java b/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/birthday/BirthDayControllerTest.java index 31a7e4a996..8414bd4e3c 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/birthday/BirthDayControllerTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/birthday/BirthDayControllerTest.java @@ -81,4 +81,23 @@ void testGETFindByValueNameOfTheMonthNotAuthorized() { assertEquals(HttpStatus.FORBIDDEN, thrown.getStatus()); } + + @Test + void testGETFindByValueDoesNotIncludeIgnoredBirthdays() { + + MemberProfile memberProfileOfAdmin = createAnUnrelatedUser(); + assignAdminRole(memberProfileOfAdmin); + + MemberProfile memberProfile = createADefaultMemberProfileWithBirthDay(); + MemberProfile ignoredMember = createUserWithIgnoredBirthday(); + + final HttpRequest request = HttpRequest. + GET(String.format("/?month=%s", memberProfile.getBirthDate().getMonth().toString())).basicAuth(memberProfileOfAdmin.getWorkEmail(), ADMIN_ROLE); + + final HttpResponse> response = client.toBlocking().exchange(request, Argument.listOf(BirthDayResponseDTO.class)); + + assertEquals(1, response.body().size()); + assertEquals(memberProfile.getId(), response.body().get(0).getUserId()); + assertEquals(HttpStatus.OK, response.getStatus()); + } } diff --git a/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/currentuser/CurrentUserDtoTest.java b/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/currentuser/CurrentUserDtoTest.java index 2896592e57..6de8a0bd0e 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/currentuser/CurrentUserDtoTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/memberprofile/currentuser/CurrentUserDtoTest.java @@ -2,6 +2,7 @@ import com.objectcomputing.checkins.services.TestContainersSuite; import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileResponseDTO; import io.micronaut.validation.validator.Validator; import jakarta.inject.Inject; import jakarta.validation.ConstraintViolation; @@ -54,7 +55,7 @@ void testPopulatedDTO() { assertEquals("some.last.name", dto.getLastName()); dto.setName(dto.getFirstName() + ' ' + dto.getLastName()); - dto.setMemberProfile(new MemberProfile()); + dto.setMemberProfile(new MemberProfileResponseDTO()); Set> violations = validator.validate(dto); assertTrue(violations.isEmpty()); diff --git a/server/src/test/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseControllerTest.java b/server/src/test/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseControllerTest.java index c4d94aa70d..7f4985c5b6 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseControllerTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseControllerTest.java @@ -9,6 +9,7 @@ import com.objectcomputing.checkins.util.Util; import com.objectcomputing.checkins.configuration.CheckInsConfiguration; import com.objectcomputing.checkins.services.SlackSearchReplacement; +import com.objectcomputing.checkins.services.slack.SlackSignature; import io.micronaut.core.util.StringUtils; import io.micronaut.core.type.Argument; @@ -32,11 +33,8 @@ import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.util.Base64; import java.time.Instant; +import java.net.URLEncoder; import static com.objectcomputing.checkins.services.role.RoleType.Constants.ADMIN_ROLE; import static com.objectcomputing.checkins.services.role.RoleType.Constants.MEMBER_ROLE; @@ -57,6 +55,9 @@ class PulseResponseControllerTest extends TestContainersSuite implements MemberP @Inject private SlackSearchReplacement slackSearch; + @Inject + private SlackSignature slackSignature; + private Map hierarchy; @BeforeEach @@ -536,16 +537,17 @@ void testUpdateInvalidDatePulseResponse() { @Test void testCreateAPulseResponseFromSlack() { MemberProfile memberProfile = createADefaultMemberProfile(); - slackSearch.users.put("SLACK_ID_HI", memberProfile.getWorkEmail()); + String slackId = "SLACK_ID_HI"; + slackSearch.users.put(slackId, memberProfile.getWorkEmail()); - final String rawBody = "payload=%7B%22type%22%3A+%22view_submission%22%2C+%22user%22%3A+%7B%22id%22%3A+%22SLACK_ID_HI%22%7D%2C+%22view%22%3A+%7B%22id%22%3A+%22VNHU13V36%22%2C+%22type%22%3A+%22modal%22%2C+%22state%22%3A+%7B%22values%22%3A+%7B%22internalNumber%22%3A+%7B%22internalScore%22%3A+%7B%22selected_option%22%3A+%7B%22type%22%3A+%22radio_buttons%22%2C+%22value%22%3A+%224%22%7D%7D%7D%2C+%22internalText%22%3A+%7B%22internalFeelings%22%3A+%7B%22type%22%3A+%22plain_text_input%22%2C+%22value%22%3A+%22I+am+a+robot.%22%7D%7D%2C+%22externalNumber%22%3A+%7B%22externalScore%22%3A+%7B%22selected_option%22%3A+%7B%22type%22%3A+%22radio_buttons%22%2C+%22value%22%3A+%225%22%7D%7D%7D%2C+%22externalText%22%3A+%7B%22externalFeelings%22%3A+%7B%22type%22%3A+%22plain_text_input%22%2C+%22value%22%3A+%22You+are+a+robot.%22%7D%7D%7D%7D%7D%7D"; + final String rawBody = getSlackPulsePayload(slackId); long currentTime = Instant.now().getEpochSecond(); String timestamp = String.valueOf(currentTime); final HttpRequest request = HttpRequest.POST("/external", rawBody) .header("Content-Type", "application/x-www-form-urlencoded") - .header("X-Slack-Signature", slackSignature(timestamp, rawBody)) + .header("X-Slack-Signature", slackSignature.generate(timestamp, rawBody)) .header("X-Slack-Request-Timestamp", timestamp); final HttpResponse response = client.toBlocking().exchange(request); @@ -553,29 +555,6 @@ void testCreateAPulseResponseFromSlack() { assertEquals(HttpStatus.OK, response.getStatus()); } - private String slackSignature(String timestamp, String rawBody) { - String baseString = "v0:" + timestamp + ":" + rawBody; - String secret = configuration.getApplication() - .getSlack().getSigningSecret(); - - try { - // Generate HMAC SHA-256 signature - Mac mac = Mac.getInstance("HmacSHA256"); - SecretKeySpec secretKeySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); - mac.init(secretKeySpec); - byte[] hash = mac.doFinal(baseString.getBytes(StandardCharsets.UTF_8)); - - // Convert hash to hex - StringBuilder hexString = new StringBuilder(); - for (byte b : hash) { - hexString.append(String.format("%02x", b)); - } - return "v0=" + hexString.toString(); - } catch (Exception e) { - return null; - } - } - private static PulseResponseCreateDTO createPulseResponseCreateDTO() { return createPulseResponseCreateDTO(UUID.randomUUID()); } @@ -603,4 +582,53 @@ private MemberProfile profile(String key) { private UUID id(String key) { return profile(key).getId(); } + + private String getSlackPulsePayload(String slackId) { + return "payload=" + + URLEncoder.encode(String.format(""" +{ + "type": "view_submission", + "user": { + "id": "%s" + }, + "view": { + "id": "VNHU13V36", + "type": "modal", + "callback_id": "pulseSubmission", + "state": { + "values": { + "internalNumber": { + "internalScore": { + "selected_option": { + "type": "radio_buttons", + "value": "4" + } + } + }, + "internalText": { + "internalFeelings": { + "type": "plain_text_input", + "value": "I am a robot." + } + }, + "externalNumber": { + "externalScore": { + "selected_option": { + "type": "radio_buttons", + "value": "5" + } + } + }, + "externalText": { + "externalFeelings": { + "type": "plain_text_input", + "value": "You are a robot." + } + } + } + } + } +} +""", slackId)); + } } 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 ae589d9729..6ca02c27f2 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 @@ -13,6 +13,7 @@ import com.objectcomputing.checkins.services.fixture.MemberProfileFixture; import com.objectcomputing.checkins.services.memberprofile.MemberProfile; import com.objectcomputing.checkins.services.CurrentUserServicesReplacement; +import com.objectcomputing.checkins.services.SlackReaderReplacement; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -30,11 +31,15 @@ @Property(name = "replace.mailjet.factory", value = StringUtils.TRUE) @Property(name = "replace.currentuserservices", value = StringUtils.TRUE) +@Property(name = "replace.slackreader", value = StringUtils.TRUE) class CheckServicesImplTest extends TestContainersSuite implements FeedbackTemplateFixture, FeedbackRequestFixture, MemberProfileFixture, RoleFixture { @Inject CurrentUserServicesReplacement currentUserServices; + @Inject + private SlackReaderReplacement slackReader; + @Inject @Named(MailJetFactory.MJML_FORMAT) private MailJetFactoryReplacement.MockEmailSender emailSender; diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml index 132a9b1a6f..0eb4040fd9 100644 --- a/server/src/test/resources/application-test.yml +++ b/server/src/test/resources/application-test.yml @@ -45,6 +45,7 @@ check-ins: webhook-url: https://bogus.objectcomputing.com/slack bot-token: BOGUS_TOKEN signing-secret: BOGUS_SIGNING_SECRET + kudos-channel: SLACK_KUDOS_CHANNEL_ID --- aes: key: BOGUS_TEST_KEY diff --git a/web-ui/package.json b/web-ui/package.json index 4db02ff8e3..1f396ae4e2 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "web-ui", - "version": "0.8.14", + "version": "0.8.15", "private": true, "type": "module", "dependencies": { @@ -24,7 +24,7 @@ "canvas-confetti": "^1.6.0", "date-fns": "^2.24.0", "dayjs": "^1.11.11", - "dompurify": "^3.1.0", + "dompurify": "^3.2.4", "fuse.js": "^6.4.6", "html-react-parser": "^5.1.12", "isomorphic-fetch": "^3.0.0", diff --git a/web-ui/src/api/kudos.js b/web-ui/src/api/kudos.js index ee17acf429..a9a9601232 100644 --- a/web-ui/src/api/kudos.js +++ b/web-ui/src/api/kudos.js @@ -57,7 +57,7 @@ export const getAllKudos = async (cookie, isPending) => { }); }; -export const approveKudos = async (kudos, cookie) => { +export const updateKudos = async (kudos, cookie) => { return resolve({ method: "put", url: kudosUrl, @@ -70,6 +70,19 @@ export const approveKudos = async (kudos, cookie) => { }); }; +export const approveKudos = async (kudos, cookie) => { + return resolve({ + method: "put", + url: `${kudosUrl}/approve`, + data: kudos, + responseType: "json", + headers: { + "X-CSRF-Header": cookie, + Accept: 'application/json', + 'Content-Type': 'application/json;charset=UTF-8'} + }); +}; + export const deleteKudos = async (kudosId, cookie) => { return resolve({ method: "delete", @@ -77,4 +90,4 @@ export const deleteKudos = async (kudosId, cookie) => { responseType: "json", headers: { "X-CSRF-Header": cookie, Accept: 'application/json' } }); -}; \ No newline at end of file +}; diff --git a/web-ui/src/components/kudos/PublicKudosCard.jsx b/web-ui/src/components/kudos/PublicKudosCard.jsx index dc554602d8..8a27875432 100644 --- a/web-ui/src/components/kudos/PublicKudosCard.jsx +++ b/web-ui/src/components/kudos/PublicKudosCard.jsx @@ -17,8 +17,12 @@ import { DialogContentText, DialogActions, TextField, + Link, } from "@mui/material"; -import { selectCsrfToken, selectProfile } from "../../context/selectors"; +import { + selectCsrfToken, + selectActiveOrInactiveProfile, +} from "../../context/selectors"; import { AppContext } from "../../context/AppContext"; import { getAvatarURL } from "../../api/api"; import DateFnsUtils from "@date-io/date-fns"; @@ -49,7 +53,102 @@ const KudosCard = ({ kudos }) => { const { state, dispatch } = useContext(AppContext); const csrf = selectCsrfToken(state); - const sender = selectProfile(state, kudos.senderId); + const sender = selectActiveOrInactiveProfile(state, kudos.senderId); + + const regexIndexOf = (text, regex, start) => { + const indexInSuffix = text.slice(start).search(regex); + return indexInSuffix < 0 ? indexInSuffix : indexInSuffix + start; + }; + + const linkMember = (member, name, message) => { + const components = []; + let index = 0; + do { + index = regexIndexOf(message, + new RegExp('\\b' + name + '\\b', 'i'), index); + if (index != -1) { + const link = + {name} + ; + if (index > 0) { + components.push(message.slice(0, index)); + } + components.push(link); + message = message.slice(index + name.length); + } + } while(index != -1); + components.push(message); + return components; + }; + + const searchNames = (member, members) => { + const names = []; + if (member.middleName) { + names.push(`${member.firstName} ${member.middleName} ${member.lastName}`); + } + const firstAndLast = `${member.firstName} ${member.lastName}`; + if (!members.some((k) => k.id != member.id && + firstAndLast == `${k.firstName} ${k.lastName}`)) { + names.push(firstAndLast); + } + if (!members.some((k) => k.id != member.id && + (member.lastName == k.lastName || + member.lastName == k.firstName))) { + // If there are no other recipients with a name that contains this + // member's last name, we can replace based on that. + names.push(member.lastName); + } + if (!members.some((k) => k.id != member.id && + (member.firstName == k.lastName || + member.firstName == k.firstName))) { + // If there are no other recipients with a name that contains this + // member's first name, we can replace based on that. + names.push(member.firstName); + } + return names; + }; + + const linkNames = (kudos) => { + const lines = []; + let index = 0; + for (let line of kudos.message.split('\n')) { + const components = [ line ]; + for (let member of kudos.recipientMembers) { + const names = searchNames(member, kudos.recipientMembers); + for (let name of names) { + for (let i = 0; i < components.length; i++) { + const component = components[i]; + if (typeof(component) === "string") { + const built = linkMember(member, name, component); + if (built.length > 1) { + components.splice(i, 1, ...built); + } + } + } + } + } + lines.push( + + {components} + + ); + index++; + } + return lines; + }; + + const multiTooltip = (num, list) => { + let tooltip = ""; + let prefix = ""; + for (let member of list.slice(-num)) { + tooltip += prefix + `${member.firstName} ${member.lastName}`; + prefix = ", "; + } + return + {`+${num}`} + ; + }; const getRecipientComponent = useCallback(() => { if (kudos.recipientTeam) { @@ -67,7 +166,9 @@ const KudosCard = ({ kudos }) => { } return ( - + multiTooltip( + extra, kudos.recipientMembers)}> {kudos.recipientMembers.map((member) => ( { subheaderTypographyProps={{variant:"subtitle1"}} /> - {kudos.message} + <> + {linkNames(kudos)} + {kudos.recipientTeam && ( - + multiTooltip( + extra, kudos.recipientMembers)}> {kudos.recipientMembers.map((member) => ( { + snapshot( + + + + ); +}); diff --git a/web-ui/src/components/kudos/__snapshots__/PublicKudosCard.test.jsx.snap b/web-ui/src/components/kudos/__snapshots__/PublicKudosCard.test.jsx.snap new file mode 100644 index 0000000000..1cc67a9fd2 --- /dev/null +++ b/web-ui/src/components/kudos/__snapshots__/PublicKudosCard.test.jsx.snap @@ -0,0 +1,134 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renders correctly 1`] = ` +
+
+
+
+
+
+

+ +3 +

+
+
+ +
+
+ +
+
+ +
+
+
+
+ + Kudos! + + + from +
+
+ +
+ +
+
+
+
+
+

+ + Brock + + and + + Brockman + + did a great job helping + + Clark + + , Jimmy Olsen, + + Jimmy T. Olsen + + , and + + Johnson + +

+
+
+
+`; diff --git a/web-ui/src/components/kudos_card/KudosCard.jsx b/web-ui/src/components/kudos_card/KudosCard.jsx index 168413127b..b94fe452cc 100644 --- a/web-ui/src/components/kudos_card/KudosCard.jsx +++ b/web-ui/src/components/kudos_card/KudosCard.jsx @@ -16,17 +16,25 @@ import { DialogContentText, DialogActions, TextField, + FormGroup, + FormControlLabel, + Checkbox, } from "@mui/material"; -import { selectCsrfToken, selectProfile } from "../../context/selectors"; +import { + selectCsrfToken, + selectActiveOrInactiveProfile, +} from "../../context/selectors"; +import MemberSelector from '../member_selector/MemberSelector'; import { AppContext } from "../../context/AppContext"; import { getAvatarURL } from "../../api/api"; import DateFnsUtils from "@date-io/date-fns"; import CheckIcon from "@mui/icons-material/Check"; import CloseIcon from "@mui/icons-material/Close"; +import EditIcon from "@mui/icons-material/Edit"; import TeamIcon from "@mui/icons-material/Groups"; import "./KudosCard.css"; -import { approveKudos, deleteKudos } from "../../api/kudos"; +import { approveKudos, deleteKudos, updateKudos } from "../../api/kudos"; import { UPDATE_TOAST } from "../../context/actions"; const dateUtils = new DateFnsUtils(); @@ -42,18 +50,23 @@ const propTypes = { recipientMembers: PropTypes.array, }).isRequired, includeActions: PropTypes.bool, + includeEdit: PropTypes.bool, onKudosAction: PropTypes.func, }; -const KudosCard = ({ kudos, includeActions, onKudosAction }) => { +const KudosCard = ({ kudos, includeActions, includeEdit, onKudosAction }) => { const { state, dispatch } = useContext(AppContext); const csrf = selectCsrfToken(state); const [expanded, setExpanded] = useState(true); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - // const [deleteReason, setDeleteReason] = useState(""); // TODO: setup optional reason for deleting a kudos + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [kudosPublic, setKudosPublic] = useState(kudos.publiclyVisible); + const [kudosMessage, setKudosMessage] = useState(kudos.message); + const [memberSelectorOpen, setMemberSelectorOpen] = useState(false); + const [kudosRecipientMembers, setKudosRecipientMembers] = useState(kudos.recipientMembers); - const sender = selectProfile(state, kudos.senderId); + const sender = selectActiveOrInactiveProfile(state, kudos.senderId); const getRecipientComponent = useCallback(() => { if (kudos.recipientTeam) { @@ -104,7 +117,7 @@ const KudosCard = ({ kudos, includeActions, onKudosAction }) => { toast: "Kudos approved", }, }); - onKudosAction(); + onKudosAction && onKudosAction(); } else { dispatch({ type: UPDATE_TOAST, @@ -126,10 +139,10 @@ const KudosCard = ({ kudos, includeActions, onKudosAction }) => { type: UPDATE_TOAST, payload: { severity: "success", - toast: "Pending kudos deleted", + toast: "Kudos deleted", }, }); - onKudosAction(); + onKudosAction && onKudosAction(); } else { dispatch({ type: UPDATE_TOAST, @@ -141,62 +154,131 @@ const KudosCard = ({ kudos, includeActions, onKudosAction }) => { } }, [kudos, csrf, dispatch, onKudosAction]); + const updateKudosCallback = useCallback(async () => { + // Close the dialog. + setEditDialogOpen(false); + + // Update the modifiable parts. + const proposed = { + id: kudos.id, + message: kudosMessage, + publiclyVisible: kudosPublic, + recipientMembers: kudosRecipientMembers, + }; + + // Update on the server. + const res = await updateKudos(proposed, csrf); + if (res.error) { + dispatch({ + type: UPDATE_TOAST, + payload: { + severity: "error", + toast: "Failed to update kudos", + }, + }); + } else { + dispatch({ + type: UPDATE_TOAST, + payload: { + severity: "success", + toast: "Kudos Updated", + }, + }); + onKudosAction && onKudosAction(); + } + }, [kudos, kudosMessage, kudosPublic, kudosRecipientMembers, csrf, dispatch, onKudosAction]); + const getStatusComponent = useCallback(() => { const dateApproved = kudos.dateApproved ? new Date(kudos.dateApproved.join("/")) : null; - const dateCreated = new Date(kudos.dateCreated.join("/")); + const info = []; + const actions = []; if (includeActions) { - return ( -
- - - - - - -
+ actions.push( + + + ); } else if (dateApproved) { - return ( - <> - - Received{" "} - {dateApproved ? dateUtils.format(dateApproved, "MM/dd/yyyy") : ""} + info.push( + + Received{" "} + {dateApproved ? dateUtils.format(dateApproved, "MM/dd/yyyy") : ""} + + ); + } else { + const dateCreated = new Date(kudos.dateCreated.join("/")); + if (kudos.publiclyVisible) { + info.push( + + Pending - + ); + } + info.push( + + Created {dateUtils.format(dateCreated, "MM/dd/yyyy")} + + ); + } + if (includeEdit) { + actions.push( + + + + ); + } + if (includeActions || includeEdit) { + actions.push( + + + ); } + return <> + {info.length > 0 &&
+ {info} +
} + {actions.length > 0 &&
+ {actions} +
} + ; + }, [kudos, includeActions, includeEdit, approveKudosCallback]); - return ( - <> - - Pending - - - Created {dateUtils.format(dateCreated, "MM/dd/yyyy")} - - - ); - }, [kudos, includeActions, approveKudosCallback]); + const reloadKudosValues = () => { + setKudosMessage(kudos.message); + setKudosPublic(kudos.publiclyVisible); + setKudosRecipientMembers(kudos.recipientMembers); + }; const dateApproved = kudos.dateApproved ? new Date(kudos.dateApproved.join("/")) @@ -208,28 +290,60 @@ const KudosCard = ({ kudos, includeActions, onKudosAction }) => { Delete Kudos - Are you sure you want to deny approval for these kudos? The kudos + Are you sure you want to complete this action? The kudos will be deleted. + + + + + + + + Edit Kudos + + + + } + label="Public" + onChange={e => { + setKudosPublic(e.target.checked); + }} + /> + { + setKudosMessage(event.target.value); + }} rows={5} style={{ marginTop: "2rem" }} variant="outlined" /> - - diff --git a/web-ui/src/components/kudos_card/KudosCard.test.jsx b/web-ui/src/components/kudos_card/KudosCard.test.jsx new file mode 100644 index 0000000000..f411e0441e --- /dev/null +++ b/web-ui/src/components/kudos_card/KudosCard.test.jsx @@ -0,0 +1,157 @@ +import React from 'react'; +import KudosCard from './KudosCard'; +import { AppContextProvider } from '../../context/AppContext'; + +const initialState = { + state: { + csrf: 'O_3eLX2-e05qpS_yOeg1ZVAs9nDhspEi', + teams: [], + userProfile: { + id: "1", + firstName: 'Jimmy', + lastName: 'Johnson', + role: ['MEMBER'], + }, + terminatedMembers: [ + { + id: "5", + firstName: 'Jerry', + lastName: 'Garcia', + name: 'Jerry Garcia', + role: ['MEMBER'], + }, + ], + memberProfiles: [ + { + id: "1", + firstName: 'Jimmy', + lastName: 'Johnson', + name: 'Jimmy Johnson', + role: ['MEMBER'], + }, + { + id: "2", + firstName: 'Jimmy', + lastName: 'Olsen', + name: 'Jimmy Olsen', + role: ['MEMBER'], + }, + { + id: "3", + firstName: 'Clark', + lastName: 'Kent', + name: 'Clark Kent', + role: ['MEMBER'], + }, + { + id: "4", + firstName: 'Kent', + lastName: 'Brockman', + name: 'Kent Brockman', + role: ['MEMBER'], + }, + { + id: "6", + firstName: 'Brock', + lastName: 'Smith', + name: 'Brock Smith', + role: ['MEMBER'], + }, + { + id: "7", + firstName: 'Jimmy', + middleName: 'T.', + lastName: 'Olsen', + name: 'Jimmy T. Olsen', + role: ['MEMBER'], + }, + ], + } +}; + +const terminated = { + id: 'test-terminated-kudos', + message: "Brock and Brockman did a great job helping Clark, Jimmy Olsen, Jimmy T. Olsen, and Johnson", + senderId: "5", + dateCreated: [ 2025, 2, 14 ], + recipientMembers: [ + { + id: "1", + firstName: 'Jimmy', + lastName: 'Johnson', + role: ['MEMBER'], + }, + { + id: "2", + firstName: 'Jimmy', + lastName: 'Olsen', + role: ['MEMBER'], + }, + { + id: "3", + firstName: 'Clark', + lastName: 'Kent', + role: ['MEMBER'], + }, + { + id: "6", + firstName: 'Brock', + lastName: 'Smith', + role: ['MEMBER'], + }, + { + id: "4", + firstName: 'Kent', + lastName: 'Brockman', + role: ['MEMBER'], + }, + { + id: "7", + firstName: 'Jimmy', + middleName: 'T.', + lastName: 'Olsen', + role: ['MEMBER'], + }, + ], +}; + +const kudos = { + id: 'test-kudos', + message: "Jimmy is awesome!", + senderId: "1", + dateCreated: [ 2025, 2, 17 ], + recipientMembers: [ + { + id: "2", + firstName: 'Jimmy', + lastName: 'Olsen', + role: ['MEMBER'], + }, + ], +}; + +it('inactive renders correctly', () => { + snapshot( + + {}} + /> + + ); +}); + +it('active renders correctly', () => { + snapshot( + + {}} + /> + + ); +}); diff --git a/web-ui/src/components/kudos_card/__snapshots__/KudosCard.test.jsx.snap b/web-ui/src/components/kudos_card/__snapshots__/KudosCard.test.jsx.snap new file mode 100644 index 0000000000..12d66db3fa --- /dev/null +++ b/web-ui/src/components/kudos_card/__snapshots__/KudosCard.test.jsx.snap @@ -0,0 +1,469 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`active renders correctly 1`] = ` +
+
+
+
+
+
+ +
+ + Jimmy Olsen + +
+

+ received kudos from +

+
+
+ +
+ + Jimmy Johnson + +
+
+
+
+ + + +
+
+
+
+
+
+
+
+

+ Jimmy is awesome! +

+
+
+
+
+
+
+`; + +exports[`inactive renders correctly 1`] = ` +
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+

+ received kudos from +

+
+
+ +
+ + Jerry Garcia + +
+
+
+
+ + + +
+
+
+
+
+
+
+
+

+ Brock and Brockman did a great job helping Clark, Jimmy Olsen, Jimmy T. Olsen, and Johnson +

+
+

+ Members: +

+
+
+ +
+ + Jimmy Johnson + +
+
+
+ +
+ + Jimmy Olsen + +
+
+
+ +
+ + Clark Kent + +
+
+
+ +
+ + Brock Smith + +
+
+
+ +
+ + Kent Brockman + +
+
+
+ +
+ + Jimmy Olsen + +
+
+
+
+
+
+
+
+`; diff --git a/web-ui/src/components/reviews/TeamReviews.jsx b/web-ui/src/components/reviews/TeamReviews.jsx index ef70a54d5e..87a30b468a 100644 --- a/web-ui/src/components/reviews/TeamReviews.jsx +++ b/web-ui/src/components/reviews/TeamReviews.jsx @@ -172,7 +172,7 @@ const TeamReviews = ({ onBack, periodId }) => { useEffect(() => { const myId = currentUser?.id; const supervisors = selectSupervisors(state); - const isManager = supervisors.some(s => s.id === myId); + const isManager = supervisors.some(s => s?.id === myId); const period = selectReviewPeriod(state, periodId); if (period) { setApprovalState(period.reviewStatus === ReviewStatus.AWAITING_APPROVAL); diff --git a/web-ui/src/components/reviews/periods/ReviewPeriods.jsx b/web-ui/src/components/reviews/periods/ReviewPeriods.jsx index b62d2e9803..cedd2d151e 100644 --- a/web-ui/src/components/reviews/periods/ReviewPeriods.jsx +++ b/web-ui/src/components/reviews/periods/ReviewPeriods.jsx @@ -327,8 +327,13 @@ const ReviewPeriods = ({ onPeriodSelected, mode }) => { case ReviewStatus.OPEN: onPeriodSelected(id); break; + case ReviewStatus.CLOSED: + if (mode === selfReviewMode) { + onPeriodSelected(id); + } + break; default: - // We do nothing if the status is CLOSED or UNKNOWN. + // We do nothing if the status is UNKNOWN. break; } }, diff --git a/web-ui/src/components/routes/Routes.jsx b/web-ui/src/components/routes/Routes.jsx index 7c238fa72c..66d1800f70 100644 --- a/web-ui/src/components/routes/Routes.jsx +++ b/web-ui/src/components/routes/Routes.jsx @@ -106,7 +106,7 @@ export default function Routes() { - +
diff --git a/web-ui/src/context/AppContext.jsx b/web-ui/src/context/AppContext.jsx index 5dd1752607..78481c04e4 100644 --- a/web-ui/src/context/AppContext.jsx +++ b/web-ui/src/context/AppContext.jsx @@ -23,7 +23,7 @@ import { } from '../api/member'; import { selectCanViewCheckinsPermission, - selectCanEditAllOrganizationMembers, + selectCanViewTerminatedMembers, } from './selectors'; import { getAllRoles, getAllUserRoles } from '../api/roles'; import { getMemberSkills } from '../api/memberskill'; @@ -191,7 +191,7 @@ const AppContextProvider = props => { if (csrf && userProfile && !memberProfiles) { dispatch({ type: UPDATE_PEOPLE_LOADING, payload: true }); getMemberProfiles(); - if (selectCanEditAllOrganizationMembers(state)) { + if (selectCanViewTerminatedMembers(state)) { getTerminatedMembers(); } } diff --git a/web-ui/src/context/actions.js b/web-ui/src/context/actions.js index c51b7e707b..5e97a4b95a 100644 --- a/web-ui/src/context/actions.js +++ b/web-ui/src/context/actions.js @@ -28,6 +28,7 @@ export const UPDATE_TEAMS = '@@check-ins/update_teams'; export const UPDATE_TERMINATED_MEMBERS = '@@check-ins/update_terminated_members'; export const UPDATE_TOAST = '@@check-ins/update_toast'; +export const UPDATE_CURRENT_USER_PROFILE = '@@check-ins/update_current_user_profile'; export const UPDATE_USER_BIO = '@@check-ins/update_bio'; export const UPDATE_FEEEDBACK_SUGGESTIONS = '@@check-ins/update_feedback_suggestions'; diff --git a/web-ui/src/context/reducer.js b/web-ui/src/context/reducer.js index 6c484898d9..541d5184ae 100644 --- a/web-ui/src/context/reducer.js +++ b/web-ui/src/context/reducer.js @@ -27,7 +27,7 @@ import { UPDATE_TEAMS, UPDATE_TEAM_MEMBERS, UPDATE_TOAST, - UPDATE_USER_BIO, + UPDATE_CURRENT_USER_PROFILE, UPDATE_PEOPLE_LOADING, UPDATE_TEAMS_LOADING, UPDATE_REVIEW_PERIOD, @@ -59,13 +59,42 @@ export const initialState = { reviewPeriods: [] }; +// Converts member dates *in place*, I don't love this. I'll be back, convertMemberDates...don't get comfortable. +const convertMemberDates = member => { + member.birthDay = Array.isArray(member.birthDay) + ? new Date(member.birthDay.join('/')) + : member && member.birthDay + ? member.birthDay + : null; + member.startDate = Array.isArray(member.startDate) + ? new Date(member.startDate.join('/')) + : member && member.startDate + ? member.startDate + : new Date(); + member.terminationDate = Array.isArray(member.terminationDate) + ? new Date(member.terminationDate.join('/')) + : member && member.terminationDate + ? member.terminationDate + : null; +}; + export const reducer = (state, action) => { switch (action.type) { case MY_PROFILE_UPDATE: - state.userProfile = action.payload; - break; - case UPDATE_USER_BIO: - state.userProfile.memberProfile.bioText = action.payload; + convertMemberDates(action.payload.memberProfile); + state.userProfile = { ...action.payload }; + break; + case UPDATE_CURRENT_USER_PROFILE: + convertMemberDates(action.payload); + state.userProfile = { ...state.userProfile, memberProfile: { ...action.payload } }; + const profileId = action.payload.id; + const memberProfiles = state.memberProfiles.reduce((acc, current) => { + if(current.id !== profileId) { + acc.push({...current}); + } + return acc; + }, [{ ...action.payload }]) + state.memberProfiles = memberProfiles; break; case ADD_CHECKIN: if (state?.checkins?.length > 0) { @@ -144,43 +173,20 @@ export const reducer = (state, action) => { state.loading = { ...state.loading, memberProfiles: action.payload }; break; case UPDATE_MEMBER_PROFILES: - action.payload.forEach(member => { - member.birthDay = Array.isArray(member.birthDay) - ? new Date(member.birthDay.join('/')) - : member && member.birthDay - ? member.birthDay - : null; - member.startDate = Array.isArray(member.startDate) - ? new Date(member.startDate.join('/')) - : member && member.startDate - ? member.startDate - : new Date(); - member.terminationDate = Array.isArray(member.terminationDate) - ? new Date(member.terminationDate.join('/')) - : member && member.terminationDate - ? member.terminationDate - : null; + action.payload.forEach(convertMemberDates); + const currentProfileId = state?.userProfile?.memberProfile?.id; + const currentProfile = action.payload.find((current) => { + if(currentProfileId && current.id === currentProfileId) { + return current; + } }); + if(currentProfile) { + state.userProfile.memberProfile = { ...currentProfile }; + } state.memberProfiles = action.payload; break; case UPDATE_TERMINATED_MEMBERS: - action.payload.forEach(member => { - member.birthDay = Array.isArray(member.birthDay) - ? new Date(member.birthDay.join('/')) - : member && member.birthDay - ? member.birthDay - : null; - member.startDate = Array.isArray(member.startDate) - ? new Date(member.startDate.join('/')) - : member && member.startDate - ? member.startDate - : new Date(); - member.terminationDate = Array.isArray(member.terminationDate) - ? new Date(member.terminationDate.join('/')) - : member && member.terminationDate - ? member.terminationDate - : null; - }); + action.payload.forEach(convertMemberDates); state.terminatedMembers = action.payload; break; case UPDATE_TEAM_MEMBERS: diff --git a/web-ui/src/context/selectors.js b/web-ui/src/context/selectors.js index 984f7d43a5..27d53c1c10 100644 --- a/web-ui/src/context/selectors.js +++ b/web-ui/src/context/selectors.js @@ -1,7 +1,7 @@ import { createSelector } from 'reselect'; export const selectMemberProfiles = state => state.memberProfiles || []; -export const selectTerminatedMembers = state => state.terminatedMembers; +export const selectTerminatedMembers = state => state.terminatedMembers || []; export const selectMemberSkills = state => state.memberSkills || []; export const selectSkills = state => state.skills || []; export const selectTeamMembers = state => state.teamMembers; @@ -242,6 +242,14 @@ export const selectCanEditAllOrganizationMembers = hasPermission( 'CAN_EDIT_ALL_ORGANIZATION_MEMBERS', ); +export const selectCanViewTerminatedMembers = createSelector( + selectCanEditAllOrganizationMembers, + hasPermission( + 'CAN_VIEW_TERMINATED_MEMBERS' + ), + (canEdit, canView) => canEdit || canView +); + export const selectIsPDL = createSelector( selectUserProfile, userProfile => @@ -327,6 +335,13 @@ export const selectProfile = createSelector( (profileMap, profileId) => profileMap[profileId] ); +export const selectActiveOrInactiveProfile = createSelector( + selectProfileMap, + selectProfileMapForTerminatedMembers, + (state, profileId) => profileId, + (profileMap, termedProfileMap, profileId) => profileMap[profileId] || termedProfileMap[profileId] +); + export const selectSkill = createSelector( selectSkills, (state, skillId) => skillId, diff --git a/web-ui/src/context/selectors.test.js b/web-ui/src/context/selectors.test.js index 0ba8e98cdb..8f6007702e 100644 --- a/web-ui/src/context/selectors.test.js +++ b/web-ui/src/context/selectors.test.js @@ -20,7 +20,10 @@ import { selectSupervisorHierarchyIds, selectSubordinates, selectIsSubordinateOfCurrentUser, - selectHasReportPermission + selectHasReportPermission, + selectActiveOrInactiveProfile, + selectCanEditAllOrganizationMembers, + selectCanViewTerminatedMembers, } from './selectors'; describe('Selectors', () => { @@ -1525,4 +1528,155 @@ describe('Selectors', () => { expect(selectHasReportPermission(testState)).toBe(false); }); + + it("selectCanEditAllOrganizationMembers should return false when user does not have 'CAN_EDIT_ALL_ORGANIZATION_MEMBERS' permission", () => { + const testState = { + userProfile: { + firstName: 'Huey', + lastName: 'Emmerich', + role: 'MEMBER', + permissions: [ + { permission: 'CAN_VIEW_FEEDBACK_REQUEST' }, + { permission: 'CAN_VIEW_FEEDBACK_ANSWER' }, + ] + } + }; + + expect(selectCanEditAllOrganizationMembers(testState)).toBe(false); + }); + + it("selectCanEditAllOrganizationMembers should return true when user has 'CAN_EDIT_ALL_ORGANIZATION_MEMBERS' permission", () => { + const testState = { + userProfile: { + firstName: 'Huey', + lastName: 'Emmerich', + role: 'MEMBER', + permissions: [ + { permission: 'CAN_VIEW_FEEDBACK_REQUEST' }, + { permission: 'CAN_EDIT_ALL_ORGANIZATION_MEMBERS' }, + { permission: 'CAN_VIEW_FEEDBACK_ANSWER' }, + ] + } + }; + + expect(selectCanEditAllOrganizationMembers(testState)).toBe(true); + }); + + it("selectCanViewTerminatedMembers should return false when user does not have 'CAN_EDIT_ALL_ORGANIZATION_MEMBERS' or 'CAN_VIEW_TERMINATED_MEMBERS' permission", () => { + const testState = { + userProfile: { + firstName: 'Huey', + lastName: 'Emmerich', + role: 'MEMBER', + permissions: [ + { permission: 'CAN_VIEW_FEEDBACK_REQUEST' }, + { permission: 'CAN_VIEW_FEEDBACK_ANSWER' }, + ] + } + }; + + expect(selectCanViewTerminatedMembers(testState)).toBe(false); + }); + + it("selectCanViewTerminatedMembers should return true when user has 'CAN_EDIT_ALL_ORGANIZATION_MEMBERS' or 'CAN_VIEW_TERMINATED_MEMBERS' permissions", () => { + const testState = { + userProfile: { + firstName: 'Huey', + lastName: 'Emmerich', + role: 'MEMBER', + permissions: [ + { permission: 'CAN_VIEW_FEEDBACK_REQUEST' }, + { permission: 'CAN_EDIT_ALL_ORGANIZATION_MEMBERS' }, + { permission: 'CAN_VIEW_FEEDBACK_ANSWER' }, + ] + } + }; + const otherTestState = { + userProfile: { + firstName: 'Huey', + lastName: 'Emmerich', + role: 'MEMBER', + permissions: [ + { permission: 'CAN_VIEW_FEEDBACK_REQUEST' }, + { permission: 'CAN_VIEW_TERMINATED_MEMBERS' }, + { permission: 'CAN_VIEW_FEEDBACK_ANSWER' }, + ] + } + }; + + expect(selectCanViewTerminatedMembers(testState)).toBe(true); + expect(selectCanViewTerminatedMembers(otherTestState)).toBe(true); + }); + + it('selectActiveOrInactiveProfile should a profile if active or inactive', () => { + const activeTestMember = { + id: 1, + bioText: 'foo', + employeeId: 11, + name: 'A Person', + firstName: 'A', + lastName: 'PersonA', + location: 'St Louis', + title: 'engineer', + workEmail: 'employee@sample.com', + pdlId: 9, + startDate: [2012, 9, 29], + }; + const inactiveTestMember = { + id: 2, + bioText: 'foo', + employeeId: 12, + name: 'B Person', + firstName: 'B', + lastName: 'PersonB', + location: 'St Louis', + title: 'engineer', + workEmail: 'employee@sample.com', + pdlId: 9, + startDate: [2012, 9, 29], + terminationDate: [2013, 9, 29], + }; + /** @type MemberProfile[] */ + const testActiveMemberProfiles = [ + activeTestMember, + { + id: 3, + bioText: 'foo', + employeeId: 13, + name: 'C Person', + firstName: 'C', + lastName: 'PersonC', + location: 'St Louis', + title: 'engineer', + workEmail: 'employee@sample.com', + pdlId: 9, + startDate: [2012, 9, 29], + } + ]; + /** @type MemberProfile[] */ + const testInactiveMemberProfiles = [ + inactiveTestMember, + { + id: 4, + bioText: 'foo', + employeeId: 13, + name: 'D Person', + firstName: 'D', + lastName: 'PersonD', + location: 'St Louis', + title: 'engineer', + workEmail: 'employee@sample.com', + pdlId: 9, + startDate: [2012, 9, 29], + terminationDate: [2013, 9, 29], + } + ]; + const testState = { + memberProfiles: testActiveMemberProfiles, + terminatedMembers: testInactiveMemberProfiles, + }; + + expect(selectActiveOrInactiveProfile(testState, activeTestMember.id)).toEqual(activeTestMember); + expect(selectActiveOrInactiveProfile(testState, inactiveTestMember.id)).toEqual(inactiveTestMember); + }); }); diff --git a/web-ui/src/pages/KudosPage.css b/web-ui/src/pages/KudosPage.css index ed9bab05df..f6dd9dd140 100644 --- a/web-ui/src/pages/KudosPage.css +++ b/web-ui/src/pages/KudosPage.css @@ -18,6 +18,7 @@ .kudos-page .kudos-page-header { display: flex; + gap: .5em; justify-content: flex-end; } diff --git a/web-ui/src/pages/KudosPage.jsx b/web-ui/src/pages/KudosPage.jsx index 21074c0ed0..df07832640 100644 --- a/web-ui/src/pages/KudosPage.jsx +++ b/web-ui/src/pages/KudosPage.jsx @@ -1,6 +1,10 @@ import React, { useCallback, useContext, useEffect, useState } from "react"; +import { useParams } from 'react-router-dom'; import { styled } from "@mui/material/styles"; import { Button, Tab, Typography } from "@mui/material"; +import MenuItem from '@mui/material/MenuItem'; +import FormControl from '@mui/material/FormControl'; +import TextField from '@mui/material/TextField'; import { TabContext, TabList, TabPanel } from "@mui/lab"; import { AppContext } from "../context/AppContext"; import { @@ -8,7 +12,7 @@ import { selectCurrentUser, selectHasCreateKudosPermission, } from "../context/selectors"; -import { getReceivedKudos, getSentKudos } from "../api/kudos"; +import { getReceivedKudos, getSentKudos, getAllKudos } from "../api/kudos"; import { UPDATE_TOAST } from "../context/actions"; import KudosCard from "../components/kudos_card/KudosCard"; @@ -40,74 +44,132 @@ const Root = styled("div")({ }, }); +const validTabName = (name) => { + switch (name) { + case "received": + case "sent": + case "public": + break; + default: + name && console.warn(`Invalid tab: ${name}`); + name = "received"; + } + return name; +} + +const DateRange = { + THREE_MONTHS: '3mo', + SIX_MONTHS: '6mo', + ONE_YEAR: '1yr', + ALL_TIME: 'all' +}; + const KudosPage = () => { + const { initialTab } = useParams(); const { state, dispatch } = useContext(AppContext); const csrf = selectCsrfToken(state); const currentUser = selectCurrentUser(state); const [kudosDialogOpen, setKudosDialogOpen] = useState(false); - const [kudosTab, setKudosTab] = useState("RECEIVED"); + const [kudosTab, setKudosTab] = useState(validTabName(initialTab)); const [receivedKudos, setReceivedKudos] = useState([]); const [receivedKudosLoading, setReceivedKudosLoading] = useState(true); const [sentKudos, setSentKudos] = useState([]); const [sentKudosLoading, setSentKudosLoading] = useState(true); + const [publicKudos, setPublicKudos] = useState([]); + const [publicKudosLoading, setPublicKudosLoading] = useState(true); + const [dateRange, setDateRange] = useState(DateRange.THREE_MONTHS); + + const isInRange = (requestDate) => { + const oldestDate = new Date(); + switch (dateRange) { + case DateRange.SIX_MONTHS: + oldestDate.setMonth(oldestDate.getMonth() - 6); + break; + case DateRange.ONE_YEAR: + oldestDate.setFullYear(oldestDate.getFullYear() - 1); + break; + case DateRange.ALL_TIME: + return true; + case DateRange.THREE_MONTHS: + default: + oldestDate.setMonth(oldestDate.getMonth() - 3); + } + + if (Array.isArray(requestDate)) { + requestDate = new Date(requestDate.join('/')); + // have to do for Safari + } + return requestDate >= oldestDate; + }; const loadReceivedKudos = useCallback(async () => { setReceivedKudosLoading(true); const res = await getReceivedKudos(currentUser.id, csrf); if (res?.payload?.data && !res.error) { setReceivedKudosLoading(false); - return res.payload.data; + return res.payload.data.filter((k) => isInRange(k.dateCreated)); } - }, [csrf, dispatch, currentUser.id]); + }, [csrf, dispatch, currentUser.id, dateRange]); const loadSentKudos = useCallback(async () => { setSentKudosLoading(true); const res = await getSentKudos(currentUser.id, csrf); if (res?.payload?.data && !res.error) { setSentKudosLoading(false); - return res.payload.data; + return res.payload.data.filter((k) => isInRange(k.dateCreated)); + } + }, [csrf, dispatch, currentUser.id, dateRange]); + + const loadPublicKudos = useCallback(async () => { + setPublicKudosLoading(true); + const res = await getAllKudos(csrf); + if (res?.payload?.data && !res.error) { + setPublicKudosLoading(false); + return res.payload.data.filter((k) => isInRange(k.dateCreated)); } - }, [csrf, dispatch, currentUser.id]); + }, [csrf, dispatch, currentUser.id, dateRange]); + + const loadAndSetReceivedKudos = () => { + loadReceivedKudos().then((data) => { + if (data) { + const filtered = data.filter((kudo) => + kudo.recipientMembers.some((member) => member.id === currentUser.id) + ); + setReceivedKudos(filtered); + } + }); + }; + + const loadAndSetSentKudos = () => { + loadSentKudos().then((data) => { + if (data) { + setSentKudos(data.filter((kudo) => kudo.senderId === currentUser.id)); + } + }); + }; + + const loadAndSetPublicKudos = () => { + loadPublicKudos().then((data) => { + if (data) { + setPublicKudos(data); + } + }); + }; useEffect(() => { if (csrf && currentUser && currentUser.id) { - loadReceivedKudos().then((data) => { - if (data) { - let filtered = data.filter((kudo) => - kudo.recipientMembers.some((member) => member.id === currentUser.id) - ); - setReceivedKudos(filtered); - } - }); - - loadSentKudos().then((data) => { - if (data) { - let filtered = data.filter( - (kudo) => kudo.senderId === currentUser.id - ); - setSentKudos(filtered); - } - }); + loadAndSetReceivedKudos(); + loadAndSetSentKudos(); + loadAndSetPublicKudos(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [csrf, currentUser, kudosTab]); + }, [csrf, currentUser, kudosTab, dateRange]); const handleTabChange = useCallback( (event, newTab) => { - switch (newTab) { - case "RECEIVED": - setKudosTab("RECEIVED"); - break; - case "SENT": - setKudosTab("SENT"); - break; - default: - console.warn(`Invalid tab: ${newTab}`); - } - - setKudosTab(newTab); + setKudosTab(validTabName(newTab)); }, // eslint-disable-next-line react-hooks/exhaustive-deps [loadReceivedKudos, loadSentKudos] @@ -120,6 +182,23 @@ const KudosPage = () => { open={kudosDialogOpen} onClose={() => setKudosDialogOpen(false)} /> + + setDateRange(e.target.value)} + value={dateRange} + variant="outlined" + > + Past 3 months + Past 6 months + Past year + All time + + {selectHasCreateKudosPermission(state) && - diff --git a/web-ui/src/pages/TeamSkillReportPage.jsx b/web-ui/src/pages/TeamSkillReportPage.jsx index 2b32a25fc0..a79d4866fe 100644 --- a/web-ui/src/pages/TeamSkillReportPage.jsx +++ b/web-ui/src/pages/TeamSkillReportPage.jsx @@ -1,4 +1,4 @@ -import React, { useContext, useRef, useState } from 'react'; +import React, { useContext, useRef, useState, useEffect } from 'react'; import { Autocomplete, Button, TextField, Typography } from '@mui/material'; @@ -72,33 +72,33 @@ const TeamSkillReportPage = () => { processedQPs ); - const handleSearch = async searchRequestDTO => { - let res = await reportSkills(searchRequestDTO, csrf); - let memberSkillsFound; - if (res && res.payload) { - memberSkillsFound = - !res.error && res.payload.data.teamMembers - ? res.payload.data.teamMembers - : undefined; - } - if (memberSkillsFound && memberProfiles) { - // Filter the member skill down to only members that are not terminated. - memberSkillsFound = memberSkillsFound.filter( - mSkill => memberProfiles.find(member => member.id == mSkill.id) - ); - - setAllSearchResults(memberSkillsFound); - let membersSelected = memberSkillsFound.filter(mSkill => - selectedMembers.some(member => member.id === mSkill.id) - ); - let newSort = sortMembersBySkill(membersSelected); - setSearchResults(newSort); - } else { - setSearchResults([]); - setAllSearchResults([]); - } - setShowRadar(true); - }; + useEffect(() => { + const handleSearch = async () => { + if (searchSkills.length > 0) { + const searchRequestDTO = createRequest(editedSearchRequest); + const res = await reportSkills(searchRequestDTO, csrf); + let memberSkillsFound; + if (res && res.payload) { + memberSkillsFound = + !res.error && res.payload.data.teamMembers + ? res.payload.data.teamMembers + : []; + } + + setAllSearchResults(memberSkillsFound); + const membersSelected = memberSkillsFound.filter(mSkill => + selectedMembers.some(member => member.id === mSkill.id) + ); + setSearchResults(sortMembersBySkill(membersSelected)); + setShowRadar(true); + } else { + setSearchResults([]); + setAllSearchResults([]); + setShowRadar(false); + } + }; + handleSearch(); + }, [selectedMembers, searchSkills]); function skillsToSkillLevel(skills) { return skills.map(skill => { @@ -194,24 +194,6 @@ const TeamSkillReportPage = () => { /> )} /> - {showRadar && (
diff --git a/web-ui/src/pages/__snapshots__/ProfilePage.test.jsx.snap b/web-ui/src/pages/__snapshots__/ProfilePage.test.jsx.snap index 257c38b7cf..d98560251a 100644 --- a/web-ui/src/pages/__snapshots__/ProfilePage.test.jsx.snap +++ b/web-ui/src/pages/__snapshots__/ProfilePage.test.jsx.snap @@ -72,6 +72,100 @@ exports[`renders correctly 1`] = `
+
+
+
+
+
+ +
+
+
+

+ Preferences +

+
+
+
+
+ + Celebrations + +
+ +
+
+
+
+
diff --git a/web-ui/src/pages/__snapshots__/SkillReportPage.test.jsx.snap b/web-ui/src/pages/__snapshots__/SkillReportPage.test.jsx.snap index 3917370cef..6275d6ddb8 100644 --- a/web-ui/src/pages/__snapshots__/SkillReportPage.test.jsx.snap +++ b/web-ui/src/pages/__snapshots__/SkillReportPage.test.jsx.snap @@ -91,20 +91,6 @@ exports[`renders correctly 1`] = `
-
- -
- diff --git a/web-ui/yarn.lock b/web-ui/yarn.lock index d063a83b16..812e2bd51a 100644 --- a/web-ui/yarn.lock +++ b/web-ui/yarn.lock @@ -1540,6 +1540,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA== +"@types/trusted-types@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/unist@*", "@types/unist@^3.0.0": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" @@ -2944,10 +2949,12 @@ domhandler@^4.2.0, domhandler@^4.2.2: dependencies: domelementtype "^2.2.0" -dompurify@^3.1.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.0.tgz#53c414317c51503183696fcdef6dd3f916c607ed" - integrity sha512-AMdOzK44oFWqHEi0wpOqix/fUNY707OmoeFDnbi3Q5I8uOpy21ufUA5cDJPr0bosxrflOVD/H2DMSvuGKJGfmQ== +dompurify@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.4.tgz#af5a5a11407524431456cf18836c55d13441cd8e" + integrity sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg== + optionalDependencies: + "@types/trusted-types" "^2.0.7" domutils@^2.4.2, domutils@^2.8.0: version "2.8.0" @@ -4917,9 +4924,9 @@ koa-convert@^2.0.0: koa-compose "^4.1.0" koa@^2.15.3: - version "2.15.3" - resolved "https://registry.yarnpkg.com/koa/-/koa-2.15.3.tgz#062809266ee75ce0c75f6510a005b0e38f8c519a" - integrity sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg== + version "2.15.4" + resolved "https://registry.yarnpkg.com/koa/-/koa-2.15.4.tgz#7000b3d8354558671adb1ba1b1c09bedb5f8da75" + integrity sha512-7fNBIdrU2PEgLljXoPWoyY4r1e+ToWCmzS/wwMPbUNs7X+5MMET1ObhJBlUkF5uZG9B6QhM2zS1TsH6adegkiQ== dependencies: accepts "^1.3.5" cache-content-type "^1.0.0"