Skip to content

Commit f7d527e

Browse files
committed
Add separate endpoint for create and invite participant group methods to RecruitmentService
Also marks `InviteNewParticipantGroup` Endpoint and related method as deprecated, and replace their usage with new create and invite method respectively.
1 parent 50ef147 commit f7d527e

File tree

32 files changed

+4104
-70
lines changed

32 files changed

+4104
-70
lines changed

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,53 @@ interface RecruitmentService : ApplicationService<RecruitmentService, Recruitmen
7474
* - not all necessary participant roles part of the study have been assigned a participant
7575
* @throws IllegalStateException when the study is not yet ready for deployment.
7676
*/
77+
@Deprecated(
78+
"Use createParticipantGroup and inviteParticipantGroup instead",
79+
ReplaceWith(
80+
"inviteParticipantGroup( studyId, createParticipantGroup( studyId, UUID.randomUUID(), group, name ).id )",
81+
"dk.cachet.carp.common.application.UUID"
82+
),
83+
level = DeprecationLevel.WARNING
84+
)
7785
suspend fun inviteNewParticipantGroup(
7886
studyId: UUID,
7987
group: Set<AssignedParticipantRoles>,
8088
name: String? = null
8189
): ParticipantGroupStatus
8290

8391
/**
84-
* Get the status of all deployed participant groups in the study with the specified [studyId].
92+
* Create a new participant [group] of previously added participants for the study with the given [studyId],
93+
* and an optional [name] representing this group, but do not yet send out invitations.
94+
* This is used to create a group of participants which can be deployed at a later time.
95+
*
96+
* When [groupId] identifies an existing group, the latest status for this group is returned.
97+
*
98+
* @throws IllegalArgumentException when:
99+
* - a study with [studyId] does not exist
100+
* - an existing participant group with [groupId] has a different configuration
101+
* - any of the participant roles specified in [group] does not exist
102+
* @throws IllegalStateException when the study is not yet ready for deployment.
103+
*/
104+
suspend fun createParticipantGroup(
105+
studyId: UUID,
106+
groupId: UUID,
107+
group: Set<AssignedParticipantRoles>,
108+
name: String? = null
109+
): ParticipantGroupStatus
110+
111+
/**
112+
* Invite the participant group with the specified [groupId]
113+
* in the study with the given [studyId] to start participating in the study.
114+
*
115+
* @throws IllegalArgumentException when:
116+
* - a study with [studyId] or participant group with [groupId] does not exist.
117+
* - not all necessary participant roles part of the study have been assigned a participant
118+
* @throws IllegalStateException when group has already been deployed.
119+
*/
120+
suspend fun inviteParticipantGroup( studyId: UUID, groupId: UUID ): ParticipantGroupStatus
121+
122+
/**
123+
* Get the status of all participant groups in the study with the specified [studyId].
85124
*
86125
* @throws IllegalArgumentException when a study with [studyId] does not exist.
87126
*/

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

Lines changed: 122 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import dk.cachet.carp.studies.application.users.Participant
1616
import dk.cachet.carp.studies.application.users.ParticipantGroupStatus
1717
import dk.cachet.carp.studies.domain.users.ParticipantRepository
1818
import dk.cachet.carp.studies.domain.users.Recruitment
19+
import dk.cachet.carp.studies.domain.users.StagedParticipantGroup
1920
import kotlinx.datetime.Clock
2021

2122

@@ -124,57 +125,153 @@ class RecruitmentServiceHost(
124125
* - not all necessary participant roles part of the study have been assigned a participant
125126
* @throws IllegalStateException when the study is not yet ready for deployment.
126127
*/
128+
@Deprecated(
129+
"Use createParticipantGroup and inviteParticipantGroup instead",
130+
replaceWith = ReplaceWith(
131+
"inviteParticipantGroup( studyId, createParticipantGroup( studyId, UUID.randomUUID(), group, name ).id )",
132+
"dk.cachet.carp.common.application.UUID"
133+
)
134+
)
127135
override suspend fun inviteNewParticipantGroup(
128136
studyId: UUID,
129137
group: Set<AssignedParticipantRoles>,
130138
name: String?
131139
): ParticipantGroupStatus
132140
{
133141
val recruitment = getRecruitmentOrThrow( studyId )
134-
val (protocol, invitations) = recruitment.createInvitations( group )
142+
// Support the deprecated API by reusing groups that were created through repeated calls with the same
143+
// configuration (participants + name) even when no explicit groupId is supplied.
144+
val existingByConfiguration = recruitment.participantGroups.entries.firstOrNull { (_, existingGroup) ->
145+
existingGroup.roleAssignments == group && existingGroup.name == name
146+
}
147+
if ( existingByConfiguration != null )
148+
{
149+
val (existingId, existingGroup) = existingByConfiguration
150+
if ( !existingGroup.isDeployed )
151+
{
152+
// A staged group should still go through the deprecated flow and trigger invitations.
153+
return inviteParticipantGroup( studyId, existingId )
154+
}
135155

136-
// In case the same participants with the same roles have been invited with the same group name before,
137-
// and that deployment is still running, return the existing group.
138-
val deployedStatus = recruitment.participantGroups.entries
139-
.firstOrNull { (_, existingGroup) ->
140-
existingGroup.roleAssignments == group && existingGroup.name == name
156+
val deploymentStatus = deploymentService.getStudyDeploymentStatus( existingId )
157+
if ( deploymentStatus !is StudyDeploymentStatus.Stopped )
158+
{
159+
return recruitment.getParticipantGroupStatus( deploymentStatus )
141160
}
142-
?.let { deploymentService.getStudyDeploymentStatus( it.key ) }
143-
if ( deployedStatus != null && deployedStatus !is StudyDeploymentStatus.Stopped )
161+
}
162+
163+
val participantGroupStatus = createParticipantGroup( studyId, uuidFactory.randomUUID(), group, name )
164+
165+
if ( participantGroupStatus is ParticipantGroupStatus.InDeployment )
144166
{
145-
return recruitment.getParticipantGroupStatus( deployedStatus )
167+
// Already deployed.
168+
return participantGroupStatus
146169
}
147170

148-
// Create participant group and mark as deployed.
149-
val participantGroup = recruitment.addParticipantGroup( group, name, uuidFactory.randomUUID() )
150-
participantGroup.markAsDeployed()
171+
// Create study deployment, which sends out invitations.
172+
// TODO: If createParticipantGroup succeeds, but inviteParticipantGroup fails, repository state will be inconsistent.
173+
// This should use eventual consistency: https://github.com/imotions/carp.core-kotlin/issues/295
174+
175+
return inviteParticipantGroup( studyId, participantGroupStatus.id )
176+
}
177+
178+
/**
179+
* Create a new participant [group] of previously added participants for the study with the given [studyId],
180+
* and an optional [name] representing this group, but do not yet send out invitations.
181+
* This is used to create a group of participants which can be deployed at a later time.
182+
*
183+
* When [groupId] identifies an existing group, the latest status for this group is returned.
184+
*
185+
* @throws IllegalArgumentException when:
186+
* - a study with [studyId] does not exist
187+
* - any of the participant roles specified in [group] does not exist
188+
* - an existing participant group with [groupId] has a different configuration
189+
* @throws IllegalStateException when the study is not yet ready for deployment.
190+
*/
191+
override suspend fun createParticipantGroup(
192+
studyId: UUID,
193+
groupId: UUID,
194+
group: Set<AssignedParticipantRoles>,
195+
name: String?
196+
): ParticipantGroupStatus
197+
{
198+
val recruitment = getRecruitmentOrThrow( studyId )
199+
200+
// When the specified groupId already exists, ensure the configuration matches and return it.
201+
val existingById = recruitment.participantGroups[ groupId ]
202+
if ( existingById != null )
203+
{
204+
require( existingById.roleAssignments == group ) {
205+
"Participant group with ID \"$groupId\" exists but has different role assignments."
206+
}
207+
require( existingById.name == name ) {
208+
"Participant group with ID \"$groupId\" exists but has a different name."
209+
}
210+
211+
return if ( existingById.isDeployed )
212+
{
213+
val deploymentStatus = deploymentService.getStudyDeploymentStatus( groupId )
214+
recruitment.getParticipantGroupStatus( deploymentStatus )
215+
}
216+
else recruitment.getParticipantGroupStatus( existingById )
217+
}
218+
219+
// Otherwise, create participant group but do not deploy yet.
220+
val participantGroup = recruitment.addParticipantGroup( group, name, groupId )
151221
participantRepository.updateRecruitment( recruitment )
222+
return recruitment.getParticipantGroupStatus( participantGroup )
223+
}
224+
225+
/**
226+
* Invite the participant group with the specified [groupId]
227+
* in the study with the given [studyId] to start participating in the study.
228+
*
229+
* @throws IllegalArgumentException when:
230+
* - a study with [studyId] or participant group with [groupId] does not exist.
231+
* - not all necessary participant roles part of the study have been assigned a participant
232+
* @throws IllegalStateException when group has already been deployed.
233+
*/
234+
override suspend fun inviteParticipantGroup( studyId: UUID, groupId: UUID ): ParticipantGroupStatus
235+
{
236+
val (recruitment, participantGroup) = getRecruitmentAndGroupOrThrow( studyId, groupId )
237+
check( !participantGroup.isDeployed ) { "Participant group has already been deployed." }
152238

153-
// Create study deployment, which sends out invitations.
154239
// TODO: If the repository gets updated but `createStudyDeployment` fails, state will be inconsistent.
155240
// This should use eventual consistency: https://github.com/imotions/carp.core-kotlin/issues/295
156-
// And probably be separate requests: https://github.com/imotions/carp.core-kotlin/issues/319
241+
242+
val (protocol, invitations) = recruitment.createInvitations( participantGroup.roleAssignments )
157243
val deploymentStatus = deploymentService.createStudyDeployment( participantGroup.id, protocol, invitations )
158244

245+
// Mark participant group as deployed.
246+
participantGroup.markAsDeployed()
247+
participantRepository.updateRecruitment( recruitment )
159248
return recruitment.getParticipantGroupStatus( deploymentStatus )
160249
}
161250

162251
/**
163-
* Get the status of all deployed participant groups in the study with the specified [studyId].
252+
* Get the status of all participant groups in the study with the specified [studyId].
164253
*
165254
* @throws IllegalArgumentException when a study with [studyId] does not exist.
166255
*/
167256
override suspend fun getParticipantGroupStatusList( studyId: UUID ): List<ParticipantGroupStatus>
168257
{
169258
val recruitment: Recruitment = getRecruitmentOrThrow( studyId )
170259

260+
// Get status list for staged participant groups.
261+
val stagedParticipantGroups = recruitment.participantGroups
262+
.filter { !it.value.isDeployed }
263+
.values
264+
.map { recruitment.getParticipantGroupStatus( it ) }
265+
171266
// Get study deployment status list.
172-
val studyDeploymentIds = recruitment.participantGroups.keys
267+
val deployedStudyDeploymentIds = recruitment.participantGroups.filter { it.value.isDeployed }.keys
268+
269+
// Get status list for deployed participant groups.
173270
val studyDeploymentStatusList: List<StudyDeploymentStatus> =
174-
if ( studyDeploymentIds.isEmpty() ) emptyList()
175-
else deploymentService.getStudyDeploymentStatusList( studyDeploymentIds )
271+
if ( deployedStudyDeploymentIds.isEmpty() ) emptyList()
272+
else deploymentService.getStudyDeploymentStatusList( deployedStudyDeploymentIds )
176273

177-
return studyDeploymentStatusList.map { recruitment.getParticipantGroupStatus( it ) }
274+
return stagedParticipantGroups + studyDeploymentStatusList.map { recruitment.getParticipantGroupStatus( it ) }
178275
}
179276

180277
/**
@@ -186,19 +283,22 @@ class RecruitmentServiceHost(
186283
*/
187284
override suspend fun stopParticipantGroup( studyId: UUID, groupId: UUID ): ParticipantGroupStatus
188285
{
189-
val recruitment = getRecruitmentWithGroupOrThrow( studyId, groupId )
286+
val (recruitment, _) = getRecruitmentAndGroupOrThrow( studyId, groupId )
190287

191288
val deploymentStatus = deploymentService.stop( groupId )
192289
return recruitment.getParticipantGroupStatus( deploymentStatus )
193290
}
194291

195-
private suspend fun getRecruitmentWithGroupOrThrow( studyId: UUID, groupId: UUID ): Recruitment
292+
private suspend fun getRecruitmentAndGroupOrThrow(
293+
studyId: UUID,
294+
groupId: UUID
295+
): Pair<Recruitment, StagedParticipantGroup>
196296
{
197297
val recruitment: Recruitment = getRecruitmentOrThrow( studyId )
198298
val participations = recruitment.participantGroups[ groupId ]
199299
requireNotNull( participations ) { "Study deployment with the specified groupId not found." }
200300

201-
return recruitment
301+
return recruitment to participations
202302
}
203303

204304
private suspend fun getRecruitmentOrThrow( studyId: UUID ): Recruitment =

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,16 +215,34 @@ class Recruitment( val studyId: UUID, id: UUID = UUID.randomUUID(), createdOn: I
215215
val deploymentId = studyDeploymentStatus.studyDeploymentId
216216
val group: StagedParticipantGroup = requireNotNull( _participantGroups[ deploymentId ] )
217217
{ "A study deployment with ID \"$deploymentId\" is not part of this recruitment." }
218-
219-
val participants = group.participantIds.map { id -> _participants.first { it.id == id } }
218+
val participants = getParticipantsFor( group )
220219
return ParticipantGroupStatus.InDeployment.fromDeploymentStatus(
221-
participants.toSet(),
220+
participants,
222221
group.roleAssignments,
223222
studyDeploymentStatus,
224223
group.name
225224
)
226225
}
227226

227+
/**
228+
* Get the [ParticipantGroupStatus] for the staged participant [group].
229+
*
230+
* @throws IllegalArgumentException when the specified [group] is not part of this recruitment.
231+
*/
232+
fun getParticipantGroupStatus( group: StagedParticipantGroup ): ParticipantGroupStatus.Staged
233+
{
234+
val participants = getParticipantsFor( group )
235+
return ParticipantGroupStatus.Staged(
236+
id = group.id,
237+
participants = participants,
238+
assignedParticipantRoles = group.roleAssignments,
239+
name = group.name
240+
)
241+
}
242+
243+
private fun getParticipantsFor( group: StagedParticipantGroup ): Set<Participant> =
244+
group.participantIds.map { id -> _participants.first { it.id == id } }.toSet()
245+
228246
/**
229247
* Get an immutable snapshot of the current state of this [Recruitment] using the specified snapshot [version].
230248
*/

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,30 @@ class RecruitmentServiceDecorator(
3333
override suspend fun getParticipants( studyId: UUID ) =
3434
invoke( RecruitmentServiceRequest.GetParticipants( studyId ) )
3535

36+
@Deprecated(
37+
"Use CreateParticipantGroup and InviteParticipantGroup instead",
38+
ReplaceWith(
39+
"inviteParticipantGroup( studyId, createParticipantGroup( studyId, UUID.randomUUID(), group, name ).id )",
40+
"dk.cachet.carp.common.application.UUID"
41+
)
42+
)
43+
@Suppress( "DEPRECATION" )
3644
override suspend fun inviteNewParticipantGroup(
3745
studyId: UUID,
3846
group: Set<AssignedParticipantRoles>,
3947
name: String?
4048
) = invoke( RecruitmentServiceRequest.InviteNewParticipantGroup( studyId, group, name ) )
4149

50+
override suspend fun createParticipantGroup(
51+
studyId: UUID,
52+
groupId: UUID,
53+
group: Set<AssignedParticipantRoles>,
54+
name: String?
55+
) = invoke( RecruitmentServiceRequest.CreateParticipantGroup( studyId, groupId, group, name ) )
56+
57+
override suspend fun inviteParticipantGroup( studyId: UUID, groupId: UUID ) =
58+
invoke( RecruitmentServiceRequest.InviteParticipantGroup( studyId, groupId ) )
59+
4260
override suspend fun getParticipantGroupStatusList( studyId: UUID ) =
4361
invoke( RecruitmentServiceRequest.GetParticipantGroupStatusList( studyId ) )
4462

@@ -49,6 +67,7 @@ class RecruitmentServiceDecorator(
4967

5068
object RecruitmentServiceInvoker : ApplicationServiceInvoker<RecruitmentService, RecruitmentServiceRequest<*>>
5169
{
70+
@Suppress( "DEPRECATION" )
5271
override suspend fun RecruitmentServiceRequest<*>.invoke( service: RecruitmentService ): Any =
5372
when ( this )
5473
{
@@ -58,6 +77,9 @@ object RecruitmentServiceInvoker : ApplicationServiceInvoker<RecruitmentService,
5877
is RecruitmentServiceRequest.GetParticipants -> service.getParticipants( studyId )
5978
is RecruitmentServiceRequest.InviteNewParticipantGroup ->
6079
service.inviteNewParticipantGroup( studyId, group, name )
80+
is RecruitmentServiceRequest.CreateParticipantGroup ->
81+
service.createParticipantGroup( studyId, groupId, group, name )
82+
is RecruitmentServiceRequest.InviteParticipantGroup -> service.inviteParticipantGroup( studyId, groupId )
6183
is RecruitmentServiceRequest.GetParticipantGroupStatusList ->
6284
service.getParticipantGroupStatusList( studyId )
6385
is RecruitmentServiceRequest.StopParticipantGroup -> service.stopParticipantGroup( studyId, groupId )

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ sealed class RecruitmentServiceRequest<out TReturn> : ApplicationServiceRequest<
6565
}
6666

6767
@Serializable
68+
@Deprecated( "Use CreateParticipantGroup and InviteParticipantGroup instead." )
6869
data class InviteNewParticipantGroup(
6970
val studyId: UUID,
7071
val group: Set<AssignedParticipantRoles>,
@@ -75,6 +76,28 @@ sealed class RecruitmentServiceRequest<out TReturn> : ApplicationServiceRequest<
7576
override fun getResponseSerializer() = serializer<ParticipantGroupStatus>()
7677
}
7778

79+
@Serializable
80+
data class CreateParticipantGroup(
81+
val studyId: UUID,
82+
val groupId: UUID,
83+
val group: Set<AssignedParticipantRoles>,
84+
val name: String? = null
85+
) :
86+
RecruitmentServiceRequest<ParticipantGroupStatus>()
87+
{
88+
override fun getResponseSerializer() = serializer<ParticipantGroupStatus>()
89+
}
90+
91+
@Serializable
92+
data class InviteParticipantGroup(
93+
val studyId: UUID,
94+
val groupId: UUID
95+
) :
96+
RecruitmentServiceRequest<ParticipantGroupStatus>()
97+
{
98+
override fun getResponseSerializer() = serializer<ParticipantGroupStatus>()
99+
}
100+
78101
@Serializable
79102
data class GetParticipantGroupStatusList( val studyId: UUID ) :
80103
RecruitmentServiceRequest<List<ParticipantGroupStatus>>()

0 commit comments

Comments
 (0)