Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion services/api/openapi.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<ContactListAdapter>,
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<ExternalTarget> {
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<ExternalMember> =
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<String, String> =
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<String, String>): List<ExternalTarget> =
page("lists") { limit, offset ->
contactsApi.getLists(limit, offset, GetProcessesSortParameter.ASC).let { page ->
Page(page.count, page.lists.orEmpty().map { it.toTarget(folderNames) })
}
}

private fun <T> page(kind: String, fetch: (Long, Long) -> Page<T>): List<T> {
val results = mutableListOf<T>()
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 <T> fetchPage(kind: String, fetch: () -> Page<T>): Page<T> = 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<T>(val count: Long?, val items: List<T>)

companion object {
const val PAGE_SIZE: Long = 50
private val log = LoggerFactory.getLogger(BrevoTargetStrategy::class.java)
}
}

private fun GetLists200ResponseListsInner.toTarget(folderNames: Map<String, String>): 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,
)
}
Original file line number Diff line number Diff line change
@@ -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<TargetDescriptorResponse> =
catalog.descriptors().map { it.toResponse() }

@GetMapping("/{system}")
@Operation(operationId = "searchCohortTargets")
fun targets(
@PathVariable system: TargetSystem,
@RequestParam(required = false) query: String?,
): List<ExternalTargetResponse> =
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<TargetCapability>,
)

@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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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")
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}!!
}

Expand All @@ -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)
Expand Down Expand Up @@ -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) =
Expand All @@ -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?)
Expand Down
Loading
Loading