Repository: lengors/scoutdesk
Pull request #13: 🔀 Merge `origin/12-support-proxied-scraping-with-strategies` into `origin/dev`
Author: login: lengors
Labels: name: enhancement
description: New feature or request
name: released on @dev
Created: 2025-05-17T19:14:21Z | Merged: 2025-05-17T19:35:19Z



Diff:
diff --git a/src/main/java/io/github/lengors/scoutdesk/api/scrapers/controllers/ScraperOwnedController.java b/src/main/java/io/github/lengors/scoutdesk/api/scrapers/controllers/ScraperOwnedController.java
new file mode 100644
index 0000000..b0f345a
--- /dev/null
+++ b/src/main/java/io/github/lengors/scoutdesk/api/scrapers/controllers/ScraperOwnedController.java
@@ -0,0 +1,44 @@
+package io.github.lengors.scoutdesk.api.scrapers.controllers;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.checkerframework.framework.qual.DefaultQualifier;
+import org.checkerframework.framework.qual.TypeUseLocation;
+import org.springframework.http.MediaType;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.AuthenticatedPrincipal;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+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;
+
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperResponse;
+import io.github.lengors.scoutdesk.api.scrapers.models.ScraperOwnedRequest;
+import io.github.lengors.scoutdesk.domain.commands.services.CommandService;
+import io.github.lengors.scoutdesk.domain.scrapers.commands.models.ScraperOwnedCommand;
+import io.github.lengors.scoutdesk.domain.scrapers.models.ScraperQuery;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import reactor.core.publisher.Flux;
+
+@RestController
+@PreAuthorize("hasRole('USER')")
+@DefaultQualifier(value = Nullable.class, locations = { TypeUseLocation.PARAMETER })
+@RequestMapping({ "/api/v1/scrapers", "/api/scrapers" })
+class ScraperOwnedController {
+  private final CommandService commandService;
+
+  ScraperOwnedController(final @NonNull CommandService commandService) {
+    this.commandService = commandService;
+  }
+
+  @PostMapping(produces = { MediaType.TEXT_EVENT_STREAM_VALUE })
+  Flux<ScraperResponse> scrap(
+      @AuthenticationPrincipal final @NotNull AuthenticatedPrincipal authenticatedPrincipal,
+      @RequestBody final @Valid @NotNull ScraperOwnedRequest scraperRequest) {
+    return commandService.executeCommand(
+        new ScraperOwnedCommand(),
+        new ScraperQuery(authenticatedPrincipal.getName(), scraperRequest.strategies(), scraperRequest.searchTerm()));
+  }
+}
diff --git a/src/main/java/io/github/lengors/scoutdesk/api/scrapers/models/ScraperOwnedRequest.java b/src/main/java/io/github/lengors/scoutdesk/api/scrapers/models/ScraperOwnedRequest.java
new file mode 100644
index 0000000..509f034
--- /dev/null
+++ b/src/main/java/io/github/lengors/scoutdesk/api/scrapers/models/ScraperOwnedRequest.java
@@ -0,0 +1,29 @@
+package io.github.lengors.scoutdesk.api.scrapers.models;
+
+import java.util.Set;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.checkerframework.framework.qual.DefaultQualifier;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import jakarta.validation.constraints.NotNull;
+
+/**
+ * Request to scrape a website with specific strategies and search term.
+ *
+ * @param strategies the set of strategies to use for scraping
+ * @param searchTerm the search term to use for scraping
+ *
+ * @author lengors
+ */
+@DefaultQualifier(Nullable.class)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record ScraperOwnedRequest(
+    @JsonProperty("strategies") @NotNull Set<@NotNull String> strategies,
+    @JsonProperty("search_term") @NotNull String searchTerm) {
+
+}
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/collections/services/IterableConverters.java b/src/main/java/io/github/lengors/scoutdesk/domain/collections/services/IterableConverters.java
new file mode 100644
index 0000000..c47c5d4
--- /dev/null
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/collections/services/IterableConverters.java
@@ -0,0 +1,40 @@
+package io.github.lengors.scoutdesk.domain.collections.services;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Utility class for converting iterables to other types.
+ *
+ * This class provides a method to convert an iterable to a set. If the iterable
+ * is already a set, it is returned as is. Otherwise, a new HashSet is created
+ * and populated with the elements of the iterable.
+ *
+ * @author lengors
+ */
+public final class IterableConverters {
+
+  private IterableConverters() {
+    throw new UnsupportedOperationException("Utility class cannot be instantiated");
+  }
+
+  /**
+   * Converts an iterable to a set. If the iterable is already a set, it is
+   * returned as is. Otherwise, a new HashSet is created and populated with the
+   * elements of the iterable.
+   *
+   * @param <T>      The type of elements in the iterable
+   * @param iterable The iterable to convert
+   * @return A set containing the elements of the iterable
+   */
+  public static <T> Set<T> toSet(final Iterable<T> iterable) {
+    final Set<T> output;
+    if (iterable instanceof Set<T> set) {
+      output = set;
+    } else {
+      output = new HashSet<>();
+      iterable.forEach(output::add);
+    }
+    return output;
+  }
+}
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/commands/models/ScraperOwnedCommand.java b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/commands/models/ScraperOwnedCommand.java
new file mode 100644
index 0000000..1c0dd06
--- /dev/null
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/commands/models/ScraperOwnedCommand.java
@@ -0,0 +1,18 @@
+package io.github.lengors.scoutdesk.domain.scrapers.commands.models;
+
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperResponse;
+import io.github.lengors.scoutdesk.domain.commands.models.Command;
+import io.github.lengors.scoutdesk.domain.scrapers.models.ScraperQuery;
+import reactor.core.publisher.Flux;
+
+/**
+ * Command to scrape data using a query.
+ *
+ * This command is used to trigger the scraping process based on the provided
+ * query parameters.
+ *
+ * @author lengors
+ */
+public record ScraperOwnedCommand() implements Command<ScraperQuery, Flux<ScraperResponse>> {
+
+}
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/commands/services/ScraperOwnedCommand.java b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/commands/services/ScraperOwnedCommand.java
new file mode 100644
index 0000000..7ef388e
--- /dev/null
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/commands/services/ScraperOwnedCommand.java
@@ -0,0 +1,57 @@
+package io.github.lengors.scoutdesk.domain.scrapers.commands.services;
+
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperInputs;
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperProfile;
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperRequest;
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperResponse;
+import io.github.lengors.scoutdesk.domain.commands.services.CommandHandler;
+import io.github.lengors.scoutdesk.domain.commands.services.CommandService;
+import io.github.lengors.scoutdesk.domain.scrapers.commands.models.ScraperOwnedCommand;
+import io.github.lengors.scoutdesk.domain.scrapers.models.ScraperQuery;
+import io.github.lengors.scoutdesk.domain.scrapers.profiles.models.ScraperOwnedProfileEntity;
+import io.github.lengors.scoutdesk.domain.scrapers.strategies.commands.models.FindScraperOwnedStrategyEntityBatchCommand;
+import io.github.lengors.scoutdesk.domain.scrapers.strategies.filters.ScraperOwnedStrategyBatchByReferenceOwnerAndReferenceNameBatchFilter;
+import io.github.lengors.scoutdesk.domain.scrapers.strategies.models.ScraperOwnedStrategyEntity;
+import io.github.lengors.scoutdesk.integrations.webscout.commands.models.ScraperCommand;
+import reactor.core.publisher.Flux;
+
+@Service
+class ScraperOwnedCommandHandler implements CommandHandler<ScraperOwnedCommand, ScraperQuery, Flux<ScraperResponse>> {
+  private final CommandService commandService;
+
+  ScraperOwnedCommandHandler(@Lazy final CommandService commandService) {
+    this.commandService = commandService;
+  }
+
+  @Override
+  public Flux<ScraperResponse> handle(
+      final ScraperOwnedCommand command,
+      final ScraperQuery input) {
+    final var strategies = commandService.executeCommand(
+        new FindScraperOwnedStrategyEntityBatchCommand(ScraperOwnedStrategyEntity.LazyRelationship.PROFILES),
+        new ScraperOwnedStrategyBatchByReferenceOwnerAndReferenceNameBatchFilter(input.owner(), input.strategies()));
+
+    final var profiles = strategies
+        .stream()
+        .flatMap(strategy -> strategy
+            .getProfiles()
+            .stream())
+        .map(profile -> new ScraperProfile(
+            profile.getSpecification().getReference().fullyQualifiedName(),
+            buildInputs(profile)))
+        .toList();
+
+    return commandService.executeCommand(new ScraperCommand(), new ScraperRequest(input.searchTerm(), profiles));
+  }
+
+  private static ScraperInputs buildInputs(final ScraperOwnedProfileEntity profile) {
+    final var inputs = new ScraperInputs();
+    profile
+        .getInputs()
+        .forEach(inputs::setAdditionalProperty);
+    return inputs;
+  }
+}
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/models/ScraperQuery.java b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/models/ScraperQuery.java
new file mode 100644
index 0000000..55d1922
--- /dev/null
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/models/ScraperQuery.java
@@ -0,0 +1,19 @@
+package io.github.lengors.scoutdesk.domain.scrapers.models;
+
+import java.util.Set;
+
+/**
+ * Represents a query for scraping data.
+ *
+ * @param owner      The owner of the scraper.
+ * @param strategies The set of strategies to be used in the scraping process.
+ * @param searchTerm The term to search for during the scraping process.
+ *
+ * @author lengors
+ */
+public record ScraperQuery(
+    String owner,
+    Set<String> strategies,
+    String searchTerm) {
+
+}
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/profiles/commands/services/FindScraperOwnedProfileEntityBatchCommandHandler.java b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/profiles/commands/services/FindScraperOwnedProfileEntityBatchCommandHandler.java
index cd99477..7fe7018 100644
--- a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/profiles/commands/services/FindScraperOwnedProfileEntityBatchCommandHandler.java
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/profiles/commands/services/FindScraperOwnedProfileEntityBatchCommandHandler.java
@@ -1,14 +1,13 @@
 package io.github.lengors.scoutdesk.domain.scrapers.profiles.commands.services;
 
-import java.util.HashSet;
 import java.util.List;
-import java.util.Set;
 import java.util.stream.Collectors;
 
 import org.apache.commons.lang3.tuple.Pair;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import io.github.lengors.scoutdesk.domain.collections.services.IterableConverters;
 import io.github.lengors.scoutdesk.domain.commands.services.CommandHandler;
 import io.github.lengors.scoutdesk.domain.persistence.exceptions.models.EntityNotFoundException;
 import io.github.lengors.scoutdesk.domain.scrapers.profiles.commands.models.FindScraperOwnedProfileEntityBatchCommand;
@@ -50,14 +49,7 @@ private List<ScraperOwnedProfileEntity> findAllByReferenceOwnerAndReferenceName(
         referenceOwner,
         referenceNames);
 
-    final Set<String> expectedNames;
-    if (referenceNames instanceof Set<String> set) {
-      expectedNames = set;
-    } else {
-      expectedNames = new HashSet<>();
-      referenceNames.forEach(expectedNames::add);
-    }
-
+    final var expectedNames = IterableConverters.toSet(referenceNames);
     if (!entities
         .stream()
         .map(ScraperOwnedProfileEntity::getReference)
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/models/FindScraperOwnedStrategyEntityBatchCommand.java b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/models/FindScraperOwnedStrategyEntityBatchCommand.java
index dbb09c5..43e1d76 100644
--- a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/models/FindScraperOwnedStrategyEntityBatchCommand.java
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/models/FindScraperOwnedStrategyEntityBatchCommand.java
@@ -1,20 +1,37 @@
 package io.github.lengors.scoutdesk.domain.scrapers.strategies.commands.models;
 
 import java.util.List;
+import java.util.Set;
 
 import io.github.lengors.scoutdesk.domain.commands.models.Command;
 import io.github.lengors.scoutdesk.domain.scrapers.strategies.filters.ScraperOwnedStrategyBatchFilter;
 import io.github.lengors.scoutdesk.domain.scrapers.strategies.models.ScraperOwnedStrategyEntity;
 
 /**
- * Command to find a batch of scraper-owned strategy entities.
+ * Command to find a batch of scraper-owned strategy entities using lazy
+ * relationships.
  *
  * This command is used to retrieve a list of strategy entities based on the
- * specified filter criteria.
+ * specified lazy relationships.
+ *
+ * @param lazyRelationships The lazy relationships to use for finding the
+ *                          strategy entities.
  *
  * @author lengors
  */
-public record FindScraperOwnedStrategyEntityBatchCommand()
+public record FindScraperOwnedStrategyEntityBatchCommand(
+    Iterable<ScraperOwnedStrategyEntity.LazyRelationship> lazyRelationships)
     implements Command<ScraperOwnedStrategyBatchFilter, List<ScraperOwnedStrategyEntity>> {
 
+  /**
+   * Constructor to create a command with a set of lazy relationships.
+   *
+   * @param lazyRelationships The set of lazy relationships to use for finding the
+   *                          strategy entities.
+   */
+  public FindScraperOwnedStrategyEntityBatchCommand(
+      final ScraperOwnedStrategyEntity.LazyRelationship... lazyRelationships) {
+    this(Set.of(lazyRelationships));
+  }
+
 }
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/FindScraperOwnedStrategyEntityBatchCommandHandler.java b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/FindScraperOwnedStrategyEntityBatchCommandHandler.java
index 4d88b86..a6bc423 100644
--- a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/FindScraperOwnedStrategyEntityBatchCommandHandler.java
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/FindScraperOwnedStrategyEntityBatchCommandHandler.java
@@ -1,15 +1,21 @@
 package io.github.lengors.scoutdesk.domain.scrapers.strategies.commands.services;
 
 import java.util.List;
+import java.util.stream.Collectors;
 
+import org.apache.commons.lang3.tuple.Pair;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import io.github.lengors.scoutdesk.domain.collections.services.IterableConverters;
 import io.github.lengors.scoutdesk.domain.commands.services.CommandHandler;
+import io.github.lengors.scoutdesk.domain.persistence.exceptions.models.EntityNotFoundException;
 import io.github.lengors.scoutdesk.domain.scrapers.strategies.commands.models.FindScraperOwnedStrategyEntityBatchCommand;
+import io.github.lengors.scoutdesk.domain.scrapers.strategies.filters.ScraperOwnedStrategyBatchByReferenceOwnerAndReferenceNameBatchFilter;
 import io.github.lengors.scoutdesk.domain.scrapers.strategies.filters.ScraperOwnedStrategyBatchByReferenceOwnerFilter;
 import io.github.lengors.scoutdesk.domain.scrapers.strategies.filters.ScraperOwnedStrategyBatchFilter;
 import io.github.lengors.scoutdesk.domain.scrapers.strategies.models.ScraperOwnedStrategyEntity;
+import io.github.lengors.scoutdesk.domain.scrapers.strategies.models.ScraperOwnedStrategyReference;
 import io.github.lengors.scoutdesk.domain.scrapers.strategies.repositories.ScraperOwnedStrategyRepository;
 
 @Service
@@ -28,9 +34,48 @@ class FindScraperOwnedStrategyEntityBatchCommandHandler implements
   public List<ScraperOwnedStrategyEntity> handle(
       final FindScraperOwnedStrategyEntityBatchCommand command,
       final ScraperOwnedStrategyBatchFilter input) {
-    return switch (input) {
+    final var entities = switch (input) {
       case ScraperOwnedStrategyBatchByReferenceOwnerFilter(var referenceOwner) ->
         scraperOwnedStrategyRepository.findAllByReferenceOwner(referenceOwner);
+
+      case ScraperOwnedStrategyBatchByReferenceOwnerAndReferenceNameBatchFilter(var owner, var names) ->
+        findAllByReferenceOwnerAndReferenceName(owner, names);
     };
+
+    command
+        .lazyRelationships()
+        .forEach(lazyRelationship -> {
+          switch (lazyRelationship) {
+            case PROFILES -> entities.forEach(entity -> entity
+                .getProfiles()
+                .size());
+
+            case null -> {
+              // No lazy relationship to load
+            }
+          }
+        });
+
+    return entities;
+  }
+
+  private List<ScraperOwnedStrategyEntity> findAllByReferenceOwnerAndReferenceName(
+      final String referenceOwner,
+      final Iterable<String> referenceNames) {
+    final var entities = scraperOwnedStrategyRepository.findAllByReferenceOwnerAndReferenceNameIn(
+        referenceOwner,
+        referenceNames);
+
+    final var expectedNames = IterableConverters.toSet(referenceNames);
+    if (!entities
+        .stream()
+        .map(ScraperOwnedStrategyEntity::getReference)
+        .map(ScraperOwnedStrategyReference::name)
+        .collect(Collectors.toUnmodifiableSet())
+        .containsAll(expectedNames)) {
+      throw new EntityNotFoundException(ScraperOwnedStrategyEntity.class, Pair.of(referenceOwner, expectedNames));
+    }
+
+    return entities;
   }
 }
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/UpdateScraperOwnedStrategyCommandHandler.java b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/UpdateScraperOwnedStrategyCommandHandler.java
index 44d6c98..1649598 100644
--- a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/UpdateScraperOwnedStrategyCommandHandler.java
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/UpdateScraperOwnedStrategyCommandHandler.java
@@ -47,7 +47,9 @@ public ScraperOwnedStrategy handle(
       case DELETE -> profiles.forEach(entity::removeProfile);
       case OVERRIDE -> entity.setProfiles(profiles);
       case UPDATE -> profiles.forEach(entity::addProfile);
-      case null -> entity.setProfiles(profiles);
+      case null -> {
+        // No operation to perform
+      }
     }
 
     final var updatedEntity = scraperOwnedStrategyRepository.save(entity);
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/filters/ScraperOwnedStrategyBatchByReferenceOwnerAndReferenceNameBatchFilter.java b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/filters/ScraperOwnedStrategyBatchByReferenceOwnerAndReferenceNameBatchFilter.java
new file mode 100644
index 0000000..eb9a244
--- /dev/null
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/filters/ScraperOwnedStrategyBatchByReferenceOwnerAndReferenceNameBatchFilter.java
@@ -0,0 +1,19 @@
+package io.github.lengors.scoutdesk.domain.scrapers.strategies.filters;
+
+/**
+ * Filter for a batch of scraper-owned strategies based on the owner and a list
+ * of names.
+ *
+ * This filter is used to specify the owner of the strategies and a list of
+ * names to filter the strategies by.
+ *
+ * @param owner The owner of the strategies.
+ * @param names The list of names to filter the strategies by.
+ *
+ * @author lengors
+ */
+public record ScraperOwnedStrategyBatchByReferenceOwnerAndReferenceNameBatchFilter(
+    String owner,
+    Iterable<String> names) implements ScraperOwnedStrategyBatchFilter {
+
+}
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/filters/ScraperOwnedStrategyBatchFilter.java b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/filters/ScraperOwnedStrategyBatchFilter.java
index 2241fa4..b728db1 100644
--- a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/filters/ScraperOwnedStrategyBatchFilter.java
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/filters/ScraperOwnedStrategyBatchFilter.java
@@ -9,6 +9,8 @@
  *
  * @author lengors
  */
-public sealed interface ScraperOwnedStrategyBatchFilter permits ScraperOwnedStrategyBatchByReferenceOwnerFilter {
+public sealed interface ScraperOwnedStrategyBatchFilter
+    permits ScraperOwnedStrategyBatchByReferenceOwnerAndReferenceNameBatchFilter,
+    ScraperOwnedStrategyBatchByReferenceOwnerFilter {
 
 }
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/models/ScraperOwnedStrategyEntity.java b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/models/ScraperOwnedStrategyEntity.java
index 2e1b0da..a14b50c 100644
--- a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/models/ScraperOwnedStrategyEntity.java
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/models/ScraperOwnedStrategyEntity.java
@@ -50,6 +50,22 @@ public final class ScraperOwnedStrategyEntity {
   @NotNull
   private Set<ScraperOwnedProfileEntity> profiles;
 
+  /**
+   * Enum representing the lazy loading relationships for the strategy entity.
+   *
+   * This is used to specify which relationships should be loaded lazily when
+   * fetching the strategy entity from the database.
+   *
+   * @author lengors
+   */
+  public enum LazyRelationship {
+
+    /**
+     * The profiles relationship.
+     */
+    PROFILES
+  }
+
   /**
    * Constructor for {@link ScraperOwnedStrategyEntity}.
    *
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/repositories/ScraperOwnedStrategyRepository.java b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/repositories/ScraperOwnedStrategyRepository.java
index e67c0b2..44dfd36 100644
--- a/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/repositories/ScraperOwnedStrategyRepository.java
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/repositories/ScraperOwnedStrategyRepository.java
@@ -20,6 +20,17 @@
 public interface ScraperOwnedStrategyRepository
     extends CrudRepository<ScraperOwnedStrategyEntity, ScraperOwnedStrategyReference> {
 
+  /**
+   * Find all scraper-owned strategies by their reference owner and names.
+   *
+   * @param referenceOwner The owner of the strategies
+   * @param referenceNames The names of the strategies
+   * @return A list of scraper-owned strategies
+   */
+  List<ScraperOwnedStrategyEntity> findAllByReferenceOwnerAndReferenceNameIn(
+      String referenceOwner,
+      Iterable<String> referenceNames);
+
   /**
    * Find all scraper-owned strategies by their reference owner.
    *
diff --git a/src/main/java/io/github/lengors/scoutdesk/domain/spring/security/services/ProxiedAuthenticationSuccessHandler.java b/src/main/java/io/github/lengors/scoutdesk/domain/spring/security/services/ProxiedAuthenticationSuccessHandler.java
index 03e0af1..e09ddb7 100644
--- a/src/main/java/io/github/lengors/scoutdesk/domain/spring/security/services/ProxiedAuthenticationSuccessHandler.java
+++ b/src/main/java/io/github/lengors/scoutdesk/domain/spring/security/services/ProxiedAuthenticationSuccessHandler.java
@@ -21,6 +21,13 @@
 public class ProxiedAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
   private static final Logger LOG = LoggerFactory.getLogger(ProxiedAuthenticationSuccessHandler.class);
 
+  /**
+   * Default constructor.
+   */
+  public ProxiedAuthenticationSuccessHandler() {
+    // Default constructor
+  }
+
   /**
    * Called when a user has been successfully authenticated.
    *
diff --git a/src/main/java/io/github/lengors/scoutdesk/integrations/webscout/clients/WebscoutClient.java b/src/main/java/io/github/lengors/scoutdesk/integrations/webscout/clients/WebscoutClient.java
new file mode 100644
index 0000000..efeda19
--- /dev/null
+++ b/src/main/java/io/github/lengors/scoutdesk/integrations/webscout/clients/WebscoutClient.java
@@ -0,0 +1,50 @@
+package io.github.lengors.scoutdesk.integrations.webscout.clients;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperRequest;
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperResponse;
+import io.github.lengors.scoutdesk.integrations.webscout.properties.WebscoutClientConnectionDetails;
+import reactor.core.publisher.Flux;
+
+/**
+ * Client for interacting with the Webscout REST API for scraping data.
+ *
+ * Provides methods to send scraping requests and receive responses.
+ *
+ * @author lengors
+ */
+@Component
+public class WebscoutClient {
+  private static final Logger LOG = LoggerFactory.getLogger(WebscoutClient.class);
+  private static final String BASE_PATH = "api/v1/scrapers";
+
+  private final WebClient webClient;
+
+  WebscoutClient(
+      final WebscoutClientConnectionDetails webscoutClientConnectionDetails,
+      final WebClient.Builder webClientBuilder) {
+    LOG.info("Setting {} up: {}", getClass().getSimpleName(), webscoutClientConnectionDetails.url());
+    this.webClient = webClientBuilder
+        .baseUrl(webscoutClientConnectionDetails.url())
+        .build();
+  }
+
+  /**
+   * Sends a scraping request to the Webscout API.
+   *
+   * @param request The scraping request to send
+   * @return A Flux of ScraperResponse containing the scraping results
+   */
+  public Flux<ScraperResponse> scrap(final ScraperRequest request) {
+    return webClient
+        .post()
+        .uri(BASE_PATH)
+        .bodyValue(request)
+        .retrieve()
+        .bodyToFlux(ScraperResponse.class);
+  }
+}
diff --git a/src/main/java/io/github/lengors/scoutdesk/integrations/webscout/commands/models/ScraperCommand.java b/src/main/java/io/github/lengors/scoutdesk/integrations/webscout/commands/models/ScraperCommand.java
new file mode 100644
index 0000000..0ab27fe
--- /dev/null
+++ b/src/main/java/io/github/lengors/scoutdesk/integrations/webscout/commands/models/ScraperCommand.java
@@ -0,0 +1,18 @@
+package io.github.lengors.scoutdesk.integrations.webscout.commands.models;
+
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperRequest;
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperResponse;
+import io.github.lengors.scoutdesk.domain.commands.models.Command;
+import reactor.core.publisher.Flux;
+
+/**
+ * Command to scrape data using a request.
+ *
+ * This command is used to trigger the scraping process based on the provided
+ * request parameters.
+ *
+ * @author lengors
+ */
+public record ScraperCommand() implements Command<ScraperRequest, Flux<ScraperResponse>> {
+
+}
diff --git a/src/main/java/io/github/lengors/scoutdesk/integrations/webscout/commands/services/ScraperCommandHandler.java b/src/main/java/io/github/lengors/scoutdesk/integrations/webscout/commands/services/ScraperCommandHandler.java
new file mode 100644
index 0000000..8ba8a8c
--- /dev/null
+++ b/src/main/java/io/github/lengors/scoutdesk/integrations/webscout/commands/services/ScraperCommandHandler.java
@@ -0,0 +1,24 @@
+package io.github.lengors.scoutdesk.integrations.webscout.commands.services;
+
+import org.springframework.stereotype.Service;
+
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperRequest;
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperResponse;
+import io.github.lengors.scoutdesk.domain.commands.services.CommandHandler;
+import io.github.lengors.scoutdesk.integrations.webscout.clients.WebscoutClient;
+import io.github.lengors.scoutdesk.integrations.webscout.commands.models.ScraperCommand;
+import reactor.core.publisher.Flux;
+
+@Service
+class ScraperCommandHandler implements CommandHandler<ScraperCommand, ScraperRequest, Flux<ScraperResponse>> {
+  private final WebscoutClient webscoutClient;
+
+  ScraperCommandHandler(final WebscoutClient webscoutClient) {
+    this.webscoutClient = webscoutClient;
+  }
+
+  @Override
+  public Flux<ScraperResponse> handle(final ScraperCommand command, final ScraperRequest input) {
+    return webscoutClient.scrap(input);
+  }
+}
diff --git a/src/test/java/io/github/lengors/scoutdesk/api/scrapers/controllers/ScraperOwnedControllerTest.java b/src/test/java/io/github/lengors/scoutdesk/api/scrapers/controllers/ScraperOwnedControllerTest.java
new file mode 100644
index 0000000..c87902d
--- /dev/null
+++ b/src/test/java/io/github/lengors/scoutdesk/api/scrapers/controllers/ScraperOwnedControllerTest.java
@@ -0,0 +1,189 @@
+package io.github.lengors.scoutdesk.api.scrapers.controllers;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.FieldSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperResponse;
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperResponseResult;
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperResponseResultBrand;
+import io.github.lengors.protoscout.domain.scrapers.models.ScraperResponseResultPrice;
+import io.github.lengors.scoutdesk.api.scrapers.models.ScraperOwnedRequest;
+import io.github.lengors.scoutdesk.domain.scrapers.profiles.repositories.ScraperOwnedProfileRepository;
+import io.github.lengors.scoutdesk.domain.scrapers.specifications.repositories.ScraperOwnedSpecificationRepository;
+import io.github.lengors.scoutdesk.domain.scrapers.strategies.repositories.ScraperOwnedStrategyRepository;
+import io.github.lengors.scoutdesk.integrations.webscout.clients.WebscoutRestClient;
+import io.github.lengors.scoutdesk.testing.utilities.ResourceUtils;
+import io.github.lengors.scoutdesk.testing.utilities.TestSuite;
+
+@TestSuite.Defaults
+class ScraperOwnedControllerTest implements TestSuite {
+  @SuppressWarnings("unused")
+  private static final String[] TEST_GROUPS = new String[] {
+      "Scoutdesk Users", "Scoutdesk Developers", "Scoutdesk Admins"
+  };
+
+  private final ResourceUtils resourceUtils;
+  private final WebscoutRestClient webscoutRestClient;
+  private final PlatformTransactionManager platformTransactionManager;
+  private final ScraperOwnedProfileRepository scraperOwnedProfileRepository;
+  private final ScraperOwnedSpecificationRepository scraperOwnedSpecificationRepository;
+  private final ScraperOwnedStrategyRepository scraperOwnedStrategyRepository;
+  private final WebTestClient webTestClient;
+
+  ScraperOwnedControllerTest(
+      @Autowired final ResourceUtils resourceUtils,
+      @Autowired final WebscoutRestClient webscoutRestClient,
+      @Autowired final PlatformTransactionManager platformTransactionManager,
+      @Autowired final ScraperOwnedProfileRepository scraperOwnedProfileRepository,
+      @Autowired final ScraperOwnedSpecificationRepository scraperOwnedSpecificationRepository,
+      @Autowired final ScraperOwnedStrategyRepository scraperOwnedStrategyRepository,
+      @Autowired final WebTestClient webTestClient) {
+    this.resourceUtils = resourceUtils;
+    this.webscoutRestClient = webscoutRestClient;
+    this.platformTransactionManager = platformTransactionManager;
+    this.scraperOwnedProfileRepository = scraperOwnedProfileRepository;
+    this.scraperOwnedSpecificationRepository = scraperOwnedSpecificationRepository;
+    this.scraperOwnedStrategyRepository = scraperOwnedStrategyRepository;
+    this.webTestClient = webTestClient;
+  }
+
+  @Override
+  public PlatformTransactionManager getPlatformTransactionManager() {
+    return platformTransactionManager;
+  }
+
+  @Override
+  public ResourceUtils getResourceUtils() {
+    return resourceUtils;
+  }
+
+  @Override
+  public ScraperOwnedProfileRepository getScraperOwnedProfileRepository() {
+    return scraperOwnedProfileRepository;
+  }
+
+  @Override
+  public ScraperOwnedSpecificationRepository getScraperOwnedSpecificationRepository() {
+    return scraperOwnedSpecificationRepository;
+  }
+
+  @Override
+  public ScraperOwnedStrategyRepository getScraperOwnedStrategyRepository() {
+    return scraperOwnedStrategyRepository;
+  }
+
+  @Override
+  public WebscoutRestClient getWebscoutRestClient() {
+    return webscoutRestClient;
+  }
+
+  @ParameterizedTest
+  @FieldSource("TEST_GROUPS")
+  void shouldCorrectlyScrape(final String testGroup) {
+    webTestClient
+        .post()
+        .uri("/api/v1/scrapers")
+        .header("X-authentik-username", "tester-x")
+        .header("X-authentik-groups", testGroup)
+        .bodyValue(new ScraperOwnedRequest(Set.of("test-strategy-x", "test-strategy-y"), "test-term"))
+        .exchange()
+        .expectStatus()
+        .isOk()
+        .expectHeader()
+        .contentType(MediaType.TEXT_EVENT_STREAM)
+        .expectBodyList(ScraperResponse.class)
+        .value(response -> {
+          final var expectedCount = 3;
+          Assertions.assertEquals(expectedCount, response.size());
+          Assertions.assertTrue(response.containsAll(
+              Arrays.asList(
+                  new ScraperResponseResult(
+                      "https://example.com",
+                      "tester-1-test-specification-1",
+                      "TEST-DESCRIPTION-X-TEST-TERM",
+                      new ScraperResponseResultBrand("test-brand-description-x-test-term", null),
+                      new ScraperResponseResultPrice("0.5", "EUR"),
+                      null,
+                      Collections.emptyList(),
+                      null,
+                      null,
+                      null,
+                      null,
+                      Collections.emptyList()),
+                  new ScraperResponseResult(
+                      "https://example.com",
+                      "tester-1-test-specification-1",
+                      "TEST-DESCRIPTION-Y-TEST-TERM",
+                      new ScraperResponseResultBrand("test-brand-description-y-test-term", null),
+                      new ScraperResponseResultPrice("0.5", "EUR"),
+                      null,
+                      Collections.emptyList(),
+                      null,
+                      null,
+                      null,
+                      null,
+                      Collections.emptyList()),
+                  new ScraperResponseResult(
+                      "https://example.com",
+                      "tester-1-test-specification-1",
+                      "TEST-DESCRIPTION-Z-TEST-TERM",
+                      new ScraperResponseResultBrand("test-brand-description-z-test-term", null),
+                      new ScraperResponseResultPrice("0.5", "EUR"),
+                      null,
+                      Collections.emptyList(),
+                      null,
+                      null,
+                      null,
+                      null,
+                      Collections.emptyList()))));
+        });
+  }
+
+  @ParameterizedTest
+  @FieldSource("TEST_GROUPS")
+  void shouldFailToScrapeDueToMissingStrategy(final String testGroup) {
+    webTestClient
+        .post()
+        .uri("/api/v1/scrapers")
+        .header("X-authentik-username", "tester-x")
+        .header("X-authentik-groups", testGroup)
+        .bodyValue(new ScraperOwnedRequest(Set.of("test-strategy-a"), "test-term"))
+        .exchange()
+        .expectStatus()
+        .isNotFound();
+  }
+
+  @Test
+  void shouldFailToScrapeWithUnauthorized() {
+    webTestClient
+        .post()
+        .uri("/api/v1/scrapers")
+        .bodyValue(new ScraperOwnedRequest(Set.of("test-strategy-x", "test-strategy-y"), "test-term"))
+        .exchange()
+        .expectStatus()
+        .isUnauthorized();
+  }
+
+  @Test
+  void shouldFailToScrapeWithForbidden() {
+    webTestClient
+        .post()
+        .uri("/api/v1/scrapers")
+        .header("X-authentik-username", "tester-x")
+        .header("X-authentik-groups", "Other")
+        .bodyValue(new ScraperOwnedRequest(Set.of("test-strategy-x", "test-strategy-y"), "test-term"))
+        .exchange()
+        .expectStatus()
+        .isForbidden();
+  }
+}


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR merges support for proxied scraping with user-owned strategies into the development branch, introducing new controllers, commands, filters, and client integrations to route and execute scraping requests based on repository-backed strategies and profiles.

- Adds `ScraperOwnedController` and request/response models with validation and security.
- Implements command handlers to fetch strategies, build profile inputs, and delegate scraping to `WebscoutClient`.
- Enhances domain batch filters, repositories, and converters for strategy/profile lookup and lazy loading.

### Reviewed Changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File                                                                                         | Description                                                      |
| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| src/test/java/io/github/lengors/scoutdesk/api/scrapers/controllers/ScraperOwnedControllerTest.java | Adds integration tests for the new scraping endpoints            |
| src/main/java/io/github/lengors/scoutdesk/integrations/webscout/commands/services/ScraperCommandHandler.java | New handler delegating scrape commands to `WebscoutClient`       |
| src/main/java/io/github/lengors/scoutdesk/integrations/webscout/commands/models/ScraperCommand.java | New command model for scraping                                   |
| src/main/java/io/github/lengors/scoutdesk/integrations/webscout/clients/WebscoutClient.java  | Introduces `WebscoutClient` to call external scraping API        |
| src/main/java/io/github/lengors/scoutdesk/domain/spring/security/services/ProxiedAuthenticationSuccessHandler.java | Adds default constructor documentation                           |
| src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/repositories/ScraperOwnedStrategyRepository.java | Adds method to query strategies by owner and name list           |
| src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/models/ScraperOwnedStrategyEntity.java | Defines `LazyRelationship` enum for profiles                     |
| src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/filters/ScraperOwnedStrategyBatchFilter.java | Expands sealed filter interface to new batch filter              |
| src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/filters/ScraperOwnedStrategyBatchByReferenceOwnerAndReferenceNameBatchFilter.java | New filter record for owner + names batch queries               |
| src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/UpdateScraperOwnedStrategyCommandHandler.java | Refines switch logic for profile updates                         |
| src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/FindScraperOwnedStrategyEntityBatchCommandHandler.java | Adds name-based strategy batch lookup with validation and lazy loading |
| src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/models/FindScraperOwnedStrategyEntityBatchCommand.java | Extends batch command with lazy relationship support             |
| src/main/java/io/github/lengors/scoutdesk/domain/scrapers/profiles/commands/services/FindScraperOwnedProfileEntityBatchCommandHandler.java | Optimizes set conversion using `IterableConverters`             |
| src/main/java/io/github/lengors/scoutdesk/domain/scrapers/models/ScraperQuery.java           | Introduces `ScraperQuery` model                                  |
| src/main/java/io/github/lengors/scoutdesk/domain/scrapers/commands/services/ScraperOwnedCommandHandler.java | Orchestrates strategy/profile resolution and scraping command   |
| src/main/java/io/github/lengors/scoutdesk/domain/scrapers/commands/models/ScraperOwnedCommand.java | New command record for owner-based scraping                     |
| src/main/java/io/github/lengors/scoutdesk/domain/collections/services/IterableConverters.java | Utility to convert `Iterable` to `Set`                           |
| src/main/java/io/github/lengors/scoutdesk/api/scrapers/models/ScraperOwnedRequest.java     | Defines API request model with JSON and validation annotations    |
| src/main/java/io/github/lengors/scoutdesk/api/scrapers/controllers/ScraperOwnedController.java | New REST controller with security, validation, and streaming     |
</details>



<details>
<summary>Comments suppressed due to low confidence (4)</summary>

**src/main/java/io/github/lengors/scoutdesk/integrations/webscout/clients/WebscoutClient.java:42**
* [nitpick] The method name 'scrap' may be misleading; consider renaming it to 'scrape' to accurately reflect its purpose.
```
public Flux<ScraperResponse> scrap(final ScraperRequest request) {
```
**src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/UpdateScraperOwnedStrategyCommandHandler.java:49**
* Using 'case null' in a switch on an enum or non-null value is unreachable and can cause compilation issues; replace it with a default case or handle null input before the switch.
```
case null -> entity.setProfiles(profiles);
```
**src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/FindScraperOwnedStrategyEntityBatchCommandHandler.java:49**
* Iterating over entities to trigger lazy loading via getProfiles().size() may lead to N+1 query issues; consider using a fetch join or batch loading strategy for better performance.
```
command.lazyRelationships().forEach(lazyRelationship -> {
```
**src/main/java/io/github/lengors/scoutdesk/api/scrapers/controllers/ScraperOwnedController.java:37**
* [nitpick] The controller method name 'scrap' could be mistaken for 'scrape'; consider renaming to 'scrape' for clarity.
```
Flux<ScraperResponse> scrap(
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-17T19:55:00Z
user: login: lengors
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-17T19:57:30Z

Review comments:
diff_hunk: @@ -28,9 +34,48 @@ class FindScraperOwnedStrategyEntityBatchCommandHandler implements
   public List<ScraperOwnedStrategyEntity> handle(
       final FindScraperOwnedStrategyEntityBatchCommand command,
       final ScraperOwnedStrategyBatchFilter input) {
-    return switch (input) {
+    final var entities = switch (input) {
       case ScraperOwnedStrategyBatchByReferenceOwnerFilter(var referenceOwner) ->
         scraperOwnedStrategyRepository.findAllByReferenceOwner(referenceOwner);
+
+      case ScraperOwnedStrategyBatchByReferenceOwnerAndReferenceNameBatchFilter(var owner, var names) ->
+        findAllByReferenceOwnerAndReferenceName(owner, names);
     };
+
+    command
+        .lazyRelationships()
+        .forEach(lazyRelationship -> {
+          switch (lazyRelationship) {
+            case PROFILES -> entities.forEach(entity -> entity
+                .getProfiles()
+                .size());
+
+            case null -> {
+              // No lazy relationship to load
path: src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/FindScraperOwnedStrategyEntityBatchCommandHandler.java
user: login: Copilot
body: The 'case null' branch in this switch is unreachable and should be replaced with a default case or the null check should be performed before switching.
```suggestion
            default -> {
              // No lazy relationship to load or unhandled value
```
created_at: 2025-05-17T19:55:00Z
author_association: NONE
diff_hunk: @@ -28,9 +34,48 @@ class FindScraperOwnedStrategyEntityBatchCommandHandler implements
   public List<ScraperOwnedStrategyEntity> handle(
       final FindScraperOwnedStrategyEntityBatchCommand command,
       final ScraperOwnedStrategyBatchFilter input) {
-    return switch (input) {
+    final var entities = switch (input) {
       case ScraperOwnedStrategyBatchByReferenceOwnerFilter(var referenceOwner) ->
         scraperOwnedStrategyRepository.findAllByReferenceOwner(referenceOwner);
+
+      case ScraperOwnedStrategyBatchByReferenceOwnerAndReferenceNameBatchFilter(var owner, var names) ->
+        findAllByReferenceOwnerAndReferenceName(owner, names);
     };
+
+    command
+        .lazyRelationships()
+        .forEach(lazyRelationship -> {
+          switch (lazyRelationship) {
+            case PROFILES -> entities.forEach(entity -> entity
+                .getProfiles()
+                .size());
+
+            case null -> {
+              // No lazy relationship to load
path: src/main/java/io/github/lengors/scoutdesk/domain/scrapers/strategies/commands/services/FindScraperOwnedStrategyEntityBatchCommandHandler.java
user: login: lengors
body: It's preferrable to be explicit.
created_at: 2025-05-17T19:57:30Z
author_association: OWNER

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-17T19:35:03Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=lengors_scoutdesk&pullRequest=13) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [3 New issues](https://sonarcloud.io/project/issues?id=lengors_scoutdesk&pullRequest=13&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=lengors_scoutdesk&pullRequest=13&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=lengors_scoutdesk&pullRequest=13&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [94.6% Coverage on New Code](https://sonarcloud.io/component_measures?id=lengors_scoutdesk&pullRequest=13&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=lengors_scoutdesk&pullRequest=13&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=lengors_scoutdesk&pullRequest=13)


author_association: NONE
user: login: actions-automator[bot]
created_at: 2025-05-17T19:36:03Z
body: :tada: This PR is included in version 1.0.0-dev.4 :tada:

The release is available on [GitHub release](https://github.com/lengors/scoutdesk/releases/tag/1.0.0-dev.4)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE