Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import net.blueshell.api.cohort.persistence.CohortMemberRepository
import net.blueshell.api.cohort.persistence.CohortRepository
import net.blueshell.api.cohort.persistence.CohortSubjectRepository
import net.blueshell.api.cohort.persistence.state
import net.blueshell.api.platform.integration.mock.MockCohortPort
import net.blueshell.api.platform.integration.mock.MockTargetStrategy
import net.blueshell.api.sync.persistence.ExternalIdMapping
import net.blueshell.api.sync.persistence.ExternalIdMappingRepository
import net.blueshell.api.shared.enums.Role
Expand Down Expand Up @@ -41,11 +41,11 @@ class CohortLedgerAutoflushIT : UserTestSupport() {
private lateinit var remediation: CohortRemediationService

@Autowired
private lateinit var mockCohortPort: MockCohortPort
private lateinit var mockTarget: MockTargetStrategy

@BeforeEach
fun resetCohortPort() {
mockCohortPort.clear()
fun resetTarget() {
mockTarget.clear()
}

@Test
Expand All @@ -65,7 +65,7 @@ class CohortLedgerAutoflushIT : UserTestSupport() {
),
)
externalIds.saveAndFlush(ExternalIdMapping("USER", user.id!!, TargetSystem.BREVO.name, "ext-1"))
mockCohortPort.seedMember("ext-1", "list-99", "Ada Remote")
mockTarget.seedMember("ext-1", "list-99", "Ada Remote")

assertThatCode { remediation.verifyCohort(cohort.id!!) }.doesNotThrowAnyException()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import net.blueshell.api.cohort.persistence.CohortSubject
import net.blueshell.api.cohort.persistence.CohortSubjectType
import net.blueshell.api.cohort.persistence.CohortRepository
import net.blueshell.api.cohort.persistence.CohortSubjectRepository
import net.blueshell.api.platform.integration.mock.MockCohortPort
import net.blueshell.api.platform.integration.mock.MockTargetStrategy
import net.blueshell.api.sync.api.ExternalIdMappingService.Companion.USER_AGGREGATE
import net.blueshell.api.sync.persistence.ExternalIdMapping
import net.blueshell.api.sync.persistence.ExternalIdMappingRepository
Expand All @@ -24,8 +24,8 @@ import tools.jackson.databind.ObjectMapper
* path. [AbstractJsonJobHandler.handle][net.blueshell.api.jobs.api.AbstractJsonJobHandler]
* is `@Transactional`, so a job is always dispatched with a transaction
* active. The application services suspend it (`PROPAGATION_NOT_SUPPORTED`)
* around every [CohortPort][net.blueshell.api.cohort.domain.CohortPort]
* call; [MockCohortPort] records whether a transaction was actually active
* around every [TargetStrategy][net.blueshell.api.cohort.domain.TargetStrategy]
* call; [MockTargetStrategy] records whether a transaction was actually active
* at each call so we can assert it never is.
*/
@SpringBootTest
Expand All @@ -41,7 +41,7 @@ class CohortProviderTransactionBoundaryIT : UserTestSupport() {

@Autowired private lateinit var externalIds: ExternalIdMappingRepository

@Autowired private lateinit var port: MockCohortPort
@Autowired private lateinit var port: MockTargetStrategy

@Autowired private lateinit var objectMapper: ObjectMapper

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import org.springframework.modulith.PackageInfo
* Code-defined audiences — "active members", "paid for this period" — evaluated into `CohortMember`
* rows and reconciled against the mailing list or group that stands for them in an external system.
*
* `port/in` is what other modules may call and `port/out` is the driven side, one `CohortPort` per
* target system; neither is a REST surface, the controllers under `adapter/web` are.
* One `TargetStrategy` per target system stands between the module and that system; the REST
* surface is the controllers under `web`.
*/
@PackageInfo
@ApplicationModule(
Expand All @@ -26,7 +26,7 @@ import org.springframework.modulith.PackageInfo
// committees — this is a definition reaching into persistence, and it
// wants a projection published through committee :: api instead.
"committee :: entities",
// List membership is pushed through ContactListAdapter.
// The list catalogue, its folders and its membership all go through ContactListAdapter.
"contact :: api",
// Period cohorts are built from ContributionService and
// ContributionPeriodService, and react to ContributionChanged.
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,21 @@ package net.blueshell.api.cohort.domain

import net.blueshell.api.cohort.persistence.CohortKind
import net.blueshell.api.contact.api.ContactListAdapter
import net.blueshell.api.contact.api.ContactServiceException
import net.blueshell.api.contact.api.ContactListRef
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.GetContactsSortParameter
import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClientResponseException

/**
* Brevo's [TargetStrategy], stated entirely in terms of [ContactListAdapter]: the catalogue, the
* folders and the membership writes are all that module's, so nothing Brevo-shaped reaches here
* beyond the numeric id its lists are keyed by (API ADR-019).
*/
@Service
@Profile("!test & !dev")
class BrevoTargetStrategy(
contactListAdapters: List<ContactListAdapter>,
private val contactsApi: ContactsApi,
) : TargetStrategy {
private val lists = contactListAdapters.single { it.system == ContactSystem.BREVO }

Expand All @@ -40,7 +39,9 @@ class BrevoTargetStrategy(

override fun catalog(query: String?): List<ExternalTarget> {
val q = query?.trim()?.lowercase().orEmpty()
return listTargets(folderNames())
val folderNames = lists.listFolders()
return lists.listAll()
.map { it.toTarget(folderNames[it.folderId]) }
.filter { it.matches(q) }
.sortedWith(compareBy({ it.folderLabel.orEmpty() }, { it.label }))
}
Expand Down Expand Up @@ -99,66 +100,26 @@ class BrevoTargetStrategy(
private fun pathTo(folder: String?): List<String> =
listOfNotNull(descriptor.systemLabel, folder?.takeIf { it.isNotBlank() })

private fun folderNames(): Map<String, String> =
page("folders") { limit, offset ->
contactsApi.getFolders(limit, offset, GetContactsSortParameter.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, GetContactsSortParameter.ASC).let { page ->
Page(page.count, page.lists.orEmpty().map { it.toTarget(folderNames, ::pathTo) })
}
}

private fun <T> page(kind: String, fetch: (Long, Long) -> Page<T>): List<T> {
val results = mutableListOf<T>()
var offset = 0L
while (true) {
val page = try {
fetch(PAGE_SIZE, offset)
} 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)
}
results += page.items
if (page.items.size < PAGE_SIZE || page.count != null && results.size >= page.count) break
offset += PAGE_SIZE
}
return results
}
private fun ContactListRef.toTarget(folder: String?): ExternalTarget = ExternalTarget(
system = system,
externalId = externalListId.toString(),
kind = descriptor.kind,
label = name,
folderLabel = folder,
memberCount = memberCount,
path = pathTo(folder),
)

private fun ExternalTarget.matches(query: String): Boolean =
query.isBlank() ||
externalId == query ||
label.lowercase().contains(query) ||
folderLabel.orEmpty().lowercase().contains(query)

/**
* Brevo keys lists and contacts by number, so an id that is not one names nothing there.
* The sole conversion between the port's [String] ids and Brevo's own.
*/
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>,
pathTo: (String?) -> List<String>,
): ExternalTarget {
val folder = folderNames[folderId.toString()]
return ExternalTarget(
system = TargetSystem.BREVO,
externalId = id.toString(),
kind = CohortKind.LIST,
label = name,
folderLabel = folder,
memberCount = uniqueSubscribers,
path = pathTo(folder),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import java.time.LocalDateTime

/**
* Drives one `(user, cohort)` sync end to end: resolves both external ids, picks the
* [CohortPort] for the cohort's system and asks it to apply the change.
* [TargetStrategy] for the cohort's system and asks it to apply the change.
*
* An ADD with no user external id enqueues `SyncContact` and throws retryably, so the retry
* lands once the contact exists. An ADD with no cohort target id fails terminally — linking a
Expand All @@ -28,7 +28,7 @@ import java.time.LocalDateTime
class CohortMembershipSyncService(
private val cohorts: CohortRepository,
private val ledger: CohortLedger,
private val registry: CohortPortRegistry,
private val strategies: TargetStrategies,
private val externalIds: ExternalIdMappingService,
private val targetIds: CohortTargetIds,
private val jobs: TrackedJobDispatcher,
Expand All @@ -52,15 +52,15 @@ class CohortMembershipSyncService(
val system = runCatching { TargetSystem.valueOf(cohort.system) }.getOrElse {
throw NonRetryableJobException("Cohort $cohortId has unknown system '${cohort.system}'")
}
val port = registry.require(system)
val strategy = strategies.requireForJob(system)

when (intent) {
SyncCohortMembershipIntent.ADD -> add(userId, cohort, port)
SyncCohortMembershipIntent.REMOVE -> remove(userId, cohort, port)
SyncCohortMembershipIntent.ADD -> add(userId, cohort, strategy)
SyncCohortMembershipIntent.REMOVE -> remove(userId, cohort, strategy)
}
}

private fun add(userId: Long, cohort: Cohort, port: CohortPort) {
private fun add(userId: Long, cohort: Cohort, strategy: TargetStrategy) {
val cohortId = cohort.id!!
val system = cohort.system
val externalUserId = externalIds.find(USER_AGGREGATE, userId, system)?.externalId
Expand All @@ -74,7 +74,7 @@ class CohortMembershipSyncService(
if (externalCohortId == null) {
throw CohortTargetNotLinkedException(cohortId, system)
}
outsideTransaction.executeWithoutResult { port.addMember(externalUserId, externalCohortId) }
outsideTransaction.executeWithoutResult { strategy.add(strategy.handle(externalCohortId), externalUserId) }

// Stamp the ledger so the desired row reads as synced. This is the
// primary path to healthy; reconcile only verifies afterwards.
Expand All @@ -84,7 +84,7 @@ class CohortMembershipSyncService(
log.debug("Added user {} to {} cohort {} (ext={})", userId, system, cohortId, externalCohortId)
}

private fun remove(userId: Long, cohort: Cohort, port: CohortPort) {
private fun remove(userId: Long, cohort: Cohort, strategy: TargetStrategy) {
val cohortId = cohort.id!!
val system = cohort.system
val externalUserId = externalIds.find(USER_AGGREGATE, userId, system)?.externalId
Expand All @@ -96,7 +96,7 @@ class CohortMembershipSyncService(
)
return
}
outsideTransaction.executeWithoutResult { port.removeMember(externalUserId, externalCohortId) }
outsideTransaction.executeWithoutResult { strategy.remove(strategy.handle(externalCohortId), externalUserId) }
log.debug("Removed user {} from {} cohort {} (ext={})", userId, system, cohortId, externalCohortId)
}

Expand Down

This file was deleted.

This file was deleted.

Loading
Loading