Skip to content

Commit 50ef147

Browse files
committed
Persist participant roleAssignment in StagedParticipantGroup
Preparing for separate creation and invitation for new participant groups. This change provides the ability to modify `StagedParticipantGroup` before they are invited. This change requires data migration, currently role assignments are persisted in deployment subsystem. For implementations with event-driven databases, the `ParticipantGroupAdded` event now takes new parameters consisting of a `Set<AssignedParticipantRoles> and an optional `name: String?`.
1 parent a22826d commit 50ef147

File tree

26 files changed

+400
-118
lines changed

26 files changed

+400
-118
lines changed

carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceHost.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,11 @@ class RecruitmentServiceHost(
133133
val recruitment = getRecruitmentOrThrow( studyId )
134134
val (protocol, invitations) = recruitment.createInvitations( group )
135135

136-
// In case the same participants have been invited before,
136+
// In case the same participants with the same roles have been invited with the same group name before,
137137
// and that deployment is still running, return the existing group.
138-
// TODO: The same participants might be invited for different role names, which we currently cannot differentiate between.
139-
val toDeployParticipantIds = group.map { it.participantId }.toSet()
140138
val deployedStatus = recruitment.participantGroups.entries
141-
.firstOrNull { (_, group) ->
142-
group.participantIds == toDeployParticipantIds && group.name == name
139+
.firstOrNull { (_, existingGroup) ->
140+
existingGroup.roleAssignments == group && existingGroup.name == name
143141
}
144142
?.let { deploymentService.getStudyDeploymentStatus( it.key ) }
145143
if ( deployedStatus != null && deployedStatus !is StudyDeploymentStatus.Stopped )
@@ -148,7 +146,7 @@ class RecruitmentServiceHost(
148146
}
149147

150148
// Create participant group and mark as deployed.
151-
val participantGroup = recruitment.addParticipantGroup( toDeployParticipantIds, name, uuidFactory.randomUUID() )
149+
val participantGroup = recruitment.addParticipantGroup( group, name, uuidFactory.randomUUID() )
152150
participantGroup.markAsDeployed()
153151
participantRepository.updateRecruitment( recruitment )
154152

carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/users/ParticipantGroupStatus.kt

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ sealed class ParticipantGroupStatus
3535
*/
3636
abstract val participants: Set<Participant>
3737

38+
/**
39+
* The participant role assignments in this group.
40+
*/
41+
abstract val assignedParticipantRoles: Set<AssignedParticipantRoles>
42+
3843

3944
/**
4045
* The [participants] have not yet been invited. The list of participants can still be modified.
@@ -43,6 +48,7 @@ sealed class ParticipantGroupStatus
4348
data class Staged(
4449
override val id: UUID,
4550
override val participants: Set<Participant>,
51+
override val assignedParticipantRoles: Set<AssignedParticipantRoles>,
4652
override val name: String? = null
4753
) : ParticipantGroupStatus()
4854

@@ -60,6 +66,7 @@ sealed class ParticipantGroupStatus
6066
*/
6167
fun fromDeploymentStatus(
6268
participants: Set<Participant>,
69+
roleAssignment: Set<AssignedParticipantRoles>,
6370
deploymentStatus: StudyDeploymentStatus,
6471
name: String?
6572
): InDeployment
@@ -73,14 +80,29 @@ sealed class ParticipantGroupStatus
7380
is StudyDeploymentStatus.Invited,
7481
is StudyDeploymentStatus.DeployingDevices ->
7582
// If deployment was ready at one point (`startedOn`), consider the study 'Running'.
76-
if ( startedOn == null ) Invited( id, participants, createdOn, deploymentStatus, name )
77-
else Running( id, participants, createdOn, deploymentStatus, startedOn, name )
83+
if ( startedOn == null )
84+
{
85+
Invited( id, participants, roleAssignment, createdOn, deploymentStatus, name )
86+
}
87+
else
88+
{
89+
Running( id, participants, roleAssignment, createdOn, deploymentStatus, startedOn, name )
90+
}
7891
is StudyDeploymentStatus.Running ->
79-
Running( id, participants, createdOn, deploymentStatus, checkNotNull( startedOn ), name )
92+
Running(
93+
id,
94+
participants,
95+
roleAssignment,
96+
createdOn,
97+
deploymentStatus,
98+
checkNotNull( startedOn ),
99+
name
100+
)
80101
is StudyDeploymentStatus.Stopped ->
81102
Stopped(
82103
id,
83104
participants,
105+
roleAssignment,
84106
createdOn,
85107
deploymentStatus,
86108
startedOn,
@@ -111,6 +133,7 @@ sealed class ParticipantGroupStatus
111133
data class Invited(
112134
override val id: UUID,
113135
override val participants: Set<Participant>,
136+
override val assignedParticipantRoles: Set<AssignedParticipantRoles>,
114137
override val invitedOn: Instant,
115138
override val studyDeploymentStatus: StudyDeploymentStatus,
116139
override val name: String? = null
@@ -124,6 +147,7 @@ sealed class ParticipantGroupStatus
124147
data class Running(
125148
override val id: UUID,
126149
override val participants: Set<Participant>,
150+
override val assignedParticipantRoles: Set<AssignedParticipantRoles>,
127151
override val invitedOn: Instant,
128152
override val studyDeploymentStatus: StudyDeploymentStatus,
129153
/**
@@ -141,6 +165,7 @@ sealed class ParticipantGroupStatus
141165
data class Stopped(
142166
override val id: UUID,
143167
override val participants: Set<Participant>,
168+
override val assignedParticipantRoles: Set<AssignedParticipantRoles>,
144169
override val invitedOn: Instant,
145170
override val studyDeploymentStatus: StudyDeploymentStatus,
146171
/**

carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/Recruitment.kt

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package dk.cachet.carp.studies.domain.users
33
import dk.cachet.carp.common.application.EmailAddress
44
import dk.cachet.carp.common.application.UUID
55
import dk.cachet.carp.common.application.users.AccountIdentity
6+
import dk.cachet.carp.common.application.users.AssignedTo
67
import dk.cachet.carp.common.application.users.EmailAccountIdentity
78
import dk.cachet.carp.common.application.users.UsernameAccountIdentity
89
import dk.cachet.carp.common.domain.AggregateRoot
@@ -29,7 +30,10 @@ class Recruitment( val studyId: UUID, id: UUID = UUID.randomUUID(), createdOn: I
2930
sealed class Event : DomainEvent
3031
{
3132
data class ParticipantAdded( val participant: Participant ) : Event()
32-
data class ParticipantGroupAdded( val participantIds: Set<UUID> ) : Event()
33+
data class ParticipantGroupAdded(
34+
val participants: Set<AssignedParticipantRoles>,
35+
val name: String? = null
36+
) : Event()
3337
}
3438

3539

@@ -174,27 +178,29 @@ class Recruitment( val studyId: UUID, id: UUID = UUID.randomUUID(), createdOn: I
174178
private val _participantGroups: MutableMap<UUID, StagedParticipantGroup> = mutableMapOf()
175179

176180
/**
177-
* Create and add the participants identified by [participantIds] as a participant group, optionally with a [name]
178-
* representing this group.
181+
* Create and add the [participants] with assigned roles to a participant group, and optionally give it a [name].
179182
*
180-
* @throws IllegalArgumentException when one or more of the participants aren't in this recruitment.
183+
* @throws IllegalArgumentException when:
184+
* - one or more of the participants aren't in this recruitment.
185+
* - any of the participant roles specified in [participants] are not part of the configured study protocol.
181186
* @throws IllegalStateException when the study is not yet ready for deployment.
182187
*/
183188
fun addParticipantGroup(
184-
participantIds: Set<UUID>,
189+
participants: Set<AssignedParticipantRoles>,
185190
name: String? = null,
186191
id: UUID = UUID.randomUUID()
187192
): StagedParticipantGroup
188193
{
189-
require( participants.map { it.id }.containsAll( participantIds ) )
190-
{ "One of the participants for which to create a participant group isn't part of this recruitment." }
191-
check( getStatus() is RecruitmentStatus.ReadyForDeployment ) { "The study is not yet ready for deployment." }
194+
val status = getStatus()
195+
check( status is RecruitmentStatus.ReadyForDeployment ) { "The study is not yet ready for deployment." }
196+
197+
validateRoleAssignments( status.studyProtocol, participants )
192198

193199
val group = StagedParticipantGroup( id, name )
194-
group.addParticipants( participantIds )
200+
group.addParticipants( participants )
195201

196202
_participantGroups[ group.id ] = group
197-
event( Event.ParticipantGroupAdded( participantIds ) )
203+
event( Event.ParticipantGroupAdded( participants, null ) )
198204

199205
return group
200206
}
@@ -213,6 +219,7 @@ class Recruitment( val studyId: UUID, id: UUID = UUID.randomUUID(), createdOn: I
213219
val participants = group.participantIds.map { id -> _participants.first { it.id == id } }
214220
return ParticipantGroupStatus.InDeployment.fromDeploymentStatus(
215221
participants.toSet(),
222+
group.roleAssignments,
216223
studyDeploymentStatus,
217224
group.name
218225
)
@@ -223,4 +230,29 @@ class Recruitment( val studyId: UUID, id: UUID = UUID.randomUUID(), createdOn: I
223230
*/
224231
override fun getSnapshot( version: Int ): RecruitmentSnapshot =
225232
RecruitmentSnapshot.fromParticipantRecruitment( this, version )
233+
234+
/**
235+
* Validate that all participants exist in this recruitment and that all assigned roles are part of the protocol.
236+
*
237+
* @throws IllegalArgumentException when:
238+
* - one or more of the participants aren't in this recruitment.
239+
* - any of the participant roles specified in [participants] are not part of the configured study protocol.
240+
*/
241+
private fun validateRoleAssignments( protocol: StudyProtocolSnapshot, participants: Set<AssignedParticipantRoles> )
242+
{
243+
require( this.participants.map { it.id }.containsAll( participants.participantIds() ) )
244+
{ "One of the participants for which to create a participant group isn't part of this recruitment." }
245+
246+
val assignedParticipantRoles = participants
247+
.map { it.assignedRoles }
248+
.filterIsInstance<AssignedTo.Roles>()
249+
.flatMap { it.roleNames }
250+
.toSet()
251+
val availableRoles = protocol.participantRoles.map { it.role }.toSet()
252+
253+
assignedParticipantRoles.forEach { assigned ->
254+
require( assigned in availableRoles )
255+
{ "The assigned participant role \"$assigned\" is not part of the study protocol." }
256+
}
257+
}
226258
}

carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/StagedParticipantGroup.kt

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package dk.cachet.carp.studies.domain.users
22

33
import dk.cachet.carp.common.application.UUID
4+
import dk.cachet.carp.studies.application.users.AssignedParticipantRoles
5+
import dk.cachet.carp.studies.application.users.participantIds
46
import kotlinx.serialization.*
57

68

@@ -17,32 +19,35 @@ data class StagedParticipantGroup(
1719
/**
1820
* An optional name to represent the group of participants.
1921
*/
20-
val name: String? = null,
22+
var name: String? = null,
2123
)
2224
{
23-
private val _participantIds: MutableSet<UUID> = mutableSetOf()
25+
private val _roleAssignments: MutableSet<AssignedParticipantRoles> = mutableSetOf()
26+
/**
27+
* The roles assigned to participants in this group.
28+
*/
29+
val roleAssignments: Set<AssignedParticipantRoles>
30+
get() = _roleAssignments
2431
val participantIds: Set<UUID>
25-
get() = _participantIds
32+
get() = _roleAssignments.participantIds()
2633

2734
/**
2835
* Determines whether this participant group has been deployed.
2936
*/
3037
var isDeployed: Boolean = false
3138
private set
3239

33-
34-
3540
/**
36-
* Add participants with [participantIds] to this group.
41+
* Add [participants] with assigned roles to this group.
3742
* This is only allowed when the group hasn't been deployed yet.
3843
*
3944
* @throws IllegalStateException when this participant group is already deployed.
4045
*/
41-
fun addParticipants( participantIds: Set<UUID> )
46+
fun addParticipants( participants: Set<AssignedParticipantRoles> )
4247
{
43-
check( !isDeployed ) { "Can't add participant after a participant group has been deployed." }
48+
check( !isDeployed ) { "Can't add participants after a participant group has been deployed." }
4449

45-
_participantIds.addAll( participantIds )
50+
_roleAssignments.addAll( participants )
4651
}
4752

4853
/**

carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/infrastructure/versioning/RecruitmentServiceApiMigrator.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@ private val major1Minor2To3Migration =
7070
}
7171
}
7272

73-
// Remove newly added 'name' field from 'ParticipantGroupStatus'.
73+
// Remove newly added fields from `ParticipantGroupStatus`.
7474
json.remove( "name" )
75+
json.remove( "assignedParticipantRoles" )
7576
}
7677

7778
override fun migrateEvent( event: JsonObject ) = event

carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceTest.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ interface RecruitmentServiceTest
185185
setOf( assignedParticipant ),
186186
"Group 1"
187187
)
188+
189+
// Deploy the same participants in a group with a different name.
188190
val groupStatus2 = recruitmentService.inviteNewParticipantGroup(
189191
studyId,
190192
setOf( assignedParticipant ),
@@ -230,6 +232,7 @@ interface RecruitmentServiceTest
230232
val assignedP1 = AssignedParticipantRoles( p1.id, AssignedTo.All )
231233
recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignedP1 ), sameName )
232234

235+
// Second group with different participants but same group same name.
233236
val p2 = recruitmentService.addParticipant( studyId, EmailAddress( "test2@test.com" ) )
234237
val assignedP2 = AssignedParticipantRoles( p2.id, AssignedTo.All )
235238
recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignedP2 ), sameName )
@@ -239,6 +242,31 @@ interface RecruitmentServiceTest
239242
assertEquals( participantGroups[0].name, participantGroups[1].name )
240243
}
241244

245+
@Test
246+
fun inviteNewParticipantGroup_for_same_participant_different_roles_succeeds() = runTest {
247+
val (recruitmentService, studyService) = createSUT()
248+
val (studyId, protocol) = createLiveStudy( studyService )
249+
val participant1 = recruitmentService.addParticipant( studyId, EmailAddress( "test@test.com" ) )
250+
val participant2 = recruitmentService.addParticipant( studyId, EmailAddress( "test2@test.com" ) )
251+
val role1 = protocol.participantRoles.map { it.role }.first()
252+
val role2 = protocol.participantRoles.map { it.role }.last()
253+
254+
// First group: participant1 has role1, participant2 has role2.
255+
val assignedSpecificRoles1 = AssignedParticipantRoles( participant1.id, AssignedTo.Roles( setOf ( role1 ) ) )
256+
val assignedSpecificRoles2 = AssignedParticipantRoles( participant2.id, AssignedTo.Roles( setOf ( role2 ) ) )
257+
val groupStatus1 = recruitmentService.inviteNewParticipantGroup(
258+
studyId, setOf( assignedSpecificRoles1, assignedSpecificRoles2 )
259+
)
260+
261+
// Second group: participant1 has role2, participant2 has role1.
262+
val assignedSpecificRoles3 = AssignedParticipantRoles( participant1.id, AssignedTo.Roles( setOf ( role2 ) ) )
263+
val assignedSpecificRoles4 = AssignedParticipantRoles( participant2.id, AssignedTo.Roles( setOf ( role1 ) ) )
264+
val groupStatus2 = recruitmentService.inviteNewParticipantGroup(
265+
studyId, setOf( assignedSpecificRoles3, assignedSpecificRoles4 )
266+
)
267+
assertNotEquals( groupStatus1, groupStatus2 )
268+
}
269+
242270
@Test
243271
fun getParticipantGroupStatusList_returns_multiple_deployments() = runTest {
244272
val (recruitmentService, studyService) = createSUT()

carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/users/ParticipantGroupStatusTest.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class ParticipantGroupStatusTest
1818
private val participantGroupName = "Test group"
1919
private val deviceStatusList = emptyList<DeviceDeploymentStatus>()
2020
private val participants: Set<Participant> = emptySet()
21+
private val roleAssignments: Set<AssignedParticipantRoles> = emptySet()
2122
private val participantStatusList: List<ParticipantStatus> = emptyList()
2223

2324

@@ -28,6 +29,7 @@ class ParticipantGroupStatusTest
2829

2930
val status = ParticipantGroupStatus.InDeployment.fromDeploymentStatus(
3031
participants,
32+
roleAssignments,
3133
deployingDevices,
3234
participantGroupName
3335
)
@@ -42,6 +44,7 @@ class ParticipantGroupStatusTest
4244

4345
val status = ParticipantGroupStatus.InDeployment.fromDeploymentStatus(
4446
participants,
47+
roleAssignments,
4548
redeployingDevices,
4649
participantGroupName
4750
)

carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/domain/CreateTestObjects.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package dk.cachet.carp.studies.domain
33
import dk.cachet.carp.common.application.EmailAddress
44
import dk.cachet.carp.common.application.UUID
55
import dk.cachet.carp.common.application.devices.Smartphone
6+
import dk.cachet.carp.common.application.users.AssignedTo
67
import dk.cachet.carp.deployments.application.users.StudyInvitation
78
import dk.cachet.carp.protocols.domain.StudyProtocol
89
import dk.cachet.carp.protocols.infrastructure.test.createComplexProtocol
10+
import dk.cachet.carp.studies.application.users.AssignedParticipantRoles
911
import dk.cachet.carp.studies.domain.users.Recruitment
1012

1113

@@ -34,9 +36,10 @@ fun createComplexRecruitment(): Recruitment
3436
val studyId = UUID.randomUUID()
3537
val recruitment = Recruitment( studyId ).apply {
3638
val participant = addParticipant( EmailAddress( "test@test.com" ) )
39+
val roleAssignment = AssignedParticipantRoles( participant.id, AssignedTo.All )
3740
val name = "Test Group"
3841
lockInStudy( createComplexProtocol().getSnapshot(), StudyInvitation( "Test" ) )
39-
addParticipantGroup( setOf( participant.id ), name )
42+
addParticipantGroup( setOf( roleAssignment ), name )
4043
}
4144

4245
return recruitment

0 commit comments

Comments
 (0)