NomGraphClient.java

package no.nav.data.team.resource;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import no.nav.data.common.security.SecurityProperties;
import no.nav.data.common.security.TokenProvider;
import no.nav.data.common.utils.DateUtil;
import no.nav.data.common.utils.JsonUtils;
import no.nav.data.common.utils.MetricUtils;
import no.nav.data.common.utils.StreamUtils;
import no.nav.data.team.integration.process.GraphQLRequest;
import no.nav.data.team.resource.domain.Resource;
import no.nav.data.team.resource.dto.NomGraphQlResponse.MultiOrg;
import no.nav.data.team.resource.dto.NomGraphQlResponse.MultiRessurs;
import no.nav.data.team.resource.dto.NomGraphQlResponse.SingleOrg;
import no.nav.data.team.resource.dto.NomGraphQlResponse.SingleRessurs;
import no.nav.data.team.resource.dto.ResourceUnitsResponse;
import no.nav.nom.graphql.model.*;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;

import java.lang.reflect.Method;
import java.time.Duration;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static java.util.Objects.isNull;
import static java.util.Objects.requireNonNull;
import static no.nav.data.common.utils.StreamUtils.distinctByKey;
import static no.nav.data.common.web.TraceHeaderRequestInterceptor.correlationInterceptor;

/**
 * Cannot be used in dev atm, as teamkat runs as nav.no, and nom as trygdeetaten.no
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class NomGraphClient {

    private RestTemplate restTemplate;
    private final RestTemplateBuilder restTemplateBuilder;
    private final SecurityProperties securityProperties;
    private final TokenProvider tokenProvider;
    private final NomGraphQLProperties properties;

    private static final String getResourceQuery = StreamUtils.readCpFile("nom/graphql/queries/get_org_for_ident.graphql");
    private static final String getOrgQuery = StreamUtils.readCpFile("nom/graphql/queries/get_org_with_organiseringer.graphql");
    private static final String getLeaderMemberQuery = StreamUtils.readCpFile("nom/graphql/queries/get_personer_for_org.graphql");
    private static final String getOrgOverQuery = StreamUtils.readCpFile("nom/graphql/queries/get_org_with_organisering_over.graphql");
    private static final String getOrgWithNameAndLeaderQuery = StreamUtils.readCpFile("nom/graphql/queries/get_org_with_leder.graphql");
    private static final String getOrgEnheterWithLederOrganiseringUnder = StreamUtils.readCpFile("nom/graphql/queries/get_org_with_leder_organisering_under.graphql");
    private static final String getHeleHierarkietTilLederOgOrgtilknytningerQuery = StreamUtils.readCpFile("nom/graphql/queries/get_hele_hierarkiet_til_leder_og_orgtilknytninger.graphql");
    private static final String searchForRessurs = StreamUtils.readCpFile("nom/graphql/queries/search_ressurs.graphql");
    private static final String scopeTemplate = "api://%s-gcp.nom.nom-api/.default";

    private static final Cache<String, RessursDto> ressursCache = MetricUtils.register("nomRessursCache",
            Caffeine.newBuilder().recordStats()
                    .expireAfterWrite(Duration.ofMinutes(10))
                    .maximumSize(1000).build());

    private static final Cache<String, OrgEnhetDto> orgCache = MetricUtils.register("nomOrgCache",
            Caffeine.newBuilder().recordStats()
                    .expireAfterWrite(Duration.ofMinutes(10))
                    .maximumSize(1000).build());

    private static final Cache<String, List<String>> leaderCache = MetricUtils.register("nomLeaderCache",
            Caffeine.newBuilder().recordStats()
                    .expireAfterWrite(Duration.ofMinutes(10))
                    .maximumSize(1000).build());

    private static final Cache<String, OrgEnhetDto> orgOverCache = MetricUtils.register("nomOrgOverCache",
            Caffeine.newBuilder().recordStats()
                    .expireAfterWrite(Duration.ofMinutes(10))
                    .maximumSize(1000).build());

    private static final Cache<String, OrgEnhetDto> orgUnderWithLeaderCache = MetricUtils.register("nomOrgUnderWithLeaderCache",
            Caffeine.newBuilder().recordStats()
                    .expireAfterWrite(Duration.ofMinutes(10))
                    .maximumSize(1000).build());

    private static final Cache<String, List<String>> leaderHeleHierarkietOgAnsatteCache = MetricUtils.register("leaderHeleHierarkietOgAnsatteCache",
            Caffeine.newBuilder().recordStats()
                    .expireAfterWrite(Duration.ofMinutes(30))
                    .maximumSize(2000).build());

    public Optional<RessursDto> getRessurs(String navIdent) {
        return Optional.ofNullable(getRessurser(List.of(navIdent)).get(navIdent));
    }

    public Optional<OrgEnhetDto> getOrgenhetMedOverOrganisering(String nomId) {
        return Optional.ofNullable(getOrgWithOrganiseringOver(nomId).get(nomId));
    }

    public Optional<OrgEnhetDto> getOrgEnhet(String nomId) {
        var org = orgCache.get(nomId, key -> {
            var req = new GraphQLRequest(getOrgQuery, Map.of("nomId", nomId));

            var res = template().postForEntity(properties.getUrl(), req, SingleOrg.class);
            logErrors("getOrgWithOrganiseringer", res.getBody());
            var orgEnhet = requireNonNull(res.getBody()).getData().getOrgEnhet();
            if (orgEnhet != null) {
                orgEnhet.setOrganiseringer(distinctByKey(orgEnhet.getOrganiseringer(), o -> o.getOrgEnhet().getId()));
            }
            return orgEnhet;
        });
        return Optional.ofNullable(org);
    }

    public List<OrgEnhetDto> getOrgEnheter(List<String> orgIds) {
        var req = new GraphQLRequest(getOrgWithNameAndLeaderQuery, Map.of("ids", orgIds));
        var res = template().postForEntity(properties.getUrl(), req, MultiOrg.class);
        logErrors("getOrgEnheter", res.getBody());
        return requireNonNull(res.getBody()).getData().getOrgEnheter().stream()
                .map(MultiOrg.DataWrapper.OrgEnhetWrapper::getOrgEnhet)
                .toList();
    }

    public Optional<ResourceUnitsResponse> getUnits(String navIdent) {
        return getRessurs(navIdent)
                .map(r -> ResourceUnitsResponse.from(r, getLeaderMembersActiveOnly(navIdent), this::getOrgEnhet));
    }

    public Map<String, RessursDto> getRessurser(List<String> navIdents) {
        return ressursCache.getAll(navIdents, idents -> {
            var req = new GraphQLRequest(getResourceQuery, Map.of("navIdenter", idents));
            var res = template().postForEntity(properties.getUrl(), req, MultiRessurs.class);
            logErrors("getDepartments", res.getBody());
            return requireNonNull(res.getBody()).getData().getRessurserAsMap();
        });
    }

    public Map<String, OrgEnhetDto> getOrgWithOrganiseringOver(String nomId) {
        return orgOverCache.getAll(Collections.singleton(nomId), id -> {
            var req = new GraphQLRequest(getOrgOverQuery, Map.of("nomId", nomId));
            var res = template().postForEntity(properties.getUrl(), req, SingleOrg.class);
            logErrors("getOrgOver", res.getBody());
            return Map.of(requireNonNull(res.getBody()).getData().getOrgEnhet().getId(), res.getBody().getData().getOrgEnhet());
        });
    }

    public OrgEnhetDto getOrgEnhetMedUnderOrganiseringOgLedere(String nomId) {
        return orgUnderWithLeaderCache.get(nomId, id -> {
            var req = new GraphQLRequest(getOrgEnheterWithLederOrganiseringUnder, Map.of("id", nomId));
            var res = template().postForEntity(properties.getUrl(), req, SingleOrg.class);
            logErrors("getOrgEnheterWithLederOrganiseringUnder", res.getBody());
            return requireNonNull(res.getBody()).getData().getOrgEnhet();
        });
    }

    public List<String> getLeaderMembersActiveOnly(String navIdent) {
        var nomClient = NomClient.getInstance();
        return getLeaderMembers(navIdent).stream()
                .map(nomClient::getByNavIdent)
                .filter(Optional::isPresent).map(Optional::get)
                .filter(it -> !it.isInactive()).map(Resource::getNavIdent).toList();
    }

    public List<String> getLeaderMembers(String navIdent) {
        return leaderCache.get(navIdent, ident -> {
            var req = new GraphQLRequest(getLeaderMemberQuery, Map.of("navident", navIdent));
            var res = template().postForEntity(properties.getUrl(), req, SingleRessurs.class);
            logErrors("getLeaderMembers", res.getBody());
            var orgenheter = Optional.ofNullable(res.getBody())
                    .map(SingleRessurs::getData)
                    .map(SingleRessurs.DataWrapper::getRessurs)
                    .stream()
                    .map(RessursDto::getLederFor)
                    .flatMap(Collection::stream)
                    .map(LederOrgEnhetDto::getOrgEnhet)
                    .filter(org -> DateUtil.isNow(org.getGyldigFom(), org.getGyldigTom())).toList();

            var directMembers = new ArrayList<String>();
            for (var org : orgenheter) {
                var refId = org.getId();
                var ressurser = org.getKoblinger().stream().map(OrgEnhetsKoblingDto::getRessurs);
                var okRessurser = ressurser.filter(it -> !it.getNavident().equals(navIdent) && this.ressursHarEnRelevantOrgtilknytning(it, refId));
                directMembers.addAll(okRessurser.map(RessursDto::getNavident).filter(Objects::nonNull).toList());
            }

            var subDepMembers = orgenheter.stream()
                    .map(OrgEnhetDto::getOrganiseringer)
                    .flatMap(Collection::stream)
                    .map(OrganiseringDto::getOrgEnhet)
                    .map(OrgEnhetDto::getLeder)
                    .flatMap(Collection::stream)
                    .map(OrgEnhetsLederDto::getRessurs)
                    .map(RessursDto::getNavident)
                    .filter(Objects::nonNull)
                    .filter(id -> !id.equals(navIdent))
                    .toList();


            var x = UUID.randomUUID();
            log.debug("{}: getLeaderMembers for {}: orgenheter size {}, directMembers size {}, subDepartmentMembers size {}",x, navIdent, orgenheter.size(), directMembers.size(), subDepMembers);
            log.debug("{}\n{}",x,res.getBody().toString());
            return Stream.concat(directMembers.stream(), subDepMembers.stream())
                    .distinct()
                    .toList();
        });
    }

    public Optional<ResourceUnitsResponse> getLeaderMembersActiveOnlyV2(String navident, boolean includeMembers) {
        var nomClient = NomClient.getInstance();
        List<String> resources = includeMembers ? getNavidenterUnderLeaderByLeaderByNavident(navident).stream()
                .filter(ident -> !ident.equals(navident))
                .map(nomClient::getByNavIdent)
                .filter(Optional::isPresent).map(Optional::get)
                .filter(it -> !it.isInactive()).map(Resource::getNavIdent).toList()
                : Collections.emptyList();
        return getRessurs(navident).map(r -> ResourceUnitsResponse.from(r, resources, this::getOrgEnhet));
    }

    private List<String> getNavidenterUnderLeaderByLeaderByNavident(String navident) {
        return leaderHeleHierarkietOgAnsatteCache.get(navident, ident -> {
            Set<String> navidenter = new HashSet<>();
            var req = new GraphQLRequest(getHeleHierarkietTilLederOgOrgtilknytningerQuery, Map.of("navident", ident));
            var res = template().postForEntity(properties.getUrl(), req, SingleRessurs.class);
            logErrors("getOrgEnhetIdByLeaderByNavident", res.getBody());
            var lederForList = requireNonNull(res.getBody()).getData().getRessurs().getLederFor();
            log.info("getOrgEnhetIdByLeaderByNavident {}", lederForList);
            var orgEnheterLederFor = lederForList.stream()
                    .map(LederOrgEnhetDto::getOrgEnhet)
                    .toList();
            orgEnheterLederFor.forEach(orgEnhetDto -> findNavidenter(orgEnhetDto, navidenter));
            return new ArrayList<>(navidenter);
        });
    }

    private void findNavidenter(OrgEnhetDto orgEnhet, Set<String> navidenter) {
        List<OrgEnhetDto> underOrgEnheter = isNull(orgEnhet.getOrganiseringer()) ? Collections.emptyList()
                : orgEnhet.getOrganiseringer().stream()
                .map(OrganiseringDto::getOrgEnhet)
                .filter(Objects::nonNull)
                .toList();

        if (!underOrgEnheter.isEmpty()) {
            underOrgEnheter.forEach(orgEnhetDto -> findNavidenter(orgEnhetDto, navidenter));
            var navidentUnderheter = underOrgEnheter.stream()
                    .map(OrgEnhetDto::getOrgTilknytninger)
                    .flatMap(Collection::stream)
                    .map(OrgTilknytningDto::getRessurs)
                    .map(RessursDto::getNavident)
                    .collect(Collectors.toSet());

            navidenter.addAll(navidentUnderheter);
        } else {
            navidenter.addAll(
                    orgEnhet.getOrgTilknytninger().stream()
                            .map(OrgTilknytningDto::getRessurs)
                            .map(RessursDto::getNavident)
                            .collect(Collectors.toSet())
            );
        }
    }

    @SneakyThrows
    public void logErrors(String query, @Nullable Object body) {
        if (body == null) {
            return;
        }
        Method getErrors = requireNonNull(ReflectionUtils.findMethod(body.getClass(), "getErrors"));
        var errors = Optional.ofNullable((ArrayNode) getErrors.invoke(body));
        errors.ifPresent(errorArray -> log.error("Error during graphql query {} {}", query, JsonUtils.toJson(errorArray)));
    }

    private RestOperations template() {
        if (restTemplate == null) {
            restTemplate = restTemplateBuilder
                    .additionalInterceptors(correlationInterceptor(), tokenInterceptor())
                    .messageConverters(new JacksonJsonHttpMessageConverter())
                    .build();
        }
        return restTemplate;
    }

    private boolean ressursHarEnRelevantOrgtilknytning(RessursDto ressursDto, String akseptertOrgenhetId){
        var out = false;
        if (ressursDto.getOrgTilknytninger() != null) {
            for (var orgTilknytning : ressursDto.getOrgTilknytninger()) {
                var idErRelevant = orgTilknytning.getOrgEnhet().getId().equals(akseptertOrgenhetId);
                if (idErRelevant && orgTilknytning.getErDagligOppfolging()) {
                    var intervallOkBefore = orgTilknytning.getGyldigFom().isBefore(LocalDate.now().plusDays(1));
                    var intervallOkAfter = (orgTilknytning.getGyldigTom() == null || LocalDate.now().minusDays(1).isBefore(orgTilknytning.getGyldigTom()));
                    out |= intervallOkBefore && intervallOkAfter;
                }
            }
        }
        return out;
    }

    @SneakyThrows
    private ClientHttpRequestInterceptor tokenInterceptor() {
        return (request, body, execution) -> {
            if (securityProperties.isEnabled()){
                String token = tokenProvider.getConsumerToken(getScope());
                log.debug("tokenInterceptor adding token: %s... for scope '%s'".formatted( (token != null && token.length() > 12 ? token.substring(0,11) : token ), getScope()));
                request.getHeaders().add(HttpHeaders.AUTHORIZATION, token);
            }
            return execution.execute(request, body);
        };
    }

    private String getScope() {
        return scopeTemplate.formatted(securityProperties.isDev() ? "dev" : "prod");
    }

    public List<String> searchForNavidentByName(String name) {
        var req = new GraphQLRequest(searchForRessurs, Map.of("term", name));
        var resJsonList = template().postForEntity(properties.getUrl(), req, ObjectNode.class);
        var body = resJsonList.getBody();

        { // check for errors
            var hasHttpError = !resJsonList.getStatusCode().is2xxSuccessful();
            if(hasHttpError){
                throw new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed searchForNavidentByName via nom-graphql");
            }
            var maybeGraphQlErrors = body == null ? null : body.get("errors");
            var hasGraphQlError = maybeGraphQlErrors != null && !maybeGraphQlErrors.isEmpty();
            if (hasGraphQlError) {
                throw new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed searchForNavidentByName via nom-graphql");
            }
        }
        if(body == null){
            return List.of();
        }
        var data = Optional.ofNullable(body.get("data")).map(dataNode -> dataNode.get("searchRessurs")).orElse(null);
        if(data == null){
            return List.of();
        }
        return data.valueStream().map(it -> Optional.ofNullable(it.get("navident")).map(JsonNode::asText)).filter(Optional::isPresent).map(Optional::get).toList();
    }
}