From 5713c82d37d62552ade2d0003d3713e12af9fa38 Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Tue, 30 Jun 2026 11:01:48 +0000 Subject: [PATCH 1/3] feat(cohort): generic target strategy and catalog for all external systems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a system-agnostic CohortTarget core: one TargetStrategy port (with a TargetDescriptor carrying kind, labels and capabilities), a TargetStrategies facade, and a generic TargetCatalog + controller. Brevo becomes one strategy; adding Discord/Google is one strategy class plus a TargetSystem value, not a new table/service/picker. The kindFor(system) switches in targeting and provisioning are removed — kind comes from the strategy descriptor. --- .../adapter/brevo/BrevoTargetStrategy.kt | 145 ++++++++++++++++++ .../adapter/web/CohortTargetController.kt | 66 ++++++++ .../application/CohortProvisioningService.kt | 11 +- .../application/CohortTargetingService.kt | 54 ++++--- .../cohort/application/TargetCatalog.kt | 34 ++++ .../cohort/application/TargetStrategies.kt | 22 +++ .../cohort/port/out/TargetStrategy.kt | 50 ++++++ .../integration/mock/MockTargetStrategy.kt | 83 ++++++++++ .../adapter/brevo/BrevoTargetStrategyTest.kt | 86 +++++++++++ .../CohortProvisioningServiceTest.kt | 24 ++- .../application/CohortTargetingServiceTest.kt | 73 +++++++-- .../cohort/application/TargetCatalogTest.kt | 68 ++++++++ 12 files changed, 670 insertions(+), 46 deletions(-) create mode 100644 services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/adapter/brevo/BrevoTargetStrategy.kt create mode 100644 services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/adapter/web/CohortTargetController.kt create mode 100644 services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/TargetCatalog.kt create mode 100644 services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/TargetStrategies.kt create mode 100644 services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/port/out/TargetStrategy.kt create mode 100644 services/api/src/main/kotlin/net/blueshell/api/platform/integration/mock/MockTargetStrategy.kt create mode 100644 services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/adapter/brevo/BrevoTargetStrategyTest.kt create mode 100644 services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/TargetCatalogTest.kt diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/adapter/brevo/BrevoTargetStrategy.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/adapter/brevo/BrevoTargetStrategy.kt new file mode 100644 index 000000000..449796d75 --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/adapter/brevo/BrevoTargetStrategy.kt @@ -0,0 +1,145 @@ +package net.blueshell.api.platform.integration.cohort.adapter.brevo + +import net.blueshell.api.platform.integration.cohort.persistence.CohortKind +import net.blueshell.api.platform.integration.cohort.port.out.ExternalMember +import net.blueshell.api.platform.integration.cohort.port.out.ExternalTarget +import net.blueshell.api.platform.integration.cohort.port.out.TargetCapability +import net.blueshell.api.platform.integration.cohort.port.out.TargetDescriptor +import net.blueshell.api.platform.integration.cohort.port.out.TargetStrategy +import net.blueshell.api.platform.integration.contact.adapter.ContactListAdapter +import net.blueshell.api.platform.integration.contact.adapter.ContactServiceException +import net.blueshell.api.shared.enums.ContactSystem +import net.blueshell.api.shared.enums.TargetSystem +import net.blueshell.clients.brevo.api.ContactsApi +import net.blueshell.clients.brevo.model.GetLists200ResponseListsInner +import net.blueshell.clients.brevo.model.GetProcessesSortParameter +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClientResponseException + +@Service +@Profile("!test & !dev") +class BrevoTargetStrategy( + contactListAdapters: List, + private val contactsApi: ContactsApi, +) : TargetStrategy { + private val lists = contactListAdapters.single { it.system == ContactSystem.BREVO } + + override val descriptor = TargetDescriptor( + system = TargetSystem.BREVO, + kind = CohortKind.LIST, + systemLabel = "Brevo", + targetLabel = "Brevo list", + idLabel = "List id", + folderLabel = "Folder", + capabilities = setOf( + TargetCapability.CATALOG, + TargetCapability.CREATE, + TargetCapability.READ_MEMBERS, + TargetCapability.WRITE_MEMBERS, + TargetCapability.DELETE, + ), + ) + + override fun catalog(query: String?): List { + val folderNames = folders() + val q = query?.trim()?.lowercase().orEmpty() + return listTargets(folderNames) + .filter { it.matches(q) } + .sortedWith(compareBy({ it.folderLabel.orEmpty() }, { it.label })) + } + + override fun create(label: String, folder: String?): ExternalTarget = + ExternalTarget( + system = system, + externalId = lists.createList(label, folder).toString(), + kind = descriptor.kind, + label = label, + folderLabel = folder, + ) + + override fun members(target: ExternalTarget): List = + lists.listMembers(target.externalId.toBrevoId("externalId", "members")) + .map { ExternalMember(it.externalUserId.toString(), it.email) } + .filter { it.externalUserId.isNotBlank() } + + override fun add(target: ExternalTarget, externalUserId: String) { + lists.addToList( + externalUserId.toBrevoId("externalUserId", "add"), + target.externalId.toBrevoId("externalId", "add"), + ) + } + + override fun remove(target: ExternalTarget, externalUserId: String) { + lists.removeFromList( + externalUserId.toBrevoId("externalUserId", "remove"), + target.externalId.toBrevoId("externalId", "remove"), + ) + } + + override fun delete(target: ExternalTarget) { + lists.deleteList(target.externalId.toBrevoId("externalId", "delete")) + } + + private fun folders(): Map = + page("folders") { limit, offset -> + contactsApi.getFolders(limit, offset, GetProcessesSortParameter.ASC).let { page -> + Page(page.count, page.folders.orEmpty().associate { it.id.toString() to it.name }.entries.toList()) + } + }.associate { it.key to it.value } + + private fun listTargets(folderNames: Map): List = + page("lists") { limit, offset -> + contactsApi.getLists(limit, offset, GetProcessesSortParameter.ASC).let { page -> + Page(page.count, page.lists.orEmpty().map { it.toTarget(folderNames) }) + } + } + + private fun page(kind: String, fetch: (Long, Long) -> Page): List { + val results = mutableListOf() + var offset = 0L + while (true) { + val page = fetchPage(kind) { fetch(PAGE_SIZE, offset) } + results += page.items + if (page.items.size < PAGE_SIZE || page.count != null && results.size >= page.count) break + offset += PAGE_SIZE + } + return results + } + + private fun fetchPage(kind: String, fetch: () -> Page): Page = try { + fetch() + } catch (e: RestClientResponseException) { + if (e.statusCode.value() == 429) log.warn("Brevo target catalog {} fetch was rate limited", kind) + throw ContactServiceException("Failed to fetch Brevo $kind catalog", e) + } + + private fun ExternalTarget.matches(query: String): Boolean = + query.isBlank() || + externalId == query || + label.lowercase().contains(query) || + folderLabel.orEmpty().lowercase().contains(query) + + private fun String.toBrevoId(field: String, operation: String): Long = + toLongOrNull() ?: throw InvalidExternalIdException("Brevo $operation: $field \"$this\" is not numeric") + + private data class Page(val count: Long?, val items: List) + + companion object { + const val PAGE_SIZE: Long = 50 + private val log = LoggerFactory.getLogger(BrevoTargetStrategy::class.java) + } +} + +private fun GetLists200ResponseListsInner.toTarget(folderNames: Map): ExternalTarget { + val folderExternalId = folderId?.toString() + return ExternalTarget( + system = TargetSystem.BREVO, + externalId = id.toString(), + kind = CohortKind.LIST, + label = name, + folderLabel = folderExternalId?.let { folderNames[it] }, + memberCount = uniqueSubscribers, + ) +} diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/adapter/web/CohortTargetController.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/adapter/web/CohortTargetController.kt new file mode 100644 index 000000000..a41c3fb6a --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/adapter/web/CohortTargetController.kt @@ -0,0 +1,66 @@ +package net.blueshell.api.platform.integration.cohort.adapter.web + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.tags.Tag +import net.blueshell.api.platform.integration.cohort.application.TargetCatalog +import net.blueshell.api.platform.integration.cohort.persistence.CohortKind +import net.blueshell.api.platform.integration.cohort.port.out.ExternalTarget +import net.blueshell.api.platform.integration.cohort.port.out.TargetCapability +import net.blueshell.api.platform.integration.cohort.port.out.TargetDescriptor +import net.blueshell.api.shared.enums.TargetSystem +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/management/cohort-targets") +@Tag(name = "Cohort Targets", description = "Admin: external cohort target catalog") +@PreAuthorize("hasAuthority('ADMIN')") +class CohortTargetController( + private val catalog: TargetCatalog, +) { + @GetMapping("/systems") + @Operation(operationId = "listCohortTargetSystems") + fun systems(): List = + catalog.descriptors().map { it.toResponse() } + + @GetMapping("/{system}") + @Operation(operationId = "searchCohortTargets") + fun targets( + @PathVariable system: TargetSystem, + @RequestParam(required = false) query: String?, + ): List = + catalog.search(system, query).map { it.toResponse() } +} + +@Schema(name = "TargetDescriptor") +data class TargetDescriptorResponse( + val system: TargetSystem, + val kind: CohortKind, + val systemLabel: String, + val targetLabel: String, + val idLabel: String, + val folderLabel: String?, + val capabilities: Set, +) + +@Schema(name = "ExternalTarget") +data class ExternalTargetResponse( + val system: TargetSystem, + val externalId: String, + val kind: CohortKind, + val label: String, + val folderLabel: String?, + val memberCount: Long?, + val linkedCohortId: Long?, +) + +private fun TargetDescriptor.toResponse() = + TargetDescriptorResponse(system, kind, systemLabel, targetLabel, idLabel, folderLabel, capabilities) + +private fun ExternalTarget.toResponse() = + ExternalTargetResponse(system, externalId, kind, label, folderLabel, memberCount, linkedCohortId) diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortProvisioningService.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortProvisioningService.kt index 42279f86f..2c6871bcf 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortProvisioningService.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortProvisioningService.kt @@ -2,7 +2,6 @@ package net.blueshell.api.platform.integration.cohort.application import net.blueshell.api.platform.integration.cohort.persistence.Cohort import net.blueshell.api.platform.integration.cohort.persistence.CohortFactKind -import net.blueshell.api.platform.integration.cohort.persistence.CohortKind import net.blueshell.api.platform.integration.cohort.persistence.CohortSubject import net.blueshell.api.platform.integration.cohort.persistence.CohortSubjectType import net.blueshell.api.platform.integration.cohort.persistence.repository.CohortRepository @@ -23,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional class CohortProvisioningService( private val subjects: CohortSubjectRepository, private val cohorts: CohortRepository, + private val strategies: TargetStrategies, ) { @Transactional fun provision(spec: CohortProvisioningSpec): CohortProvisioningResult { @@ -43,19 +43,14 @@ class CohortProvisioningService( ?: cohorts.save( Cohort( system = spec.system.name, - kind = kindFor(spec.system), + kind = strategies.descriptor(spec.system).kind, label = spec.label, folder = spec.folder, subjectId = subject.id, ), - ) + ) return CohortProvisioningResult.Ready(cohort) } - - private fun kindFor(system: TargetSystem): CohortKind = when (system) { - TargetSystem.BREVO -> CohortKind.LIST - TargetSystem.GOOGLE_CALENDAR -> error("$system has no cohort target kind") - } } /** diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingService.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingService.kt index e433fedb2..e09278dda 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingService.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingService.kt @@ -1,12 +1,11 @@ package net.blueshell.api.platform.integration.cohort.application import net.blueshell.api.platform.integration.cohort.persistence.Cohort -import net.blueshell.api.platform.integration.cohort.persistence.CohortKind import net.blueshell.api.platform.integration.cohort.persistence.repository.CohortRepository import net.blueshell.api.platform.integration.cohort.persistence.repository.CohortSubjectRepository import net.blueshell.api.platform.integration.cohort.port.`in`.CohortTargeting import net.blueshell.api.platform.integration.cohort.port.`in`.CohortTargetRef -import net.blueshell.api.platform.integration.cohort.port.out.CohortPortRegistry +import net.blueshell.api.platform.integration.cohort.port.out.ExternalTarget import net.blueshell.api.shared.enums.TargetSystem import net.blueshell.api.shared.job.CohortJobs import net.blueshell.api.shared.job.NonRetryableJobException @@ -29,7 +28,7 @@ class CohortTargetingService( private val cohortRepo: CohortRepository, private val subjectRepo: CohortSubjectRepository, private val targetIds: CohortTargetIds, - private val registry: CohortPortRegistry, + private val strategies: TargetStrategies, private val jobs: TrackedJobDispatcher, transactionManager: PlatformTransactionManager, ) : CohortTargeting { @@ -44,7 +43,12 @@ class CohortTargetingService( propagationBehavior = TransactionDefinition.PROPAGATION_NOT_SUPPORTED } - override fun linkExisting(subjectId: Long, system: TargetSystem, externalId: String): CohortMappingRow = + override fun linkExisting(subjectId: Long, system: TargetSystem, externalId: String): CohortMappingRow { + resolveTarget(system, externalId) + return linkExistingLocal(subjectId, system, externalId) + } + + private fun linkExistingLocal(subjectId: Long, system: TargetSystem, externalId: String): CohortMappingRow = writeTransaction.execute { val subject = requireSubject(subjectId) val existing = cohortRepo.findBySubjectIdAndSystem(subjectId, system.name) @@ -72,12 +76,12 @@ class CohortTargetingService( requireNoExistingMapping(subjectId, system) } - val externalId = outsideTransaction.execute { registry.require(system).createCohort(label, folderHint) }!! + val target = outsideTransaction.execute { strategies.require(system).create(label, folderHint) }!! return writeTransaction.execute { val cohort = cohortRepo.save(newCohort(system, label, folder = folderHint, subjectId = subjectId)) - targetIds.record(cohort, externalId) - CohortMappingRow(cohort, externalId) + targetIds.record(cohort, target.externalId) + CohortMappingRow(cohort, target.externalId) }!! } @@ -88,15 +92,14 @@ class CohortTargetingService( deletePrevious: Boolean, reconcileNow: Boolean, ): CohortMappingRow { + val prep = writeTransaction.execute { + val cohort = requireOwnedCohort(subjectId, cohortId) + TargetSystem.valueOf(cohort.system) + }!! + resolveTarget(prep, externalId) + val switched = writeTransaction.execute { - val cohort = cohortRepo.findById(cohortId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Cohort $cohortId not found") - } - // The route carries the subject id; reject a cohort that is not its - // target so a wrong-path admin call cannot repoint another subject's. - if (cohort.subjectId != subjectId) { - throw ResponseStatusException(HttpStatus.NOT_FOUND, "Cohort $cohortId is not a target of subject $subjectId") - } + val cohort = requireOwnedCohort(subjectId, cohortId) val system = TargetSystem.valueOf(cohort.system) val previousExternalId = targetIds.find(cohort) targetIds.record(cohort, externalId) @@ -133,7 +136,8 @@ class CohortTargetingService( } override fun deleteTarget(system: TargetSystem, externalTargetId: String) { - outsideTransaction.executeWithoutResult { registry.require(system).deleteCohort(externalTargetId) } + val target = ExternalTarget(system, externalTargetId, strategies.descriptor(system).kind, externalTargetId) + outsideTransaction.executeWithoutResult { strategies.require(system).delete(target) } } private fun requireSubject(subjectId: Long) = @@ -153,16 +157,24 @@ class CohortTargetingService( private fun newCohort(system: TargetSystem, label: String, folder: String?, subjectId: Long) = Cohort( system = system.name, - kind = kindFor(system), + kind = strategies.descriptor(system).kind, label = label, folder = folder, subjectId = subjectId, ) - private fun kindFor(system: TargetSystem): CohortKind = when (system) { - TargetSystem.BREVO -> CohortKind.LIST - TargetSystem.GOOGLE_CALENDAR -> - throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$system has no cohort target kind") + private fun requireOwnedCohort(subjectId: Long, cohortId: Long): Cohort { + val cohort = cohortRepo.findById(cohortId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Cohort $cohortId not found") + } + if (cohort.subjectId != subjectId) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Cohort $cohortId is not a target of subject $subjectId") + } + return cohort + } + + private fun resolveTarget(system: TargetSystem, externalId: String) { + outsideTransaction.execute { strategies.require(system).resolve(externalId) } } private data class Switched(val cohort: Cohort, val system: TargetSystem, val previousExternalId: String?) diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/TargetCatalog.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/TargetCatalog.kt new file mode 100644 index 000000000..4cb20854e --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/TargetCatalog.kt @@ -0,0 +1,34 @@ +package net.blueshell.api.platform.integration.cohort.application + +import net.blueshell.api.platform.integration.cohort.persistence.repository.CohortRepository +import net.blueshell.api.platform.integration.cohort.port.out.ExternalTarget +import net.blueshell.api.platform.integration.cohort.port.out.TargetCapability +import net.blueshell.api.platform.integration.cohort.port.out.TargetDescriptor +import net.blueshell.api.shared.enums.TargetSystem +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional + +@Service +class TargetCatalog( + private val strategies: TargetStrategies, + private val cohorts: CohortRepository, +) { + @Transactional(propagation = Propagation.NOT_SUPPORTED) + fun search(system: TargetSystem, query: String?): List { + val strategy = strategies.require(system) + if (!strategy.descriptor.supports(TargetCapability.CATALOG)) return emptyList() + + val linked = linkedCohorts(system) + return strategy.catalog(query).map { target -> + target.copy(linkedCohortId = linked[target.externalId]) + } + } + + fun descriptors(): List = strategies.descriptors() + + private fun linkedCohorts(system: TargetSystem): Map = + cohorts.findAllBySystem(system.name) + .mapNotNull { cohort -> cohort.externalId?.takeIf { it.isNotBlank() }?.let { it to cohort.id!! } } + .toMap() +} diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/TargetStrategies.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/TargetStrategies.kt new file mode 100644 index 000000000..c8b2d0278 --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/TargetStrategies.kt @@ -0,0 +1,22 @@ +package net.blueshell.api.platform.integration.cohort.application + +import net.blueshell.api.platform.integration.cohort.port.out.TargetDescriptor +import net.blueshell.api.platform.integration.cohort.port.out.TargetStrategy +import net.blueshell.api.shared.enums.TargetSystem +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException + +@Component +class TargetStrategies(strategies: List) { + private val bySystem = strategies.associateBy { it.system } + + fun require(system: TargetSystem): TargetStrategy = + bySystem[system] + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$system is not a cohort target") + + fun descriptor(system: TargetSystem): TargetDescriptor = require(system).descriptor + + fun descriptors(): List = + bySystem.values.map { it.descriptor }.sortedBy { it.systemLabel } +} diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/port/out/TargetStrategy.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/port/out/TargetStrategy.kt new file mode 100644 index 000000000..4e1fa85a7 --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/port/out/TargetStrategy.kt @@ -0,0 +1,50 @@ +package net.blueshell.api.platform.integration.cohort.port.out + +import net.blueshell.api.platform.integration.cohort.persistence.CohortKind +import net.blueshell.api.shared.enums.TargetSystem + +enum class TargetCapability { CATALOG, CREATE, READ_MEMBERS, WRITE_MEMBERS, DELETE } + +data class TargetDescriptor( + val system: TargetSystem, + val kind: CohortKind, + val systemLabel: String, + val targetLabel: String, + val idLabel: String, + val folderLabel: String? = null, + val capabilities: Set, +) { + fun supports(capability: TargetCapability): Boolean = capability in capabilities +} + +data class ExternalTarget( + val system: TargetSystem, + val externalId: String, + val kind: CohortKind, + val label: String, + val folderLabel: String? = null, + val memberCount: Long? = null, + val linkedCohortId: Long? = null, +) + +data class ExternalMember(val externalUserId: String, val label: String?) + +interface TargetStrategy { + val descriptor: TargetDescriptor + val system: TargetSystem get() = descriptor.system + + fun catalog(query: String?): List = emptyList() + + fun resolve(externalId: String): ExternalTarget? = + catalog(externalId).firstOrNull { it.externalId == externalId } + + fun members(target: ExternalTarget): List + + fun add(target: ExternalTarget, externalUserId: String) + + fun remove(target: ExternalTarget, externalUserId: String) + + fun create(label: String, folder: String?): ExternalTarget + + fun delete(target: ExternalTarget) +} diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/mock/MockTargetStrategy.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/mock/MockTargetStrategy.kt new file mode 100644 index 000000000..52dd43530 --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/mock/MockTargetStrategy.kt @@ -0,0 +1,83 @@ +package net.blueshell.api.platform.integration.mock + +import net.blueshell.api.platform.integration.cohort.persistence.CohortKind +import net.blueshell.api.platform.integration.cohort.port.out.ExternalMember +import net.blueshell.api.platform.integration.cohort.port.out.ExternalTarget +import net.blueshell.api.platform.integration.cohort.port.out.TargetCapability +import net.blueshell.api.platform.integration.cohort.port.out.TargetDescriptor +import net.blueshell.api.platform.integration.cohort.port.out.TargetStrategy +import net.blueshell.api.shared.enums.TargetSystem +import org.springframework.context.annotation.Primary +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Service +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +@Service +@Primary +@Profile("test | dev") +class MockTargetStrategy : TargetStrategy { + override val descriptor = TargetDescriptor( + system = TargetSystem.BREVO, + kind = CohortKind.LIST, + systemLabel = "Brevo", + targetLabel = "Brevo list", + idLabel = "List id", + folderLabel = "Folder", + capabilities = setOf( + TargetCapability.CATALOG, + TargetCapability.CREATE, + TargetCapability.READ_MEMBERS, + TargetCapability.WRITE_MEMBERS, + TargetCapability.DELETE, + ), + ) + + private val targets = ConcurrentHashMap() + private val members = ConcurrentHashMap, String>() + private val ids = AtomicLong(9000) + + override fun catalog(query: String?): List = + targets.values.filter { it.matches(query.orEmpty().trim().lowercase()) } + + override fun resolve(externalId: String): ExternalTarget? = targets[externalId] + + override fun create(label: String, folder: String?): ExternalTarget { + val target = ExternalTarget(system, ids.getAndIncrement().toString(), descriptor.kind, label, folder) + targets[target.externalId] = target + return target + } + + override fun members(target: ExternalTarget): List = + members.entries + .filter { it.key.second == target.externalId } + .map { ExternalMember(it.key.first, it.value.ifBlank { null }) } + + override fun add(target: ExternalTarget, externalUserId: String) { + members[externalUserId to target.externalId] = "" + } + + override fun remove(target: ExternalTarget, externalUserId: String) { + members.remove(externalUserId to target.externalId) + } + + override fun delete(target: ExternalTarget) { + targets.remove(target.externalId) + members.keys.removeIf { it.second == target.externalId } + } + + fun seed(target: ExternalTarget) { + targets[target.externalId] = target + } + + fun clear() { + targets.clear() + members.clear() + } + + private fun ExternalTarget.matches(query: String): Boolean = + query.isBlank() || + externalId == query || + label.lowercase().contains(query) || + folderLabel.orEmpty().lowercase().contains(query) +} diff --git a/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/adapter/brevo/BrevoTargetStrategyTest.kt b/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/adapter/brevo/BrevoTargetStrategyTest.kt new file mode 100644 index 000000000..71ec8e652 --- /dev/null +++ b/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/adapter/brevo/BrevoTargetStrategyTest.kt @@ -0,0 +1,86 @@ +package net.blueshell.api.platform.integration.cohort.adapter.brevo + +import net.blueshell.api.platform.integration.contact.adapter.ContactListAdapter +import net.blueshell.api.platform.integration.contact.adapter.ContactServiceException +import net.blueshell.api.shared.enums.ContactSystem +import net.blueshell.clients.brevo.api.ContactsApi +import net.blueshell.clients.brevo.model.GetFolder +import net.blueshell.clients.brevo.model.GetFolders200Response +import net.blueshell.clients.brevo.model.GetLists200Response +import net.blueshell.clients.brevo.model.GetLists200ResponseListsInner +import net.blueshell.clients.brevo.model.GetProcessesSortParameter +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.web.client.RestClientResponseException + +class BrevoTargetStrategyTest { + private val contactsApi: ContactsApi = mock() + private val lists: ContactListAdapter = mock { + whenever(it.system).thenReturn(ContactSystem.BREVO) + } + private val strategy = BrevoTargetStrategy(listOf(lists), contactsApi) + + @Test + fun `pages folders and lists and maps folder names and counts`() { + whenever(contactsApi.getFolders(eq(50L), eq(0L), eq(GetProcessesSortParameter.ASC))) + .thenReturn(GetFolders200Response().count(51).folders((1L..50L).map { folder(it, "Folder $it") })) + whenever(contactsApi.getFolders(eq(50L), eq(50L), eq(GetProcessesSortParameter.ASC))) + .thenReturn(GetFolders200Response().count(51).folders(listOf(folder(51L, "Contribution periods")))) + whenever(contactsApi.getLists(eq(50L), eq(0L), eq(GetProcessesSortParameter.ASC))) + .thenReturn(GetLists200Response().count(51).lists((1L..50L).map { list(it, "List $it", 1L) })) + whenever(contactsApi.getLists(eq(50L), eq(50L), eq(GetProcessesSortParameter.ASC))) + .thenReturn(GetLists200Response().count(51).lists(listOf(list(99L, "Paid 2026", 51L, 728L)))) + + val targets = strategy.catalog(null) + val paid = targets.single { it.externalId == "99" } + + assertThat(targets).hasSize(51) + assertThat(paid.folderLabel).isEqualTo("Contribution periods") + assertThat(paid.memberCount).isEqualTo(728L) + } + + @Test + fun `filters by query after fetching the bounded catalog`() { + whenever(contactsApi.getFolders(eq(50L), eq(0L), eq(GetProcessesSortParameter.ASC))) + .thenReturn(GetFolders200Response().count(1).folders(listOf(folder(1L, "Members")))) + whenever(contactsApi.getLists(eq(50L), eq(0L), eq(GetProcessesSortParameter.ASC))) + .thenReturn(GetLists200Response().count(2).lists(listOf(list(10L, "Guests", 1L), list(11L, "Paid", 1L)))) + + val targets = strategy.catalog("paid") + + assertThat(targets).extracting { it.externalId }.containsExactly("11") + } + + @Test + fun `treats Brevo rate limiting as retryable`() { + doThrow(error(429)).whenever(contactsApi) + .getFolders(eq(50L), eq(0L), eq(GetProcessesSortParameter.ASC)) + + assertThatThrownBy { strategy.catalog(null) } + .isInstanceOf(ContactServiceException::class.java) + .hasCauseInstanceOf(RestClientResponseException::class.java) + } + + private fun folder(id: Long, name: String): GetFolder = + GetFolder().id(id).name(name) + + private fun list( + id: Long, + name: String, + folderId: Long, + unique: Long = 10L + id, + ): GetLists200ResponseListsInner = + GetLists200ResponseListsInner() + .id(id) + .name(name) + .folderId(folderId) + .uniqueSubscribers(unique) + + private fun error(status: Int): RestClientResponseException = + RestClientResponseException("$status error", status, "error", null, ByteArray(0), null) +} diff --git a/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortProvisioningServiceTest.kt b/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortProvisioningServiceTest.kt index b47c683f9..6982133f8 100644 --- a/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortProvisioningServiceTest.kt +++ b/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortProvisioningServiceTest.kt @@ -11,6 +11,11 @@ import net.blueshell.api.platform.integration.cohort.persistence.CohortSubject import net.blueshell.api.platform.integration.cohort.persistence.CohortSubjectType import net.blueshell.api.platform.integration.cohort.persistence.repository.CohortRepository import net.blueshell.api.platform.integration.cohort.persistence.repository.CohortSubjectRepository +import net.blueshell.api.platform.integration.cohort.port.out.ExternalMember +import net.blueshell.api.platform.integration.cohort.port.out.ExternalTarget +import net.blueshell.api.platform.integration.cohort.port.out.TargetCapability +import net.blueshell.api.platform.integration.cohort.port.out.TargetDescriptor +import net.blueshell.api.platform.integration.cohort.port.out.TargetStrategy import net.blueshell.api.shared.enums.TargetSystem import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -19,7 +24,7 @@ class CohortProvisioningServiceTest { private val subjects: CohortSubjectRepository = mockk(relaxed = true) private val cohorts: CohortRepository = mockk(relaxed = true) - private val service = CohortProvisioningService(subjects, cohorts) + private val service = CohortProvisioningService(subjects, cohorts, TargetStrategies(listOf(TestStrategy))) private fun spec(system: TargetSystem = TargetSystem.BREVO) = CohortProvisioningSpec( factKind = CohortFactKind.COMMITTEE, @@ -108,4 +113,21 @@ class CohortProvisioningServiceTest { assertThat(result).isEqualTo(CohortProvisioningResult.Ready(googleCohort)) verify(exactly = 0) { subjects.save(any()) } } + + private object TestStrategy : TargetStrategy { + override val descriptor = TargetDescriptor( + system = TargetSystem.BREVO, + kind = CohortKind.LIST, + systemLabel = "Brevo", + targetLabel = "Brevo list", + idLabel = "List id", + capabilities = setOf(TargetCapability.CREATE), + ) + + override fun members(target: ExternalTarget): List = emptyList() + override fun add(target: ExternalTarget, externalUserId: String) = Unit + override fun remove(target: ExternalTarget, externalUserId: String) = Unit + override fun create(label: String, folder: String?): ExternalTarget = error("not used") + override fun delete(target: ExternalTarget) = Unit + } } diff --git a/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingServiceTest.kt b/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingServiceTest.kt index 61121a693..3cfb50e7c 100644 --- a/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingServiceTest.kt +++ b/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingServiceTest.kt @@ -3,8 +3,13 @@ package net.blueshell.api.platform.integration.cohort.application import net.blueshell.api.platform.integration.cohort.persistence.Cohort import net.blueshell.api.platform.integration.cohort.persistence.repository.CohortRepository import net.blueshell.api.platform.integration.cohort.persistence.repository.CohortSubjectRepository -import net.blueshell.api.platform.integration.cohort.port.out.CohortPort -import net.blueshell.api.platform.integration.cohort.port.out.CohortPortRegistry +import net.blueshell.api.platform.integration.cohort.persistence.CohortKind +import net.blueshell.api.platform.integration.cohort.persistence.CohortSubject +import net.blueshell.api.platform.integration.cohort.persistence.CohortSubjectType +import net.blueshell.api.platform.integration.cohort.port.out.ExternalTarget +import net.blueshell.api.platform.integration.cohort.port.out.TargetCapability +import net.blueshell.api.platform.integration.cohort.port.out.TargetDescriptor +import net.blueshell.api.platform.integration.cohort.port.out.TargetStrategy import net.blueshell.api.shared.enums.TargetSystem import net.blueshell.api.shared.job.CohortJobs import net.blueshell.api.shared.job.NonRetryableJobException @@ -27,7 +32,7 @@ import org.springframework.web.server.ResponseStatusException import java.util.Optional /** - * Unit test for [CohortTargetingService]. The port edges (CohortPort, + * Unit test for [CohortTargetingService]. The strategy edges, * CohortTargetIds, job dispatcher) are the seams — no Spring context. * A no-op transaction manager runs the TransactionTemplate callbacks inline. */ @@ -36,9 +41,9 @@ class CohortTargetingServiceTest { private val cohortRepo = mock() private val subjectRepo = mock() private val targetIds = mock() - private val registry = mock() private val jobs = mock() - private val port = mock() + private val strategy = mock() + private val strategies: TargetStrategies private val txManager = object : PlatformTransactionManager { override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus() @@ -46,7 +51,15 @@ class CohortTargetingServiceTest { override fun rollback(status: TransactionStatus) {} } - private val service = CohortTargetingService(cohortRepo, subjectRepo, targetIds, registry, jobs, txManager) + private val service: CohortTargetingService + + init { + whenever(strategy.system).thenReturn(TargetSystem.BREVO) + whenever(strategy.descriptor).thenReturn(brevoDescriptor) + whenever(strategy.resolve(any())).thenReturn(null) + strategies = TargetStrategies(listOf(strategy)) + service = CohortTargetingService(cohortRepo, subjectRepo, targetIds, strategies, jobs, txManager) + } @Test fun `create does not touch the provider when the subject already maps the system`() { @@ -57,7 +70,7 @@ class CohortTargetingServiceTest { service.create(1L, TargetSystem.BREVO, "Members", null) } - verifyNoInteractions(registry) + verify(strategy, never()).create(any(), any()) verify(cohortRepo, never()).save(any()) } @@ -66,13 +79,12 @@ class CohortTargetingServiceTest { val saved = mock { on { id } doReturn 42L } whenever(subjectRepo.findById(1L)).thenReturn(Optional.of(mock())) whenever(cohortRepo.findBySubjectIdAndSystem(1L, "BREVO")).thenReturn(null) - whenever(registry.require(TargetSystem.BREVO)).thenReturn(port) - whenever(port.createCohort("Members", "Lists")).thenReturn("999") + whenever(strategy.create("Members", "Lists")).thenReturn(target("999", "Members", "Lists")) whenever(cohortRepo.save(any())).thenReturn(saved) val row = service.create(1L, TargetSystem.BREVO, "Members", "Lists") - verify(port).createCohort("Members", "Lists") + verify(strategy).create("Members", "Lists") verify(targetIds).record(saved, "999") assert(row.externalId == "999") } @@ -87,13 +99,12 @@ class CohortTargetingServiceTest { } whenever(cohortRepo.findById(7L)).thenReturn(Optional.of(cohort)) whenever(targetIds.find(cohort)).thenReturn(null) - whenever(registry.require(TargetSystem.BREVO)).thenReturn(port) assertThrows { service.materialize(7L) } - verify(port, never()).createCohort(any(), any()) + verify(strategy, never()).create(any(), any()) verify(targetIds, never()).record(any(), any()) } @@ -106,7 +117,7 @@ class CohortTargetingServiceTest { val ref = service.materialize(7L) assert(ref.externalId == "existing") - verifyNoInteractions(registry) + verify(strategy, never()).create(any(), any()) verify(targetIds, never()).record(any(), any()) } @@ -122,12 +133,28 @@ class CohortTargetingServiceTest { val row = service.linkExisting(1L, TargetSystem.BREVO, "list-123") + verify(strategy).resolve("list-123") verify(cohortRepo, never()).save(any()) verify(targetIds).record(cohort, "list-123") assert(row.cohort == cohort) assert(row.externalId == "list-123") } + @Test + fun `linkExisting allows ids that are not present in the catalog`() { + val subject = CohortSubject(CohortSubjectType.CUSTOM, "Members") + val saved = mock { on { id } doReturn 7L } + whenever(subjectRepo.findById(1L)).thenReturn(Optional.of(subject)) + whenever(cohortRepo.findBySubjectIdAndSystem(1L, "BREVO")).thenReturn(null) + whenever(cohortRepo.save(any())).thenReturn(saved) + whenever(strategy.resolve("missing-list")).thenReturn(null) + + service.linkExisting(1L, TargetSystem.BREVO, "missing-list") + + verify(strategy).resolve("missing-list") + verify(targetIds).record(saved, "missing-list") + } + @Test fun `switch enqueues delete-previous and reconcile when asked`() { val cohort = mock { on { system } doReturn "BREVO"; on { subjectId } doReturn 1L } @@ -136,6 +163,7 @@ class CohortTargetingServiceTest { service.switchTarget(1L, 7L, "new-list", deletePrevious = true, reconcileNow = true) + verify(strategy).resolve("new-list") verify(targetIds).record(cohort, "new-list") verify(jobs).enqueue( eq(CohortJobs.DeleteExternalTarget), @@ -173,10 +201,23 @@ class CohortTargetingServiceTest { @Test fun `deleteTarget calls the provider`() { - whenever(registry.require(TargetSystem.BREVO)).thenReturn(port) - service.deleteTarget(TargetSystem.BREVO, "stale-list") - verify(port).deleteCohort("stale-list") + verify(strategy).delete(target("stale-list", "stale-list", null)) + } + + private fun target(id: String, label: String, folder: String?) = + ExternalTarget(TargetSystem.BREVO, id, CohortKind.LIST, label, folder) + + private companion object { + val brevoDescriptor = TargetDescriptor( + system = TargetSystem.BREVO, + kind = CohortKind.LIST, + systemLabel = "Brevo", + targetLabel = "Brevo list", + idLabel = "List id", + folderLabel = "Folder", + capabilities = setOf(TargetCapability.CATALOG, TargetCapability.CREATE, TargetCapability.DELETE), + ) } } diff --git a/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/TargetCatalogTest.kt b/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/TargetCatalogTest.kt new file mode 100644 index 000000000..c9c9101b9 --- /dev/null +++ b/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/TargetCatalogTest.kt @@ -0,0 +1,68 @@ +package net.blueshell.api.platform.integration.cohort.application + +import net.blueshell.api.platform.integration.cohort.persistence.Cohort +import net.blueshell.api.platform.integration.cohort.persistence.CohortKind +import net.blueshell.api.platform.integration.cohort.persistence.repository.CohortRepository +import net.blueshell.api.platform.integration.cohort.port.out.ExternalMember +import net.blueshell.api.platform.integration.cohort.port.out.ExternalTarget +import net.blueshell.api.platform.integration.cohort.port.out.TargetCapability +import net.blueshell.api.platform.integration.cohort.port.out.TargetDescriptor +import net.blueshell.api.platform.integration.cohort.port.out.TargetStrategy +import net.blueshell.api.shared.enums.TargetSystem +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class TargetCatalogTest { + private val cohorts: CohortRepository = mock() + private val strategy = RecordingStrategy() + private val catalog = TargetCatalog(TargetStrategies(listOf(strategy)), cohorts) + + @Test + fun `descriptors come from registered target strategies`() { + assertThat(catalog.descriptors()).containsExactly(strategy.descriptor) + } + + @Test + fun `search annotates linked cohort ids from active mappings`() { + val linked = Cohort("BREVO", CohortKind.LIST, "Members").apply { + id = 42L + externalId = "2" + } + whenever(cohorts.findAllBySystem("BREVO")).thenReturn(listOf(linked)) + + val results = catalog.search(TargetSystem.BREVO, "members") + + assertThat(strategy.queries).containsExactly("members") + assertThat(results).extracting { it.linkedCohortId }.containsExactly(null, 42L) + } + + private class RecordingStrategy : TargetStrategy { + val queries = mutableListOf() + + override val descriptor = TargetDescriptor( + system = TargetSystem.BREVO, + kind = CohortKind.LIST, + systemLabel = "Brevo", + targetLabel = "Brevo list", + idLabel = "List id", + folderLabel = "Folder", + capabilities = setOf(TargetCapability.CATALOG), + ) + + override fun catalog(query: String?): List { + queries += query + return listOf(target("1", "Guests"), target("2", "Members")) + } + + override fun members(target: ExternalTarget): List = emptyList() + override fun add(target: ExternalTarget, externalUserId: String) = Unit + override fun remove(target: ExternalTarget, externalUserId: String) = Unit + override fun create(label: String, folder: String?): ExternalTarget = error("not used") + override fun delete(target: ExternalTarget) = Unit + + private fun target(id: String, label: String) = + ExternalTarget(TargetSystem.BREVO, id, CohortKind.LIST, label) + } +} From d15d1a76c33395bad66bdec50fadc3018c812223 Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Tue, 30 Jun 2026 11:01:49 +0000 Subject: [PATCH 2/3] feat(cohort): descriptor-driven external target picker Replace the Brevo-specific picker with one component driven by each system's target descriptor: the CATALOG capability renders a searchable combobox (fetched once, filtered client-side), otherwise a manual id field, with create/folder fields shown per capability. One picker serves Brevo lists, and future Discord roles / Google groups, with no per-system branches. --- .../src/domains/cohorts/adapters/cohorts.ts | 60 +++++++++++++ .../cohorts/components/TargetPickerModal.vue | 78 ++++++++++++++--- .../cohorts/composables/useTargetPicker.ts | 64 +++++++++++++- .../composables/useTargetPicker.test.ts | 84 +++++++++++++++++++ 4 files changed, 273 insertions(+), 13 deletions(-) create mode 100644 services/frontend/tests/unit/domains/cohorts/composables/useTargetPicker.test.ts diff --git a/services/frontend/src/domains/cohorts/adapters/cohorts.ts b/services/frontend/src/domains/cohorts/adapters/cohorts.ts index 93313c486..af7073a86 100644 --- a/services/frontend/src/domains/cohorts/adapters/cohorts.ts +++ b/services/frontend/src/domains/cohorts/adapters/cohorts.ts @@ -9,12 +9,16 @@ import { getDrift, linkExistingTarget, linkUser, + listCohortTargetSystems, + searchCohortTargets, switchTarget, } from "@/services/api" import type { CohortMapping as ApiCohortMapping, DriftReport as ApiDriftReport, + ExternalTarget as ApiExternalTarget, ExtraRow as ApiExtraRow, + TargetDescriptor as ApiTargetDescriptor, } from "@/services/api" export type TargetSystem = ApiDriftReport["system"] @@ -133,6 +137,28 @@ export type TargetMapping = { export type AddTargetResult = { type: "ok"; mapping: TargetMapping } | { type: "conflict" } +export type TargetCapability = ApiTargetDescriptor["capabilities"][number] + +export type TargetDescriptor = { + system: TargetSystem + kind: ApiTargetDescriptor["kind"] + systemLabel: string + targetLabel: string + idLabel: string + folderLabel: string | null + capabilities: TargetCapability[] +} + +export type ExternalTarget = { + system: TargetSystem + externalId: string + kind: ApiExternalTarget["kind"] + label: string + folderLabel: string | null + memberCount: number | null + linkedCohortId: number | null +} + function toTargetMapping(raw: ApiCohortMapping): TargetMapping { return { cohortId: raw.cohortId, @@ -193,3 +219,37 @@ export async function switchCohortTarget( }) return toTargetMapping(res.data!) } + +export async function fetchTargetDescriptors(): Promise { + const res = await listCohortTargetSystems() + return (res.data ?? []).map(toTargetDescriptor) +} + +export async function fetchTargetOptions(system: TargetSystem): Promise { + const res = await searchCohortTargets({ path: { system } }) + return (res.data ?? []).map(toExternalTarget) +} + +function toTargetDescriptor(raw: ApiTargetDescriptor): TargetDescriptor { + return { + system: raw.system, + kind: raw.kind, + systemLabel: raw.systemLabel, + targetLabel: raw.targetLabel, + idLabel: raw.idLabel, + folderLabel: raw.folderLabel ?? null, + capabilities: [...raw.capabilities], + } +} + +function toExternalTarget(raw: ApiExternalTarget): ExternalTarget { + return { + system: raw.system, + externalId: raw.externalId, + kind: raw.kind, + label: raw.label, + folderLabel: raw.folderLabel ?? null, + memberCount: raw.memberCount ?? null, + linkedCohortId: raw.linkedCohortId ?? null, + } +} diff --git a/services/frontend/src/domains/cohorts/components/TargetPickerModal.vue b/services/frontend/src/domains/cohorts/components/TargetPickerModal.vue index 6071b993a..b2241145e 100644 --- a/services/frontend/src/domains/cohorts/components/TargetPickerModal.vue +++ b/services/frontend/src/domains/cohorts/components/TargetPickerModal.vue @@ -1,5 +1,5 @@