@@ -527,6 +527,24 @@ class ChallengeDAL @Inject() (
527527 }
528528
529529 this .permission.hasObjectWriteAccess(challenge, user)
530+
531+ // Check for existing non-deleted challenge with same name in same project
532+ this .withMRConnection { implicit c =>
533+ val existingChallenge = SQL """
534+ SELECT id FROM challenges
535+ WHERE parent_id = ${challenge.general.parent}
536+ AND LOWER(name) = LOWER( ${challenge.name})
537+ AND (deleted = false OR deleted IS NULL)
538+ LIMIT 1
539+ """ .as(SqlParser .long(" id" ).singleOpt)
540+
541+ if (existingChallenge.isDefined) {
542+ throw new UniqueViolationException (
543+ s " Challenge with name ${challenge.name} already exists in the database "
544+ )
545+ }
546+ }
547+
530548 this .cacheManager.withOptionCaching { () =>
531549 val insertedChallenge =
532550 this .withMRTransaction { implicit c =>
@@ -553,7 +571,7 @@ class ChallengeDAL @Inject() (
553571 ${challenge.extra.limitReviewTags}, ${challenge.extra.taskStyles}, ${challenge.general.requiresLocal}, ${challenge.extra.isArchived},
554572 ${challenge.extra.reviewSetting}, ${challenge.extra.datasetUrl}, ${challenge.requireConfirmation}, ${challenge.requireRejectReason},
555573 ${asJson(challenge.extra.taskWidgetLayout.getOrElse(Json .parse(" {}" )))}
556- ) ON CONFLICT(parent_id, LOWER(name)) DO NOTHING RETURNING # ${this .retrieveColumns}"""
574+ ) RETURNING # ${this .retrieveColumns}"""
557575 .as(this .parser.* )
558576 .headOption
559577 }
@@ -656,6 +674,25 @@ class ChallengeDAL @Inject() (
656674 val name = (updates \ " name" ).asOpt[String ].getOrElse(cachedItem.name)
657675 val ownerId = (updates \ " ownerId" ).asOpt[Long ].getOrElse(cachedItem.general.owner)
658676 val parentId = (updates \ " parentId" ).asOpt[Long ].getOrElse(cachedItem.general.parent)
677+
678+ // Check if name or parent changed and if so, validate uniqueness
679+ if (name != cachedItem.name || parentId != cachedItem.general.parent) {
680+ val existingChallenge = SQL """
681+ SELECT id FROM challenges
682+ WHERE parent_id = $parentId
683+ AND LOWER(name) = LOWER( $name)
684+ AND (deleted = false OR deleted IS NULL)
685+ AND id != $id
686+ LIMIT 1
687+ """ .as(SqlParser .long(" id" ).singleOpt)
688+
689+ if (existingChallenge.isDefined) {
690+ throw new UniqueViolationException (
691+ s " Challenge with name $name already exists in the database "
692+ )
693+ }
694+ }
695+
659696 val difficulty =
660697 (updates \ " difficulty" ).asOpt[Int ].getOrElse(cachedItem.general.difficulty)
661698 val description =
0 commit comments