@@ -16,6 +16,7 @@ import dk.cachet.carp.studies.application.users.Participant
1616import dk.cachet.carp.studies.application.users.ParticipantGroupStatus
1717import dk.cachet.carp.studies.domain.users.ParticipantRepository
1818import dk.cachet.carp.studies.domain.users.Recruitment
19+ import dk.cachet.carp.studies.domain.users.StagedParticipantGroup
1920import 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 =
0 commit comments