NotificationMessageGenerator.java

package no.nav.data.team.notify;

import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import no.nav.data.common.auditing.domain.AuditVersion;
import no.nav.data.common.auditing.domain.AuditVersionRepository;
import no.nav.data.common.exceptions.NotFoundException;
import no.nav.data.common.storage.StorageService;
import no.nav.data.team.contact.domain.ContactMessage;
import no.nav.data.team.notify.domain.NotificationTask;
import no.nav.data.team.notify.domain.NotificationTask.AuditTarget;
import no.nav.data.team.notify.dto.MailModels;
import no.nav.data.team.notify.dto.MailModels.InactiveModel;
import no.nav.data.team.notify.dto.MailModels.NudgeModel;
import no.nav.data.team.notify.dto.MailModels.Resource;
import no.nav.data.team.notify.dto.MailModels.TypedItem;
import no.nav.data.team.notify.dto.MailModels.UpdateItem;
import no.nav.data.team.notify.dto.MailModels.UpdateModel;
import no.nav.data.team.notify.dto.MailModels.UpdateModel.TargetType;
import no.nav.data.team.po.domain.ProductArea;
import no.nav.data.team.resource.NomClient;
import no.nav.data.team.shared.Lang;
import no.nav.data.team.shared.domain.Member;
import no.nav.data.team.shared.domain.Membered;
import no.nav.data.team.team.domain.Team;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;

import static no.nav.data.common.utils.StreamUtils.convert;
import static no.nav.data.common.utils.StreamUtils.filterCommonElements;
import static no.nav.data.team.contact.domain.ContactMessage.Paragraph.VarselUrl.url;

@Slf4j
@Service
public class NotificationMessageGenerator {

    private final AuditVersionRepository auditVersionRepository;
    private final LoadingCache<UUID, AuditVersion> auditCache;
    private final LoadingCache<UUID, ProductArea> paCache;
    private final UrlGenerator urlGenerator;
    private final NomClient nomClient;

    public NotificationMessageGenerator(AuditVersionRepository auditVersionRepository,
            StorageService storageService, UrlGenerator urlGenerator, NomClient nomClient) {
        this.auditVersionRepository = auditVersionRepository;
        this.auditCache = Caffeine.newBuilder().recordStats()
                .expireAfterWrite(Duration.ofMinutes(5))
                .maximumSize(1000).build(id -> auditVersionRepository.findById(id).orElseThrow());
        this.paCache = Caffeine.newBuilder().recordStats()
                .expireAfterWrite(Duration.ofMinutes(1))
                .maximumSize(1000).build(id -> storageService.get(id, ProductArea.class));

        this.urlGenerator = urlGenerator;
        this.nomClient = nomClient;
    }

    public NotificationMessage<UpdateModel> updateSummary(NotificationTask task) {
        var model = new UpdateModel();
        model.setBaseUrl(urlGenerator.getBaseUrl());
        model.setTime(task.getTime());
        task.getTargets().forEach(this::fetchAuditVersions);

        task.getTargets().forEach(t -> {
            if (t.isSilent()) {
                // target is here only for calculating other diffs, ie. teams in/out of product area
                return;
            }
            if (t.isCreate()) {
                AuditVersion auditVersion = t.getCurrAuditVersion();
                model.getCreated().add(auditToTypedItem(auditVersion, false));
            } else if (t.isDelete()) {
                AuditVersion auditVersion = t.getPrevAuditVersion();
                model.getDeleted().add(auditToTypedItem(auditVersion, true));
            } else if (t.isEdit()) { // is edit check -> temp fix due to scheduler bug
                AuditVersion prevVersion = t.getPrevAuditVersion();
                AuditVersion currVersion = t.getCurrAuditVersion();
                UpdateItem diff = diffItem(prevVersion, currVersion, task);
                if (diff.hasChanged()) {
                    model.getUpdated().add(diff);
                }
            }
        });

        boolean isEmpty = model.getCreated().isEmpty() && model.getDeleted().isEmpty() && model.getUpdated().isEmpty();
        return new NotificationMessage<>("Teamkatalog oppdatering", model, urlGenerator.isDev(), isEmpty);
    }

    private void fetchAuditVersions(AuditTarget auditTarget) {
        Optional.ofNullable(auditTarget.getPrevAuditId()).ifPresent(id -> auditTarget.setPrevAuditVersion(auditCache.get(id)));
        Optional.ofNullable(auditTarget.getCurrAuditId()).ifPresent(id -> auditTarget.setCurrAuditVersion(auditCache.get(id)));
    }

    private UpdateItem diffItem(AuditVersion prevVersion, AuditVersion currVersion, NotificationTask task) {
        var item = UpdateItem.builder();

        var toName = nameFor(currVersion);
        item.fromName(nameFor(prevVersion));
        item.toName(toName);
        item.item(new TypedItem(typeForAudit(currVersion), currVersion.getTableId(), urlGenerator.urlFor(currVersion), toName));

        if (prevVersion.isTeam()) {
            Team prevData = prevVersion.getTeamData();
            Team currData = currVersion.getTeamData();
            item.fromTeamType(Lang.teamType(prevData.getTeamType()));
            item.toTeamType(Lang.teamType(currData.getTeamType()));

            if (!Objects.equals(prevData.getProductAreaId(), currData.getProductAreaId())) {
                Optional.ofNullable(prevData.getProductAreaId()).map(this::getPa).ifPresent(item::oldProductArea);
                Optional.ofNullable(currData.getProductAreaId()).map(this::getPa).ifPresent(item::newProductArea);
            }
        }
        if (prevVersion.isProductArea()) {
            var newTeams = new ArrayList<AuditTarget>();
            var removedTeams = new ArrayList<AuditTarget>();
            var paId = UUID.fromString(prevVersion.getTableId());
            // Task with productArea targets will contain all it's teams' targets
            var teamTargets = task.getTargets().stream()
                    .filter(AuditTarget::isTeam)
                    .filter(t -> paId.equals(paIdForTeamAudit(t.getPrevAuditVersion())) || paId.equals(paIdForTeamAudit(t.getCurrAuditVersion())))
                    .collect(Collectors.toList());

            log.info("Looking into teams for pa {} {} of {} total targets", paId, teamTargets.size(), task.getTargets().size());

            teamTargets.forEach(t -> {
                if (t.isCreate() || !paId.equals(paIdForTeamAudit(t.getPrevAuditVersion()))) {
                    log.info("Team added {}", t.getTargetId());
                    newTeams.add(t);
                } else if (t.isDelete() || !paId.equals(paIdForTeamAudit(t.getCurrAuditVersion()))) {
                    log.info("Team removed {}", t.getTargetId());
                    removedTeams.add(t);
                }
            });
            item.newTeams(convert(newTeams, this::teamTargetToTypedItem));
            item.removedTeams(convert(removedTeams, this::teamTargetToTypedItem));

            ProductArea prevData = prevVersion.getProductAreaData();
            ProductArea currData = currVersion.getProductAreaData();
            item.fromAreaType(Lang.areaType(prevData.getAreaType()));
            item.toAreaType(Lang.areaType(currData.getAreaType()));
        }
        var fromMembers = members(prevVersion);
        var toMembers = members(currVersion);

        item.removedMembers(convertMember(filterCommonElements(fromMembers, toMembers, Member::getNavIdent)));
        item.newMembers(convertMember(filterCommonElements(toMembers, fromMembers, Member::getNavIdent)));

        return item.build();
    }

    private TypedItem getPa(UUID id) {
        ProductArea pa = null;
        try {
            pa = paCache.get(id);
        } catch (NotFoundException e) {
            log.trace("Product area has been deleted {}", id);
        }
        if (pa == null) {
            pa = auditVersionRepository.findByTableIdOrderByTimeDescLimitOne(id.toString()).getProductAreaData();
            return paToItem(pa, true);
        }
        return paToItem(pa, false);
    }

    private String teamNameFor(AuditTarget teamTarget) {
        return teamTarget.isCreate() || teamTarget.isUpdate() ? teamTarget.getCurrAuditVersion().getTeamData().getName()
                : teamTarget.getPrevAuditVersion().getTeamData().getName();
    }

    private UUID paIdForTeamAudit(AuditVersion auditVersion) {
        return auditVersion == null ? null : auditVersion.getTeamData().getProductAreaId();
    }

    private List<Resource> convertMember(List<? extends Member> list) {
        return convertIdents(convert(list, Member::getNavIdent));
    }

    private List<Resource> convertIdents(List<String> list) {
        return convert(list, ident -> new Resource(urlGenerator.resourceUrl(ident), nomClient.getNameForIdent(ident).orElse(ident), ident));
    }

    private List<Member> members(AuditVersion version) {
        if (version.isTeam()) {
            return List.copyOf(version.getTeamData().getMembers());
        } else if (version.isProductArea()) {
            return List.copyOf(version.getProductAreaData().getMembers());
        }
        return List.of();
    }

    public ContactMessage nudgeTime(Membered membered, String role) {
        NudgeModel model = NudgeModel.builder()
                .targetUrl(urlGenerator.urlFor(membered.getClass(), membered.getId()))
                .targetName(membered.getName())
                .targetType(Lang.objectType(membered.getClass()))
                .recipientRole(role.toLowerCase())
                .cutoffTime(NotificationConstants.NUDGE_TIME_CUTOFF_DESCRIPTION)
                .build();

        var subject = "Teamkatalog påminnelse for %s %s".formatted(model.getTargetType(), model.getTargetName());

        return new ContactMessage(subject, "nudge")
                .paragraph("Hei, det har nå gått over %s siden %%s ble sist oppdatert.".formatted(model.getCutoffTime()),
                        url(model.getTargetUrl(), "%s %s".formatted(model.getTargetType(), model.getTargetName())))
                .paragraph("Som %s mottar du derfor en påminnelse for å sikre at innholdet er korrekt.".formatted(model.getRecipientRole()))
                .footer(model.getTargetUrl());
    }

    public ContactMessage inactive(Membered membered, String role, List<String> identsInactive) {
        InactiveModel model = InactiveModel.builder()
                .targetUrl(urlGenerator.urlFor(membered.getClass(), membered.getId()))
                .targetName(membered.getName())
                .targetType(Lang.objectType(membered.getClass()))
                .recipientRole(role.toLowerCase())
                .members(convertIdents(identsInactive))
                .build();

        String subject = "Medlemmer av %s %s har blitt inaktive".formatted(model.getTargetType(), model.getTargetName());
        var message = new ContactMessage(subject, "inactive")
                .paragraph("Hei, %s har nå fått inaktive medlem(mer)", url(model.getTargetUrl(), "%s %s".formatted(model.getTargetType(), model.getTargetName())))
                .paragraph("Som %s mottar du derfor en påminnelse for å sikre at innholdet er korrekt.".formatted(model.getRecipientRole()))
                .spacing()
                .paragraph("Nye inaktive medlemmer:");

        for (MailModels.Resource member : model.getMembers()) {
            message.paragraph(" - %s", url(member.getUrl(), member.getName()));
        }
        message.footer(model.getTargetUrl());

        return message;
    }

    private TargetType typeForAudit(AuditVersion auditVersion) {
        if (auditVersion.isProductArea()) {
            return TargetType.AREA;
        } else if (auditVersion.isTeam()) {
            return TargetType.TEAM;
        }
        return null;
    }

    private String nameFor(AuditVersion auditVersion) {
        if (auditVersion.isTeam()) {
            return auditVersion.getTeamData().getName();
        } else if (auditVersion.isProductArea()) {
            return auditVersion.getProductAreaData().getName();
        }
        return StringUtils.EMPTY;
    }

    private TypedItem auditToTypedItem(AuditVersion auditVersion, boolean deleted) {
        return new TypedItem(typeForAudit(auditVersion), auditVersion.getTableId(), urlGenerator.urlFor(auditVersion), nameFor(auditVersion), deleted);
    }

    private TypedItem teamTargetToTypedItem(AuditTarget target) {
        return new TypedItem(TargetType.TEAM, target.getTargetId().toString(), urlGenerator.urlFor(Team.class, target.getTargetId()), teamNameFor(target), target.isDelete());
    }

    private TypedItem paToItem(ProductArea pa, boolean deleted) {
        return new TypedItem(TargetType.AREA, pa.getId().toString(), urlGenerator.urlFor(pa.getClass(), pa.getId()), pa.getName(), deleted);
    }

    @Data
    public static class NotificationMessage<T> {

        private final String subject;
        private final T model;
        private final boolean empty;
        private final boolean dev;

        NotificationMessage(String subject, T model, boolean dev, boolean isEmpty) {
            this.subject = subject + (dev ? " [DEV]" : "");
            this.model = model;
            this.empty = isEmpty;
            this.dev = dev;
        }

        NotificationMessage(String subject, T model, boolean dev) {
            this(subject, model, dev, false);
        }
    }

}