Shall be used, when unexpected exceptions occur during a service task execution, to indicate
+ * to the client, that some action might be advised here.
+ *
+ * @since 1.0.0
+ */
+class ServiceException extends RuntimeException {
+ ServiceException() {
+ super()
+ }
+
+ ServiceException(String message) {
+ super(message)
+ }
+}
diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/api/UpdateSearchService.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/api/UpdateSearchService.groovy
new file mode 100644
index 0000000..716fb1c
--- /dev/null
+++ b/src/main/groovy/life/qbic/samplestatus/reporter/api/UpdateSearchService.groovy
@@ -0,0 +1,29 @@
+package life.qbic.samplestatus.reporter.api
+
+import java.time.Instant
+
+/**
+ * Returns the last update search time point.
+ * @return the last update search time point.
+ *
+ * @since 1.0.0
+ */
+ Optional getLastUpdateSearchTimePoint()
+
+ /**
+ * Saves the last search time-point persistently.
+ * @param lastSearchTimePoint the time-point of the last search
+ *
+ * @since 1.0.0
+ */
+ void saveLastSearchTimePoint(Instant lastSearchTimePoint)
+}
diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/commands/ReportSinceInstant.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/commands/ReportSinceInstant.groovy
new file mode 100644
index 0000000..dea0cd1
--- /dev/null
+++ b/src/main/groovy/life/qbic/samplestatus/reporter/commands/ReportSinceInstant.groovy
@@ -0,0 +1,93 @@
+package life.qbic.samplestatus.reporter.commands
+
+import life.qbic.samplestatus.reporter.Result
+import life.qbic.samplestatus.reporter.SampleStatusReporter
+import life.qbic.samplestatus.reporter.SampleUpdate
+import life.qbic.samplestatus.reporter.api.LimsQueryService
+import life.qbic.samplestatus.reporter.api.UpdateSearchService
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import picocli.CommandLine
+
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+import java.util.stream.Collectors
+
+/**
+ * Command that runs the app for a given instant
+ *
+ * @since 1.0.0
+ */
+@CommandLine.Command(name = "SampleStatusReporter", version = "1.0.0", mixinStandardHelpOptions = true)
+class ReportSinceInstant implements Runnable {
+
+ private static final Logger log = LoggerFactory.getLogger(ReportSinceInstant.class)
+
+ @CommandLine.Option(names = ["-t", "--time-point"], description = "Point in time from where to search for updates e.g. '2022-01-01T00:00:00Z'.\nDefaults to the last successful run. \nIf never run successfully defaults to the same time yesterday.")
+ final Instant timePoint
+
+ private final LimsQueryService limsQueryService
+ private final SampleStatusReporter statusReporter
+ private final UpdateSearchService updateSearchService
+
+
+ ReportSinceInstant(LimsQueryService limsQueryService, SampleStatusReporter statusReporter, UpdateSearchService updateSearchService) {
+ this.limsQueryService = limsQueryService
+ this.statusReporter = statusReporter
+ this.updateSearchService = updateSearchService
+
+ if (!timePoint) {
+ timePoint = defaultTimePoint(updateSearchService)
+ }
+ }
+
+ private static Instant defaultTimePoint(UpdateSearchService updateSearchService) {
+ return updateSearchService.getLastUpdateSearchTimePoint().orElse(Instant.now().minus(1, ChronoUnit.DAYS))
+ }
+
+
+ /**
+ * When an object implementing interface {@code Runnable} is used
+ * to create a thread, starting the thread causes the object's
+ * {@code run} method to be called in that separately executing
+ * thread.
+ *
+ * The general contract of the method {@code run} is that it may
+ * take any action whatsoever.
+ *
+ * @see java.lang.Thread#run()
+ */
+ @Override
+ void run() {
+ Instant executionTime = Instant.now()
+ List> updatedSamples
+ try {
+ log.info("Gathering updated samples since $timePoint ...")
+ updatedSamples = limsQueryService.getUpdatedSamples(getTimePoint())
+ log.info("Found ${updatedSamples.size()} updated samples.")
+ } catch (Exception e) {
+ throw new RuntimeException("Could not report sample updates successfully.", e)
+ }
+
+ def errors = updatedSamples.stream()
+ .filter(Result::isError)
+ .map(Result::getError).collect()
+ if (errors.size() > 0) {
+ def errorMessages = errors.stream().map(RuntimeException::getMessage).collect(Collectors.joining("\n\t"))
+ errors.forEach((Exception e) -> log.debug(e.getMessage(), e))
+ throw new RuntimeException("Encountered ${errors.size()} errors retrieving updated samples: \n\t$errorMessages")
+ }
+
+ try {
+ updatedSamples.stream()
+ .filter(Result::isOk)
+ .map(Result::getValue)
+ .peek(it -> log.info("\tUpdating $it"))
+ .forEach(statusReporter::reportSampleStatusUpdate)
+ log.info("Finished processing.")
+ } catch (Exception e) {
+ throw new RuntimeException("Could not report sample updates successfully.", e)
+ }
+ updateSearchService.saveLastSearchTimePoint(executionTime)
+ }
+}
diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/DtoParseException.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/DtoParseException.groovy
new file mode 100644
index 0000000..6ad51ac
--- /dev/null
+++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/DtoParseException.groovy
@@ -0,0 +1,12 @@
+package life.qbic.samplestatus.reporter.services
+
+/**
+ * Exception to be thrown when parsing a data transfer object fails.
+ *
+ * @since 1.0.0
+ */
+class DtoParseException extends RuntimeException {
+ DtoParseException(String message) {
+ super(message)
+ }
+}
diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/LastUpdateSearch.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/LastUpdateSearch.groovy
new file mode 100644
index 0000000..0a65deb
--- /dev/null
+++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/LastUpdateSearch.groovy
@@ -0,0 +1,48 @@
+package life.qbic.samplestatus.reporter.services
+
+import life.qbic.samplestatus.reporter.api.UpdateSearchService
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.stereotype.Component
+
+import javax.annotation.PostConstruct
+import java.time.Instant
+
+@Component
+class LastUpdateSearch implements UpdateSearchService {
+
+ @Value('${service.last-update.file}')
+ String filePath
+
+ private Instant lastSearch
+
+ @PostConstruct
+ void init() {
+ // Read the file content
+ String lastSearchDate = new File(filePath).getText('UTF-8').trim()
+
+ if (lastSearchDate) {
+ // Parse the first line as date with format "YYYY-mm-dd'T'HH:mm:ss.SSSZ" (ISO 8601)
+ // For example "2021-12-01T12:00:00.000Z"
+ lastSearch = Instant.parse(lastSearchDate)
+ }
+ }
+
+ /**
+ * @inheritDocs
+ */
+ @Override
+ Optional getLastUpdateSearchTimePoint() {
+ return Optional.ofNullable(this.lastSearch)
+ }
+
+ /**
+ * @inheritDocs
+ */
+ @Override
+ void saveLastSearchTimePoint(Instant lastSearchTimePoint) {
+ new File(filePath).withWriter {
+ it.write(lastSearchTimePoint.toString())
+ lastSearch = lastSearchTimePoint
+ }
+ }
+}
diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/NcctLocationService.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/NcctLocationService.groovy
new file mode 100644
index 0000000..70bf77b
--- /dev/null
+++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/NcctLocationService.groovy
@@ -0,0 +1,43 @@
+package life.qbic.samplestatus.reporter.services
+
+import life.qbic.samplestatus.reporter.api.Location
+import life.qbic.samplestatus.reporter.api.LocationService
+import life.qbic.samplestatus.reporter.api.Person
+import life.qbic.samplestatus.reporter.api.SampleTrackingService
+import life.qbic.samplestatus.reporter.services.users.UserService
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.boot.context.properties.ConfigurationProperties
+import org.springframework.stereotype.Component
+
+/**
+ *
+ *
+ * Provides the current location of the configured LIMS. In this case the LIMS for the NCCT.
+ *
+ * @since 1.0.0
+ */
+@Component
+@ConfigurationProperties
+class NcctLocationService implements LocationService {
+
+ @Value('${service.sampletracking.location.user}')
+ private String userId
+
+ @Autowired
+ private SampleTrackingService sampleTrackingService
+
+ @Autowired
+ private UserService userService
+
+ @Override
+ Optional getCurrentLocation() {
+ return sampleTrackingService.getLocationForUser(userId)
+ }
+
+ @Override
+ Optional getResponsiblePerson() {
+ Optional responsiblePerson = userService.getPerson(userId)
+ return responsiblePerson
+ }
+}
diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/QbicSampleTrackingService.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/QbicSampleTrackingService.groovy
new file mode 100644
index 0000000..7f11136
--- /dev/null
+++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/QbicSampleTrackingService.groovy
@@ -0,0 +1,201 @@
+package life.qbic.samplestatus.reporter.services
+
+import groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+import life.qbic.samplestatus.reporter.api.Address
+import life.qbic.samplestatus.reporter.api.Location
+import life.qbic.samplestatus.reporter.api.Person
+import life.qbic.samplestatus.reporter.api.SampleTrackingService
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.boot.context.properties.ConfigurationProperties
+import org.springframework.stereotype.Component
+
+import javax.annotation.PostConstruct
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+import static java.time.ZoneOffset.UTC
+
+/**
+ *
+ * This service provides fundamental access to sample-tracking persistence. It allows to retrieve information and stores information in the system.
+ *
+ * @since 1.0.0
+ */
+@Component
+@ConfigurationProperties
+class QbicSampleTrackingService implements SampleTrackingService {
+
+ @Value('${service.sampletracking.url}')
+ private String sampleTrackingBaseUrl
+
+ @Value('${service.sampletracking.location.endpoint}')
+ private String locationEndpoint
+
+ @Value('${service.sampletracking.auth.user}')
+ private String serviceUser
+
+ @Value('${service.sampletracking.auth.password}')
+ private String servicePassword
+
+ private String locationEndpointPath
+
+ @PostConstruct
+ void initService() {
+ locationEndpointPath = sampleTrackingBaseUrl + locationEndpoint
+ }
+
+ @Override
+ Optional getLocationForUser(String userId) {
+ URI requestURI = createUserLocationURI(userId)
+ HttpResponse response = requestLocation(requestURI)
+ return Optional.of(response.body()).flatMap(DtoMapper::parseLocationOfJson)
+ }
+
+ @Override
+ void updateSampleLocation(String sampleCode, Location location, String status, Instant timestamp, Person responsiblePerson) throws SampleUpdateException {
+ String locationJson = DtoMapper.createJsonFromLocationWithStatus(location, status, responsiblePerson, timestamp)
+ HttpResponse response = requestSampleUpdate(createSampleUpdateURI(sampleCode), locationJson)
+ if (response.statusCode() != 200) {
+ throw new SampleUpdateException("Could not update $sampleCode to ${location.getLabel()} - ${response.statusCode()} : ${response.headers()}: ${response.body()}")
+ }
+ }
+
+ private HttpResponse requestSampleUpdate(URI requestURI, String locationJson) {
+ HttpRequest request = HttpRequest
+ .newBuilder(requestURI)
+ .header("Content-Type", "application/json")
+ .PUT(HttpRequest.BodyPublishers.ofString(locationJson))
+ .build()
+ HttpClient client = HttpClient.newBuilder()
+ .authenticator(getAuthenticator())
+ .build()
+ return client.send(request, HttpResponse.BodyHandlers.ofString())
+ }
+
+ private URI createUserLocationURI(String userId) {
+ return URI.create("${this.locationEndpointPath}/${userId}")
+ }
+
+ private URI createSampleUpdateURI(String sampleCode) {
+ return URI.create("${sampleTrackingBaseUrl}/samples/${sampleCode}/currentLocation/")
+ }
+
+ private HttpResponse requestLocation(URI requestURI) {
+ HttpRequest request = HttpRequest.newBuilder().GET().uri(requestURI).build()
+ HttpClient client = HttpClient.newBuilder()
+ .authenticator(getAuthenticator()).build()
+ return client.send(request, HttpResponse.BodyHandlers.ofString())
+ }
+
+ private Authenticator getAuthenticator() {
+ return new Authenticator() {
+ @Override
+ protected PasswordAuthentication getPasswordAuthentication() {
+ return new PasswordAuthentication(serviceUser, servicePassword.toCharArray())
+ }
+ }
+ }
+
+ private static class DtoMapper {
+
+ private static final LOCATION_NAME = "name"
+ private static final LOCATION_CONTACT = "responsible_person"
+ private static final LOCATION_CONTACT_EMAIL = "responsible_person_email"
+ private static final LOCATION_ADDRESS = "address"
+ private static final ADDRESS_AFFILIATION = "affiliation"
+ private static final ADDRESS_STREET = "street"
+ private static final ADDRESS_ZIP = "zip_code"
+ private static final ADDRESS_COUNTRY = "country"
+
+ protected static Optional parseLocationOfJson(String putativeLocationJson) {
+ println putativeLocationJson
+
+ List