NotificationService.java
package no.nav.data.team.notify;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import no.nav.data.common.auditing.domain.AuditVersionRepository;
import no.nav.data.common.exceptions.NotFoundException;
import no.nav.data.common.exceptions.ValidationException;
import no.nav.data.common.mail.EmailService;
import no.nav.data.common.mail.MailTask;
import no.nav.data.common.security.SecurityUtils;
import no.nav.data.common.storage.StorageService;
import no.nav.data.common.utils.MetricUtils;
import no.nav.data.team.contact.domain.ContactAddress;
import no.nav.data.team.contact.domain.ContactMessage;
import no.nav.data.team.integration.slack.SlackClient;
import no.nav.data.team.notify.domain.GenericNotificationTask.InactiveMembers;
import no.nav.data.team.notify.domain.Notification;
import no.nav.data.team.notify.domain.Notification.NotificationChannel;
import no.nav.data.team.notify.domain.Notification.NotificationTime;
import no.nav.data.team.notify.domain.Notification.NotificationType;
import no.nav.data.team.notify.domain.NotificationTask;
import no.nav.data.team.notify.dto.Changelog;
import no.nav.data.team.notify.dto.MailModels.UpdateModel;
import no.nav.data.team.notify.dto.NotificationDto;
import no.nav.data.team.resource.NomClient;
import no.nav.data.team.resource.domain.Resource;
import no.nav.data.team.shared.Lang;
import no.nav.data.team.shared.domain.Membered;
import no.nav.data.team.team.domain.Team;
import no.nav.data.team.team.domain.Role;
import org.apache.commons.lang3.NotImplementedException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Month;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import static no.nav.data.common.utils.StreamUtils.*;
import static no.nav.data.team.contact.domain.Channel.EPOST;
@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationService {
private final StorageService storage;
private final NomClient nomClient;
private final EmailService emailService;
private final TemplateService templateService;
private final SlackClient slackClient;
private final NotificationSlackMessageConverter notificationSlackMessageConverter;
private final SecurityUtils securityUtils;
private final AuditVersionRepository auditVersionRepository;
private final NotificationMessageGenerator messageGenerator;
private final AuditDiffService auditDiffService;
private final Cache<String, Changelog> changelogCache = MetricUtils.register("changelogCache",
Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(15))
.maximumSize(500)
.recordStats().build());
// changes before this date have non-backwards compatible formats
private static final LocalDateTime earliestChangelog = LocalDateTime.of(2020, Month.APRIL, 24, 0, 0);
public Notification save(NotificationDto dto) {
dto.validate();
securityUtils.assertIsUserOrAdmin(dto.getIdent(), "Cannot edit other users notifications");
if (nomClient.getByNavIdent(dto.getIdent()).isEmpty()) {
throw new ValidationException("Couldn't find user " + dto.getIdent());
}
return storage.save(new Notification(dto));
}
public Notification delete(UUID id) {
var notification = storage.get(id, Notification.class);
securityUtils.assertIsUserOrAdmin(notification.getIdent(), "Cannot delete other users notifications");
return storage.delete(id, Notification.class);
}
public boolean notifyTask(NotificationTask task) {
log.info("Sending notification for task {}", task);
var resource = nomClient.getByNavIdent(task.getIdent());
if (resource.isPresent() &&
(
resource.get().getEmail() == null
|| resource.get().getEmail().isBlank()
|| (resource.get().getEndDate() != null && resource.get().getEndDate().isBefore(LocalDate.now()))
)
) {
log.warn("No email found for user {}, skipping notification task", task.getIdent());
return false;
}
var email = getEmailForIdent(task.getIdent());
var message = messageGenerator.updateSummary(task);
if (message.isEmpty()) {
log.info("Skipping task, end message is empty taskId {}", task.getId());
return true;
}
if (task.getChannel() == NotificationChannel.EMAIL) {
sendUpdateMail(email, message.getModel(), message.getSubject());
} else if (task.getChannel() == NotificationChannel.SLACK) {
var blocks = notificationSlackMessageConverter.convertTeamUpdateModel(message.getModel());
try {
slackClient.sendMessageToUser(email, message.getSubject(), blocks);
} catch (NotFoundException e) {
sendUpdateMail(email, message.getModel(), message.getSubject() + " - Erstatning for slack melding. Klarte ikke finne din slack bruker.");
}
}
return true;
}
private void sendUpdateMail(String email, UpdateModel model, String subject) {
String body = templateService.teamUpdate(model);
emailService.sendMail(MailTask.builder().to(email).subject(subject).body(body).build());
}
public void nudge(Membered object) {
sendMessage(object, recipients -> messageGenerator.nudgeTime(object, recipients.role()));
}
public void inactive(InactiveMembers task) {
Membered object = storage.get(task.getId(), task.getType());
sendMessage(object, recipients -> messageGenerator.inactive(object, recipients.role(), task.getIdentsInactive()));
}
private void sendMessage(Membered membered, Function<Recipients, ContactMessage> messageGenerator) {
try {
var recipients = getRecipients(membered);
if (recipients.isEmpty()) {
log.info("No recipients found for contact to {}: {}", membered.type(), membered.getName());
return;
}
var contactMessage = messageGenerator.apply(recipients);
// TODO consider schedule slack messages async (like email) to guard against slack downtime
for (var recipient : recipients.addresses) {
switch (recipient.getType()) {
case EPOST -> emailService.scheduleMail(MailTask.builder().to(recipient.getAddress()).subject(contactMessage.getTitle()).body(contactMessage.toHtml()).build());
case SLACK -> slackClient.sendMessageToChannel(recipient.getAddress(), contactMessage.getTitle(), contactMessage.toSlack());
case SLACK_USER -> slackClient.sendMessageToUserId(recipient.getAddress(), contactMessage.getTitle(), contactMessage.toSlack());
default -> throw new NotImplementedException("%s is not an implemented varsel type".formatted(recipient.getType()));
}
}
} catch (Exception e) {
log.error("Failed to send message to %s %s".formatted(membered.type(), membered.getName()), e);
throw e;
}
}
private Recipients getRecipients(Membered object) {
if (object instanceof Team team) {
if (!CollectionUtils.isEmpty(team.getContactAddresses())) {
return new Recipients("Kontaktadresse", team.getContactAddresses());
} else if (!StringUtils.isBlank(team.getContactPersonIdent())) {
return new Recipients("Kontaktperson", List.of(new ContactAddress(getEmailForIdent(team.getContactPersonIdent()), EPOST)));
}
}
var role = Role.LEAD;
List<String> emails = getEmails(object, role);
return new Recipients(Lang.roleName(role), convert(emails, e -> new ContactAddress(e, EPOST)));
}
private List<String> getEmails(Membered object, Role role) {
return safeStream(object.getMembers())
.filter(m -> m.getRoles().contains(role))
.map(l -> {
try {
return getEmailForIdent(l.getNavIdent());
} catch (MailNotFoundException e) {
log.warn("email not found", e);
return null;
}
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
private String getEmailForIdent(String ident) {
return nomClient.getByNavIdent(ident)
.filter(resource -> resource.getEndDate() == null || resource.getEndDate().isAfter(LocalDate.now()))
.filter(resource -> resource.getEmail() != null)
.map(Resource::getEmail)
.orElseThrow(() -> new MailNotFoundException("Can't find email for " + ident));
}
public String changelogMail(NotificationType type, UUID targetId, LocalDateTime start, LocalDateTime end) {
var model = changelog(type, targetId, start, end);
if (model == null) {
return "empty";
}
log.info("new {} removes {} updates {}", model.getCreated(), model.getDeleted(), model.getUpdated());
return templateService.teamUpdate(model);
}
public Changelog changelogJson(NotificationType type, UUID targetId, LocalDateTime start, LocalDateTime end) {
return changelogCache.get("" + type + targetId + start + end, k -> Changelog.from(changelog(type, targetId, start, end)));
}
private UpdateModel changelog(NotificationType type, UUID targetId, LocalDateTime start, LocalDateTime end) {
var notifications = List.of(Notification.builder()
.channels(List.of(NotificationChannel.EMAIL))
.target(targetId)
.type(type)
.ident("MANUAL")
.time(NotificationTime.ALL)
.build());
start = start.isBefore(earliestChangelog) ? earliestChangelog : start;
var audits = auditVersionRepository.findByTimeBetween(start, end);
var tasks = auditDiffService.createTask(audits, notifications);
var task = tryFind(tasks, t -> t.getChannel() == NotificationChannel.EMAIL);
if (task.isEmpty() || task.get().getTargets().isEmpty()) {
return null;
}
return messageGenerator.updateSummary(task.get()).getModel();
}
record Recipients(String role, List<ContactAddress> addresses) {
boolean isEmpty() {
return addresses.isEmpty();
}
}
}