diff --git a/.github/workflows/aam-backend-service-build-and-publish.yml b/.github/workflows/aam-backend-service-build-and-publish.yml index 101d944..6de5584 100644 --- a/.github/workflows/aam-backend-service-build-and-publish.yml +++ b/.github/workflows/aam-backend-service-build-and-publish.yml @@ -8,8 +8,6 @@ on: push: tags: - "*" - branches: - - main paths: - '.github/workflows/aam-backend-service-build-and-publish.yml' - 'application/aam-backend-service/**' @@ -148,7 +146,6 @@ jobs: with: platforms: ${{ matrix.platform }} context: ./application/aam-backend-service - target: build labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha diff --git a/application/aam-backend-service/build.gradle.kts b/application/aam-backend-service/build.gradle.kts index 1719a2a..9e4ce7a 100644 --- a/application/aam-backend-service/build.gradle.kts +++ b/application/aam-backend-service/build.gradle.kts @@ -54,7 +54,6 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") // needed in some tests runtimeOnly("org.postgresql:postgresql:42.7.4") - runtimeOnly("com.h2database:h2") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") @@ -92,6 +91,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.springframework.security:spring-security-test") + testImplementation(kotlin("test")) } jacoco { diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/Application.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/Application.kt index 12475fe..ac445f9 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/Application.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/Application.kt @@ -5,6 +5,7 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication import org.springframework.data.jpa.repository.config.EnableJpaRepositories import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController import java.util.* @@ -13,11 +14,11 @@ import java.util.* @ConfigurationPropertiesScan @EnableScheduling @EnableJpaRepositories +@EnableMethodSecurity class Application fun main(args: Array) { TimeZone.setDefault(TimeZone.getTimeZone("UTC")) - runApplication(*args) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/DefaultCouchDbClient.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/DefaultCouchDbClient.kt index 68cac6f..bcf2d7c 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/DefaultCouchDbClient.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/DefaultCouchDbClient.kt @@ -24,8 +24,7 @@ class DefaultCouchDbClient( ) : CouchDbClient { private val logger = LoggerFactory.getLogger(javaClass) - - + enum class DefaultCouchDbClientErrorCode : AamErrorCode { INVALID_RESPONSE, PARSING_ERROR, diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt index e8906e3..3141671 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt @@ -9,7 +9,10 @@ import org.springframework.data.repository.CrudRepository import org.springframework.stereotype.Repository import java.util.* -@Entity +/** + * Store latest sync sequence for each database for change detection + */ +@Entity(name = "couchdb_sync_entry") data class SyncEntry( @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/di/ReportCalculationEventListener.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/di/ReportCalculationEventListener.kt index 71b8315..6507d1c 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/di/ReportCalculationEventListener.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/di/ReportCalculationEventListener.kt @@ -5,7 +5,7 @@ import com.aamdigital.aambackendservice.reporting.domain.event.ReportCalculation import com.aamdigital.aambackendservice.reporting.reportcalculation.core.ReportCalculationRequest import com.aamdigital.aambackendservice.reporting.reportcalculation.di.ReportCalculationQueueConfiguration.Companion.REPORT_CALCULATION_EVENT_QUEUE import com.aamdigital.aambackendservice.reporting.reportcalculation.usecase.DefaultReportCalculationUseCase -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.databind.ObjectMapper import io.micrometer.observation.Observation import io.micrometer.observation.ObservationRegistry import org.slf4j.LoggerFactory @@ -28,7 +28,8 @@ import org.springframework.stereotype.Component ) class ReportCalculationEventListener( val observationRegistry: ObservationRegistry, - val reportCalculationUseCase: DefaultReportCalculationUseCase + val reportCalculationUseCase: DefaultReportCalculationUseCase, + val objectMapper: ObjectMapper, ) { private val logger = LoggerFactory.getLogger(javaClass) @@ -63,7 +64,7 @@ class ReportCalculationEventListener( response.cause ) - is UseCaseOutcome.Success -> logger.trace(jacksonObjectMapper().writeValueAsString(response)) + is UseCaseOutcome.Success -> logger.trace(objectMapper.writeValueAsString(response)) } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/rest/ObjectMapperConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/rest/ObjectMapperConfiguration.kt new file mode 100644 index 0000000..50de803 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/rest/ObjectMapperConfiguration.kt @@ -0,0 +1,35 @@ +package com.aamdigital.aambackendservice.rest + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter + + +@Configuration +class ObjectMapperConfiguration { + + @Bean + @Primary + fun objectMapper(): ObjectMapper { + val mapper = Jackson2ObjectMapperBuilder() + mapper.featuresToEnable( + DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, + DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE + ) + return mapper.build() + } + + @Bean + fun mappingJackson2HttpMessageConverter(): MappingJackson2HttpMessageConverter { + val builder = Jackson2ObjectMapperBuilder() + builder.featuresToEnable( + DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, + DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE + ) + return MappingJackson2HttpMessageConverter(builder.build()) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/AamAuthenticationConverter.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/AamAuthenticationConverter.kt new file mode 100644 index 0000000..e909d6b --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/AamAuthenticationConverter.kt @@ -0,0 +1,35 @@ +package com.aamdigital.aambackendservice.security + +import org.springframework.core.convert.converter.Converter +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken +import org.springframework.stereotype.Component + +@Component +class AamAuthenticationConverter : Converter { + override fun convert(source: Jwt): AbstractAuthenticationToken { + return JwtAuthenticationToken(source, getClientAuthorities(source)) + } + + private fun getClientAuthorities(jwt: Jwt): Collection { + val realmAccessClaim = jwt.getClaimAsMap("realm_access") ?: return emptyList() + + val roles: List = if (realmAccessClaim.containsKey("roles")) { + when (val rolesClaim = realmAccessClaim["roles"]) { + is List<*> -> rolesClaim.filterIsInstance() + else -> emptyList() + } + } else { + emptyList() + } + + return roles + .filter { it.startsWith("aam_") } + .map { + SimpleGrantedAuthority("ROLE_$it") + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt index 5d66c74..aa9a7b4 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt @@ -1,6 +1,6 @@ package com.aamdigital.aambackendservice.security -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpMethod @@ -16,7 +16,11 @@ import org.springframework.security.web.SecurityFilterChain class SecurityConfiguration { @Bean - fun filterChain(http: HttpSecurity): SecurityFilterChain { + fun filterChain( + http: HttpSecurity, + aamAuthenticationConverter: AamAuthenticationConverter, + objectMapper: ObjectMapper, + ): SecurityFilterChain { http { authorizeRequests { authorize(HttpMethod.GET, "/", permitAll) @@ -40,22 +44,20 @@ class SecurityConfiguration { authenticationEntryPoint = AamAuthenticationEntryPoint( parentEntryPoint = BearerTokenAuthenticationEntryPoint(), - objectMapper = jacksonObjectMapper() + objectMapper = objectMapper ) } oauth2ResourceServer { jwt { + jwtAuthenticationConverter = aamAuthenticationConverter authenticationEntryPoint = AamAuthenticationEntryPoint( parentEntryPoint = BearerTokenAuthenticationEntryPoint(), - objectMapper = jacksonObjectMapper() + objectMapper = objectMapper ) } } } return http.build() } - - } - diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/controller/SkillAdminController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/controller/SkillAdminController.kt new file mode 100644 index 0000000..e09221a --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/controller/SkillAdminController.kt @@ -0,0 +1,106 @@ +package com.aamdigital.aambackendservice.skill.controller + +import com.aamdigital.aambackendservice.error.HttpErrorDto +import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesRequest +import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesUseCase +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileSyncRepository +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.time.Instant +import java.time.ZoneOffset +import kotlin.jvm.optionals.getOrElse + +data class SkillDto( + val projectId: String, + val latestSync: String, +) + +enum class SyncModeDto { + DELTA, + FULL, +} + +@RestController +@RequestMapping("/v1/skill") +@ConditionalOnProperty( + prefix = "features.skill-api", + name = ["mode"], + havingValue = "skilllab", + matchIfMissing = false +) +class SkillAdminController( + private val skillLabFetchUserProfileUpdatesUseCase: FetchUserProfileUpdatesUseCase, + private val skillLabUserProfileSyncRepository: SkillLabUserProfileSyncRepository, +) { + + private val logger = LoggerFactory.getLogger(javaClass) + + @GetMapping("/sync") + @PreAuthorize("hasAuthority('ROLE_skill_admin')") + fun fetchSyncStatus(): ResponseEntity> { + val result = skillLabUserProfileSyncRepository.findAll().mapNotNull { + SkillDto( + projectId = it.projectId, + latestSync = it.latestSync.toString() + ) + } + + return ResponseEntity.ok().body(result) + } + + /** + * Fetch data from external system and sync data. + * For details of parameters like syncMode, see docs/api-specs/skill-api-v1.yaml + */ + @PostMapping("/sync/{projectId}") + @PreAuthorize("hasAuthority('ROLE_skill_admin')") + fun triggerSync( + @PathVariable projectId: String, + syncMode: SyncModeDto = SyncModeDto.DELTA, + updatedFrom: String? = null, + ): ResponseEntity { + + val result = skillLabUserProfileSyncRepository.findByProjectId(projectId).getOrElse { + return ResponseEntity.notFound().build() + } + + when (syncMode) { + SyncModeDto.DELTA -> if (!updatedFrom.isNullOrBlank()) { + result.latestSync = Instant.parse(updatedFrom).atOffset(ZoneOffset.UTC) + skillLabUserProfileSyncRepository.save(result) + } + + SyncModeDto.FULL -> skillLabUserProfileSyncRepository.delete(result) + } + + try { + skillLabFetchUserProfileUpdatesUseCase.run( + request = FetchUserProfileUpdatesRequest( + projectId = projectId + ) + ) + } catch (ex: Exception) { + logger.error( + "[${this.javaClass.name}] An error occurred: {}", + ex.localizedMessage, + ex + ) + return ResponseEntity.internalServerError().body( + HttpErrorDto( + errorCode = "INTERNAL_SERVER_ERROR", + errorMessage = ex.localizedMessage, + ) + ) + } + + + return ResponseEntity.noContent().build() + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/controller/SkillController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/controller/SkillController.kt new file mode 100644 index 0000000..9582862 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/controller/SkillController.kt @@ -0,0 +1,156 @@ +package com.aamdigital.aambackendservice.skill.controller + +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +import com.aamdigital.aambackendservice.error.HttpErrorDto +import com.aamdigital.aambackendservice.skill.core.SearchUserProfileData +import com.aamdigital.aambackendservice.skill.core.SearchUserProfileRequest +import com.aamdigital.aambackendservice.skill.core.SearchUserProfileUseCase +import com.aamdigital.aambackendservice.skill.domain.EscoSkill +import com.aamdigital.aambackendservice.skill.domain.SkillUsage +import com.aamdigital.aambackendservice.skill.domain.UserProfile +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileRepository +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +data class PaginationDto( + val currentPage: Int, + val pageSize: Int, + val totalPages: Int, + val totalElements: Int, +) + +data class FetchUserProfilesDto( + val pagination: PaginationDto, + val results: List, +) + +@RestController +@RequestMapping("/v1/skill") +@ConditionalOnProperty( + prefix = "features.skill-api", + name = ["mode"], + havingValue = "skilllab", + matchIfMissing = false +) +class SkillController( + private val searchUserProfileUseCase: SearchUserProfileUseCase, + private val userProfileRepository: SkillLabUserProfileRepository, + private val objectMapper: ObjectMapper, +) { + + companion object { + private const val MAX_PAGE_SIZE = 100 + } + + @GetMapping("/user-profile") + @PreAuthorize("hasAnyAuthority('ROLE_aam_skill_reader')") + fun fetchUserProfiles( + fullName: String = "", + email: String = "", + phone: String = "", + page: Int = 1, + pageSize: Int = 10, + ): ResponseEntity { + if (page < 1) { + return getBadRequestResponse(message = "Page must be greater than 0") + } + + if (pageSize < 1) { + return getBadRequestResponse(message = "Page size must not be less than one") + } + + if (pageSize > MAX_PAGE_SIZE) { + return getBadRequestResponse(message = "Max pageSize limit is $MAX_PAGE_SIZE") + } + + val result = searchUserProfileUseCase.run( + request = SearchUserProfileRequest( + fullName = fullName, + email = email, + phone = phone, + page = page, + pageSize = pageSize + ), + ) + + return when (result) { + is UseCaseOutcome.Failure<*> -> { + when (result.errorCode) { + else -> ResponseEntity.badRequest().body( + ResponseEntity.internalServerError().body( + HttpErrorDto( + errorCode = result.errorCode.toString(), + errorMessage = result.errorMessage + ) + ) + ) + } + ResponseEntity.badRequest().body( + result.errorMessage + ) + } + + is UseCaseOutcome.Success -> ResponseEntity.ok().body( + FetchUserProfilesDto( + pagination = PaginationDto( + currentPage = page, + pageSize = pageSize, + totalElements = result.data.totalElements, + totalPages = result.data.totalPages, + ), + results = result.data.result + ) + ) + } + } + + @GetMapping("/user-profile/{id}") + @PreAuthorize("hasAuthority('ROLE_aam_skill_reader')") + fun fetchUserProfile( + @PathVariable id: String, + ): ResponseEntity { + return if (!userProfileRepository.existsByExternalIdentifier(id)) { + ResponseEntity.status(HttpStatus.NOT_FOUND).body( + HttpErrorDto( + errorCode = "NOT_FOUND", + errorMessage = "No UserProfile found with this id: $id" + ) + ) + } else { + val entity = userProfileRepository.findByExternalIdentifier(id) + ResponseEntity.ok().body( + UserProfile( + id = entity.externalIdentifier, + fullName = entity.fullName, + phone = entity.mobileNumber, + email = entity.email, + skills = entity.skills.map { + EscoSkill( + usage = objectMapper.convertValue(it.usage.uppercase(), SkillUsage::class.java), + escoUri = it.escoUri + ) + }, + updatedAtExternalSystem = entity.updatedAt, + importedAt = entity.importedAt?.toInstant(), + latestSyncAt = entity.latestSyncAt?.toInstant(), + ) + ) + } + } + + private fun getBadRequestResponse(message: String): ResponseEntity { + return ResponseEntity.badRequest().body( + HttpErrorDto( + errorCode = "BAD_REQUEST", + errorMessage = message + ) + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/DefaultUserProfileUpdateConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/DefaultUserProfileUpdateConsumer.kt new file mode 100644 index 0000000..26dc089 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/DefaultUserProfileUpdateConsumer.kt @@ -0,0 +1,66 @@ +package com.aamdigital.aambackendservice.skill.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.queue.core.QueueMessageParser +import com.aamdigital.aambackendservice.skill.core.event.UserProfileUpdateEvent +import com.aamdigital.aambackendservice.skill.di.UserProfileUpdateEventQueueConfiguration.Companion.USER_PROFILE_UPDATE_QUEUE +import com.rabbitmq.client.Channel +import org.slf4j.LoggerFactory +import org.springframework.amqp.AmqpRejectAndDontRequeueException +import org.springframework.amqp.core.Message +import org.springframework.amqp.rabbit.annotation.RabbitListener + +open class DefaultUserProfileUpdateConsumer( + private val messageParser: QueueMessageParser, + private val syncUserProfileUseCase: SyncUserProfileUseCase, +) : UserProfileUpdateConsumer { + + private val logger = LoggerFactory.getLogger(javaClass) + + @RabbitListener( + queues = [USER_PROFILE_UPDATE_QUEUE], + concurrency = "1-1" + ) + override fun consume(rawMessage: String, message: Message, channel: Channel) { + val type = try { + messageParser.getTypeKClass(rawMessage.toByteArray()) + } catch (ex: AamException) { + throw AmqpRejectAndDontRequeueException("[${ex.code}] ${ex.localizedMessage}", ex) + } + + when (type.qualifiedName) { + UserProfileUpdateEvent::class.qualifiedName -> { + val payload = messageParser.getPayload( + body = rawMessage.toByteArray(), + kClass = UserProfileUpdateEvent::class + ) + try { + syncUserProfileUseCase.run( + SyncUserProfileRequest( + userProfile = DomainReference(payload.userProfileId), + project = DomainReference(payload.projectId), + ) + ) + } catch (ex: Exception) { + throw AmqpRejectAndDontRequeueException( + "[USECASE_ERROR] ${ex.localizedMessage}", + ex + ) + } + return + } + + else -> { + logger.warn( + "[DefaultUserProfileUpdateConsumer] Could not find any use case for this EventType: {}", + type.qualifiedName, + ) + + throw AmqpRejectAndDontRequeueException( + "[NO_USECASE_CONFIGURED] Could not found matching use case for: ${type.qualifiedName}", + ) + } + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/DefaultUserProfileUpdatePublisher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/DefaultUserProfileUpdatePublisher.kt new file mode 100644 index 0000000..4e67c97 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/DefaultUserProfileUpdatePublisher.kt @@ -0,0 +1,60 @@ +package com.aamdigital.aambackendservice.skill.core + +import com.aamdigital.aambackendservice.error.AamErrorCode +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.error.InternalServerException +import com.aamdigital.aambackendservice.queue.core.QueueMessage +import com.aamdigital.aambackendservice.skill.core.event.UserProfileUpdateEvent +import com.fasterxml.jackson.databind.ObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.amqp.AmqpException +import org.springframework.amqp.rabbit.core.RabbitTemplate +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.* + +class DefaultUserProfileUpdatePublisher( + private val objectMapper: ObjectMapper, + private val rabbitTemplate: RabbitTemplate, +) : UserProfileUpdatePublisher { + + enum class DefaultUserProfileUpdatePublisherErrorCode : AamErrorCode { + EVENT_PUBLISH_ERROR + } + + private val logger = LoggerFactory.getLogger(javaClass) + + @Throws(AamException::class) + override fun publish(channel: String, event: UserProfileUpdateEvent): QueueMessage { + val message = QueueMessage( + id = UUID.randomUUID(), + eventType = UserProfileUpdateEvent::class.java.canonicalName, + event = event, + createdAt = Instant.now() + .atOffset(ZoneOffset.UTC) + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + ) + + try { + rabbitTemplate.convertAndSend( + channel, + objectMapper.writeValueAsString(message) + ) + } catch (ex: AmqpException) { + throw InternalServerException( + message = "Could not publish UserProfileUpdateEvent: $event", + code = DefaultUserProfileUpdatePublisherErrorCode.EVENT_PUBLISH_ERROR, + cause = ex + ) + } + + logger.trace( + "[DefaultNotificationEventPublisher]: publish message to channel '{}' Payload: {}", + channel, + objectMapper.writeValueAsString(message) + ) + + return message + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/FetchUserProfileUpdatesUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/FetchUserProfileUpdatesUseCase.kt new file mode 100644 index 0000000..a985f43 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/FetchUserProfileUpdatesUseCase.kt @@ -0,0 +1,20 @@ +package com.aamdigital.aambackendservice.skill.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.domain.DomainUseCase +import com.aamdigital.aambackendservice.domain.UseCaseData +import com.aamdigital.aambackendservice.domain.UseCaseRequest + +data class FetchUserProfileUpdatesRequest( + val projectId: String, +) : UseCaseRequest + +/** + * result will be a list of user profiles with updates available. + */ +data class FetchUserProfileUpdatesData( + val result: List +) : UseCaseData + +abstract class FetchUserProfileUpdatesUseCase : + DomainUseCase() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SearchUserProfileUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SearchUserProfileUseCase.kt new file mode 100644 index 0000000..3649e46 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SearchUserProfileUseCase.kt @@ -0,0 +1,23 @@ +package com.aamdigital.aambackendservice.skill.core + +import com.aamdigital.aambackendservice.domain.DomainUseCase +import com.aamdigital.aambackendservice.domain.UseCaseData +import com.aamdigital.aambackendservice.domain.UseCaseRequest +import com.aamdigital.aambackendservice.skill.domain.UserProfile + +data class SearchUserProfileRequest( + val fullName: String?, + val email: String?, + val phone: String?, + val page: Int, + val pageSize: Int, +) : UseCaseRequest + +data class SearchUserProfileData( + val result: List, + val totalElements: Int, + val totalPages: Int, +) : UseCaseData + +abstract class SearchUserProfileUseCase : + DomainUseCase() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SqlSearchUserProfileUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SqlSearchUserProfileUseCase.kt new file mode 100644 index 0000000..3c3568f --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SqlSearchUserProfileUseCase.kt @@ -0,0 +1,184 @@ +package com.aamdigital.aambackendservice.skill.core + +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +import com.aamdigital.aambackendservice.skill.domain.EscoSkill +import com.aamdigital.aambackendservice.skill.domain.SkillUsage +import com.aamdigital.aambackendservice.skill.domain.UserProfile +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileEntity +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileRepository +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.data.domain.Example +import org.springframework.data.domain.ExampleMatcher +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable + +/** + * Search for an SkillLabUserProfile with provided information from SearchUserProfileRequest + * + * 1. search for exact email matches + * 2. search for mobile phone matches + * 3. search for name + * + * The first exact match will be returned. Otherwise, a list of possible matches is returned + * + */ +class SqlSearchUserProfileUseCase( + private val userProfileRepository: SkillLabUserProfileRepository, + private val objectMapper: ObjectMapper, +) : SearchUserProfileUseCase() { + + companion object { + val MATCHER_EMAIL = ExampleMatcher.matchingAny() + .withIgnorePaths( + "id", + "externalIdentifier", + "fullName", + "mobileNumber", + "skills", + "updatedAt", + "latestSyncAt", + "importedAt", + ) + .withIgnoreCase() + .withIncludeNullValues() + .withStringMatcher(ExampleMatcher.StringMatcher.EXACT) + + val MATCHER_PHONE = ExampleMatcher.matchingAny() + .withIgnorePaths( + "id", + "externalIdentifier", + "fullName", + "email", + "skills", + "updatedAt", + "latestSyncAt", + "importedAt", + ) + .withIgnoreCase() + .withIncludeNullValues() + .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING) + + val MATCHER_NAME = ExampleMatcher.matchingAll() + .withIgnorePaths( + "id", + "externalIdentifier", + "mobileNumber", + "email", + "skills", + "updatedAt", + "latestSyncAt", + "importedAt", + ) + .withIgnoreCase() + .withIncludeNullValues() + .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING) + } + + override fun apply(request: SearchUserProfileRequest): UseCaseOutcome { + val userProfile = SkillLabUserProfileEntity( + id = 0, + externalIdentifier = "", + fullName = request.fullName, + mobileNumber = request.phone, + email = request.email, + skills = emptySet(), + updatedAt = "", + latestSyncAt = null, + importedAt = null, + ) + + var searchResults: Page + val pageable = Pageable.ofSize(request.pageSize).withPage(request.page - 1) + + // check for exact matches for email + if (!userProfile.email.isNullOrBlank()) { + searchResults = userProfileRepository.findAll( + Example.of(userProfile, MATCHER_EMAIL), pageable + ) + + if (!searchResults.isEmpty) { + return asSuccessResponse(searchResults) + } + } + + // check for matches for phone + if (!userProfile.mobileNumber.isNullOrBlank()) { + searchResults = userProfileRepository.findAll( + Example.of(userProfile, MATCHER_PHONE), pageable + ) + + if (!searchResults.isEmpty) { + return asSuccessResponse(searchResults) + } + } + + // check for name + searchResults = if (!userProfile.fullName.isNullOrBlank()) { + userProfileRepository.findAll( + Example.of(userProfile, MATCHER_NAME), pageable + ) + } else { + Page.empty() + } + + // search runs with just firstName/lastName + if (searchResults.isEmpty && !userProfile.fullName.isNullOrBlank()) { + val nameParts = userProfile.fullName?.split(" ") ?: listOf() + + if (nameParts.size < 2) { + return asSuccessResponse(searchResults) + } + + val searchNames = listOf(nameParts.first(), nameParts.last()) + val partNameSearchResults = mutableSetOf() + + searchNames.forEach { name -> + userProfile.fullName = name + partNameSearchResults.addAll( + userProfileRepository.findAll( + Example.of(userProfile, MATCHER_NAME), pageable + ).toList() + ) + } + + val result = partNameSearchResults.toList().take(request.pageSize) + + searchResults = PageImpl( + result, + ) + } + + return asSuccessResponse(searchResults) + } + + private fun asSuccessResponse( + results: Page, + ): UseCaseOutcome.Success = UseCaseOutcome.Success( + data = SearchUserProfileData( + result = results.toList().map { + toDto(it) + }, + totalElements = results.totalElements.toInt(), + totalPages = results.totalPages + ) + ) + + private fun toDto(it: SkillLabUserProfileEntity): UserProfile { + return UserProfile( + id = it.externalIdentifier, + fullName = it.fullName, + email = it.email, + phone = it.mobileNumber, + skills = it.skills.map { skill -> + EscoSkill( + usage = objectMapper.convertValue(skill.usage.uppercase(), SkillUsage::class.java), + escoUri = skill.escoUri + ) + }, + latestSyncAt = it.latestSyncAt?.toInstant(), + importedAt = it.importedAt?.toInstant(), + updatedAtExternalSystem = it.updatedAt + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SyncUserProfileUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SyncUserProfileUseCase.kt new file mode 100644 index 0000000..6fc2863 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SyncUserProfileUseCase.kt @@ -0,0 +1,19 @@ +package com.aamdigital.aambackendservice.skill.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.domain.DomainUseCase +import com.aamdigital.aambackendservice.domain.UseCaseData +import com.aamdigital.aambackendservice.domain.UseCaseRequest +import com.aamdigital.aambackendservice.skill.domain.UserProfile + +data class SyncUserProfileRequest( + val userProfile: DomainReference, + val project: DomainReference, +) : UseCaseRequest + +data class SyncUserProfileData( + val result: UserProfile +) : UseCaseData + +abstract class SyncUserProfileUseCase : + DomainUseCase() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/UserProfileUpdateConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/UserProfileUpdateConsumer.kt new file mode 100644 index 0000000..b77a09d --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/UserProfileUpdateConsumer.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.skill.core + +import com.rabbitmq.client.Channel +import org.springframework.amqp.core.Message + +interface UserProfileUpdateConsumer { + fun consume(rawMessage: String, message: Message, channel: Channel) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/UserProfileUpdatePublisher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/UserProfileUpdatePublisher.kt new file mode 100644 index 0000000..b5e25b0 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/UserProfileUpdatePublisher.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.skill.core + +import com.aamdigital.aambackendservice.queue.core.QueueMessage +import com.aamdigital.aambackendservice.skill.core.event.UserProfileUpdateEvent + +interface UserProfileUpdatePublisher { + fun publish(channel: String, event: UserProfileUpdateEvent): QueueMessage +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/event/UserProfileUpdateEvent.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/event/UserProfileUpdateEvent.kt new file mode 100644 index 0000000..7fb3753 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/event/UserProfileUpdateEvent.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.skill.core.event + +import com.aamdigital.aambackendservice.events.DomainEvent + +data class UserProfileUpdateEvent( + val projectId: String, + val userProfileId: String, +) : DomainEvent() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/di/SkillConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/di/SkillConfiguration.kt new file mode 100644 index 0000000..9059104 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/di/SkillConfiguration.kt @@ -0,0 +1,34 @@ +package com.aamdigital.aambackendservice.skill.di + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.ConfigurationProperties + + +/** + * Configures behaviour of the skill-api. + * + * @mode skilllab + * Use SkillLab as SkillProvider, needs skilllab-api-client-configuration + * + * @mode disabled + * Disable skill-api. Endpoints are not reachable, nothing is imported. + * + */ +@ConfigurationProperties("features.skill-api") +class FeatureConfigurationSkillApi( + val mode: String, +) + +@ConfigurationProperties("skilllab-api-client-configuration") +@ConditionalOnProperty( + prefix = "features.skill-api", + name = ["mode"], + havingValue = "skilllab", + matchIfMissing = false +) +class SkillLabApiClientConfiguration( + val basePath: String, + val apiKey: String, + val projectId: String, + val responseTimeoutInSeconds: Int = 30, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/di/SkillConfigurationSkillLab.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/di/SkillConfigurationSkillLab.kt new file mode 100644 index 0000000..6293e84 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/di/SkillConfigurationSkillLab.kt @@ -0,0 +1,91 @@ +package com.aamdigital.aambackendservice.skill.di + +import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesUseCase +import com.aamdigital.aambackendservice.skill.core.SearchUserProfileUseCase +import com.aamdigital.aambackendservice.skill.core.SqlSearchUserProfileUseCase +import com.aamdigital.aambackendservice.skill.core.SyncUserProfileUseCase +import com.aamdigital.aambackendservice.skill.core.UserProfileUpdatePublisher +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileRepository +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileSyncRepository +import com.aamdigital.aambackendservice.skill.skilllab.SkillLabClient +import com.aamdigital.aambackendservice.skill.skilllab.SkillLabFetchUserProfileUpdatesUseCase +import com.aamdigital.aambackendservice.skill.skilllab.SkillLabSyncUserProfileUseCase +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpHeaders +import org.springframework.http.client.SimpleClientHttpRequestFactory +import org.springframework.web.client.RestClient + + +@Configuration +@ConditionalOnProperty( + prefix = "features.skill-api", + name = ["mode"], + havingValue = "skilllab", + matchIfMissing = false +) +class SkillConfigurationSkillLab { + + @Bean(name = ["skilllab-api-client"]) + fun skillLabApiClient( + configuration: SkillLabApiClientConfiguration + ): RestClient { + val clientBuilder = RestClient.builder().baseUrl(configuration.basePath) + + clientBuilder.defaultRequest { request -> + request.headers { + it.set(HttpHeaders.AUTHORIZATION, "Bearer ${configuration.apiKey}") + } + } + + clientBuilder.requestFactory(SimpleClientHttpRequestFactory().apply { + setReadTimeout(configuration.responseTimeoutInSeconds * 1000) + setConnectTimeout(configuration.responseTimeoutInSeconds * 1000) + }) + + return clientBuilder.build() + } + + @Bean + fun skillLabClient( + @Qualifier("skilllab-api-client") restClient: RestClient, + objectMapper: ObjectMapper, + ): SkillLabClient = SkillLabClient( + http = restClient, + objectMapper = objectMapper, + ) + + @Bean + fun skillLabFetchUserProfileUpdatedUseCase( + skillLabClient: SkillLabClient, + skillLabUserProfileSyncRepository: SkillLabUserProfileSyncRepository, + userProfileUpdatePublisher: UserProfileUpdatePublisher, + ): FetchUserProfileUpdatesUseCase = SkillLabFetchUserProfileUpdatesUseCase( + skillLabClient = skillLabClient, + skillLabUserProfileSyncRepository = skillLabUserProfileSyncRepository, + userProfileUpdatePublisher = userProfileUpdatePublisher, + ) + + @Bean + fun skillLabSyncUserProfileUseCase( + skillLabClient: SkillLabClient, + skillLabUserProfileRepository: SkillLabUserProfileRepository, + objectMapper: ObjectMapper, + ): SyncUserProfileUseCase = SkillLabSyncUserProfileUseCase( + skillLabClient = skillLabClient, + skillLabUserProfileRepository = skillLabUserProfileRepository, + objectMapper = objectMapper, + ) + + @Bean + fun sqlSearchUserProfileUseCase( + skillLabUserProfileRepository: SkillLabUserProfileRepository, + objectMapper: ObjectMapper, + ): SearchUserProfileUseCase = SqlSearchUserProfileUseCase( + userProfileRepository = skillLabUserProfileRepository, + objectMapper = objectMapper, + ) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/di/UserProfileUpdateEventQueueConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/di/UserProfileUpdateEventQueueConfiguration.kt new file mode 100644 index 0000000..146d522 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/di/UserProfileUpdateEventQueueConfiguration.kt @@ -0,0 +1,49 @@ +package com.aamdigital.aambackendservice.skill.di + +import com.aamdigital.aambackendservice.queue.core.QueueMessageParser +import com.aamdigital.aambackendservice.skill.core.DefaultUserProfileUpdateConsumer +import com.aamdigital.aambackendservice.skill.core.DefaultUserProfileUpdatePublisher +import com.aamdigital.aambackendservice.skill.core.SyncUserProfileUseCase +import com.aamdigital.aambackendservice.skill.core.UserProfileUpdateConsumer +import com.aamdigital.aambackendservice.skill.core.UserProfileUpdatePublisher +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.amqp.core.Queue +import org.springframework.amqp.core.QueueBuilder +import org.springframework.amqp.rabbit.core.RabbitTemplate +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +@ConditionalOnProperty( + prefix = "features.skill-api", + name = ["mode"], + havingValue = "skilllab", + matchIfMissing = false +) +class UserProfileUpdateEventQueueConfiguration { + + companion object { + const val USER_PROFILE_UPDATE_QUEUE = "skill.userProfile.update" + } + + @Bean("user-profile-update-queue") + fun userProfileUpdateQueue(): Queue = QueueBuilder + .durable(USER_PROFILE_UPDATE_QUEUE) + .build() + + @Bean + fun defaultUserProfileUpdatePublisher( + objectMapper: ObjectMapper, + rabbitTemplate: RabbitTemplate, + ): UserProfileUpdatePublisher = DefaultUserProfileUpdatePublisher( + objectMapper = objectMapper, + rabbitTemplate = rabbitTemplate, + ) + + @Bean + fun defaultUserProfileUpdateConsumer( + messageParser: QueueMessageParser, + syncUserProfileUseCase: SyncUserProfileUseCase, + ): UserProfileUpdateConsumer = DefaultUserProfileUpdateConsumer(messageParser, syncUserProfileUseCase) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/domain/EscoSkill.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/domain/EscoSkill.kt new file mode 100644 index 0000000..0ea1a9a --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/domain/EscoSkill.kt @@ -0,0 +1,28 @@ +package com.aamdigital.aambackendservice.skill.domain + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue +import com.fasterxml.jackson.annotation.JsonProperty + +enum class SkillUsage { + ALMOST_NEVER, + SOMETIMES, + OFTEN, + ALMOST_ALWAYS, + ALWAYS, + + @JsonProperty("BI-WEEKLY") + BI_WEEKLY, + + @JsonEnumDefaultValue + UNKNOWN, +} + +/** + * Representation of an ESCO Skill + * see: https://esco.ec.europa.eu/en/classification/skill_main + */ +data class EscoSkill( + val escoUri: String, + val usage: SkillUsage, +) + diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/domain/UserProfile.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/domain/UserProfile.kt new file mode 100644 index 0000000..265de2d --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/domain/UserProfile.kt @@ -0,0 +1,17 @@ +package com.aamdigital.aambackendservice.skill.domain + +import java.time.Instant + +/** + * Representation of an Individual in an external system + */ +data class UserProfile( + var id: String, + var fullName: String?, + var phone: String?, + var email: String?, + var skills: List, + var updatedAtExternalSystem: String?, + var importedAt: Instant? = null, + var latestSyncAt: Instant? = null, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/job/SyncSkillsJob.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/job/SyncSkillsJob.kt new file mode 100644 index 0000000..bebc2eb --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/job/SyncSkillsJob.kt @@ -0,0 +1,55 @@ +package com.aamdigital.aambackendservice.skill.job + +import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesRequest +import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesUseCase +import com.aamdigital.aambackendservice.skill.di.SkillLabApiClientConfiguration +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.Scheduled + +/** + * Configures cron job for scheduled sync all SkillLab changes + */ +@Configuration +@ConditionalOnProperty( + prefix = "features.skill-api", + name = ["mode"], + havingValue = "skilllab", + matchIfMissing = false +) +class SyncSkillsJob( + private val skillLabFetchUserProfileUpdatesUseCase: FetchUserProfileUpdatesUseCase, + private val skillLabApiClientConfiguration: SkillLabApiClientConfiguration, +) { + + private val logger = LoggerFactory.getLogger(javaClass) + + companion object { + private var ERROR_COUNTER: Int = 0 + private var MAX_ERROR_COUNT: Int = 5 + } + + @Scheduled(fixedDelay = (60000 * 10)) // every 10 minutes + fun checkForSkillLabChanges() { + if (ERROR_COUNTER >= MAX_ERROR_COUNT) { + logger.trace("${this.javaClass.name}: MAX_ERROR_COUNT reached. Not starting job again.") + return + } + + try { + skillLabFetchUserProfileUpdatesUseCase.run( + request = FetchUserProfileUpdatesRequest( + projectId = skillLabApiClientConfiguration.projectId + ) + ) + } catch (ex: Exception) { + logger.error( + "[${this.javaClass.name}] An error occurred (count: $ERROR_COUNTER): {}", + ex.localizedMessage + ) + logger.debug("[${this.javaClass.name}] Debug information", ex) + ERROR_COUNTER += 1 + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileEntity.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileEntity.kt new file mode 100644 index 0000000..d8e9c72 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileEntity.kt @@ -0,0 +1,51 @@ +package com.aamdigital.aambackendservice.skill.repository + +import jakarta.persistence.Column +import jakarta.persistence.ElementCollection +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.SourceType +import org.hibernate.annotations.UpdateTimestamp +import java.time.OffsetDateTime + +@Entity +data class SkillLabUserProfileEntity( + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + val id: Long = 0, + + @Column(unique = true) + var externalIdentifier: String, + + @Column + var fullName: String?, + + @Column + var mobileNumber: String?, + + @Column + var email: String?, + + @ElementCollection(fetch = FetchType.EAGER) + var skills: Set, + + /** + * represents the latest update at skillLab + */ + @Column + var updatedAt: String?, + + /** latest update within our system */ + @UpdateTimestamp(source = SourceType.DB) + @Column(updatable = true) + var latestSyncAt: OffsetDateTime? = null, + + /** original date (first latestSyncAt) when added to our system */ + @CreationTimestamp(source = SourceType.DB) + @Column(updatable = true) + var importedAt: OffsetDateTime? = null, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileRepository.kt new file mode 100644 index 0000000..981d307 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileRepository.kt @@ -0,0 +1,15 @@ +package com.aamdigital.aambackendservice.skill.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.repository.PagingAndSortingRepository + + +/** + * JpaRepository and PagingAndSortingRepository interfaces provide all necessary query functions + * Check Docs for further information: https://docs.spring.io/spring-data/jpa/reference/jpa/getting-started.html + */ +interface SkillLabUserProfileRepository : JpaRepository, + PagingAndSortingRepository { + fun existsByExternalIdentifier(externalIdentifier: String): Boolean + fun findByExternalIdentifier(externalIdentifier: String): SkillLabUserProfileEntity +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileSyncEntity.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileSyncEntity.kt new file mode 100644 index 0000000..22c56cb --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileSyncEntity.kt @@ -0,0 +1,23 @@ +package com.aamdigital.aambackendservice.skill.repository + +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.Table +import java.time.OffsetDateTime + +/** + * Stores the latest successful SkillLabFetchUserProfileUpdateUseCase run date for each project. + */ +@Entity +@Table(indexes = [Index(columnList = "projectId", unique = true)]) +data class SkillLabUserProfileSyncEntity( + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + var id: Long = 0, + + var projectId: String, + var latestSync: OffsetDateTime, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileSyncRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileSyncRepository.kt new file mode 100644 index 0000000..74dec87 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileSyncRepository.kt @@ -0,0 +1,9 @@ +package com.aamdigital.aambackendservice.skill.repository + +import org.springframework.data.repository.CrudRepository +import java.util.* + +interface SkillLabUserProfileSyncRepository : CrudRepository { + + fun findByProjectId(projectId: String): Optional +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillReferenceEntity.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillReferenceEntity.kt new file mode 100644 index 0000000..880575f --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillReferenceEntity.kt @@ -0,0 +1,16 @@ +package com.aamdigital.aambackendservice.skill.repository + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +@Embeddable +data class SkillReferenceEntity( + @Column(unique = true) + var externalIdentifier: String, + + @Column + var escoUri: String, + + @Column + var usage: String, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabClient.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabClient.kt new file mode 100644 index 0000000..e27795d --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabClient.kt @@ -0,0 +1,154 @@ +package com.aamdigital.aambackendservice.skill.skilllab + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.AamErrorCode +import com.aamdigital.aambackendservice.error.ExternalSystemException +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.data.domain.Pageable +import org.springframework.http.MediaType +import org.springframework.web.client.RestClient +import java.util.* + +data class SkillLabErrorResponseDto( + var error: SkillLabErrorDetailsDto +) + +data class SkillLabErrorDetailsDto( + var code: Int, + var title: String = "", + var message: String = "", + var detail: String = "", +) + +data class SkillLabProfilesResponseDto( + val pagination: SkillLabPaginationDto, + val results: List, +) + +data class SkillLabProfileIdDto( + val id: String, +) + +data class SkillLabPaginationDto( + @JsonProperty("current_page") + val currentPage: Int, + @JsonProperty("per_page") + val perPage: Int, + @JsonProperty("total_entries") + val totalEntries: Int, +) + +data class SkillLabSkillDto( + var id: UUID = UUID.randomUUID(), + @JsonProperty("external_id") + val externalId: String, + val choice: String, +) + +data class SkillLabExperienceDto( + @JsonProperty("experiences_skills") + var experiencesSkills: MutableList, +) + +data class SkillLabProfileResponseDto( + val profile: SkillLabProfileDto, +) + +data class SkillLabProfileDto( + var id: String, + var city: String?, + var country: String?, + var projects: List = emptyList(), + @JsonProperty("mobile_number") + var mobileNumber: String?, + @JsonProperty("full_name") + var fullName: String?, + var email: String?, + @JsonProperty("street_and_house_number") + var streetAndHouseNumber: String?, + @JsonProperty("arrival_in_country") + var arrivalInCountry: String?, + var nationality: String?, + var gender: String?, + var birthday: String?, + @JsonProperty("gender_custom") + var genderCustom: String?, + var experiences: List = emptyList(), + @JsonProperty("updated_at") + var updatedAt: String?, +// var languages: List, +) + +enum class SkillLabUserProfileStorageAamErrorCode : AamErrorCode { + EMPTY_RESPONSE, + NOT_FOUND, + INTERNAL_SERVER_ERROR +} + +class SkillLabClient( + val http: RestClient, + val objectMapper: ObjectMapper, +) { + fun fetchUserProfile(externalIdentifier: DomainReference): SkillLabProfileResponseDto { + return http.get() + .uri("/profiles/${externalIdentifier.id}") + .accept(MediaType.APPLICATION_JSON) + .exchange { _, clientResponse -> + val response = clientResponse.bodyTo(String::class.java) ?: throw ExternalSystemException( + message = "Empty or invalid response from server", + code = SkillLabUserProfileStorageAamErrorCode.EMPTY_RESPONSE + ) + + if (clientResponse.statusCode.is2xxSuccessful) { + val skillLabResponse = + objectMapper.readValue(response, SkillLabProfileResponseDto::class.java) // todo try catch + + skillLabResponse + } else { + val error = objectMapper.readValue(response, SkillLabErrorResponseDto::class.java) + + throw ExternalSystemException( + message = error.error.message, + code = SkillLabUserProfileStorageAamErrorCode.INTERNAL_SERVER_ERROR + ) + + } + } + } + + fun fetchUserProfiles(pageable: Pageable, updatedFrom: String?): List { + val uri = if (updatedFrom.isNullOrBlank()) { + "/profiles?page=${pageable.pageNumber}&perPage=${pageable.pageSize}" + } else { + "/profiles?page=${pageable.pageNumber}&perPage=${pageable.pageSize}&updated_from=$updatedFrom" + } + + return http.get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .exchange { _, clientResponse -> + val response = clientResponse.bodyTo(String::class.java) ?: throw ExternalSystemException( + message = "Empty or invalid response from server", + code = SkillLabUserProfileStorageAamErrorCode.EMPTY_RESPONSE + ) + + if (clientResponse.statusCode.is2xxSuccessful) { + val skillLabResponse = objectMapper.readValue(response, SkillLabProfilesResponseDto::class.java) + skillLabResponse.results.map { profileDto -> + DomainReference( + id = profileDto.id, + ) + } + } else { + val error = objectMapper.readValue(response, SkillLabErrorResponseDto::class.java) + + throw ExternalSystemException( + message = error.error.message, + code = SkillLabUserProfileStorageAamErrorCode.INTERNAL_SERVER_ERROR + ) + + } + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabFetchUserProfileUpdatesUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabFetchUserProfileUpdatesUseCase.kt new file mode 100644 index 0000000..2d09bb3 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabFetchUserProfileUpdatesUseCase.kt @@ -0,0 +1,111 @@ +package com.aamdigital.aambackendservice.skill.skilllab + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +import com.aamdigital.aambackendservice.error.AamErrorCode +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesData +import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesRequest +import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesUseCase +import com.aamdigital.aambackendservice.skill.core.UserProfileUpdatePublisher +import com.aamdigital.aambackendservice.skill.core.event.UserProfileUpdateEvent +import com.aamdigital.aambackendservice.skill.di.UserProfileUpdateEventQueueConfiguration +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileSyncEntity +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileSyncRepository +import org.springframework.data.domain.Pageable +import java.time.Instant +import java.time.ZoneOffset +import kotlin.jvm.optionals.getOrNull + +enum class SkillLabFetchUserProfileUpdatesErrorCode : AamErrorCode { + EXTERNAL_SYSTEM_ERROR, EVENT_PUBLISH_ERROR +} + +/** + * Fetch latest changes for this SkillLab tenant and create SyncUserProfileEvents for each changed UserProfile + */ +class SkillLabFetchUserProfileUpdatesUseCase( + private val skillLabClient: SkillLabClient, + private val skillLabUserProfileSyncRepository: SkillLabUserProfileSyncRepository, + private val userProfileUpdatePublisher: UserProfileUpdatePublisher, +) : FetchUserProfileUpdatesUseCase() { + + companion object { + private const val PAGE_SIZE = 50 + private const val MAX_RESULTS_LIMIT = 10_000 + } + + override fun apply(request: FetchUserProfileUpdatesRequest): UseCaseOutcome { + val results = mutableListOf() + var currentSync = skillLabUserProfileSyncRepository.findByProjectId(request.projectId).getOrNull() + var page = 1 + + do { + val batch = try { + fetchNextBatch( + pageable = Pageable.ofSize(PAGE_SIZE).withPage(page++), + currentSync = currentSync, + ) + } catch (ex: AamException) { + return UseCaseOutcome.Failure( + errorCode = SkillLabFetchUserProfileUpdatesErrorCode.EXTERNAL_SYSTEM_ERROR, + errorMessage = ex.localizedMessage, + cause = ex, + ) + } + results.addAll(batch) + } while (batch.size >= PAGE_SIZE && results.size < MAX_RESULTS_LIMIT) + + results.forEach { + try { + userProfileUpdatePublisher.publish( + UserProfileUpdateEventQueueConfiguration.USER_PROFILE_UPDATE_QUEUE, + UserProfileUpdateEvent( + projectId = request.projectId, + userProfileId = it.id + ) + ) + } catch (ex: Exception) { + return UseCaseOutcome.Failure( + errorCode = SkillLabFetchUserProfileUpdatesErrorCode.EVENT_PUBLISH_ERROR, + errorMessage = ex.localizedMessage, + cause = ex, + ) + } + } + + if (currentSync != null) { + currentSync.latestSync = Instant.now().atOffset(ZoneOffset.UTC) + } else { + currentSync = SkillLabUserProfileSyncEntity( + projectId = request.projectId, + latestSync = Instant.now().atOffset(ZoneOffset.UTC), + ) + } + + skillLabUserProfileSyncRepository.save(currentSync) + + return UseCaseOutcome.Success( + data = FetchUserProfileUpdatesData( + result = results.map { + DomainReference(id = it.id) + } + ) + ) + } + + private fun fetchNextBatch( + pageable: Pageable, + currentSync: SkillLabUserProfileSyncEntity?, + ): List = if (currentSync == null) { + skillLabClient.fetchUserProfiles( + pageable = pageable, + updatedFrom = null + ) + } else { + skillLabClient.fetchUserProfiles( + pageable = pageable, + updatedFrom = currentSync.latestSync.toString() + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabSyncUserProfileUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabSyncUserProfileUseCase.kt new file mode 100644 index 0000000..2e8f0b5 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabSyncUserProfileUseCase.kt @@ -0,0 +1,114 @@ +package com.aamdigital.aambackendservice.skill.skilllab + +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +import com.aamdigital.aambackendservice.error.AamErrorCode +import com.aamdigital.aambackendservice.skill.core.SyncUserProfileData +import com.aamdigital.aambackendservice.skill.core.SyncUserProfileRequest +import com.aamdigital.aambackendservice.skill.core.SyncUserProfileUseCase +import com.aamdigital.aambackendservice.skill.domain.EscoSkill +import com.aamdigital.aambackendservice.skill.domain.SkillUsage +import com.aamdigital.aambackendservice.skill.domain.UserProfile +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileEntity +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileRepository +import com.aamdigital.aambackendservice.skill.repository.SkillReferenceEntity +import com.fasterxml.jackson.databind.ObjectMapper + +enum class SkillLabSyncUserProfileErrorCode : AamErrorCode { + IO_ERROR +} + +/** + * Will load a specific UserProfile from SkillLab, store it to the database and returns the profile afterwards + */ +class SkillLabSyncUserProfileUseCase( + private val skillLabClient: SkillLabClient, + private val skillLabUserProfileRepository: SkillLabUserProfileRepository, + private val objectMapper: ObjectMapper, +) : SyncUserProfileUseCase() { + + override fun apply(request: SyncUserProfileRequest): UseCaseOutcome { + val userProfile = skillLabClient.fetchUserProfile( + externalIdentifier = request.userProfile + ) + + val allSkillsEntities = getSkillEntities(userProfile.profile) + val userProfileEntity = fetchUserProfileEntity(userProfile.profile, allSkillsEntities) + + userProfileEntity.mobileNumber.let { + if (!it.isNullOrBlank()) { + userProfileEntity.mobileNumber = formatMobileNumber(it) + } + } + + try { + skillLabUserProfileRepository.save( + userProfileEntity + ) + } catch (ex: Exception) { + return UseCaseOutcome.Failure( + errorCode = SkillLabSyncUserProfileErrorCode.IO_ERROR, + errorMessage = ex.localizedMessage, + cause = ex + ) + } + + return UseCaseOutcome.Success( + data = SyncUserProfileData( + result = UserProfile( + id = userProfileEntity.externalIdentifier, + fullName = userProfileEntity.fullName, + phone = userProfileEntity.mobileNumber, + email = userProfileEntity.email, + skills = allSkillsEntities.map { skill -> + EscoSkill( + usage = objectMapper.convertValue(skill.usage.uppercase(), SkillUsage::class.java), + escoUri = skill.escoUri + ) + }, + updatedAtExternalSystem = userProfileEntity.updatedAt, + importedAt = userProfileEntity.importedAt?.toInstant(), + latestSyncAt = userProfileEntity.latestSyncAt?.toInstant(), + ) + ) + ) + } + + private fun formatMobileNumber(mobileNumber: String): String = mobileNumber + .replace(" ", "") + .replace("-", "") + .trim() + + private fun fetchUserProfileEntity( + userProfile: SkillLabProfileDto, + allSkillsEntities: Set + ): SkillLabUserProfileEntity = if (skillLabUserProfileRepository.existsByExternalIdentifier(userProfile.id)) { + val entity = skillLabUserProfileRepository.findByExternalIdentifier(userProfile.id) + entity.fullName = userProfile.fullName + entity.mobileNumber = userProfile.mobileNumber + entity.email = userProfile.email + entity.skills = allSkillsEntities + entity.updatedAt = userProfile.updatedAt + + entity + } else { + SkillLabUserProfileEntity( + externalIdentifier = userProfile.id, + fullName = userProfile.fullName, + mobileNumber = userProfile.mobileNumber, + email = userProfile.email, + skills = allSkillsEntities.toSet(), + updatedAt = userProfile.updatedAt, + ) + } + + private fun getSkillEntities(userProfile: SkillLabProfileDto): Set = userProfile.experiences + .flatMap { it.experiencesSkills } + .map { + SkillReferenceEntity( + externalIdentifier = it.id.toString(), + escoUri = it.externalId, + usage = it.choice + ) + } + .toSet() +} diff --git a/application/aam-backend-service/src/main/resources/application.yaml b/application/aam-backend-service/src/main/resources/application.yaml index 98e44b3..adcefd2 100644 --- a/application/aam-backend-service/src/main/resources/application.yaml +++ b/application/aam-backend-service/src/main/resources/application.yaml @@ -6,15 +6,13 @@ spring: jackson: deserialization: accept-empty-string-as-null-object: true + read-unknown-enum-values-using-default-value: true rabbitmq: listener: simple: prefetch: 1 datasource: - driver-class-name: org.h2.Driver - username: local - password: local - url: jdbc:h2:file:./data/dbh2;DB_CLOSE_DELAY=-1 + driver-class-name: org.postgresql.Driver jpa: generate-ddl: true hibernate: @@ -26,6 +24,10 @@ spring: multipart: max-file-size: 5MB +server: + servlet: + context-path: /api + management: endpoint: health: @@ -41,14 +43,25 @@ management: sampling: probability: 1 +features: + skill-api: + mode: disabled + # enable/disables event processing for each queue events: listener: report-calculation: enabled: true +logging: + logback: + rollingpolicy: + max-file-size: 100MB + total-size-cap: 100MB + clean-history-on-start: true --- +# spring: config: @@ -70,21 +83,23 @@ spring: observation-enabled: true username: local-spring password: docker - + datasource: + url: jdbc:postgresql://localhost:5402/aam-backend-service + username: admin + password: docker server: error: include-message: always include-binding-errors: always port: 9000 - servlet: - context-path: /api logging: level: # org.springframework.amqp.rabbit: warn # org.springframework.web: debug # org.springframework.http: debug + # org.springframework.security: debug com.aamdigital.aambackendservice: trace aam-render-api-client-configuration: @@ -106,13 +121,23 @@ sqs-client-configuration: basic-auth-username: admin basic-auth-password: docker +skilllab-api-client-configuration: + api-key: skilllab-api-key + project-id: dummy-project + base-path: http://localhost:9005/skilllab + response-timeout-in-seconds: 15 + +features: + skill-api: + mode: skilllab + events: listener: report-calculation: - enabled: true + enabled: false database-change-detection: - enabled: true + enabled: false sentry: logging: diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/container/TestContainers.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/container/TestContainers.kt index dc567be..3f1bbbc 100644 --- a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/container/TestContainers.kt +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/container/TestContainers.kt @@ -24,6 +24,7 @@ object TestContainers { CONTAINER_COUCHDB.start() CONTAINER_SQS.start() CONTAINER_PDF.start() + CONTAINER_POSTGRES.start() registry.add( "spring.security.oauth2.resourceserver.jwt.issuer-uri" ) { @@ -50,6 +51,12 @@ object TestContainers { "http://localhost:${CONTAINER_KEYCLOAK.getMappedPort(8080)}" + "/realms/dummy-realm/protocol/openid-connect/token" } + registry.add( + "spring.datasource.url", + ) { + "jdbc:postgresql://localhost:${CONTAINER_POSTGRES.getMappedPort(5432)}" + + "/aam_backend_service_test" + } } @Container @@ -87,6 +94,25 @@ object TestContainers { ) .withExposedPorts(5984) + @Container + @JvmStatic + val CONTAINER_POSTGRES: GenericContainer<*> = + GenericContainer( + DockerImageName + .parse("postgres") + .withTag("16.5-bookworm") + ) + .withNetwork(network) + .withNetworkAliases("postgres") + .withEnv( + mapOf( + Pair("POSTGRES_DB", "aam_backend_service_test"), + Pair("POSTGRES_USER", "admin"), + Pair("POSTGRES_PASSWORD", "docker"), + ) + ) + .withExposedPorts(5432) + @Container @JvmStatic val CONTAINER_SQS: GenericContainer<*> = diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/rest/ObjectMapperConfigurationTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/rest/ObjectMapperConfigurationTest.kt new file mode 100644 index 0000000..d2313ce --- /dev/null +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/rest/ObjectMapperConfigurationTest.kt @@ -0,0 +1,38 @@ +package com.aamdigital.aambackendservice.rest + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import junit.framework.TestCase +import org.assertj.core.api.Assertions.assertThat +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import kotlin.test.Test + +@SpringBootTest(classes = [ObjectMapperConfiguration::class]) +class ObjectMapperConfigurationTest { + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `objectMapper should not be null`() { + assertThat(objectMapper).isNotNull + } + + @Test + fun `objectMapper feature ACCEPT_EMPTY_STRING_AS_NULL_OBJECT should be enabled`() { + TestCase.assertTrue( + objectMapper.isEnabled( + DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT + ) + ) + } + + @Test + fun `objectMapper feature READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE should be enabled`() { + TestCase.assertTrue( + objectMapper.isEnabled( + DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE + ) + ) + } +} diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/skill/core/SqlSearchUserProfileUseCaseTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/skill/core/SqlSearchUserProfileUseCaseTest.kt new file mode 100644 index 0000000..91550f9 --- /dev/null +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/skill/core/SqlSearchUserProfileUseCaseTest.kt @@ -0,0 +1,467 @@ +package com.aamdigital.aambackendservice.skill.core + +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +import com.aamdigital.aambackendservice.skill.core.SqlSearchUserProfileUseCase.Companion.MATCHER_EMAIL +import com.aamdigital.aambackendservice.skill.core.SqlSearchUserProfileUseCase.Companion.MATCHER_NAME +import com.aamdigital.aambackendservice.skill.core.SqlSearchUserProfileUseCase.Companion.MATCHER_PHONE +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileEntity +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.reset +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.data.domain.Example +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder +import kotlin.test.assertEquals + +@ExtendWith(MockitoExtension::class) +class SqlSearchUserProfileUseCaseTest { + private lateinit var service: SqlSearchUserProfileUseCase + + @Mock + private lateinit var userProfileRepository: SkillLabUserProfileRepository + + @BeforeEach + fun setup() { + reset(userProfileRepository) + service = SqlSearchUserProfileUseCase( + userProfileRepository = userProfileRepository, + objectMapper = Jackson2ObjectMapperBuilder().build() + ) + } + + + @Test + fun `should return exact match for email address`() { + // given + whenever( + userProfileRepository.findAll( + eq( + Example.of( + getSkillLabUserProfileEntity( + fullName = "Max Muster", + mobileNumber = "123", + email = "example@mail.local", + ), + MATCHER_EMAIL + ) + ), + any() + ) + ).thenReturn( + PageImpl( + listOf( + getSkillLabUserProfileEntity( + fullName = "Max Muster", + mobileNumber = null, + email = "example@mail.local", + ) + ) + ) + ) + + // when + val response = service.run( + SearchUserProfileRequest( + email = "example@mail.local", + fullName = "Max Muster", + phone = "123", + page = 1, + pageSize = 50, + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + userProfileRepository, + times(1) + ).findAll( + any>(), + any() + ) + + assertEquals(1, (response as UseCaseOutcome.Success).data.totalElements) + } + + @Test + fun `should return exact match for mobileNumber when email check returns no match`() { + val exampleEntity = getSkillLabUserProfileEntity( + fullName = "Max Muster", + mobileNumber = "123456789", + email = "foo@mail.local", + ) + + val responseEntity = getSkillLabUserProfileEntity( + fullName = "Max Muster", + mobileNumber = "123456789", + email = "example@mail.local", + ) + + // given + whenever( + userProfileRepository.findAll( + eq( + Example.of( + exampleEntity, + MATCHER_EMAIL + ) + ), + any() + ) + ).thenReturn(Page.empty()) + + whenever( + userProfileRepository.findAll( + eq( + Example.of( + exampleEntity, + MATCHER_PHONE + ) + ), + any() + ) + ).thenReturn( + PageImpl(listOf(responseEntity)) + ) + + // when + val response = service.run( + SearchUserProfileRequest( + email = "foo@mail.local", + fullName = "Max Muster", + phone = "123456789", + page = 1, + pageSize = 50, + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + userProfileRepository, + times(2) + ).findAll( + any>(), + any() + ) + + assertEquals(1, (response as UseCaseOutcome.Success).data.totalElements) + } + + @ParameterizedTest + @ValueSource( + strings = ["null", "", " "], + ) + fun `should return exact match for mobileNumber when email is nullOrBlank`(emailRawValue: String?) { + val emailValue = if (emailRawValue == "null") { + null + } else { + emailRawValue + } + + val exampleEntity = getSkillLabUserProfileEntity( + fullName = "Max Muster", + mobileNumber = "123456789", + email = emailValue, + ) + + val responseEntity = getSkillLabUserProfileEntity( + fullName = "Max Muster", + mobileNumber = "123456789", + email = "example@mail.local", + ) + + // given + whenever( + userProfileRepository.findAll( + eq( + Example.of( + exampleEntity, + MATCHER_PHONE + ) + ), + any() + ) + ).thenReturn( + PageImpl(listOf(responseEntity)) + ) + + // when + val response = service.run( + SearchUserProfileRequest( + email = emailValue, + fullName = "Max Muster", + phone = "123456789", + page = 1, + pageSize = 50, + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + userProfileRepository, + times(1) + ).findAll( + any>(), + any() + ) + + assertEquals(1, (response as UseCaseOutcome.Success).data.totalElements) + } + + @Test + fun `should return matches for fullName`() { + val exampleEntity = getSkillLabUserProfileEntity( + fullName = "Max Muster", + mobileNumber = null, + email = null, + ) + + val responseEntity = getSkillLabUserProfileEntity( + fullName = "Max Muster", + mobileNumber = "123456789", + email = "example@mail.local", + ) + + // given + whenever( + userProfileRepository.findAll( + eq( + Example.of( + exampleEntity, + MATCHER_NAME + ) + ), + any() + ) + ).thenReturn( + PageImpl(listOf(responseEntity)) + ) + + // when + val response = service.run( + SearchUserProfileRequest( + email = null, + fullName = "Max Muster", + phone = null, + page = 1, + pageSize = 50, + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + userProfileRepository, + times(1) + ).findAll( + any>(), + any() + ) + + assertEquals(1, (response as UseCaseOutcome.Success).data.totalElements) + } + + @Test + fun `should return matches for split fullName`() { + val responseEntity = getSkillLabUserProfileEntity( + fullName = "Max Muster", + mobileNumber = "123456789", + email = "example@mail.local", + ) + + // given + whenever( + userProfileRepository.findAll( + eq( + Example.of( + getSkillLabUserProfileEntity( + fullName = "Max Martin Muster", + mobileNumber = null, + email = null, + ), + MATCHER_NAME + ) + ), + any() + ) + ).thenReturn(Page.empty()) + + whenever( + userProfileRepository.findAll( + eq( + Example.of( + getSkillLabUserProfileEntity( + fullName = "Max", + mobileNumber = null, + email = null, + ), + MATCHER_NAME + ) + ), + any() + ) + ).thenReturn( + PageImpl(listOf(responseEntity)) + ) + + whenever( + userProfileRepository.findAll( + eq( + Example.of( + getSkillLabUserProfileEntity( + fullName = "Muster", + mobileNumber = null, + email = null, + ), + MATCHER_NAME + ) + ), + any() + ) + ).thenReturn( + PageImpl(listOf(responseEntity)) + ) + + // when + val response = service.run( + SearchUserProfileRequest( + email = null, + fullName = "Max Martin Muster", + phone = null, + page = 1, + pageSize = 50, + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + userProfileRepository, + times(3) + ).findAll( + any>(), + any() + ) + + assertEquals(1, (response as UseCaseOutcome.Success).data.totalElements) + } + + @Test + fun `should skip namePart search if not enough nameParts`() { + // given + whenever( + userProfileRepository.findAll( + eq( + Example.of( + getSkillLabUserProfileEntity( + fullName = "Mila", + mobileNumber = null, + email = null, + ), + MATCHER_NAME + ) + ), + any() + ) + ).thenReturn(Page.empty()) + + // when + val response = service.run( + SearchUserProfileRequest( + email = null, + fullName = "Mila", + phone = null, + page = 1, + pageSize = 50, + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + userProfileRepository, + times(1) + ).findAll( + any>(), + any() + ) + + assertEquals(0, (response as UseCaseOutcome.Success).data.totalElements) + } + + + @ParameterizedTest + @ValueSource( + strings = ["null", "", " "], + ) + fun `should return empty list when all search parameter are empty`(rawValue: String?) { + val emptyOrBlankValue = if (rawValue == "null") { + null + } else { + rawValue + } + + // when + val response = service.run( + SearchUserProfileRequest( + email = emptyOrBlankValue, + fullName = emptyOrBlankValue, + phone = emptyOrBlankValue, + page = 1, + pageSize = 50, + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + userProfileRepository, + times(0) + ).findAll( + any>(), + any() + ) + + assertEquals(0, (response as UseCaseOutcome.Success).data.totalElements) + } + + private fun getSkillLabUserProfileEntity( + externalIdentifier: String = "", + fullName: String, + mobileNumber: String?, + email: String?, + ): SkillLabUserProfileEntity = SkillLabUserProfileEntity( + id = 0, + externalIdentifier = externalIdentifier, + fullName = fullName, + mobileNumber = mobileNumber, + email = email, + skills = emptySet(), + updatedAt = "", + latestSyncAt = null, + importedAt = null, + ) +} diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/skill/domain/SkillUsageTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/skill/domain/SkillUsageTest.kt new file mode 100644 index 0000000..5a771ab --- /dev/null +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/skill/domain/SkillUsageTest.kt @@ -0,0 +1,33 @@ +package com.aamdigital.aambackendservice.skill.domain + +import com.aamdigital.aambackendservice.rest.ObjectMapperConfiguration +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import kotlin.test.Test + +@SpringBootTest(classes = [ObjectMapperConfiguration::class]) +class SkillUsageTest { + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `objectMapper feature READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE should be enabled`() { + assertTrue(objectMapper.isEnabled(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE)) + } + + @Test + fun `should parse invalid values to UNKNOWN with objectMapper`() { + val result = objectMapper.convertValue("invalid-value", SkillUsage::class.java) + assertEquals(SkillUsage.UNKNOWN, result) + } + + @Test + fun `should parse BI_WEEKLY correctly with objectMapper`() { + val result = objectMapper.convertValue("BI-WEEKLY", SkillUsage::class.java) + assertEquals(SkillUsage.BI_WEEKLY, result) + } +} diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabFetchUserProfileUpdatesUseCaseTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabFetchUserProfileUpdatesUseCaseTest.kt new file mode 100644 index 0000000..bec35dc --- /dev/null +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabFetchUserProfileUpdatesUseCaseTest.kt @@ -0,0 +1,320 @@ +package com.aamdigital.aambackendservice.skill.skilllab + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.domain.TestErrorCode +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +import com.aamdigital.aambackendservice.error.InternalServerException +import com.aamdigital.aambackendservice.queue.core.QueueMessage +import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesRequest +import com.aamdigital.aambackendservice.skill.core.UserProfileUpdatePublisher +import com.aamdigital.aambackendservice.skill.core.event.UserProfileUpdateEvent +import com.aamdigital.aambackendservice.skill.di.UserProfileUpdateEventQueueConfiguration +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileSyncEntity +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileSyncRepository +import okio.IOException +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.reset +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.data.domain.Pageable +import java.time.OffsetDateTime +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.* + +@ExtendWith(MockitoExtension::class) +class SkillLabFetchUserProfileUpdatesUseCaseTest { + + private lateinit var service: SkillLabFetchUserProfileUpdatesUseCase + + @Mock + lateinit var skillLabClient: SkillLabClient + + @Mock + lateinit var skillLabUserProfileSyncRepository: SkillLabUserProfileSyncRepository + + @Mock + lateinit var userProfileUpdatePublisher: UserProfileUpdatePublisher + + @BeforeEach + fun setup() { + reset( + skillLabClient, + skillLabUserProfileSyncRepository, + userProfileUpdatePublisher + ) + service = SkillLabFetchUserProfileUpdatesUseCase( + skillLabClient = skillLabClient, + skillLabUserProfileSyncRepository = skillLabUserProfileSyncRepository, + userProfileUpdatePublisher = userProfileUpdatePublisher + ) + } + + @Test + fun `should return Failure when SkillLabClient throws exception`() { + // given + whenever(skillLabClient.fetchUserProfiles(any(), anyOrNull())) + .thenAnswer { + throw InternalServerException( + message = "error", + code = TestErrorCode.TEST_EXCEPTION, + cause = null, + ) + } + + // when + val response = service.run( + FetchUserProfileUpdatesRequest( + projectId = "1", + ) + ) + + // then + + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + Assertions.assertEquals( + SkillLabFetchUserProfileUpdatesErrorCode.EXTERNAL_SYSTEM_ERROR, + (response as UseCaseOutcome.Failure).errorCode + ) + } + + @Test + fun `should publish UserProfileUpdateEvent for each UserProfile fetched from skillLabClient`() { + // given + `when`(skillLabClient.fetchUserProfiles(eq(Pageable.ofSize(50).withPage(1)), anyOrNull())).thenReturn( + listOf( + DomainReference("user-profile-1"), + DomainReference("user-profile-2"), + DomainReference("user-profile-3"), + ) + ) + + whenever(userProfileUpdatePublisher.publish(any(), any())).thenReturn( + getQueueMessage() + ) + + // when + val response = service.run( + FetchUserProfileUpdatesRequest( + projectId = "1", + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + userProfileUpdatePublisher, + times(1) + ).publish( + eq(UserProfileUpdateEventQueueConfiguration.USER_PROFILE_UPDATE_QUEUE), + eq( + UserProfileUpdateEvent( + projectId = "1", + userProfileId = "user-profile-1", + ) + ) + ) + + verify( + userProfileUpdatePublisher, + times(1) + ).publish( + eq(UserProfileUpdateEventQueueConfiguration.USER_PROFILE_UPDATE_QUEUE), + eq( + UserProfileUpdateEvent( + projectId = "1", + userProfileId = "user-profile-2", + ) + ) + ) + + verify( + userProfileUpdatePublisher, + times(1) + ).publish( + eq(UserProfileUpdateEventQueueConfiguration.USER_PROFILE_UPDATE_QUEUE), + eq( + UserProfileUpdateEvent( + projectId = "1", + userProfileId = "user-profile-3", + ) + ) + ) + } + + @Test + fun `should abort fetchNextBatch loop when reaching MAX_RESULTS_LIMIT`() { + val maxResultsLimit = 10_000 + val pageSize = 50 + + // given + whenever( + skillLabClient.fetchUserProfiles( + any(), + anyOrNull() + ) + ).thenReturn( + (1..pageSize).map { + DomainReference("user-profile-$it") + } + ) + + whenever(userProfileUpdatePublisher.publish(any(), any())).thenReturn( + getQueueMessage() + ) + + // when + val response = service.run( + FetchUserProfileUpdatesRequest( + projectId = "1", + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + userProfileUpdatePublisher, + times(maxResultsLimit) + ).publish( + eq(UserProfileUpdateEventQueueConfiguration.USER_PROFILE_UPDATE_QUEUE), + any() + ) + } + + @Test + fun `should return Failure when userProfileUpdatePublisher throws Exception`() { + // given + whenever( + skillLabClient.fetchUserProfiles( + any(), + anyOrNull() + ) + ).thenReturn( + listOf( + DomainReference("user-profile-1"), + DomainReference("user-profile-2") + ) + ) + + whenever(userProfileUpdatePublisher.publish(any(), any())).thenAnswer { + throw IOException("mock-error") + } + + // when + val response = service.run( + FetchUserProfileUpdatesRequest( + projectId = "1", + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + Assertions.assertEquals( + SkillLabFetchUserProfileUpdatesErrorCode.EVENT_PUBLISH_ERROR, + (response as UseCaseOutcome.Failure).errorCode + ) + Assertions.assertEquals("mock-error", response.errorMessage) + } + + @Test + fun `should store latestSyncEntity when SyncEntity exist for this projectId`() { + val syncEntity = SkillLabUserProfileSyncEntity( + id = 42L, + projectId = "1", + latestSync = OffsetDateTime.parse("2024-01-01T00:00:00Z"), + ) + + // given + whenever(skillLabUserProfileSyncRepository.findByProjectId(any())) + .thenReturn( + Optional.of( + syncEntity + ) + ) + + `when`(skillLabClient.fetchUserProfiles(eq(Pageable.ofSize(50).withPage(1)), anyOrNull())).thenReturn( + listOf( + DomainReference("user-profile-1"), + DomainReference("user-profile-2"), + DomainReference("user-profile-3"), + ) + ) + + whenever(userProfileUpdatePublisher.publish(any(), any())).thenReturn( + getQueueMessage() + ) + + // when + val response = service.run( + FetchUserProfileUpdatesRequest( + projectId = "1", + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + skillLabUserProfileSyncRepository, + times(1) + ).save( + eq(syncEntity) + ) + } + + @Test + fun `should store latestSyncEntity when no SyncEntity exist for this projectId`() { + // given + `when`(skillLabClient.fetchUserProfiles(eq(Pageable.ofSize(50).withPage(1)), anyOrNull())).thenReturn( + listOf( + DomainReference("user-profile-1"), + DomainReference("user-profile-2"), + DomainReference("user-profile-3"), + ) + ) + + whenever(userProfileUpdatePublisher.publish(any(), any())).thenReturn( + getQueueMessage() + ) + + // when + val response = service.run( + FetchUserProfileUpdatesRequest( + projectId = "1", + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + skillLabUserProfileSyncRepository, + times(1) + ).save( + any() + ) + } + + private fun getQueueMessage(): QueueMessage = QueueMessage( + id = UUID.fromString("00000000-0000-0000-0000-000000000000"), + eventType = "FOO", + event = UserProfileUpdateEvent( + projectId = "1", + userProfileId = "mock", + ), + createdAt = ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + ) +} diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabSyncUserProfileUseCaseTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabSyncUserProfileUseCaseTest.kt new file mode 100644 index 0000000..ed73941 --- /dev/null +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabSyncUserProfileUseCaseTest.kt @@ -0,0 +1,269 @@ +package com.aamdigital.aambackendservice.skill.skilllab + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +import com.aamdigital.aambackendservice.skill.core.SyncUserProfileRequest +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileEntity +import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileRepository +import com.aamdigital.aambackendservice.skill.repository.SkillReferenceEntity +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.reset +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder +import java.io.IOException +import java.util.* + +@ExtendWith(MockitoExtension::class) +class SkillLabSyncUserProfileUseCaseTest { + private lateinit var service: SkillLabSyncUserProfileUseCase + + @Mock + lateinit var skillLabClient: SkillLabClient + + @Mock + lateinit var skillLabUserProfileRepository: SkillLabUserProfileRepository + + @BeforeEach + fun setUp() { + reset( + skillLabClient, + skillLabUserProfileRepository, + ) + service = SkillLabSyncUserProfileUseCase( + skillLabClient = skillLabClient, + skillLabUserProfileRepository = skillLabUserProfileRepository, + objectMapper = Jackson2ObjectMapperBuilder().build(), + ) + } + + @Test + fun `should store new user profile and return Success`() { + // given + whenever(skillLabClient.fetchUserProfile(any())).thenReturn( + getSkillLabProfileResponseDto("user-profile-1", mobileNumber = "") + ) + whenever(skillLabUserProfileRepository.existsByExternalIdentifier(eq("user-profile-1"))).thenReturn(false) + + // when + val response = service.run( + SyncUserProfileRequest( + userProfile = DomainReference("user-profile-1"), + project = DomainReference("project-1"), + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + skillLabUserProfileRepository, + times(1) + ).save( + eq( + SkillLabUserProfileEntity( + id = 0L, + externalIdentifier = "user-profile-1", + fullName = "Max Muster", + mobileNumber = "", + email = "max.muster@fake.local", + skills = setOf( + SkillReferenceEntity( + externalIdentifier = "00000000-0000-0000-0000-000000000001", + escoUri = "http://link-to-esco-skill-1", + usage = "always" + ), + SkillReferenceEntity( + externalIdentifier = "00000000-0000-0000-0000-000000000002", + escoUri = "http://link-to-esco-skill-2", + usage = "always" + ), + SkillReferenceEntity( + externalIdentifier = "00000000-0000-0000-0000-000000000003", + escoUri = "http://link-to-esco-skill-3", + usage = "always" + ) + ), + updatedAt = "2022-02-02T22:22Z", + latestSyncAt = null, + importedAt = null + ) + ) + ) + } + + @Test + fun `should update existing user profile and return Success`() { + val existingEntity = SkillLabUserProfileEntity( + id = 0L, + externalIdentifier = "user-profile-1", + fullName = "Max Muster", + mobileNumber = "+49123456789", + email = "max.muster@fake.local", + skills = setOf( + SkillReferenceEntity( + externalIdentifier = "00000000-0000-0000-0000-000000000001", + escoUri = "http://link-to-esco-skill-1", + usage = "always" + ), + SkillReferenceEntity( + externalIdentifier = "00000000-0000-0000-0000-000000000002", + escoUri = "http://link-to-esco-skill-2", + usage = "always" + ), + SkillReferenceEntity( + externalIdentifier = "00000000-0000-0000-0000-000000000003", + escoUri = "http://link-to-esco-skill-3", + usage = "always" + ) + ), + updatedAt = "2022-02-02T22:22Z", + latestSyncAt = null, + importedAt = null + ) + + // given + whenever(skillLabClient.fetchUserProfile(any())).thenReturn( + getSkillLabProfileResponseDto("user-profile-1") + ) + whenever(skillLabUserProfileRepository.existsByExternalIdentifier(eq("user-profile-1"))).thenReturn(true) + whenever(skillLabUserProfileRepository.findByExternalIdentifier(eq("user-profile-1"))) + .thenReturn( + existingEntity + ) + + + // when + val response = service.run( + SyncUserProfileRequest( + userProfile = DomainReference("user-profile-1"), + project = DomainReference("project-1"), + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + verify( + skillLabUserProfileRepository, + times(1) + ).save( + eq(existingEntity) + ) + } + + @Test + fun `should and return Failure when database access throws Exception`() { + // given + whenever(skillLabClient.fetchUserProfile(any())).thenReturn( + getSkillLabProfileResponseDto("user-profile-1") + ) + whenever(skillLabUserProfileRepository.existsByExternalIdentifier(eq("user-profile-1"))).thenReturn(false) + + whenever( + skillLabUserProfileRepository.save( + eq( + SkillLabUserProfileEntity( + id = 0L, + externalIdentifier = "user-profile-1", + fullName = "Max Muster", + mobileNumber = "+49123456789", + email = "max.muster@fake.local", + skills = setOf( + SkillReferenceEntity( + externalIdentifier = "00000000-0000-0000-0000-000000000001", + escoUri = "http://link-to-esco-skill-1", + usage = "always" + ), + SkillReferenceEntity( + externalIdentifier = "00000000-0000-0000-0000-000000000002", + escoUri = "http://link-to-esco-skill-2", + usage = "always" + ), + SkillReferenceEntity( + externalIdentifier = "00000000-0000-0000-0000-000000000003", + escoUri = "http://link-to-esco-skill-3", + usage = "always" + ) + ), + updatedAt = "2022-02-02T22:22Z", + latestSyncAt = null, + importedAt = null + ) + ) + + ) + ).thenAnswer { + throw IOException("mock-error") + } + + // when + val response = service.run( + SyncUserProfileRequest( + userProfile = DomainReference("user-profile-1"), + project = DomainReference("project-1"), + ) + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + Assertions.assertEquals( + SkillLabSyncUserProfileErrorCode.IO_ERROR, + (response as UseCaseOutcome.Failure).errorCode + ) + Assertions.assertEquals("mock-error", response.errorMessage) + } + + private fun getSkillLabProfileResponseDto( + id: String = "user-profile-1", + mobileNumber: String = " +49 1234-56789 ", + ): SkillLabProfileResponseDto = + SkillLabProfileResponseDto( + profile = SkillLabProfileDto( + id = id, + mobileNumber = mobileNumber, + city = "Berlin", + country = "DE", + projects = listOf("Project 1", "Project 2", "Project 3"), + fullName = "Max Muster", + email = "max.muster@fake.local", + streetAndHouseNumber = "", + arrivalInCountry = "", + nationality = "DE", + gender = "male", + birthday = "2000-01-01", + genderCustom = null, + experiences = listOf( + SkillLabExperienceDto( + experiencesSkills = mutableListOf( + SkillLabSkillDto( + id = UUID.fromString("00000000-0000-0000-0000-000000000001"), + externalId = "http://link-to-esco-skill-1", + choice = "always", + ), + SkillLabSkillDto( + id = UUID.fromString("00000000-0000-0000-0000-000000000002"), + externalId = "http://link-to-esco-skill-2", + choice = "always", + ), + SkillLabSkillDto( + id = UUID.fromString("00000000-0000-0000-0000-000000000003"), + externalId = "http://link-to-esco-skill-3", + choice = "always", + ) + ) + ) + ), + updatedAt = "2022-02-02T22:22Z", + ) + ) +} diff --git a/application/aam-backend-service/src/test/resources/application-e2e.yaml b/application/aam-backend-service/src/test/resources/application-e2e.yaml index 4b0829e..f1b493d 100644 --- a/application/aam-backend-service/src/test/resources/application-e2e.yaml +++ b/application/aam-backend-service/src/test/resources/application-e2e.yaml @@ -6,11 +6,15 @@ spring: jackson: deserialization: accept-empty-string-as-null-object: true + read-unknown-enum-values-using-default-value: true + rabbitmq: + listener: + simple: + prefetch: 1 datasource: - driver-class-name: org.h2.Driver - username: local - password: local - url: jdbc:h2:file:./test-data/dbh2;DB_CLOSE_DELAY=-1 + driver-class-name: org.postgresql.Driver + username: admin + password: docker jpa: generate-ddl: true hibernate: @@ -18,15 +22,13 @@ spring: threads: virtual: enabled: true - rabbitmq: - virtual-host: / - listener: - direct: - retry: - enabled: true - max-attempts: 5 + servlet: + multipart: + max-file-size: 5MB server: + servlet: + context-path: / port: 9000 management: @@ -40,6 +42,9 @@ management: include: - info - health + tracing: + sampling: + probability: 0 aam-render-api-client-configuration: auth-config: @@ -59,6 +64,21 @@ sqs-client-configuration: basic-auth-username: admin basic-auth-password: docker +skilllab-api-client-configuration: + api-key: skilllab-api-key + project-id: dummy-project + base-path: http://localhost:9005/skilllab # todo test container + response-timeout-in-seconds: 15 + +features: + skill-api: + mode: disabled + +events: + listener: + report-calculation: + enabled: true + database-change-detection: enabled: false diff --git a/docs/api-specs/skill-api-v1.yaml b/docs/api-specs/skill-api-v1.yaml new file mode 100644 index 0000000..1db527e --- /dev/null +++ b/docs/api-specs/skill-api-v1.yaml @@ -0,0 +1,210 @@ +openapi: 3.0.3 +info: + title: Skill API + description: REST API for handling skill and user profile mapping. + version: 1.0.0 +servers: + - url: /v1/skill + +paths: + /user-profile: + get: + summary: Return all UserProfile + description: todo + tags: + - skills + parameters: + - name: fullName + in: query + required: false + schema: + type: string + description: Optional fullName filter + - name: email + in: query + required: false + schema: + type: string + description: Optional email filter + - name: phone + in: query + required: false + schema: + type: string + description: Optional phone filter + - name: pageSize + in: query + required: false + schema: + type: number + minimum: 1 + maximum: 100 + default: 1 + required: false + description: Number of elements returned by each page + - name: page + in: query + required: false + schema: + type: number + minimum: 1 + default: 1 + required: false + description: The page to return + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserProfilePage' + + /user-profile/{userProfileId}: + get: + summary: Return all UserProfile + description: todo + tags: + - skills + parameters: + - name: userProfileId + in: path + required: true + schema: + type: string + format: UUID + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/UserProfile' + + /sync: + get: + summary: Return sync status for projects + description: Returns a list of all configured projects and the latest sync attempt + tags: + - admin + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Sync' + + /sync/{projectId}: + post: + summary: Trigger project sync + description: Import all changes for this project from SkillProvider. You can choose to sync just the changes from an specific date (DELTA) or do a full import (FULL). + tags: + - admin + parameters: + - name: syncMode + in: query + required: false + schema: + type: string + default: DELTA + enum: + - DELTA + - FULL + - name: updatedFrom + description: Define the start date for the Delta load. Just used in syncMode DELTA, uses latest sync date if not defined manually. + in: query + required: false + schema: + type: string + default: "2024-12-03T11:50:00.231Z" + format: date-time + - name: projectId + in: path + required: true + schema: + type: string + responses: + '204': + description: Sync process started, can take some minutes until all changes are imported. + +components: + schemas: + Pagination: + type: object + properties: + currentPage: + type: number + minimum: 1 + default: 1 + pageSize: + type: number + minimum: 1 + maximum: 100 + default: 10 + totalPages: + type: number + example: 5 + totalElements: + type: number + example: 42 + + UserProfilePage: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/UserProfile' + + UserProfile: + type: object + properties: + id: + type: string + format: uuid + fullName: + type: string + phone: + type: string + email: + type: string + skills: + type: array + items: + $ref: '#/components/schemas/Skill' + updatedAtExternalSystem: + type: string + format: date-time + importedAt: + type: string + format: date-time + latestSyncAt: + type: string + format: date-time + + Sync: + type: object + properties: + project: + type: string + latestSync: + type: string + format: date-time + + Skill: + type: object + properties: + escoUri: + type: string + usage: + type: string + enum: + - ALMOST_NEVER + - SOMETIMES + - OFTEN + - ALMOST_ALWAYS + - ALWAYS diff --git a/docs/developer/docker-compose.yml b/docs/developer/docker-compose.yml index 4c7e7f9..646c325 100644 --- a/docs/developer/docker-compose.yml +++ b/docs/developer/docker-compose.yml @@ -3,15 +3,6 @@ # *************************************************************** name: aam-services services: - reverse-proxy: - image: caddy:alpine - volumes: - - ./reverse-proxy/Caddyfile:/etc/caddy/Caddyfile - ports: - - "80:80" - - "443:443" - - "2019:2019" - maildev: image: maildev/maildev ports: @@ -40,7 +31,18 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: keycloak ports: - - "5432:5432" + - "5401:5432" + + db-backend: + image: postgres:16.5-bookworm + volumes: + - ~/docker-volumes/aam-digital/aam-services/db-backend/postgresql-data:/var/lib/postgresql/data + environment: + POSTGRES_DB: aam-backend-service + POSTGRES_USER: admin + POSTGRES_PASSWORD: docker + ports: + - "5402:5432" rabbitmq: image: rabbitmq:3-management-alpine diff --git a/docs/developer/reverse-proxy/Caddyfile b/docs/developer/reverse-proxy/Caddyfile deleted file mode 100644 index 670b9ca..0000000 --- a/docs/developer/reverse-proxy/Caddyfile +++ /dev/null @@ -1,28 +0,0 @@ -{ - auto_https disable_redirects - admin 0.0.0.0:2019 -} - -auth.localhost:80, auth.localhost:443 { - handle /* { - reverse_proxy keycloak:8080 - } -} - -couchdb.localhost:80, couchdb.localhost:443 { - handle /* { - reverse_proxy db-couch:5984 - } -} - -sqs.localhost:80, sqs.localhost:443 { - handle /* { - reverse_proxy sqs:4984 - } -} - -localhost:80, localhost:443 { - handle /api/v1/reporting/* { - reverse_proxy host.docker.internal:3000 - } -} diff --git a/docs/modules/skill.md b/docs/modules/skill.md new file mode 100644 index 0000000..127815e --- /dev/null +++ b/docs/modules/skill.md @@ -0,0 +1,72 @@ +# Aam Digital - Skill Integration API + +An API to fetch external profiles from another system (specifically a skill tagging platform) +and import certain properties from matched external records into our entities via the frontend. + +----- + +## Architecture + +TODO: diagram of use cases and services + +## Setup + +### Provide environment configuration for skill module + +You can find the latest version of the needed configuration in the package `aambackendservice.skill.di`. +The classes with the `@ConfigurationProperties` defines the needed properties. + +An example configuration would be: + +#### Disable the Skill feature: (default behaviour) + +```yaml +features: + skill-api: disabled +``` + +#### Enable Skill feature with connection to SkillLab + +Here an example configuration for the SkillLab project `42` + +```yaml +features: + skill-api: skilllab + +skilllab-api-client-configuration: + base-path: https://skilllab.app//project/42 + api-key: this-is-a-secret + project-id: 42 + response-timeout-in-seconds: 30 # (default value) + +``` + +### Configure permissions in the authentication system (Keycloak) + +Example for the realm: `dummy-realm` + +#### Setup Realm roles + +- Open the Keycloak user interface and navigate to the `dummy-realm` +- Go to `Realm roles` +- Create two new roles by clicking on `Create role`: + - `skill_admin` + - `skill_reader` +- assign role to the `User(s)` or `Group(s)` who should be able to access the external profiles data + +#### Add roles mapper for clients + +It's necessary to add the roles to the JWT token to verify the roles in the backend. + +For that, add an `roles mapper` for each client that sends requests to the skill api. +This should usually be the `app` client + +- Open the Keycloak user interface and navigate to the `dummy-realm` +- Go to `Clients` +- Open the `app` client +- Switch to tab `Client scopes` +- Add the pre-defined client scope `roles` with Assigned Type `default` + +## Using the API + +_see [api-specs/skill-api](../api-specs/skill-api-v1.yaml)_