diff --git a/application/aam-backend-service/build.gradle.kts b/application/aam-backend-service/build.gradle.kts index 1719a2a..6d9ce0c 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") 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..bb204d1 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 @@ -17,7 +17,6 @@ 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/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..50aa6cd --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/controller/SkillController.kt @@ -0,0 +1,103 @@ +package com.aamdigital.aambackendservice.skill.controller + +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +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.core.SearchUserProfileRequest +import com.aamdigital.aambackendservice.skill.core.SearchUserProfileUseCase +import com.aamdigital.aambackendservice.skill.skilllab.SkillLabFetchUserProfileUpdatesErrorCode +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/v1/skill") +class SkillController( + private val fetchUserProfileUpdatesUseCase: FetchUserProfileUpdatesUseCase, // todo needs no-op implementation + private val searchUserProfileUseCase: SearchUserProfileUseCase, // todo needs no-op implementation +) { + + @GetMapping("/user-profile") + fun fetchUserProfiles( + fullName: String?, + email: String?, + phone: String?, + ): ResponseEntity { + val result = searchUserProfileUseCase.run( + request = SearchUserProfileRequest( + fullName = fullName, + email = email, + phone = phone, + ), + ) + + return when (result) { + is UseCaseOutcome.Failure<*> -> { + when (result.errorCode) { +// SkillLabFetchUserProfileUpdatesErrorCode.EXTERNAL_SYSTEM_ERROR +// -> ResponseEntity.internalServerError().body( +// HttpErrorDto( +// errorCode = result.errorCode.toString(), +// errorMessage = result.errorMessage +// ) +// ) + + 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(result.data) + } + } + + @PostMapping + fun fetchUserProfileUpdates( + @RequestBody request: FetchUserProfileUpdatesRequest, + ): ResponseEntity { + val result = fetchUserProfileUpdatesUseCase.run( + request = request + ) + + return when (result) { + is UseCaseOutcome.Failure<*> -> { + when (result.errorCode) { + SkillLabFetchUserProfileUpdatesErrorCode.EXTERNAL_SYSTEM_ERROR + -> ResponseEntity.internalServerError().body( + HttpErrorDto( + errorCode = result.errorCode.toString(), + errorMessage = result.errorMessage + ) + ) + + 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(result.data) + } + } +} 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..5e52d54 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SearchUserProfileUseCase.kt @@ -0,0 +1,19 @@ +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?, +) : UseCaseRequest + +data class SearchUserProfileData( + val result: List +) : UseCaseData + +abstract class SearchUserProfileUseCase : + DomainUseCase() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SkillStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SkillStorage.kt new file mode 100644 index 0000000..409d967 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SkillStorage.kt @@ -0,0 +1,15 @@ +package com.aamdigital.aambackendservice.skill.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.skill.domain.EscoSkill +import org.springframework.data.domain.Pageable + +interface SkillStorage { + + @Throws(AamException::class) + fun fetchSkill(externalIdentifier: DomainReference): EscoSkill + + @Throws(AamException::class) + fun fetchSkills(pageable: Pageable): List +} 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..5335645 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/SqlSearchUserProfileUseCase.kt @@ -0,0 +1,77 @@ +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 org.springframework.data.domain.Example +import org.springframework.data.domain.ExampleMatcher +import org.springframework.data.domain.Pageable + +class SqlSearchUserProfileUseCase( + private val userProfileRepository: SkillLabUserProfileRepository, +) : SearchUserProfileUseCase() { + override fun apply(request: SearchUserProfileRequest): UseCaseOutcome { + + val matcher = ExampleMatcher.matchingAll() + .withIgnorePaths( + "id", + "externalIdentifier", + "skills", + "updatedAt", + "latestSyncAt", + "importedAt", + ) + .withIgnoreCase() + .withIncludeNullValues() + .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING) + + val userProfile = SkillLabUserProfileEntity( + id = 0, + externalIdentifier = "", + fullName = request.fullName, + mobileNumber = request.phone, + email = request.email, + skills = emptySet(), + updatedAt = "", + latestSyncAt = null, + importedAt = null, + ) + + val example = Example.of(userProfile, matcher) + + val searchResults = userProfileRepository.findAll(example, Pageable.ofSize(10)) + + if (searchResults.isEmpty) { + return UseCaseOutcome.Success( + data = SearchUserProfileData( + result = emptyList(), + ) + ) + } + + return UseCaseOutcome.Success( + data = SearchUserProfileData( + result = searchResults.toList().map { + UserProfile( + id = it.externalIdentifier, + fullName = it.fullName, + email = it.email, + phone = it.mobileNumber, + skills = it.skills.map { skill -> + EscoSkill( + usage = SkillUsage.valueOf(skill.usage.uppercase()), + escoUri = skill.externalIdentifier + ) + }, + 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/UserProfileMatcher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/UserProfileMatcher.kt new file mode 100644 index 0000000..697989e --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/UserProfileMatcher.kt @@ -0,0 +1,10 @@ +package com.aamdigital.aambackendservice.skill.core + +import com.aamdigital.aambackendservice.skill.domain.UserProfile + +interface UserProfileMatcher { + + fun findExternalUserProfiles( + userProfile: UserProfile, + ) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/UserProfileStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/UserProfileStorage.kt new file mode 100644 index 0000000..3fad42d --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/core/UserProfileStorage.kt @@ -0,0 +1,15 @@ +package com.aamdigital.aambackendservice.skill.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.skill.domain.UserProfile +import org.springframework.data.domain.Pageable + +interface UserProfileStorage { + + @Throws(AamException::class) + fun fetchUserProfile(externalIdentifier: DomainReference): UserProfile + + @Throws(AamException::class) + fun fetchUserProfiles(pageable: Pageable, updatedFrom: String?): List +} 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..4e40259 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/di/SkillConfiguration.kt @@ -0,0 +1,149 @@ +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.SkillStorage +import com.aamdigital.aambackendservice.skill.core.SqlSearchUserProfileUseCase +import com.aamdigital.aambackendservice.skill.core.SyncUserProfileUseCase +import com.aamdigital.aambackendservice.skill.core.UserProfileMatcher +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.SkillLabSkillStorage +import com.aamdigital.aambackendservice.skill.skilllab.SkillLabSyncUserProfileUseCase +import com.aamdigital.aambackendservice.skill.skilllab.SkillLabUserProfileMatcher +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.ConfigurationProperties +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 + + +@ConfigurationProperties("skilllab-api-client-configuration") +@ConditionalOnProperty( + prefix = "skilllab-api-client-configuration", + name = ["enabled"], + havingValue = "true", + matchIfMissing = false +) +class AamRenderApiClientConfiguration( + val basePath: String, + val apiKey: String, + val responseTimeoutInSeconds: Int = 30, +) + +@Configuration +class SkillConfiguration { + + @Bean(name = ["skilllab-api-client"]) + @ConditionalOnProperty( + prefix = "skilllab-api-client-configuration", + name = ["enabled"], + havingValue = "true", + matchIfMissing = false + ) + fun skillLabApiClient( + configuration: AamRenderApiClientConfiguration + ): 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 + @ConditionalOnProperty( + prefix = "skilllab-api-client-configuration", + name = ["enabled"], + havingValue = "true", + matchIfMissing = false + ) + fun skillLabClient( + @Qualifier("skilllab-api-client") restClient: RestClient, + objectMapper: ObjectMapper, + ): SkillLabClient = SkillLabClient( + http = restClient, + objectMapper = objectMapper, + ) + + @Bean + @ConditionalOnProperty( + prefix = "skilllab-api-client-configuration", + name = ["enabled"], + havingValue = "true", + matchIfMissing = false + ) + fun skillLabUserProfileStorage( + @Qualifier("skilllab-api-client") restClient: RestClient, + ): SkillStorage = SkillLabSkillStorage( + http = restClient + ) + + @Bean + @ConditionalOnProperty( + prefix = "skilllab-api-client-configuration", + name = ["enabled"], + havingValue = "true", + matchIfMissing = false + ) + fun skillLabUserProfileMatcher( + @Qualifier("skilllab-api-client") restClient: RestClient, + ): UserProfileMatcher = SkillLabUserProfileMatcher( + http = restClient + ) + + @Bean + @ConditionalOnProperty( + prefix = "skilllab-api-client-configuration", + name = ["enabled"], + havingValue = "true", + matchIfMissing = false + ) + fun skillLabFetchUserProfileUpdatedUseCase( + skillLabClient: SkillLabClient, + skillLabUserProfileSyncRepository: SkillLabUserProfileSyncRepository, + userProfileUpdatePublisher: UserProfileUpdatePublisher, + ): FetchUserProfileUpdatesUseCase = SkillLabFetchUserProfileUpdatesUseCase( + skillLabClient = skillLabClient, + skillLabUserProfileSyncRepository = skillLabUserProfileSyncRepository, + userProfileUpdatePublisher = userProfileUpdatePublisher, + ) + + @Bean + @ConditionalOnProperty( + prefix = "skilllab-api-client-configuration", + name = ["enabled"], + havingValue = "true", + matchIfMissing = false + ) + fun skillLabSyncUserProfileUseCase( + skillLabClient: SkillLabClient, + skillLabUserProfileRepository: SkillLabUserProfileRepository, + ): SyncUserProfileUseCase = SkillLabSyncUserProfileUseCase( + skillLabClient = skillLabClient, + skillLabUserProfileRepository = skillLabUserProfileRepository, + ) + + @Bean + fun sqlSearchUserProfileUseCase( + skillLabUserProfileRepository: SkillLabUserProfileRepository, + ): SearchUserProfileUseCase = SqlSearchUserProfileUseCase( + userProfileRepository = skillLabUserProfileRepository, + ) +} 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..5551824 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/di/UserProfileUpdateEventQueueConfiguration.kt @@ -0,0 +1,43 @@ +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.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class UserProfileUpdateEventQueueConfiguration { + + // todo configuration + 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..7ea97d9 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/domain/EscoSkill.kt @@ -0,0 +1,19 @@ +package com.aamdigital.aambackendservice.skill.domain + +enum class SkillUsage { + ALMOST_NEVER, + SOMETIMES, + OFTEN, + ALMOST_ALWAYS, + ALWAYS, +} + +/** + * 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..f89b3a4 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/job/SyncSkillsJob.kt @@ -0,0 +1,43 @@ +package com.aamdigital.aambackendservice.skill.job + +import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesRequest +import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesUseCase +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.Scheduled + +@Configuration +class SyncSkillsJob( + private val skillLabFetchUserProfileUpdatesUseCase: FetchUserProfileUpdatesUseCase +) { + + 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)) + fun checkForCouchDbChanges() { + 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 = "343" + ) + ) + } 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..dac9837 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileEntity.kt @@ -0,0 +1,49 @@ +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?, + + @UpdateTimestamp(source = SourceType.DB) + @Column(updatable = true) + var latestSyncAt: OffsetDateTime? = null, + + @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..2f8a607 --- /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 + + +/** + * CrudRepository analyses the function name to create SQL queries + * see: https://docs.spring.io/spring-data/jpa/reference/repositories/query-methods-details.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..30064ca --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/repository/SkillLabUserProfileSyncEntity.kt @@ -0,0 +1,20 @@ +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 + +@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..d0913fe --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabClient.kt @@ -0,0 +1,156 @@ +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 -> + // todo check for 4xx response first + + 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..5276477 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabFetchUserProfileUpdatesUseCase.kt @@ -0,0 +1,101 @@ +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 store it to database. + */ +class SkillLabFetchUserProfileUpdatesUseCase( + private val skillLabClient: SkillLabClient, + private val skillLabUserProfileSyncRepository: SkillLabUserProfileSyncRepository, + private val userProfileUpdatePublisher: UserProfileUpdatePublisher, +) : FetchUserProfileUpdatesUseCase() { + + 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(50).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.isNotEmpty()) + + 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 = skillLabClient.fetchUserProfiles( + pageable = pageable, + updatedFrom = currentSync?.latestSync?.toString() + ) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabSkillStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabSkillStorage.kt new file mode 100644 index 0000000..bec0451 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabSkillStorage.kt @@ -0,0 +1,19 @@ +package com.aamdigital.aambackendservice.skill.skilllab + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.skill.core.SkillStorage +import com.aamdigital.aambackendservice.skill.domain.EscoSkill +import org.springframework.data.domain.Pageable +import org.springframework.web.client.RestClient + +class SkillLabSkillStorage( + val http: RestClient +) : SkillStorage { + override fun fetchSkill(externalIdentifier: DomainReference): EscoSkill { + TODO("Not yet implemented") + } + + override fun fetchSkills(pageable: Pageable): List { + TODO("Not yet implemented") + } +} 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..86089db --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabSyncUserProfileUseCase.kt @@ -0,0 +1,97 @@ +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 + +enum class SkillLabSyncUserProfileErrorCode : AamErrorCode { + IO_ERROR +} + +class SkillLabSyncUserProfileUseCase( + private val skillLabClient: SkillLabClient, + private val skillLabUserProfileRepository: SkillLabUserProfileRepository, +) : 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) + + 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 = SkillUsage.valueOf(skill.usage.uppercase()), + escoUri = skill.externalIdentifier + ) + }, + updatedAtExternalSystem = userProfileEntity.updatedAt, + importedAt = userProfileEntity.importedAt!!.toInstant(), + latestSyncAt = userProfileEntity.latestSyncAt!!.toInstant(), + ) + ) + ) + } + + private fun fetchUserProfileEntity( + userProfile: SkillLabProfileDto, + allSkillsEntities: Set + ) = 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/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabUserProfileMatcher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabUserProfileMatcher.kt new file mode 100644 index 0000000..1f6e15e --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/skill/skilllab/SkillLabUserProfileMatcher.kt @@ -0,0 +1,13 @@ +package com.aamdigital.aambackendservice.skill.skilllab + +import com.aamdigital.aambackendservice.skill.core.UserProfileMatcher +import com.aamdigital.aambackendservice.skill.domain.UserProfile +import org.springframework.web.client.RestClient + +class SkillLabUserProfileMatcher( + val http: RestClient +) : UserProfileMatcher { + override fun findExternalUserProfiles(userProfile: UserProfile) { + TODO("Not yet implemented") + } +} diff --git a/application/aam-backend-service/src/main/resources/application.yaml b/application/aam-backend-service/src/main/resources/application.yaml index 98e44b3..162af28 100644 --- a/application/aam-backend-service/src/main/resources/application.yaml +++ b/application/aam-backend-service/src/main/resources/application.yaml @@ -11,10 +11,7 @@ spring: 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 +23,10 @@ spring: multipart: max-file-size: 5MB +server: + servlet: + context-path: /api + management: endpoint: health: @@ -47,7 +48,6 @@ events: report-calculation: enabled: true - --- spring: @@ -70,15 +70,16 @@ 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: @@ -106,13 +107,19 @@ sqs-client-configuration: basic-auth-username: admin basic-auth-password: docker +skilllab-api-client-configuration: + enabled: true + api-key: skilllab-api-key + base-path: http://localhost:9005/skilllab + response-timeout-in-seconds: 15 + 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/resources/application-e2e.yaml b/application/aam-backend-service/src/test/resources/application-e2e.yaml index 4b0829e..34ca476 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,14 @@ spring: jackson: deserialization: accept-empty-string-as-null-object: 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 +21,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: /api port: 9000 management: @@ -40,6 +41,9 @@ management: include: - info - health + tracing: + sampling: + probability: 0 aam-render-api-client-configuration: auth-config: @@ -59,6 +63,17 @@ sqs-client-configuration: basic-auth-username: admin basic-auth-password: docker +skilllab-api-client-configuration: + enabled: false + api-key: skilllab-api-key + base-path: http://localhost:9005/skilllab # todo test container + response-timeout-in-seconds: 15 + +events: + listener: + report-calculation: + enabled: false + database-change-detection: enabled: false diff --git a/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/controller/SkillLabController.kt b/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/controller/SkillLabController.kt index 371d421..80d79db 100644 --- a/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/controller/SkillLabController.kt +++ b/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/controller/SkillLabController.kt @@ -3,6 +3,7 @@ package com.aamdigital.aamexternalmockservice.skillab.controller import com.aamdigital.aamexternalmockservice.skillab.error.SkillLabError import com.aamdigital.aamexternalmockservice.skillab.error.SkillLabErrorResponseDto import com.aamdigital.aamexternalmockservice.skillab.repository.ProfileCrudRepository +import com.aamdigital.aamexternalmockservice.skillab.repository.ProfileEntity import com.aamdigital.aamexternalmockservice.skillab.repository.ProfilePagingRepository import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.data.domain.Pageable @@ -20,6 +21,11 @@ data class UserProfilesResponse( val results: List, ) +// SkillLab API +data class UserProfileResponse( + val profile: ProfileEntity, +) + // SkillLab API data class ProfileIdDto( val id: String, @@ -76,7 +82,19 @@ class SkillLabController( fun getProfile( @PathVariable profileId: UUID, ): ResponseEntity { - return profileCrudRepository.findById(profileId).let { ResponseEntity.ok(it) } + return profileCrudRepository.findById(profileId).let { + if (it.isPresent) { + ResponseEntity.ok(UserProfileResponse(profile = it.get())) + } else { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body( + objectMapper.writeValueAsString( + SkillLabErrorResponseDto( + error = SkillLabError.NotFound() + ) + ) + ) + } + } } @RequestMapping("/**") diff --git a/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/controller/TestDataController.kt b/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/controller/TestDataController.kt index 2a6675f..9bb718e 100644 --- a/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/controller/TestDataController.kt +++ b/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/controller/TestDataController.kt @@ -5,7 +5,6 @@ import com.aamdigital.aamexternalmockservice.skillab.repository.LanguageEntity import com.aamdigital.aamexternalmockservice.skillab.repository.Proficiency import com.aamdigital.aamexternalmockservice.skillab.repository.ProfileCrudRepository import com.aamdigital.aamexternalmockservice.skillab.repository.ProfileEntity -import com.aamdigital.aamexternalmockservice.skillab.repository.SkillCrudRepository import com.aamdigital.aamexternalmockservice.skillab.repository.SkillEntity import io.github.serpro69.kfaker.Faker import io.github.serpro69.kfaker.fakerConfig @@ -21,25 +20,8 @@ import kotlin.random.Random @RequestMapping("/skilllab/test-data") class TestDataController( val profileCrudRepository: ProfileCrudRepository, - val skillCrudRepository: SkillCrudRepository, ) { - // Internal - @PostMapping("/skills") - fun addSkills( - numberOfEntities: Int = 1000, - ): ResponseEntity { - val entities = mutableListOf() - - for (i in 1..numberOfEntities) { - val entity = getSkill() - entities.add(entity) - } - - skillCrudRepository.saveAll(entities) - return ResponseEntity.ok("Added entries to database.") - } - // Internal @PostMapping("/profiles") fun addProfiles( @@ -129,7 +111,7 @@ class TestDataController( educationStatus = "finished", experiencesSkills = mutableListOf().let { list -> for (i in 1..Random.nextInt(1, 5)) { - list.add(skillCrudRepository.findRandom()) + list.add(getSkill()) } list } diff --git a/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/repository/ExperienceEntity.kt b/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/repository/ExperienceEntity.kt index 5179eb8..e8ed34b 100644 --- a/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/repository/ExperienceEntity.kt +++ b/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/repository/ExperienceEntity.kt @@ -1,6 +1,7 @@ package com.aamdigital.aamexternalmockservice.skillab.repository import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.FetchType @@ -25,9 +26,9 @@ data class ExperienceEntity( @JsonProperty("start_date") var startDate: String, - @Column + @Column(nullable = true) @JsonProperty("end_date") - var endDate: String, + var endDate: String?, @Column var city: String, @@ -51,7 +52,7 @@ data class ExperienceEntity( @JsonProperty("education_status") var educationStatus: String, - @ManyToMany(fetch = FetchType.EAGER) + @ManyToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL]) @JsonProperty("experiences_skills") var experiencesSkills: MutableList, ) diff --git a/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/repository/LanguageEntity.kt b/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/repository/LanguageEntity.kt index 46fab37..92ff662 100644 --- a/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/repository/LanguageEntity.kt +++ b/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/repository/LanguageEntity.kt @@ -12,9 +12,9 @@ data class LanguageEntity( @Column var proficiency: String, - @Column + @Column(nullable = true) @JsonProperty("assessment_level") - var assessmentLevel: String, + var assessmentLevel: String?, ) enum class Proficiency { diff --git a/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/repository/ProfileEntity.kt b/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/repository/ProfileEntity.kt index cd2cb6b..ba372c8 100644 --- a/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/repository/ProfileEntity.kt +++ b/application/aam-external-mock-service/src/main/kotlin/com/aamdigital/aamexternalmockservice/skillab/repository/ProfileEntity.kt @@ -45,7 +45,7 @@ data class ProfileEntity( @Column @JsonProperty("arrival_in_country") var arrivalInCountry: String, - + @Column var nationality: String, 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 - } -}