Skip to content

Commit cf2a90b

Browse files
committed
Initial version of Pulse Response submission through Slack.
1 parent 7fe227e commit cf2a90b

File tree

8 files changed

+276
-20
lines changed

8 files changed

+276
-20
lines changed

server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ public static class ApplicationConfig {
3434
@NotNull
3535
private NotificationsConfig notifications;
3636

37+
@NotNull
38+
private PulseResponseConfig pulseResponse;
39+
3740
@Getter
3841
@Setter
3942
@ConfigurationProperties("feedback")
@@ -89,5 +92,25 @@ public static class SlackConfig {
8992
private String botToken;
9093
}
9194
}
95+
96+
@Getter
97+
@Setter
98+
@ConfigurationProperties("pulse-response")
99+
public static class PulseResponseConfig {
100+
101+
@NotNull
102+
private SlackConfig slack;
103+
104+
@Getter
105+
@Setter
106+
@ConfigurationProperties("slack")
107+
public static class SlackConfig {
108+
@NotBlank
109+
private String signingSecret;
110+
111+
@NotBlank
112+
private String webhookUrl;
113+
}
114+
}
92115
}
93116
}

server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package com.objectcomputing.checkins.services.pulseresponse;
22

33
import com.objectcomputing.checkins.exceptions.NotFoundException;
4+
import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices;
5+
46
import io.micronaut.core.annotation.Nullable;
57
import io.micronaut.core.convert.format.Format;
8+
import io.micronaut.http.HttpStatus;
69
import io.micronaut.http.HttpRequest;
710
import io.micronaut.http.HttpResponse;
11+
import io.micronaut.http.annotation.Header;
812
import io.micronaut.http.annotation.Body;
913
import io.micronaut.http.annotation.Controller;
1014
import io.micronaut.http.annotation.Get;
@@ -14,6 +18,7 @@
1418
import io.micronaut.scheduling.annotation.ExecuteOn;
1519
import io.micronaut.security.annotation.Secured;
1620
import io.micronaut.security.rules.SecurityRule;
21+
1722
import io.swagger.v3.oas.annotations.tags.Tag;
1823
import jakarta.validation.Valid;
1924
import jakarta.validation.constraints.NotNull;
@@ -25,14 +30,19 @@
2530

2631
@Controller("/services/pulse-responses")
2732
@ExecuteOn(TaskExecutors.BLOCKING)
28-
@Secured(SecurityRule.IS_AUTHENTICATED)
2933
@Tag(name = "pulse-responses")
3034
public class PulseResponseController {
3135

3236
private final PulseResponseService pulseResponseServices;
37+
private final MemberProfileServices memberProfileServices;
38+
private final SlackSignatureVerifier slackSignatureVerifier;
3339

34-
public PulseResponseController(PulseResponseService pulseResponseServices) {
40+
public PulseResponseController(PulseResponseService pulseResponseServices,
41+
MemberProfileServices memberProfileServices,
42+
SlackSignatureVerifier slackSignatureVerifier) {
3543
this.pulseResponseServices = pulseResponseServices;
44+
this.memberProfileServices = memberProfileServices;
45+
this.slackSignatureVerifier = slackSignatureVerifier;
3646
}
3747

3848
/**
@@ -43,6 +53,7 @@ public PulseResponseController(PulseResponseService pulseResponseServices) {
4353
* @param dateTo
4454
* @return
4555
*/
56+
@Secured(SecurityRule.IS_AUTHENTICATED)
4657
@Get("/{?teamMemberId,dateFrom,dateTo}")
4758
public Set<PulseResponse> findPulseResponses(@Nullable @Format("yyyy-MM-dd") LocalDate dateFrom,
4859
@Nullable @Format("yyyy-MM-dd") LocalDate dateTo,
@@ -56,6 +67,7 @@ public Set<PulseResponse> findPulseResponses(@Nullable @Format("yyyy-MM-dd") Loc
5667
* @param pulseResponse, {@link PulseResponseCreateDTO}
5768
* @return {@link HttpResponse<PulseResponse>}
5869
*/
70+
@Secured(SecurityRule.IS_AUTHENTICATED)
5971
@Post
6072
public HttpResponse<PulseResponse> createPulseResponse(@Body @Valid PulseResponseCreateDTO pulseResponse,
6173
HttpRequest<?> request) {
@@ -70,6 +82,7 @@ public HttpResponse<PulseResponse> createPulseResponse(@Body @Valid PulseRespons
7082
* @param pulseResponse, {@link PulseResponse}
7183
* @return {@link HttpResponse<PulseResponse>}
7284
*/
85+
@Secured(SecurityRule.IS_AUTHENTICATED)
7386
@Put
7487
public HttpResponse<PulseResponse> update(@Body @Valid @NotNull PulseResponse pulseResponse,
7588
HttpRequest<?> request) {
@@ -82,6 +95,7 @@ public HttpResponse<PulseResponse> update(@Body @Valid @NotNull PulseResponse pu
8295
* @param id
8396
* @return
8497
*/
98+
@Secured(SecurityRule.IS_AUTHENTICATED)
8599
@Get("/{id}")
86100
public PulseResponse readRole(@NotNull UUID id) {
87101
PulseResponse result = pulseResponseServices.read(id);
@@ -90,4 +104,45 @@ public PulseResponse readRole(@NotNull UUID id) {
90104
}
91105
return result;
92106
}
93-
}
107+
108+
@Secured(SecurityRule.IS_ANONYMOUS)
109+
@Post("/external")
110+
public HttpResponse<PulseResponse> externalPulseResponse(
111+
@Header("X-Slack-Signature") String signature,
112+
@Header("X-Slack-Request-Timestamp") String timestamp,
113+
@Body String requestBody,
114+
HttpRequest<?> request) {
115+
// Validate the request
116+
if (slackSignatureVerifier.verifyRequest(signature,
117+
timestamp, requestBody)) {
118+
PulseResponseCreateDTO pulseResponseDTO =
119+
SlackPulseResponseConverter.get(memberProfileServices,
120+
requestBody);
121+
122+
// Create the pulse response
123+
PulseResponse pulseResponse = pulseResponseServices.unsecureSave(
124+
new PulseResponse(
125+
pulseResponseDTO.getInternalScore(),
126+
pulseResponseDTO.getExternalScore(),
127+
pulseResponseDTO.getSubmissionDate(),
128+
pulseResponseDTO.getTeamMemberId(),
129+
pulseResponseDTO.getInternalFeelings(),
130+
pulseResponseDTO.getExternalFeelings()
131+
)
132+
);
133+
134+
if (pulseResponse == null) {
135+
return HttpResponse.status(HttpStatus.CONFLICT,
136+
"Already submitted today");
137+
} else {
138+
return HttpResponse.created(pulseResponse)
139+
.headers(headers -> headers.location(
140+
URI.create(String.format("%s/%s",
141+
request.getPath(),
142+
pulseResponse.getId()))));
143+
}
144+
} else {
145+
return HttpResponse.unauthorized();
146+
}
147+
}
148+
}

server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseRepository.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import jakarta.validation.constraints.NotNull;
77

88
import java.time.LocalDate;
9+
import java.util.Optional;
910
import java.util.List;
1011
import java.util.UUID;
1112

@@ -14,4 +15,5 @@ public interface PulseResponseRepository extends CrudRepository<PulseResponse, U
1415

1516
List<PulseResponse> findByTeamMemberId(@NotNull UUID teamMemberId);
1617
List<PulseResponse> findBySubmissionDateBetween(@NotNull LocalDate dateFrom, @NotNull LocalDate dateTo);
17-
}
18+
Optional<PulseResponse> getByTeamMemberIdAndSubmissionDate(@NotNull UUID teamMemberId, @NotNull LocalDate submissionDate);
19+
}

server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ public interface PulseResponseService {
99
PulseResponse read(UUID id);
1010

1111
PulseResponse save(PulseResponse pulseResponse);
12+
PulseResponse unsecureSave(PulseResponse pulseResponse);
1213

1314
PulseResponse update(PulseResponse pulseResponse);
1415

1516
Set<PulseResponse> findByFields(UUID teamMemberId, LocalDate dateFrom, LocalDate dateTo);
16-
}
17+
}

server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,25 +48,57 @@ public PulseResponseServicesImpl(
4848

4949
@Override
5050
public PulseResponse save(PulseResponse pulseResponse) {
51-
UUID currentUserId = currentUserServices.getCurrentUser().getId();
51+
if (pulseResponse != null) {
52+
final UUID memberId = pulseResponse.getTeamMemberId();
53+
UUID currentUserId = currentUserServices.getCurrentUser().getId();
54+
if (memberId != null &&
55+
!currentUserId.equals(memberId) &&
56+
!isSubordinateTo(memberId, currentUserId)) {
57+
throw new BadArgException(
58+
String.format("User %s does not have permission to create pulse response for user %s",
59+
currentUserId, memberId));
60+
}
61+
return saveCommon(pulseResponse);
62+
} else {
63+
return null;
64+
}
65+
}
66+
67+
@Override
68+
public PulseResponse unsecureSave(PulseResponse pulseResponse) {
5269
PulseResponse pulseResponseRet = null;
5370
if (pulseResponse != null) {
71+
// External users could submit a pulse resonse multiple times. We
72+
// need to check to see if this user has already submitted one
73+
// today.
74+
boolean submitted = false;
5475
final UUID memberId = pulseResponse.getTeamMemberId();
55-
LocalDate pulseSubDate = pulseResponse.getSubmissionDate();
56-
if (pulseResponse.getId() != null) {
57-
throw new BadArgException(String.format("Found unexpected id for pulseresponse %s", pulseResponse.getId()));
58-
} else if (memberId != null &&
59-
memberRepo.findById(memberId).isEmpty()) {
60-
throw new BadArgException(String.format("Member %s doesn't exists", memberId));
61-
} else if (pulseSubDate.isBefore(LocalDate.EPOCH) || pulseSubDate.isAfter(LocalDate.MAX)) {
62-
throw new BadArgException(String.format("Invalid date for pulseresponse submission date %s", memberId));
63-
} else if (memberId != null &&
64-
!currentUserId.equals(memberId) &&
65-
!isSubordinateTo(memberId, currentUserId)) {
66-
throw new BadArgException(String.format("User %s does not have permission to create pulse response for user %s", currentUserId, memberId));
76+
if (memberId != null) {
77+
Optional<PulseResponse> existing =
78+
pulseResponseRepo.getByTeamMemberIdAndSubmissionDate(
79+
memberId, pulseResponse.getSubmissionDate());
80+
submitted = existing.isPresent();
81+
}
82+
if (!submitted) {
83+
return saveCommon(pulseResponse);
6784
}
68-
pulseResponseRet = pulseResponseRepo.save(pulseResponse);
6985
}
86+
return null;
87+
}
88+
89+
private PulseResponse saveCommon(PulseResponse pulseResponse) {
90+
final UUID memberId = pulseResponse.getTeamMemberId();
91+
LocalDate pulseSubDate = pulseResponse.getSubmissionDate();
92+
if (pulseResponse.getId() != null) {
93+
throw new BadArgException(String.format("Found unexpected id for pulseresponse %s", pulseResponse.getId()));
94+
} else if (memberId != null &&
95+
memberRepo.findById(memberId).isEmpty()) {
96+
throw new BadArgException(String.format("Member %s doesn't exists", memberId));
97+
} else if (pulseSubDate.isBefore(LocalDate.EPOCH) || pulseSubDate.isAfter(LocalDate.MAX)) {
98+
throw new BadArgException(String.format("Invalid date for pulseresponse submission date %s", memberId));
99+
}
100+
101+
PulseResponse pulseResponseRet = pulseResponseRepo.save(pulseResponse);
70102

71103
// Send low pulse survey score if appropriate
72104
sendPulseLowScoreEmail(pulseResponseRet);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.objectcomputing.checkins.services.pulseresponse;
2+
3+
import com.objectcomputing.checkins.exceptions.BadArgException;
4+
import com.objectcomputing.checkins.services.memberprofile.MemberProfile;
5+
import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices;
6+
7+
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import com.fasterxml.jackson.core.type.TypeReference;
9+
import com.fasterxml.jackson.core.JsonProcessingException;
10+
11+
import java.util.Map;
12+
import java.util.UUID;
13+
import java.time.LocalDate;
14+
15+
public class SlackPulseResponseConverter {
16+
public static PulseResponseCreateDTO get(
17+
MemberProfileServices memberProfileServices, String body) {
18+
final String key = "payload=";
19+
final int start = body.indexOf(key);
20+
if (start >= 0) {
21+
try {
22+
// Get the map of values from the string body
23+
final ObjectMapper mapper = new ObjectMapper();
24+
final Map<String, Object> map =
25+
mapper.readValue(body.substring(start + key.length()),
26+
new TypeReference<>() {});
27+
final Map<String, Object> view =
28+
(Map<String, Object>)map.get("view");
29+
final Map<String, Object> state =
30+
(Map<String, Object>)view.get("state");
31+
final Map<String, Object> values =
32+
(Map<String, Object>)state.get("values");
33+
34+
// Create the pulse DTO and fill in the values.
35+
PulseResponseCreateDTO response = new PulseResponseCreateDTO();
36+
response.setTeamMemberId(lookupUser(memberProfileServices, map));
37+
response.setSubmissionDate(LocalDate.now());
38+
response.setInternalScore(Integer.parseInt(
39+
getMappedValue(values, "internalScore")));
40+
response.setInternalFeelings(
41+
getMappedValue(values, "internalFeelings"));
42+
response.setExternalScore(Integer.parseInt(
43+
getMappedValue(values, "externalScore")));
44+
response.setExternalFeelings(
45+
getMappedValue(values, "externalFeelings"));
46+
47+
return response;
48+
} catch(JsonProcessingException ex) {
49+
throw new BadArgException(ex.getMessage());
50+
}
51+
} else {
52+
throw new BadArgException("Invalid pulse response body");
53+
}
54+
}
55+
56+
private static String getMappedValue(Map<String, Object> map, String key) {
57+
return (String)((Map<String, Object>)map.get(key)).get("value");
58+
}
59+
60+
private static UUID lookupUser(MemberProfileServices memberProfileServices,
61+
Map<String, Object> map) {
62+
// Get the user's profile map.
63+
Map<String, Object> user = (Map<String, Object>)map.get("user");
64+
Map<String, Object> profile = (Map<String, Object>)user.get("profile");
65+
66+
// Lookup the user based on the email address.
67+
String email = (String)profile.get("email");
68+
MemberProfile member = memberProfileServices.findByWorkEmail(email);
69+
return member.getId();
70+
}
71+
}

0 commit comments

Comments
 (0)