~jan0sch/smederee
Showing details for patch 52c7ef91eb3fb699353122559c5c2d71b1a9f853.
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala 2025-01-30 22:53:28.109346426 +0000 @@ -319,7 +319,7 @@ * An option to the internal database ID. */ @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def loadVcsRepositoryId(owner: UserId, name: VcsRepositoryName): IO[Option[Long]] = + protected def loadVcsRepositoryId(owner: UserId, name: VcsRepositoryName): IO[Option[VcsRepositoryId]] = connectToDb(configuration).use { con => for { statement <- IO.delay( @@ -332,7 +332,7 @@ result <- IO.delay(statement.executeQuery) account <- IO.delay { if (result.next()) { - Option(result.getLong("id")) + VcsRepositoryId.from(result.getLong("id")) } else { None } diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 2025-01-30 22:53:28.109346426 +0000 @@ -38,18 +38,20 @@ * A list of ID pairs (original repository id, forked repository id) which may be empty. */ @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def findForks(originalRepoId: Long): IO[Seq[(Long, Long)]] = + protected def findForks(originalRepoId: VcsRepositoryId): IO[Seq[(VcsRepositoryId, VcsRepositoryId)]] = connectToDb(configuration).use { con => for { statement <- IO.delay( con.prepareStatement("""SELECT original_repo, forked_repo FROM "hub"."forks" WHERE original_repo = ?""") ) - _ <- IO.delay(statement.setLong(1, originalRepoId)) + _ <- IO.delay(statement.setLong(1, originalRepoId.toLong)) result <- IO.delay(statement.executeQuery) forks <- IO.delay { - var queue = Queue.empty[(Long, Long)] + var queue = Queue.empty[(VcsRepositoryId, VcsRepositoryId)] while (result.next()) - queue = queue :+ (result.getLong("original_repo"), result.getLong("forked_repo")) + queue = queue :+ (VcsRepositoryId(result.getLong("original_repo")), VcsRepositoryId( + result.getLong("forked_repo") + )) queue } _ <- IO.delay(statement.close()) diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala 2025-01-30 22:53:28.109346426 +0000 @@ -34,12 +34,13 @@ final class DoobieVcsMetadataRepository[F[_]: Sync](tx: Transactor[F]) extends VcsMetadataRepository[F] { given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString) - given Meta[VcsRepositoryName] = Meta[String].timap(VcsRepositoryName.apply)(_.toString) - given Meta[VcsRepositoryDescription] = Meta[String].timap(VcsRepositoryDescription.apply)(_.toString) - given Meta[VcsType] = Meta[String].timap(VcsType.valueOf)(_.toString) given Meta[Uri] = Meta[String].timap(Uri.unsafeFromString)(_.toString) given Meta[UserId] = Meta[UUID].timap(UserId.apply)(_.toUUID) given Meta[Username] = Meta[String].timap(Username.apply)(_.toString) + given Meta[VcsRepositoryDescription] = Meta[String].timap(VcsRepositoryDescription.apply)(_.toString) + given Meta[VcsRepositoryId] = Meta[Long].timap(VcsRepositoryId.apply)(_.toLong) + given Meta[VcsRepositoryName] = Meta[String].timap(VcsRepositoryName.apply)(_.toString) + given Meta[VcsType] = Meta[String].timap(VcsType.valueOf)(_.toString) private val selectRepositoryColumns = fr"""SELECT @@ -55,7 +56,7 @@ JOIN "hub"."accounts" AS "accounts" ON "repos".owner = "accounts".uid""" - override def createFork(source: Long, target: Long): F[Int] = + override def createFork(source: VcsRepositoryId, target: VcsRepositoryId): F[Int] = sql"""INSERT INTO "hub"."forks" (original_repo, forked_repo) VALUES ($source, $target)""".update.run.transact(tx) override def createVcsRepository(repository: VcsRepository): F[Int] = @@ -76,24 +77,26 @@ query.query[VcsRepository].option.transact(tx) } - override def findVcsRepositoryBranches(originalRepositoryId: Long): Stream[F, (Username, VcsRepositoryName)] = { + override def findVcsRepositoryBranches( + originalRepositoryId: VcsRepositoryId + ): Stream[F, (Username, VcsRepositoryName)] = { val query = sql"""SELECT - "accounts"."name" AS "owner_name", - "repos"."name" AS "repository_name" - FROM "hub"."forks" AS "forks" - JOIN "hub"."repositories" AS "repos" - ON "forks"."forked_repo" = "repos"."id" - JOIN "hub"."accounts" AS "accounts" - ON "repos"."owner" = "accounts"."uid" - WHERE "forks"."original_repo" = $originalRepositoryId""" + "accounts"."name" AS "owner_name", + "repos"."name" AS "repository_name" + FROM "hub"."forks" AS "forks" + JOIN "hub"."repositories" AS "repos" + ON "forks"."forked_repo" = "repos"."id" + JOIN "hub"."accounts" AS "accounts" + ON "repos"."owner" = "accounts"."uid" + WHERE "forks"."original_repo" = $originalRepositoryId""" query.query[(Username, VcsRepositoryName)].stream.transact(tx) } - override def findVcsRepositoryId(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[Long]] = { + override def findVcsRepositoryId(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[VcsRepositoryId]] = { val nameFilter = fr"""name = $name""" val ownerFilter = fr"""owner = ${owner.uid}""" val query = fr"""SELECT id FROM "hub"."repositories"""" ++ whereAnd(ownerFilter, nameFilter) ++ fr"""LIMIT 1""" - query.query[Long].option.transact(tx) + query.query[VcsRepositoryId].option.transact(tx) } override def findVcsRepositoryOwner(name: Username): F[Option[VcsRepositoryOwner]] = diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala 2025-01-30 22:53:28.109346426 +0000 @@ -37,7 +37,7 @@ * @return * The number of affected database rows. */ - def createFork(source: Long, target: Long): F[Int] + def createFork(source: VcsRepositoryId, target: VcsRepositoryId): F[Int] /** Create a database entry for the given vcs repository. * @@ -75,7 +75,7 @@ * @return * A stream of tuples holding the username of the branch owner and the branch (repository) names. */ - def findVcsRepositoryBranches(originalRepositoryId: Long): Stream[F, (Username, VcsRepositoryName)] + def findVcsRepositoryBranches(originalRepositoryId: VcsRepositoryId): Stream[F, (Username, VcsRepositoryName)] /** Search for the internal database specific (auto generated) ID of the given owner / repository combination which * serves as a primary key for the database table. @@ -87,7 +87,7 @@ * @return * An option to the internal database ID. */ - def findVcsRepositoryId(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[Long]] + def findVcsRepositoryId(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[VcsRepositoryId]] /** Search for a repository owner of whom we only know the name. * diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala 2025-01-30 22:53:28.109346426 +0000 @@ -88,7 +88,7 @@ */ private def loadRepo( currentUser: Option[Account] - )(repositoryOwnerName: Username, repositoryName: VcsRepositoryName): F[Option[(VcsRepository, Long)]] = + )(repositoryOwnerName: Username, repositoryName: VcsRepositoryName): F[Option[(VcsRepository, VcsRepositoryId)]] = for { owner <- vcsMetadataRepo.findVcsRepositoryOwner(repositoryOwnerName) loadedRepo <- owner match { diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala 2025-01-30 22:53:28.109346426 +0000 @@ -64,6 +64,45 @@ } +opaque type VcsRepositoryId = Long +object VcsRepositoryId { + given Eq[VcsRepositoryId] = Eq.fromUniversalEquals + + val Format: Regex = "^-?\\d+$".r + + /** Create an instance of VcsRepositoryId from the given Long type. + * + * @param source + * An instance of type Long which will be returned as a VcsRepositoryId. + * @return + * The appropriate instance of VcsRepositoryId. + */ + def apply(source: Long): VcsRepositoryId = source + + /** Try to create an instance of VcsRepositoryId from the given Long. + * + * @param source + * A Long that should fulfil the requirements to be converted into a VcsRepositoryId. + * @return + * An option to the successfully converted VcsRepositoryId. + */ + def from(source: Long): Option[VcsRepositoryId] = Option(source) + + /** Try to create an instance of VcsRepositoryId from the given String. + * + * @param source + * A string that should fulfil the requirements to be converted into a VcsRepositoryId. + * @return + * An option to the successfully converted VcsRepositoryId. + */ + def fromString(source: String): Option[VcsRepositoryId] = + Option(source).filter(Format.matches).map(_.toLong).flatMap(from) + + extension (id: VcsRepositoryId) { + def toLong: Long = id + } +} + opaque type VcsRepositoryName = String object VcsRepositoryName { diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala 2025-01-30 22:53:28.109346426 +0000 @@ -42,7 +42,7 @@ * @param labelRepo * A repository for handling database operations for labels. * @param projectRepo - * A repository for handling database operations regarding our vcs repositories and their metadata. + * A repository for handling database operations regarding our projects and their metadata. * @tparam F * A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync. */ @@ -78,9 +78,9 @@ language <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en"))) projectAndId <- loadProject(user)(projectOwnerName, projectName) resp <- projectAndId match { - case Some((repo, repoId)) => + case Some((project, projectId)) => for { - labels <- labelRepo.allLabels(repoId).compile.toList + labels <- labelRepo.allLabels(projectId).compile.toList projectBaseUri <- Sync[F].delay( linkConfig.createFullUri( Uri(path = @@ -98,7 +98,7 @@ projectBaseUri, "Manage your project labels.".some, user, - repo + project )() ) } yield resp @@ -121,7 +121,7 @@ */ private def loadProject( currentUser: Option[Account] - )(projectOwnerName: ProjectOwnerName, projectName: ProjectName): F[Option[(Project, Long)]] = + )(projectOwnerName: ProjectOwnerName, projectName: ProjectName): F[Option[(Project, ProjectId)]] = for { owner <- projectRepo.findProjectOwner(projectOwnerName) loadedRepo <- owner match { @@ -131,8 +131,8 @@ projectRepo.findProject(owner, projectName), projectRepo.findProjectId(owner, projectName) ).mapN { - case (Some(repo), Some(repoId)) => (repo, repoId).some - case _ => None + case (Some(project), Some(projectId)) => (project, projectId).some + case _ => None } } // TODO: Replace with whatever we implement as proper permission model. ;-) @@ -155,9 +155,9 @@ language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) projectAndId <- loadProject(user.some)(projectOwnerName, projectName) resp <- projectAndId match { - case Some(repo, repoId) => + case Some(project, projectId) => for { - _ <- Sync[F].raiseUnless(repo.owner.uid === ProjectOwnerId.fromUserId(user.uid))( + _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( new Error("Only maintainers may add labels!") ) formData <- Sync[F].delay { @@ -190,16 +190,16 @@ projectBaseUri, "Manage your project labels.".some, user.some, - repo + project )(formData, FormErrors.fromNec(errors)) ) case Validated.Valid(labelData) => val label = Label(None, labelData.name, labelData.description, labelData.colour) for { - checkDuplicate <- labelRepo.findLabel(repoId)(labelData.name) + checkDuplicate <- labelRepo.findLabel(projectId)(labelData.name) resp <- checkDuplicate match { case None => - labelRepo.createLabel(repoId)(label) *> SeeOther( + labelRepo.createLabel(projectId)(label) *> SeeOther( Location(projectBaseUri.addSegment("labels")) ) case Some(_) => @@ -211,7 +211,7 @@ projectBaseUri, "Manage your project labels.".some, user.some, - repo + project )( formData, Map( @@ -238,12 +238,12 @@ csrf <- Sync[F].delay(ar.req.getCsrfToken) projectAndId <- loadProject(user.some)(projectOwnerName, projectName) resp <- projectAndId match { - case Some(repo, repoId) => + case Some(project, projectId) => for { - _ <- Sync[F].raiseUnless(repo.owner.uid === ProjectOwnerId.fromUserId(user.uid))( + _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( new Error("Only maintainers may add labels!") ) - label <- labelRepo.findLabel(repoId)(labelName) + label <- labelRepo.findLabel(projectId)(labelName) resp <- label match { case Some(label) => for { @@ -305,13 +305,13 @@ language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) projectAndId <- loadProject(user.some)(projectOwnerName, projectName) label <- projectAndId match { - case Some((_, repoId)) => labelRepo.findLabel(repoId)(labelName) - case _ => Sync[F].delay(None) + case Some((_, projectId)) => labelRepo.findLabel(projectId)(labelName) + case _ => Sync[F].delay(None) } resp <- (projectAndId, label) match { - case (Some(repo, repoId), Some(label)) => + case (Some(project, projectId), Some(label)) => for { - _ <- Sync[F].raiseUnless(repo.owner.uid === ProjectOwnerId.fromUserId(user.uid))( + _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( new Error("Only maintainers may add labels!") ) projectBaseUri <- Sync[F].delay( @@ -356,7 +356,7 @@ projectBaseUri, s"Edit label ${label.name}".some, user, - repo + project )( formData.toMap, FormErrors.fromNec(errors) @@ -366,7 +366,7 @@ val updatedLabel = label.copy(name = labelData.name, description = labelData.description, colour = labelData.colour) for { - checkDuplicate <- labelRepo.findLabel(repoId)(updatedLabel.name) + checkDuplicate <- labelRepo.findLabel(projectId)(updatedLabel.name) resp <- checkDuplicate.filterNot(_.id === updatedLabel.id) match { case None => labelRepo.updateLabel(updatedLabel) *> SeeOther( @@ -381,7 +381,7 @@ projectBaseUri, s"Edit label ${label.name}".some, user, - repo + project )( formData, Map( @@ -407,11 +407,11 @@ csrf <- Sync[F].delay(ar.req.getCsrfToken) projectAndId <- loadProject(user.some)(projectOwnerName, projectName) label <- projectAndId match { - case Some((_, repoId)) => labelRepo.findLabel(repoId)(labelName) - case _ => Sync[F].delay(None) + case Some((_, projectId)) => labelRepo.findLabel(projectId)(labelName) + case _ => Sync[F].delay(None) } resp <- (projectAndId, label) match { - case (Some(repo, repoId), Some(label)) => + case (Some(project, projectId), Some(label)) => for { projectBaseUri <- Sync[F].delay( linkConfig.createFullUri( @@ -426,7 +426,7 @@ formData <- Sync[F].delay(LabelForm.fromLabel(label)) resp <- Ok( views.html - .editLabel()(actionUri, csrf, label, projectBaseUri, s"Edit label ${label.name}".some, user, repo)( + .editLabel()(actionUri, csrf, label, projectBaseUri, s"Edit label ${label.name}".some, user, project)( formData.toMap ) ) diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala 2025-01-30 22:53:28.109346426 +0000 @@ -55,47 +55,47 @@ val linkConfig = configuration.externalUrl - /** Logic for rendering a list of all milestones for a repository and optionally management functionality. + /** Logic for rendering a list of all milestones for a project and optionally management functionality. * * @param csrf * An optional CSRF-Token that shall be used. * @param user * An optional user account for whom the list of milestones shall be rendered. - * @param repositoryOwnerName - * The username of the account who owns the repository. - * @param repositoryName - * The name of the repository. + * @param projectOwnerName + * The username of the account who owns the project. + * @param projectName + * The name of the project. * @return * An HTTP response containing the rendered HTML. */ private def doShowMilestones( csrf: Option[CsrfToken] - )(user: Option[Account])(repositoryOwnerName: Username)(repositoryName: ProjectName): F[Response[F]] = + )(user: Option[Account])(projectOwnerName: Username)(projectName: ProjectName): F[Response[F]] = for { - language <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en"))) - repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName) - resp <- repoAndId match { - case Some((repo, repoId)) => + language <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en"))) + projectAndId <- loadProject(user)(projectOwnerName, projectName) + resp <- projectAndId match { + case Some((project, projectId)) => for { - milestones <- milestoneRepo.allMilestones(repoId).compile.toList - repositoryBaseUri <- Sync[F].delay( + milestones <- milestoneRepo.allMilestones(projectId).compile.toList + projectBaseUri <- Sync[F].delay( linkConfig.createFullUri( Uri(path = Uri.Path( - Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString)) + Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) ) ) ) ) resp <- Ok( views.html.editMilestones(lang = language)( - repositoryBaseUri.addSegment("milestones"), + projectBaseUri.addSegment("milestones"), csrf, milestones, - repositoryBaseUri, - "Manage your repository milestones.".some, + projectBaseUri, + "Manage your project milestones.".some, user, - repo + project )() ) } yield resp @@ -103,33 +103,33 @@ } } yield resp - /** Load the repository metadata with the given owner and name from the database and return it and its primary key id - * if the repository exists and is readable by the given user account. + /** Load the project metadata with the given owner and name from the database and return it and its primary key id if + * the project exists and is readable by the given user account. * * @param currentUser - * The user account that is requesting access to the repository or None for a guest user. - * @param repositoryOwnerName - * The name of the account that owns the repository. - * @param repositoryName - * The name of the repository. A repository name must start with a letter or number and must contain only - * alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long. + * The user account that is requesting access to the project or None for a guest user. + * @param projectOwnerName + * The name of the account that owns the project. + * @param projectName + * The name of the project. A project name must start with a letter or number and must contain only alphanumeric + * ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long. * @return * An option to a tuple holding the [[Project]] and its primary key id. */ - private def loadRepo( + private def loadProject( currentUser: Option[Account] - )(repositoryOwnerName: Username, repositoryName: ProjectName): F[Option[(Project, Long)]] = + )(projectOwnerName: Username, projectName: ProjectName): F[Option[(Project, ProjectId)]] = for { - owner <- projectRepo.findProjectOwner(repositoryOwnerName) + owner <- projectRepo.findProjectOwner(projectOwnerName) loadedRepo <- owner match { case None => Sync[F].pure(None) case Some(owner) => ( - projectRepo.findProject(owner, repositoryName), - projectRepo.findProjectId(owner, repositoryName) + projectRepo.findProject(owner, projectName), + projectRepo.findProjectId(owner, projectName) ).mapN { - case (Some(repo), Some(repoId)) => (repo, repoId).some - case _ => None + case (Some(project), Some(projectId)) => (project, projectId).some + case _ => None } } // TODO: Replace with whatever we implement as proper permission model. ;-) @@ -143,18 +143,18 @@ } yield repoAndId private val addMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of { - case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter( - repositoryName + case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName ) / "milestones" as user => ar.req.decodeStrict[F, UrlForm] { urlForm => for { - csrf <- Sync[F].delay(ar.req.getCsrfToken) - language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) - repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName) - resp <- repoAndId match { - case Some(repo, repoId) => + csrf <- Sync[F].delay(ar.req.getCsrfToken) + language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) + projectAndId <- loadProject(user.some)(projectOwnerName, projectName) + resp <- projectAndId match { + case Some(project, projectId) => for { - _ <- Sync[F].raiseUnless(repo.owner.uid === ProjectOwnerId.fromUserId(user.uid))( + _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( new Error("Only maintainers may add milestones!") ) formData <- Sync[F].delay { @@ -167,12 +167,12 @@ } } form <- Sync[F].delay(MilestoneForm.validate(formData)) - milestones <- repoAndId.traverse(tuple => milestoneRepo.allMilestones(tuple._2).compile.toList) - repositoryBaseUri <- Sync[F].delay( + milestones <- projectAndId.traverse(tuple => milestoneRepo.allMilestones(tuple._2).compile.toList) + projectBaseUri <- Sync[F].delay( linkConfig.createFullUri( Uri(path = Uri.Path( - Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString)) + Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) ) ) ) @@ -181,35 +181,35 @@ case Validated.Invalid(errors) => BadRequest( views.html.editMilestones(lang = language)( - repositoryBaseUri.addSegment("milestones"), + projectBaseUri.addSegment("milestones"), csrf, milestones.getOrElse(List.empty), - repositoryBaseUri, - "Manage your repository milestones.".some, + projectBaseUri, + "Manage your project milestones.".some, user.some, - repo + project )(formData, FormErrors.fromNec(errors)) ) case Validated.Valid(milestoneData) => val milestone = Milestone(None, milestoneData.title, milestoneData.description, milestoneData.dueDate) for { - checkDuplicate <- milestoneRepo.findMilestone(repoId)(milestoneData.title) + checkDuplicate <- milestoneRepo.findMilestone(projectId)(milestoneData.title) resp <- checkDuplicate match { case None => - milestoneRepo.createMilestone(repoId)(milestone) *> SeeOther( - Location(repositoryBaseUri.addSegment("milestones")) + milestoneRepo.createMilestone(projectId)(milestone) *> SeeOther( + Location(projectBaseUri.addSegment("milestones")) ) case Some(_) => BadRequest( views.html.editMilestones(lang = language)( - repositoryBaseUri.addSegment("milestones"), + projectBaseUri.addSegment("milestones"), csrf, milestones.getOrElse(List.empty), - repositoryBaseUri, - "Manage your repository milestones.".some, + projectBaseUri, + "Manage your project milestones.".some, user.some, - repo + project )( formData, Map( @@ -230,20 +230,20 @@ } private val deleteMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of { - case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter( - repositoryName + case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) / "delete" as user => ar.req.decodeStrict[F, UrlForm] { urlForm => for { - csrf <- Sync[F].delay(ar.req.getCsrfToken) - repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName) - resp <- repoAndId match { - case Some(repo, repoId) => + csrf <- Sync[F].delay(ar.req.getCsrfToken) + projectAndId <- loadProject(user.some)(projectOwnerName, projectName) + resp <- projectAndId match { + case Some(project, projectId) => for { - _ <- Sync[F].raiseUnless(repo.owner.uid === ProjectOwnerId.fromUserId(user.uid))( + _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( new Error("Only maintainers may add milestones!") ) - milestone <- milestoneRepo.findMilestone(repoId)(milestoneTitle) + milestone <- milestoneRepo.findMilestone(projectId)(milestoneTitle) resp <- milestone match { case Some(milestone) => for { @@ -256,13 +256,13 @@ ) // Pick the first value (a field might get submitted multiple times)! } } - repositoryBaseUri <- Sync[F].delay( + projectBaseUri <- Sync[F].delay( linkConfig.createFullUri( Uri(path = Uri.Path( Vector( - Uri.Path.Segment(s"~$repositoryOwnerName"), - Uri.Path.Segment(repositoryName.toString) + Uri.Path.Segment(s"~$projectOwnerName"), + Uri.Path.Segment(projectName.toString) ) ) ) @@ -282,7 +282,7 @@ case false => BadRequest("Invalid form data!") case true => milestoneRepo.deleteMilestone(milestone) *> SeeOther( - Location(repositoryBaseUri.addSegment("milestones")) + Location(projectBaseUri.addSegment("milestones")) ) } } yield resp @@ -296,35 +296,35 @@ } private val editMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of { - case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter( - repositoryName + case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) as user => ar.req.decodeStrict[F, UrlForm] { urlForm => for { - csrf <- Sync[F].delay(ar.req.getCsrfToken) - language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) - repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName) - milestone <- repoAndId match { - case Some((_, repoId)) => milestoneRepo.findMilestone(repoId)(milestoneTitle) - case _ => Sync[F].delay(None) + csrf <- Sync[F].delay(ar.req.getCsrfToken) + language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) + projectAndId <- loadProject(user.some)(projectOwnerName, projectName) + milestone <- projectAndId match { + case Some((_, projectId)) => milestoneRepo.findMilestone(projectId)(milestoneTitle) + case _ => Sync[F].delay(None) } - resp <- (repoAndId, milestone) match { - case (Some(repo, repoId), Some(milestone)) => + resp <- (projectAndId, milestone) match { + case (Some(project, projectId), Some(milestone)) => for { - _ <- Sync[F].raiseUnless(repo.owner.uid === ProjectOwnerId.fromUserId(user.uid))( + _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( new Error("Only maintainers may add milestones!") ) - repositoryBaseUri <- Sync[F].delay( + projectBaseUri <- Sync[F].delay( linkConfig.createFullUri( Uri(path = Uri.Path( - Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString)) + Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) ) ) ) ) actionUri <- Sync[F].delay( - repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString) + projectBaseUri.addSegment("milestone").addSegment(milestone.title.toString) ) formData <- Sync[F].delay { urlForm.values.map { t => @@ -355,10 +355,10 @@ actionUri, csrf, milestone, - repositoryBaseUri, + projectBaseUri, s"Edit milestone ${milestone.title}".some, user, - repo + project )( formData.toMap, FormErrors.fromNec(errors) @@ -372,11 +372,11 @@ dueDate = milestoneData.dueDate ) for { - checkDuplicate <- milestoneRepo.findMilestone(repoId)(updatedMilestone.title) + checkDuplicate <- milestoneRepo.findMilestone(projectId)(updatedMilestone.title) resp <- checkDuplicate.filterNot(_.id === updatedMilestone.id) match { case None => milestoneRepo.updateMilestone(updatedMilestone) *> SeeOther( - Location(repositoryBaseUri.addSegment("milestones")) + Location(projectBaseUri.addSegment("milestones")) ) case Some(_) => BadRequest( @@ -384,10 +384,10 @@ actionUri, csrf, milestone, - repositoryBaseUri, + projectBaseUri, s"Edit milestone ${milestone.title}".some, user, - repo + project )( formData, Map( @@ -408,40 +408,40 @@ } private val showEditMilestoneForm: AuthedRoutes[Account, F] = AuthedRoutes.of { - case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter( - repositoryName + case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) / "edit" as user => for { - csrf <- Sync[F].delay(ar.req.getCsrfToken) - language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) - repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName) - milestone <- repoAndId match { - case Some((_, repoId)) => milestoneRepo.findMilestone(repoId)(milestoneTitle) - case _ => Sync[F].delay(None) + csrf <- Sync[F].delay(ar.req.getCsrfToken) + language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) + projectAndId <- loadProject(user.some)(projectOwnerName, projectName) + milestone <- projectAndId match { + case Some((_, projectId)) => milestoneRepo.findMilestone(projectId)(milestoneTitle) + case _ => Sync[F].delay(None) } - resp <- (repoAndId, milestone) match { - case (Some(repo, repoId), Some(milestone)) => + resp <- (projectAndId, milestone) match { + case (Some(project, projectId), Some(milestone)) => for { - repositoryBaseUri <- Sync[F].delay( + projectBaseUri <- Sync[F].delay( linkConfig.createFullUri( Uri(path = Uri.Path( - Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString)) + Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) ) ) ) ) - actionUri <- Sync[F].delay(repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString)) + actionUri <- Sync[F].delay(projectBaseUri.addSegment("milestone").addSegment(milestone.title.toString)) formData <- Sync[F].delay(MilestoneForm.fromMilestone(milestone)) resp <- Ok( views.html.editMilestone(lang = language)( actionUri, csrf, milestone, - repositoryBaseUri, + projectBaseUri, s"Edit milestone ${milestone.title}".some, user, - repo + project )( formData.toMap ) @@ -453,22 +453,22 @@ } private val showEditMilestonesPage: AuthedRoutes[Account, F] = AuthedRoutes.of { - case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter( - repositoryName + case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName ) / "milestones" as user => for { csrf <- Sync[F].delay(ar.req.getCsrfToken) - resp <- doShowMilestones(csrf)(user.some)(repositoryOwnerName)(repositoryName) + resp <- doShowMilestones(csrf)(user.some)(projectOwnerName)(projectName) } yield resp } private val showMilestonesForGuests: HttpRoutes[F] = HttpRoutes.of { - case req @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter( - repositoryName + case req @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName ) / "milestones" => for { csrf <- Sync[F].delay(req.getCsrfToken) - resp <- doShowMilestones(csrf)(None)(repositoryOwnerName)(repositoryName) + resp <- doShowMilestones(csrf)(None)(projectOwnerName)(projectName) } yield resp } diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala 2025-01-30 22:53:28.113346433 +0000 @@ -168,6 +168,54 @@ } yield r } + /** Create a user account from a ticket submitter in the database. + * + * @param submitter + * The submitter for which the account shall be created. + * @return + * The number of affected database rows. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def createTicketsSubmitter(submitter: Submitter): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay { + con.prepareStatement( + """INSERT INTO "tickets"."users" (uid, name, email, created_at, updated_at) VALUES(?, ?, ?, NOW(), NOW())""" + ) + } + _ <- IO.delay(statement.setObject(1, submitter.id)) + _ <- IO.delay(statement.setString(2, submitter.name.toString)) + _ <- IO.delay(statement.setString(3, s"email-${submitter.id.toString}@example.com")) + r <- IO.delay(statement.executeUpdate()) + _ <- IO.delay(statement.close()) + } yield r + } + + /** Create a tickets user account in the database. + * + * @param owner + * The user to be created. + * @return + * The number of affected database rows. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def createProjectOwner(owner: ProjectOwner): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay { + con.prepareStatement( + """INSERT INTO "tickets"."users" (uid, name, email, created_at, updated_at) VALUES(?, ?, ?, NOW(), NOW())""" + ) + } + _ <- IO.delay(statement.setObject(1, owner.uid)) + _ <- IO.delay(statement.setString(2, owner.name.toString)) + _ <- IO.delay(statement.setString(3, owner.email.toString)) + r <- IO.delay(statement.executeUpdate()) + _ <- IO.delay(statement.close()) + } yield r + } + /** Create a tickets user account in the database. * * @param user @@ -176,7 +224,7 @@ * The number of affected database rows. */ @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def createTicketsUser(user: ProjectOwner): IO[Int] = + protected def createTicketsUser(user: TicketsUser): IO[Int] = connectToDb(configuration).use { con => for { statement <- IO.delay { @@ -202,7 +250,7 @@ * An option to the internal database ID. */ @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def loadProjectId(owner: ProjectOwnerId, name: ProjectName): IO[Option[Long]] = + protected def loadProjectId(owner: ProjectOwnerId, name: ProjectName): IO[Option[ProjectId]] = connectToDb(configuration).use { con => for { statement <- IO.delay( @@ -215,7 +263,39 @@ result <- IO.delay(statement.executeQuery) account <- IO.delay { if (result.next()) { - Option(result.getLong("id")) + ProjectId.from(result.getLong("id")) + } else { + None + } + } + _ <- IO(statement.close()) + } yield account + } + + /** Find the ticket ID for the given project ID and ticket number. + * + * @param projectId + * The unique internal project id. + * @param ticketNumber + * The ticket number. + * @return + * An option to the internal database ID of the ticket. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def loadTicketId(project: ProjectId, number: TicketNumber): IO[Option[TicketId]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT id FROM "tickets"."tickets" WHERE project = ? AND number = ? LIMIT 1""" + ) + ) + _ <- IO.delay(statement.setLong(1, project.toLong)) + _ <- IO.delay(statement.setInt(2, number.toInt)) + result <- IO.delay(statement.executeQuery) + account <- IO.delay { + if (result.next()) { + TicketId.from(result.getLong("id")) } else { None } diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala 2025-01-30 22:53:28.113346433 +0000 @@ -66,14 +66,14 @@ } test("allLabels must return all labels") { - (genValidProjectOwner.sample, genValidProject.sample, genLabels.sample) match { + (genProjectOwner.sample, genProject.sample, genLabels.sample) match { case (Some(owner), Some(generatedProject), Some(labels)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val labelRepo = new DoobieLabelRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) _ <- createTicketsProject(project) projectId <- loadProjectId(owner.uid, project.name) createdLabels <- projectId match { @@ -96,14 +96,14 @@ } test("createLabel must create the label") { - (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match { + (genProjectOwner.sample, genProject.sample, genLabel.sample) match { case (Some(owner), Some(generatedProject), Some(label)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val labelRepo = new DoobieLabelRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdProjects <- createTicketsProject(project) projectId <- loadProjectId(owner.uid, project.name) createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) @@ -117,6 +117,7 @@ foundLabel.getOrElse(None) match { case None => fail("Created label not found!") case Some(foundLabel) => + assert(foundLabel.id.nonEmpty, "Label ID must not be empty!") assertEquals(foundLabel.name, label.name) assertEquals(foundLabel.description, label.description) assertEquals(foundLabel.colour, label.colour) @@ -127,14 +128,14 @@ } test("createLabel must fail if the label name already exists") { - (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match { + (genProjectOwner.sample, genProject.sample, genLabel.sample) match { case (Some(owner), Some(generatedProject), Some(label)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val labelRepo = new DoobieLabelRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdProjects <- createTicketsProject(project) projectId <- loadProjectId(owner.uid, project.name) createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) @@ -146,14 +147,14 @@ } test("deleteLabel must delete an existing label") { - (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match { + (genProjectOwner.sample, genProject.sample, genLabel.sample) match { case (Some(owner), Some(generatedProject), Some(label)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val labelRepo = new DoobieLabelRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdProjects <- createTicketsProject(project) projectId <- loadProjectId(owner.uid, project.name) createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) @@ -174,7 +175,7 @@ } test("findLabel must find existing labels") { - (genValidProjectOwner.sample, genValidProject.sample, genLabels.sample) match { + (genProjectOwner.sample, genProject.sample, genLabels.sample) match { case (Some(owner), Some(generatedProject), Some(labels)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database @@ -182,7 +183,7 @@ val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val labelRepo = new DoobieLabelRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdProjects <- createTicketsProject(project) projectId <- loadProjectId(owner.uid, project.name) createdLabels <- projectId match { @@ -199,7 +200,7 @@ } test("updateLabel must update an existing label") { - (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match { + (genProjectOwner.sample, genProject.sample, genLabel.sample) match { case (Some(owner), Some(generatedProject), Some(label)) => val updatedLabel = label.copy( name = LabelName("updated label"), @@ -211,7 +212,7 @@ val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val labelRepo = new DoobieLabelRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdProjects <- createTicketsProject(project) projectId <- loadProjectId(owner.uid, project.name) createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) @@ -235,7 +236,7 @@ } test("updateLabel must do nothing if id attribute is empty") { - (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match { + (genProjectOwner.sample, genProject.sample, genLabel.sample) match { case (Some(owner), Some(generatedProject), Some(label)) => val updatedLabel = label.copy( id = None, @@ -248,7 +249,7 @@ val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val labelRepo = new DoobieLabelRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdProjects <- createTicketsProject(project) projectId <- loadProjectId(owner.uid, project.name) createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 2025-01-30 22:53:28.113346433 +0000 @@ -66,14 +66,14 @@ } test("allMilestones must return all milestones") { - (genValidProjectOwner.sample, genValidProject.sample, genMilestones.sample) match { + (genProjectOwner.sample, genProject.sample, genMilestones.sample) match { case (Some(owner), Some(generatedProject), Some(milestones)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val milestoneRepo = new DoobieMilestoneRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdRepos <- createTicketsProject(project) repoId <- loadProjectId(owner.uid, project.name) createdMilestones <- repoId match { @@ -96,14 +96,14 @@ } test("createMilestone must create the milestone") { - (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match { + (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { case (Some(owner), Some(generatedProject), Some(milestone)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val milestoneRepo = new DoobieMilestoneRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdRepos <- createTicketsProject(project) repoId <- loadProjectId(owner.uid, project.name) createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) @@ -124,14 +124,14 @@ } test("createMilestone must fail if the milestone name already exists") { - (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match { + (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { case (Some(owner), Some(generatedProject), Some(milestone)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val milestoneRepo = new DoobieMilestoneRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdRepos <- createTicketsProject(project) repoId <- loadProjectId(owner.uid, project.name) createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) @@ -143,14 +143,14 @@ } test("deleteMilestone must delete an existing milestone") { - (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match { + (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { case (Some(owner), Some(generatedProject), Some(milestone)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val milestoneRepo = new DoobieMilestoneRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdRepos <- createTicketsProject(project) repoId <- loadProjectId(owner.uid, project.name) createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) @@ -171,7 +171,7 @@ } test("findMilestone must find existing milestones") { - (genValidProjectOwner.sample, genValidProject.sample, genMilestones.sample) match { + (genProjectOwner.sample, genProject.sample, genMilestones.sample) match { case (Some(owner), Some(generatedProject), Some(milestones)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database @@ -179,7 +179,7 @@ val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val milestoneRepo = new DoobieMilestoneRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdRepos <- createTicketsProject(project) repoId <- loadProjectId(owner.uid, project.name) createdMilestones <- repoId match { @@ -196,7 +196,7 @@ } test("updateMilestone must update an existing milestone") { - (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match { + (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { case (Some(owner), Some(generatedProject), Some(milestone)) => val updatedMilestone = milestone.copy( title = MilestoneTitle("updated milestone"), @@ -208,7 +208,7 @@ val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val milestoneRepo = new DoobieMilestoneRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdRepos <- createTicketsProject(project) repoId <- loadProjectId(owner.uid, project.name) createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) @@ -234,7 +234,7 @@ } test("updateMilestone must do nothing if id attribute is empty") { - (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match { + (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { case (Some(owner), Some(generatedProject), Some(milestone)) => val updatedMilestone = milestone.copy( id = None, @@ -247,7 +247,7 @@ val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val milestoneRepo = new DoobieMilestoneRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) createdRepos <- createTicketsProject(project) repoId <- loadProjectId(owner.uid, project.name) createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala 2025-01-30 22:53:28.113346433 +0000 @@ -24,14 +24,14 @@ final class DoobieProjectRepositoryTest extends BaseSpec { test("createProject must create a project") { - (genValidProjectOwner.sample, genValidProject.sample) match { + (genProjectOwner.sample, genProject.sample) match { case (Some(owner), Some(generatedProject)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val projectRepo = new DoobieProjectRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) _ <- projectRepo.createProject(project) foundProject <- projectRepo.findProject(owner, project.name) } yield foundProject @@ -43,14 +43,14 @@ } test("deleteProject must delete a project") { - (genValidProjectOwner.sample, genValidProject.sample) match { + (genProjectOwner.sample, genProject.sample) match { case (Some(owner), Some(generatedProject)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val projectRepo = new DoobieProjectRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) _ <- createTicketsProject(project) deleted <- projectRepo.deleteProject(project) foundProject <- projectRepo.findProject(owner, project.name) @@ -65,14 +65,14 @@ } test("findProject must return the matching project") { - (genValidProjectOwner.sample, genValidProjects.sample) match { + (genProjectOwner.sample, genProjects.sample) match { case (Some(owner), Some(generatedProject :: projects)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val projectRepo = new DoobieProjectRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) _ <- createTicketsProject(project) _ <- projects.filterNot(_.name === project.name).traverse(p => createTicketsProject(p.copy(owner = owner))) foundProject <- projectRepo.findProject(owner, project.name) @@ -85,14 +85,14 @@ } test("findProjectId must return the matching id") { - (genValidProjectOwner.sample, genValidProjects.sample) match { + (genProjectOwner.sample, genProjects.sample) match { case (Some(owner), Some(generatedProject :: projects)) => val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val projectRepo = new DoobieProjectRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) _ <- createTicketsProject(project) _ <- projects.filterNot(_.name === project.name).traverse(p => createTicketsProject(p.copy(owner = owner))) foundProjectId <- projectRepo.findProjectId(owner, project.name) @@ -107,14 +107,14 @@ } test("findProjectOwner must return the matching project owner") { - genValidProjectOwners.sample match { + genProjectOwners.sample match { case Some(owner :: owners) => val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val projectRepo = new DoobieProjectRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) - _ <- owners.filterNot(_.name === owner.name).traverse(createTicketsUser) + _ <- createProjectOwner(owner) + _ <- owners.filterNot(_.name === owner.name).traverse(createProjectOwner) foundOwner <- projectRepo.findProjectOwner(owner.name) } yield foundOwner test.map { foundOwner => @@ -125,7 +125,7 @@ } test("updateProject must update a project") { - (genValidProjectOwner.sample, genValidProject.sample, genValidProject.sample) match { + (genProjectOwner.sample, genProject.sample, genProject.sample) match { case (Some(owner), Some(firstProject), Some(secondProject)) => val project = firstProject.copy(owner = owner) val updatedProject = project.copy(description = secondProject.description, isPrivate = secondProject.isPrivate) @@ -133,7 +133,7 @@ val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) val projectRepo = new DoobieProjectRepository[IO](tx) val test = for { - _ <- createTicketsUser(owner) + _ <- createProjectOwner(owner) _ <- projectRepo.createProject(project) written <- projectRepo.updateProject(updatedProject) foundProject <- projectRepo.findProject(owner, project.name) diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala 2025-01-30 22:53:28.113346433 +0000 @@ -0,0 +1,579 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.tickets + +import java.time.OffsetDateTime + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.tickets.Generators._ +import doobie._ + +import scala.collection.immutable.Queue + +final class DoobieTicketRepositoryTest extends BaseSpec { + + /** Return the internal ids of all lables associated with the given ticket number and project id. + * + * @param projectId + * The unique internal project id. + * @param ticketNumber + * The ticket number. + * @return + * A list of label ids that may be empty. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def loadTicketLabelIds(projectId: ProjectId, ticketNumber: TicketNumber): IO[List[LabelId]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT label FROM "tickets"."ticket_labels" AS "ticket_labels" JOIN "tickets"."tickets" ON "ticket_labels".ticket = "tickets".id WHERE "tickets".project = ? AND "tickets".number = ?""" + ) + ) + _ <- IO.delay(statement.setLong(1, projectId.toLong)) + _ <- IO.delay(statement.setInt(2, ticketNumber.toInt)) + result <- IO.delay(statement.executeQuery) + labelIds <- IO.delay { + var queue = Queue.empty[LabelId] + while (result.next()) + queue = queue :+ LabelId(result.getLong("label")) + queue.toList + } + _ <- IO(statement.close()) + } yield labelIds + } + + /** Return the internal ids of all milestones associated with the given ticket number and project id. + * + * @param projectId + * The unique internal project id. + * @param ticketNumber + * The ticket number. + * @return + * A list of milestone ids that may be empty. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def loadTicketMilestoneIds(projectId: ProjectId, ticketNumber: TicketNumber): IO[List[MilestoneId]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT milestone FROM "tickets"."milestone_tickets" AS "milestone_tickets" JOIN "tickets"."tickets" ON "milestone_tickets".ticket = "tickets".id WHERE "tickets".project = ? AND "tickets".number = ?""" + ) + ) + _ <- IO.delay(statement.setLong(1, projectId.toLong)) + _ <- IO.delay(statement.setInt(2, ticketNumber.toInt)) + result <- IO.delay(statement.executeQuery) + milestoneIds <- IO.delay { + var queue = Queue.empty[MilestoneId] + while (result.next()) + queue = queue :+ MilestoneId(result.getLong("milestone")) + queue.toList + } + _ <- IO(statement.close()) + } yield milestoneIds + } + + test("addAssignee must save the assignee relation to the database") { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genTicketsUser.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(user)) => + val assignee = Assignee(AssigneeId(user.uid.toUUID), AssigneeName(user.name.toString)) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsUser(user) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket)) + _ <- projectId.traverse(projectId => ticketRepo.addAssignee(projectId)(ticket.number)(assignee)) + foundAssignees <- projectId.traverse(projectId => + ticketRepo.loadAssignees(projectId)(ticket.number).compile.toList + ) + } yield foundAssignees.getOrElse(Nil) + test.map { foundAssignees => + assertEquals(foundAssignees, List(assignee)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("addLabel must save the label relation to the database") { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(label)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- labelRepo.createLabel(projectId)(label) + createdLabel <- labelRepo.findLabel(projectId)(label.name) + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- createdLabel.traverse(cl => ticketRepo.addLabel(projectId)(ticket.number)(cl)) + foundLabels <- loadTicketLabelIds(projectId, ticket.number) + } yield (createdLabel, foundLabels) + } + } yield result + test.map { result => + val (createdLabel, foundLabels) = result + assert(createdLabel.nonEmpty, "Test label not created!") + createdLabel.flatMap(_.id) match { + case None => fail("Test label has no ID!") + case Some(labelId) => assert(foundLabels.exists(_ === labelId)) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("addMilestone must save the milestone relation to the database") { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(milestone)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- milestoneRepo.createMilestone(projectId)(milestone) + createdMilestone <- milestoneRepo.findMilestone(projectId)(milestone.title) + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- createdMilestone.traverse(cl => ticketRepo.addMilestone(projectId)(ticket.number)(cl)) + foundMilestones <- loadTicketMilestoneIds(projectId, ticket.number) + } yield (createdMilestone, foundMilestones) + } + } yield result + test.map { result => + val (createdMilestone, foundMilestones) = result + assert(createdMilestone.nonEmpty, "Test milestone not created!") + createdMilestone.flatMap(_.id) match { + case None => fail("Test milestone has no ID!") + case Some(milestoneId) => assert(foundMilestones.exists(_ === milestoneId)) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("allTickets must return all tickets for the project".ignore) { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(generatedTickets)) => + val defaultTimestamp = OffsetDateTime.now() + val tickets = + generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) + val submitters = tickets.map(_.submitter).flatten + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => IO.pure((0, Nil)) + case Some(projectId) => + for { + writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + foundTickets <- ticketRepo.allTickets(projectId).compile.toList + } yield (writtenTickets.sum, foundTickets) + } + } yield result + test.map { result => + val (writtenTickets, foundTickets) = result + assertEquals(writtenTickets, tickets.size, "Wrong number of tickets written to database!") + assertEquals(foundTickets.size, tickets.size, "Wrong number of tickets returned!") + assertEquals( + foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), + tickets.sortBy(_.number) + ) + } + case _ => fail("Could not generate data samples!") + } + } + + test("createTicket must save the ticket to the database") { + (genProjectOwner.sample, genProject.sample, genTicket.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket)) + foundTicket <- projectId.traverse(projectId => ticketRepo.findTicket(projectId)(ticket.number)) + } yield foundTicket.getOrElse(None) + test.map { foundTicket => + foundTicket match { + case None => fail("Created ticket not found!") + case Some(foundTicket) => + assertEquals( + foundTicket, + ticket.copy(createdAt = foundTicket.createdAt, updatedAt = foundTicket.updatedAt) + ) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("deleteTicket must remove the ticket from the database") { + (genProjectOwner.sample, genProject.sample, genTicket.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket)) + _ <- projectId.traverse(projectId => ticketRepo.deleteTicket(projectId)(ticket)) + foundTicket <- projectId.traverse(projectId => ticketRepo.findTicket(projectId)(ticket.number)) + } yield foundTicket.getOrElse(None) + test.map { foundTicket => + assertEquals(foundTicket, None, "Ticket was not deleted from database!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("findTicket must find existing tickets") { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(tickets)) => + val expectedTicket = tickets(scala.util.Random.nextInt(tickets.size)) + val submitters = tickets.map(_.submitter).flatten + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- projectId match { + case None => IO.pure(Nil) + case Some(projectId) => tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + } + foundTicket <- projectId.traverse(projectId => ticketRepo.findTicket(projectId)(expectedTicket.number)) + } yield foundTicket.getOrElse(None) + test.map { foundTicket => + foundTicket match { + case None => fail("Ticket not found!") + case Some(foundTicket) => + assertEquals( + foundTicket, + expectedTicket.copy(createdAt = foundTicket.createdAt, updatedAt = foundTicket.updatedAt) + ) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("findTicketId must find the unique internal id of existing tickets") { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(tickets)) => + val expectedTicket = tickets(scala.util.Random.nextInt(tickets.size)) + val submitters = tickets.map(_.submitter).flatten + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => IO.pure((None, None)) + case Some(projectId) => + for { + _ <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + expectedTicketId <- loadTicketId(projectId, expectedTicket.number) + foundTicketId <- ticketRepo.findTicketId(projectId)(expectedTicket.number) + } yield (expectedTicketId, foundTicketId) + } + } yield result + test.map { result => + val (expectedTicketId, foundTicketId) = result + assert(expectedTicketId.nonEmpty, "Expected ticket id not found!") + assertEquals(foundTicketId, expectedTicketId) + } + case _ => fail("Could not generate data samples!") + } + } + + test("loadAssignees must return all assignees of a ticket") { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genTicketsUsers.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(users)) => + val assignees = users.map(user => Assignee(AssigneeId(user.uid.toUUID), AssigneeName(user.name.toString))) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- users.traverse(createTicketsUser) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- ticket.submitter.traverse(createTicketsSubmitter) + foundAssignees <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- assignees.traverse(assignee => ticketRepo.addAssignee(projectId)(ticket.number)(assignee)) + foundAssignees <- ticketRepo.loadAssignees(projectId)(ticket.number).compile.toList + } yield foundAssignees + } + } yield foundAssignees + test.map { foundAssignees => + assertEquals(foundAssignees.sortBy(_.name), assignees.sortBy(_.name)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("loadLabels must return all labels of a ticket") { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genLabels.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(labels)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- labels.traverse(label => labelRepo.createLabel(projectId)(label)) + createdLabels <- labelRepo.allLabels(projectId).compile.toList + _ <- createdLabels.traverse(cl => ticketRepo.addLabel(projectId)(ticket.number)(cl)) + foundLabels <- ticketRepo.loadLabels(projectId)(ticket.number).compile.toList + } yield (createdLabels, foundLabels) + } + } yield result + test.map { result => + val (createdLabels, foundLabels) = result + assertEquals(foundLabels.sortBy(_.id), createdLabels.sortBy(_.id)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("loadMilestones must return all milestones of a ticket") { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genMilestones.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(milestones)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- milestones.traverse(milestone => milestoneRepo.createMilestone(projectId)(milestone)) + createdMilestones <- milestoneRepo.allMilestones(projectId).compile.toList + _ <- createdMilestones.traverse(cm => ticketRepo.addMilestone(projectId)(ticket.number)(cm)) + foundMilestones <- ticketRepo.loadMilestones(projectId)(ticket.number).compile.toList + } yield (createdMilestones, foundMilestones) + } + } yield result + test.map { result => + val (createdMilestones, foundMilestones) = result + assertEquals(foundMilestones.sortBy(_.title), createdMilestones.sortBy(_.title)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("removeAssignee must remove the assignees from the ticket") { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genTicketsUser.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(user)) => + val assignee = Assignee(AssigneeId(user.uid.toUUID), AssigneeName(user.name.toString)) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsUser(user) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- ticket.submitter.traverse(createTicketsSubmitter) + foundAssignees <- projectId match { + case None => IO.pure(Nil) + case Some(projectId) => + for { + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- ticketRepo.addAssignee(projectId)(ticket.number)(assignee) + _ <- ticketRepo.removeAssignee(projectId)(ticket)(assignee) + foundAssignees <- ticketRepo.loadAssignees(projectId)(ticket.number).compile.toList + } yield foundAssignees + } + } yield foundAssignees + test.map { foundAssignees => + assertEquals(foundAssignees, Nil) + } + case _ => fail("Could not generate data samples!") + } + } + + test("removeLabel must remove the label from the ticket") { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(label)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + foundLabels <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- labelRepo.createLabel(projectId)(label) + createdLabel <- labelRepo.findLabel(projectId)(label.name) + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- createdLabel.traverse(cl => ticketRepo.addLabel(projectId)(ticket.number)(cl)) + _ <- createdLabel.traverse(cl => ticketRepo.removeLabel(projectId)(ticket)(cl)) + foundLabels <- loadTicketLabelIds(projectId, ticket.number) + } yield foundLabels + } + } yield foundLabels + test.map { foundLabels => + assertEquals(foundLabels, Nil) + } + case _ => fail("Could not generate data samples!") + } + } + + test("removeMilestone must remove the milestone from the ticket") { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(milestone)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + foundMilestones <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- milestoneRepo.createMilestone(projectId)(milestone) + createdMilestone <- milestoneRepo.findMilestone(projectId)(milestone.title) + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- createdMilestone.traverse(ms => ticketRepo.addMilestone(projectId)(ticket.number)(ms)) + _ <- createdMilestone.traverse(ms => ticketRepo.removeMilestone(projectId)(ticket)(ms)) + foundMilestones <- loadTicketMilestoneIds(projectId, ticket.number) + } yield foundMilestones + } + } yield foundMilestones + test.map { foundMilestones => + assertEquals(foundMilestones, Nil) + } + case _ => fail("Could not generate data samples!") + } + } + + test("updateTicket must update the ticket in the database") { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genTicket.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(anotherTicket)) => + val project = generatedProject.copy(owner = owner) + val updatedTicket = + ticket.copy(title = anotherTicket.title, content = anotherTicket.content, submitter = anotherTicket.submitter) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- updatedTicket.submitter.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket)) + _ <- projectId.traverse(projectId => ticketRepo.updateTicket(projectId)(updatedTicket)) + foundTicket <- projectId.traverse(projectId => ticketRepo.findTicket(projectId)(ticket.number)) + } yield foundTicket.getOrElse(None) + test.map { foundTicket => + foundTicket match { + case None => fail("Created ticket not found!") + case Some(foundTicket) => + assertEquals( + foundTicket, + updatedTicket.copy(createdAt = foundTicket.createdAt, updatedAt = foundTicket.updatedAt) + ) + } + } + case _ => fail("Could not generate data samples!") + } + } + +} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala 2025-01-30 22:53:28.113346433 +0000 @@ -23,7 +23,7 @@ final class DoobieTicketServiceApiTest extends BaseSpec { test("createOrUpdateUser must create new users") { - genValidTicketsUser.sample match { + genTicketsUser.sample match { case Some(user) => val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) @@ -43,7 +43,7 @@ } test("createOrUpdateUser must update existing users") { - (genValidTicketsUser.sample, genValidTicketsUser.sample) match { + (genTicketsUser.sample, genTicketsUser.sample) match { case (Some(user), Some(anotherUser)) => val updatedUser = anotherUser.copy(uid = user.uid) val dbConfig = configuration.database @@ -66,7 +66,7 @@ } test("deleteUser must delete existing users") { - genValidTicketsUser.sample match { + genTicketsUser.sample match { case Some(user) => val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala 2025-01-30 22:53:28.105346420 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala 2025-01-30 22:53:28.113346433 +0000 @@ -78,11 +78,13 @@ val genProjectOwnerId: Gen[ProjectOwnerId] = Gen.delay(ProjectOwnerId.randomProjectOwnerId) + val genSubmitterId: Gen[SubmitterId] = Gen.delay(SubmitterId.randomSubmitterId) + val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID) val genUserId: Gen[UserId] = Gen.delay(UserId.randomUserId) - val genValidUsername: Gen[Username] = for { + val genUsername: Gen[Username] = for { length <- Gen.choose(2, 30) prefix <- Gen.alphaChar chars <- Gen @@ -90,21 +92,62 @@ .map(_.take(length).mkString.toLowerCase(Locale.ROOT)) } yield Username(prefix.toString.toLowerCase(Locale.ROOT) + chars) - val genValidEmailAddress: Gen[EmailAddress] = + val genSubmitter: Gen[Submitter] = for { + id <- genSubmitterId + name <- genUsername.map(name => SubmitterName(name.toString)) + } yield Submitter(id, name) + + val genEmailAddress: Gen[EmailAddress] = for { length <- Gen.choose(4, 64) chars <- Gen.nonEmptyListOf(Gen.alphaNumChar) email = chars.take(length).mkString } yield EmailAddress(email + "@example.com") - val genValidTicketsUser: Gen[TicketsUser] = for { + val genTicketContent: Gen[Option[TicketContent]] = Gen.alphaStr.map(TicketContent.from) + + val genTicketStatus: Gen[TicketStatus] = Gen.oneOf(TicketStatus.values.toList) + + val genTicketResolution: Gen[TicketResolution] = Gen.oneOf(TicketResolution.values.toList) + + val genTicketNumber: Gen[TicketNumber] = Gen.choose(0, Int.MaxValue).map(TicketNumber.apply) + + val genTicketTitle: Gen[TicketTitle] = + Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(TicketTitle.MaxLength).mkString).map(TicketTitle.apply) + + val genTicketsUser: Gen[TicketsUser] = for { uid <- genUserId - name <- genValidUsername - email <- genValidEmailAddress + name <- genUsername + email <- genEmailAddress language <- Gen.option(genLanguageCode) } yield TicketsUser(uid, name, email, language) - val genValidProjectOwnerName: Gen[ProjectOwnerName] = for { + val genTicketsUsers: Gen[List[TicketsUser]] = Gen.nonEmptyListOf(genTicketsUser) + + val genTicket: Gen[Ticket] = for { + number <- genTicketNumber + title <- genTicketTitle + content <- genTicketContent + status <- genTicketStatus + resolution <- Gen.option(genTicketResolution) + submitter <- Gen.option(genSubmitter) + createdAt <- genOffsetDateTime + updatedAt <- genOffsetDateTime + } yield Ticket( + number, + title, + content, + status, + resolution, + submitter, + createdAt, + updatedAt + ) + + val genTickets: Gen[List[Ticket]] = + Gen.nonEmptyListOf(genTicket).map(_.zipWithIndex.map(tuple => tuple._1.copy(number = TicketNumber(tuple._2 + 1)))) + + val genProjectOwnerName: Gen[ProjectOwnerName] = for { length <- Gen.choose(2, 30) prefix <- Gen.alphaChar chars <- Gen @@ -112,16 +155,16 @@ .map(_.take(length).mkString.toLowerCase(Locale.ROOT)) } yield ProjectOwnerName(prefix.toString.toLowerCase(Locale.ROOT) + chars) - val genValidProjectOwner: Gen[ProjectOwner] = for { + val genProjectOwner: Gen[ProjectOwner] = for { id <- genProjectOwnerId - name <- genValidProjectOwnerName - email <- genValidEmailAddress + name <- genProjectOwnerName + email <- genEmailAddress } yield ProjectOwner(uid = id, name = name, email = email) - given Arbitrary[ProjectOwner] = Arbitrary(genValidProjectOwner) + given Arbitrary[ProjectOwner] = Arbitrary(genProjectOwner) - val genValidProjectOwners: Gen[List[ProjectOwner]] = Gen - .nonEmptyListOf(genValidProjectOwner) + val genProjectOwners: Gen[List[ProjectOwner]] = Gen + .nonEmptyListOf(genProjectOwner) .suchThat(owners => owners.size === owners.map(_.name).distinct.size) // Ensure unique names. val genLabelName: Gen[LabelName] = @@ -162,7 +205,7 @@ val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct) - val genValidProjectName: Gen[ProjectName] = Gen + val genProjectName: Gen[ProjectName] = Gen .nonEmptyListOf( Gen.oneOf( List( @@ -212,14 +255,14 @@ val genProjectDescription: Gen[Option[ProjectDescription]] = Gen.alphaNumStr.map(_.take(ProjectDescription.MaximumLength)).map(ProjectDescription.from) - val genValidProject: Gen[Project] = + val genProject: Gen[Project] = for { - name <- genValidProjectName + name <- genProjectName description <- genProjectDescription - owner <- genValidProjectOwner + owner <- genProjectOwner isPrivate <- Gen.oneOf(List(false, true)) } yield Project(owner, name, description, isPrivate) - val genValidProjects: Gen[List[Project]] = Gen.nonEmptyListOf(genValidProject) + val genProjects: Gen[List[Project]] = Gen.nonEmptyListOf(genProject) } diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V4__add_resolution.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V4__add_resolution.sql --- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V4__add_resolution.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V4__add_resolution.sql 2025-01-30 22:53:28.113346433 +0000 @@ -0,0 +1,4 @@ +ALTER TABLE "tickets"."tickets" + ADD COLUMN "resolution" CHARACTER VARYING(16) DEFAULT NULL; + +COMMENT ON COLUMN "tickets"."tickets"."resolution" IS 'An optional resolution state of the ticket that should be set if it is closed.'; diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala 2025-01-30 22:53:28.113346433 +0000 @@ -82,7 +82,9 @@ */ opaque type AssigneeName = String object AssigneeName { - given Eq[AssigneeName] = Eq.fromUniversalEquals + given Eq[AssigneeName] = Eq.fromUniversalEquals + given Order[AssigneeName] = Order.from((x: AssigneeName, y: AssigneeName) => x.compareTo(y)) + given Ordering[AssigneeName] = implicitly[Order[AssigneeName]].toOrdering val isAlphanumeric = "^[a-z][a-z0-9]+$".r diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala 2025-01-30 22:53:28.113346433 +0000 @@ -21,20 +21,26 @@ import doobie._ import doobie.implicits._ import fs2.Stream +import org.slf4j.LoggerFactory final class DoobieLabelRepository[F[_]: Sync](tx: Transactor[F]) extends LabelRepository[F] { + private val log = LoggerFactory.getLogger(getClass) + + given LogHandler = Slf4jLogHandler.createLogHandler(log) + given Meta[ColourCode] = Meta[String].timap(ColourCode.apply)(_.toString) given Meta[LabelDescription] = Meta[String].timap(LabelDescription.apply)(_.toString) given Meta[LabelId] = Meta[Long].timap(LabelId.apply)(_.toLong) given Meta[LabelName] = Meta[String].timap(LabelName.apply)(_.toString) + given Meta[ProjectId] = Meta[Long].timap(ProjectId.apply)(_.toLong) - override def allLabels(vcsRepositoryId: Long): Stream[F, Label] = - sql"""SELECT id, name, description, colour FROM "tickets"."labels" WHERE project = $vcsRepositoryId ORDER BY name ASC""" + override def allLabels(projectId: ProjectId): Stream[F, Label] = + sql"""SELECT id, name, description, colour FROM "tickets"."labels" WHERE project = $projectId ORDER BY name ASC""" .query[Label] .stream .transact(tx) - override def createLabel(vcsRepositoryId: Long)(label: Label): F[Int] = + override def createLabel(projectId: ProjectId)(label: Label): F[Int] = sql"""INSERT INTO "tickets"."labels" ( project, @@ -43,7 +49,7 @@ colour ) VALUES ( - $vcsRepositoryId, + $projectId, ${label.name}, ${label.description}, ${label.colour} @@ -56,8 +62,8 @@ sql"""DELETE FROM "tickets"."labels" WHERE id = $id""".update.run.transact(tx) } - override def findLabel(vcsRepositoryId: Long)(name: LabelName): F[Option[Label]] = - sql"""SELECT id, name, description, colour FROM "tickets"."labels" WHERE project = $vcsRepositoryId AND name = $name LIMIT 1""" + override def findLabel(projectId: ProjectId)(name: LabelName): F[Option[Label]] = + sql"""SELECT id, name, description, colour FROM "tickets"."labels" WHERE project = $projectId AND name = $name LIMIT 1""" .query[Label] .option .transact(tx) diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala 2025-01-30 22:53:28.113346433 +0000 @@ -22,19 +22,25 @@ import doobie.implicits._ import doobie.postgres.implicits._ import fs2.Stream +import org.slf4j.LoggerFactory final class DoobieMilestoneRepository[F[_]: Sync](tx: Transactor[F]) extends MilestoneRepository[F] { + private val log = LoggerFactory.getLogger(getClass) + + given LogHandler = Slf4jLogHandler.createLogHandler(log) + given Meta[MilestoneDescription] = Meta[String].timap(MilestoneDescription.apply)(_.toString) given Meta[MilestoneId] = Meta[Long].timap(MilestoneId.apply)(_.toLong) given Meta[MilestoneTitle] = Meta[String].timap(MilestoneTitle.apply)(_.toString) + given Meta[ProjectId] = Meta[Long].timap(ProjectId.apply)(_.toLong) - override def allMilestones(projectId: Long): Stream[F, Milestone] = + override def allMilestones(projectId: ProjectId): Stream[F, Milestone] = sql"""SELECT id, title, description, due_date FROM "tickets"."milestones" WHERE project = $projectId ORDER BY due_date ASC, title ASC""" .query[Milestone] .stream .transact(tx) - override def createMilestone(projectId: Long)(milestone: Milestone): F[Int] = + override def createMilestone(projectId: ProjectId)(milestone: Milestone): F[Int] = sql"""INSERT INTO "tickets"."milestones" ( project, @@ -55,7 +61,7 @@ case Some(id) => sql"""DELETE FROM "tickets"."milestones" WHERE id = $id""".update.run.transact(tx) } - override def findMilestone(projectId: Long)(title: MilestoneTitle): F[Option[Milestone]] = + override def findMilestone(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]] = sql"""SELECT id, title, description, due_date FROM "tickets"."milestones" WHERE project = $projectId AND title = $title LIMIT 1""" .query[Milestone] .option diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala 2025-01-30 22:53:28.113346433 +0000 @@ -24,11 +24,16 @@ import doobie._ import doobie.implicits._ import doobie.postgres.implicits._ +import org.slf4j.LoggerFactory final class DoobieProjectRepository[F[_]: Sync](tx: Transactor[F]) extends ProjectRepository[F] { + private val log = LoggerFactory.getLogger(getClass) + + given LogHandler = Slf4jLogHandler.createLogHandler(log) given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString) given Meta[ProjectDescription] = Meta[String].timap(ProjectDescription.apply)(_.toString) + given Meta[ProjectId] = Meta[Long].timap(ProjectId.apply)(_.toLong) given Meta[ProjectName] = Meta[String].timap(ProjectName.apply)(_.toString) given Meta[ProjectOwnerId] = Meta[UUID].timap(ProjectOwnerId.apply)(_.toUUID) given Meta[ProjectOwnerName] = Meta[String].timap(ProjectOwnerName.apply)(_.toString) @@ -64,7 +69,7 @@ AND "projects".name = $name""".query[Project].option.transact(tx) - override def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[Long]] = + override def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[ProjectId]] = sql"""SELECT "projects".id FROM "tickets"."projects" AS "projects" @@ -73,7 +78,7 @@ WHERE "projects".owner = ${owner.uid} AND - "projects".name = $name""".query[Long].option.transact(tx) + "projects".name = $name""".query[ProjectId].option.transact(tx) override def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]] = sql"""SELECT diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala 2025-01-30 22:53:28.113346433 +0000 @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.tickets + +import java.util.UUID + +import cats.effect._ +import doobie.Fragments._ +import doobie._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import fs2.Stream +import org.slf4j.LoggerFactory + +final class DoobieTicketRepository[F[_]: Sync](tx: Transactor[F]) extends TicketRepository[F] { + private val log = LoggerFactory.getLogger(getClass) + + given LogHandler = Slf4jLogHandler.createLogHandler(log) + + given Meta[AssigneeId] = Meta[UUID].timap(AssigneeId.apply)(_.toUUID) + given Meta[AssigneeName] = Meta[String].timap(AssigneeName.apply)(_.toString) + given Meta[ColourCode] = Meta[String].timap(ColourCode.apply)(_.toString) + given Meta[LabelDescription] = Meta[String].timap(LabelDescription.apply)(_.toString) + given Meta[LabelId] = Meta[Long].timap(LabelId.apply)(_.toLong) + given Meta[LabelName] = Meta[String].timap(LabelName.apply)(_.toString) + given Meta[MilestoneDescription] = Meta[String].timap(MilestoneDescription.apply)(_.toString) + given Meta[MilestoneId] = Meta[Long].timap(MilestoneId.apply)(_.toLong) + given Meta[MilestoneTitle] = Meta[String].timap(MilestoneTitle.apply)(_.toString) + given Meta[ProjectId] = Meta[Long].timap(ProjectId.apply)(_.toLong) + given Meta[SubmitterId] = Meta[UUID].timap(SubmitterId.apply)(_.toUUID) + given Meta[SubmitterName] = Meta[String].timap(SubmitterName.apply)(_.toString) + given Meta[TicketContent] = Meta[String].timap(TicketContent.apply)(_.toString) + given Meta[TicketId] = Meta[Long].timap(TicketId.apply)(_.toLong) + given Meta[TicketNumber] = Meta[Int].timap(TicketNumber.apply)(_.toInt) + given Meta[TicketResolution] = Meta[String].timap(TicketResolution.valueOf)(_.toString) + given Meta[TicketStatus] = Meta[String].timap(TicketStatus.valueOf)(_.toString) + given Meta[TicketTitle] = Meta[String].timap(TicketTitle.apply)(_.toString) + + private val selectTicketColumns = + fr"""SELECT + "tickets".number AS number, + "tickets".title AS title, + "tickets".content AS content, + "tickets".status AS status, + "tickets".resolution AS resolution, + "submitters".uid AS submitter_uid, + "submitters".name AS submitter_name, + "tickets".created_at AS created_at, + "tickets".updated_at AS updated_at + FROM "tickets"."tickets" AS "tickets" + JOIN "tickets"."users" AS "submitters" + ON "tickets".submitter = "submitters".uid""" + + /** Construct a query fragment that fetches the internal unique ticket id from the tickets table via the given project + * id and ticket number. The fetched id can be referenced like this `SELECT id FROM ticket_id`. + * + * @param projectId + * The unique internal ID of a ticket tracking project. + * @param ticketNumber + * The unique identifier of a ticket within the project scope is its number. + * @return + * A query fragment useable within other queries which defines a common table expression using the `WITH` clause. + */ + private def withTicketId(projectId: ProjectId, ticketNumber: TicketNumber): Fragment = + fr"""WITH ticket_id AS ( + SELECT id AS id + FROM "tickets"."tickets" AS "tickets" + WHERE "tickets".project = $projectId + AND "tickets".number = $ticketNumber)""" + + override def addAssignee(projectId: ProjectId)(ticketNumber: TicketNumber)(assignee: Assignee): F[Int] = + sql"""INSERT INTO "tickets"."ticket_assignees" ( + ticket, + assignee + ) SELECT + id, + ${assignee.id} + FROM "tickets"."tickets" + WHERE project = $projectId + AND number = $ticketNumber""".update.run.transact(tx) + + override def addLabel(projectId: ProjectId)(ticketNumber: TicketNumber)(label: Label): F[Int] = + label.id match { + case None => Sync[F].pure(0) + case Some(labelId) => + sql"""INSERT INTO "tickets"."ticket_labels" ( + ticket, + label + ) SELECT + id, + $labelId + FROM "tickets"."tickets" + WHERE project = $projectId + AND number = $ticketNumber""".update.run.transact(tx) + } + + override def addMilestone(projectId: ProjectId)(ticketNumber: TicketNumber)(milestone: Milestone): F[Int] = + milestone.id match { + case None => Sync[F].pure(0) + case Some(milestoneId) => + sql"""INSERT INTO "tickets"."milestone_tickets" ( + ticket, + milestone + ) SELECT + id, + $milestoneId + FROM "tickets"."tickets" + WHERE project = $projectId + AND number = $ticketNumber""".update.run.transact(tx) + } + + override def allTickets(projectId: ProjectId): Stream[F, Ticket] = { + val projectFilter = fr""""tickets".project = $projectId""" + val tickets = selectTicketColumns ++ whereAnd(projectFilter) + tickets.query[Ticket].stream.transact(tx) + } + + override def createTicket(projectId: ProjectId)(ticket: Ticket): F[Int] = + sql"""INSERT INTO "tickets"."tickets" ( + project, + number, + title, + content, + status, + resolution, + submitter, + created_at, + updated_at + ) VALUES ( + $projectId, + ${ticket.number}, + ${ticket.title}, + ${ticket.content}, + ${ticket.status}, + ${ticket.resolution}, + ${ticket.submitter.map(_.id)}, + NOW(), + NOW() + )""".update.run.transact(tx) + + override def deleteTicket(projectId: ProjectId)(ticket: Ticket): F[Int] = + sql"""DELETE FROM "tickets"."tickets" + WHERE project = $projectId + AND number = ${ticket.number}""".update.run.transact(tx) + + override def findTicket(projectId: ProjectId)(ticketNumber: TicketNumber): F[Option[Ticket]] = { + val projectFilter = fr"""project = $projectId""" + val numberFilter = fr"""number = $ticketNumber""" + val ticket = selectTicketColumns ++ whereAnd(projectFilter, numberFilter) ++ fr"""LIMIT 1""" + ticket.query[Ticket].option.transact(tx) + } + + override def findTicketId(projectId: ProjectId)(ticketNumber: TicketNumber): F[Option[TicketId]] = + sql"""SELECT id FROM "tickets"."tickets" WHERE project = $projectId AND number = $ticketNumber""" + .query[TicketId] + .option + .transact(tx) + + override def loadAssignees(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Assignee] = { + val sqlQuery = withTicketId(projectId, ticketNumber) ++ + fr"""SELECT + "users".uid AS uid, + "users".name AS name + FROM "tickets"."ticket_assignees" AS "assignees" + JOIN "tickets"."users" AS "users" + ON "assignees".assignee = "users".uid + WHERE "assignees".ticket = (SELECT id FROM ticket_id)""" + sqlQuery.query[Assignee].stream.transact(tx) + } + + override def loadLabels(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Label] = { + val sqlQuery = withTicketId(projectId, ticketNumber) ++ + fr"""SELECT + "labels".id AS id, + "labels".name AS name, + "labels".description AS description, + "labels".colour AS colour + FROM "tickets"."labels" AS "labels" + JOIN "tickets"."ticket_labels" AS "ticket_labels" + ON "labels".id = "ticket_labels".label + WHERE "ticket_labels".ticket = (SELECT id FROM ticket_id)""" + sqlQuery.query[Label].stream.transact(tx) + } + + override def loadMilestones(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Milestone] = { + val sqlQuery = withTicketId(projectId, ticketNumber) ++ + fr"""SELECT + "milestones".id AS id, + "milestones".title AS title, + "milestones".description AS description, + "milestones".due_date AS due_date + FROM "tickets"."milestones" AS "milestones" + JOIN "tickets"."milestone_tickets" AS "milestone_tickets" + ON "milestones".id = "milestone_tickets"."milestone" + WHERE "milestone_tickets".ticket = (SELECT id FROM ticket_id) + ORDER BY "milestones".due_date ASC, "milestones".title ASC""" + sqlQuery.query[Milestone].stream.transact(tx) + } + + override def removeAssignee(projectId: ProjectId)(ticket: Ticket)(assignee: Assignee): F[Int] = { + val sqlQuery = withTicketId(projectId, ticket.number) ++ + fr"""DELETE FROM "tickets"."ticket_assignees" AS "ticket_assignees" + WHERE "ticket_assignees".ticket = (SELECT id FROM ticket_id) + AND "ticket_assignees".assignee = ${assignee.id}""" + sqlQuery.update.run.transact(tx) + } + + override def removeLabel(projectId: ProjectId)(ticket: Ticket)(label: Label): F[Int] = + label.id match { + case None => Sync[F].pure(0) + case Some(labelId) => + val sqlQuery = withTicketId(projectId, ticket.number) ++ + fr"""DELETE FROM "tickets"."ticket_labels" AS "labels" + WHERE "labels".ticket = (SELECT id FROM ticket_id) + AND "labels".label = $labelId""" + sqlQuery.update.run.transact(tx) + } + + override def removeMilestone(projectId: ProjectId)(ticket: Ticket)(milestone: Milestone): F[Int] = + milestone.id match { + case None => Sync[F].pure(0) + case Some(milestoneId) => + val sqlQuery = withTicketId(projectId, ticket.number) ++ + fr"""DELETE FROM "tickets"."milestone_tickets" AS "milestone_tickets" + WHERE "milestone_tickets".ticket = (SELECT id FROM ticket_id) + AND "milestone_tickets".milestone = $milestoneId""" + sqlQuery.update.run.transact(tx) + } + + override def updateTicket(projectId: ProjectId)(ticket: Ticket): F[Int] = + sql"""UPDATE "tickets"."tickets" SET + title = ${ticket.title}, + content = ${ticket.content}, + status = ${ticket.status}, + resolution = ${ticket.resolution}, + submitter = ${ticket.submitter.map(_.id)} + WHERE project = $projectId + AND number = ${ticket.number}""".update.run.transact(tx) + +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala 2025-01-30 22:53:28.113346433 +0000 @@ -20,15 +20,19 @@ import java.util.UUID import cats.effect._ -import cats.syntax.all._ import de.smederee.email.EmailAddress import de.smederee.i18n.LanguageCode import de.smederee.security.{ UserId, Username } import doobie._ import doobie.implicits._ import doobie.postgres.implicits._ +import org.slf4j.LoggerFactory final class DoobieTicketServiceApi[F[_]: Sync](tx: Transactor[F]) extends TicketServiceApi[F] { + private val log = LoggerFactory.getLogger(getClass) + + given LogHandler = Slf4jLogHandler.createLogHandler(log) + given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString) given Meta[LanguageCode] = Meta[String].timap(LanguageCode.apply)(_.toString) given Meta[UserId] = Meta[UUID].timap(UserId.apply)(_.toUUID) diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala 2025-01-30 22:53:28.113346433 +0000 @@ -28,23 +28,23 @@ /** Return all labels associated with the given repository. * - * @param vcsRepositoryId + * @param projectId * The unique internal ID of a vcs repository metadata entry for which all labels shall be returned. * @return * A stream of labels associated with the vcs repository which may be empty. */ - def allLabels(vcsRepositoryId: Long): Stream[F, Label] + def allLabels(projectId: ProjectId): Stream[F, Label] /** Create a database entry for the given label definition. * - * @param vcsRepositoryId + * @param projectId * The unique internal ID of a vcs repository metadata entry to which the label belongs. * @param label * The label definition that shall be written to the database. * @return * The number of affected database rows. */ - def createLabel(vcsRepositoryId: Long)(label: Label): F[Int] + def createLabel(projectId: ProjectId)(label: Label): F[Int] /** Delete the label from the database. * @@ -57,14 +57,14 @@ /** Find the label with the given name for the given vcs repository. * - * @param vcsRepositoryId + * @param projectId * The unique internal ID of a vcs repository metadata entry to which the label belongs. * @param name * The name of the label which is must be unique in the context of the repository. * @return * An option to the found label. */ - def findLabel(vcsRepositoryId: Long)(name: LabelName): F[Option[Label]] + def findLabel(projectId: ProjectId)(name: LabelName): F[Option[Label]] /** Update the database entry for the given label. * diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala 2025-01-30 22:53:28.113346433 +0000 @@ -24,7 +24,9 @@ opaque type LabelId = Long object LabelId { - given Eq[LabelId] = Eq.fromUniversalEquals + given Eq[LabelId] = Eq.fromUniversalEquals + given Ordering[LabelId] = (x: LabelId, y: LabelId) => x.compareTo(y) + given Order[LabelId] = Order.fromOrdering val Format: Regex = "^-?\\d+$".r @@ -58,7 +60,6 @@ extension (id: LabelId) { def toLong: Long = id } - } /** Extractor to retrieve an LabelId from a path parameter. diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala 2025-01-30 22:53:28.113346433 +0000 @@ -28,23 +28,23 @@ /** Return all milestones associated with the given repository. * - * @param vcsRepositoryId + * @param projectId * The unique internal ID of a vcs repository metadata entry for which all milestones shall be returned. * @return * A stream of milestones associated with the vcs repository which may be empty. */ - def allMilestones(vcsRepositoryId: Long): Stream[F, Milestone] + def allMilestones(projectId: ProjectId): Stream[F, Milestone] /** Create a database entry for the given milestone definition. * - * @param vcsRepositoryId + * @param projectId * The unique internal ID of a vcs repository metadata entry to which the milestone belongs. * @param milestone * The milestone definition that shall be written to the database. * @return * The number of affected database rows. */ - def createMilestone(vcsRepositoryId: Long)(milestone: Milestone): F[Int] + def createMilestone(projectId: ProjectId)(milestone: Milestone): F[Int] /** Delete the milestone from the database. * @@ -57,14 +57,14 @@ /** Find the milestone with the given title for the given vcs repository. * - * @param vcsRepositoryId + * @param projectId * The unique internal ID of a vcs repository metadata entry to which the milestone belongs. * @param title * The title of the milestone which is must be unique in the context of the repository. * @return * An option to the found milestone. */ - def findMilestone(vcsRepositoryId: Long)(title: MilestoneTitle): F[Option[Milestone]] + def findMilestone(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]] /** Update the database entry for the given milestone. * diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala 2025-01-30 22:53:28.113346433 +0000 @@ -63,7 +63,7 @@ * @return * An option to the internal database ID. */ - def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[Long]] + def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[ProjectId]] /** Search for a project owner of whom we only know the name. * diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala 2025-01-30 22:53:28.113346433 +0000 @@ -25,6 +25,7 @@ import de.smederee.email.EmailAddress import de.smederee.security.{ UserId, Username } +import scala.util.Try import scala.util.matching.Regex opaque type ProjectDescription = String @@ -51,6 +52,45 @@ } +opaque type ProjectId = Long +object ProjectId { + given Eq[ProjectId] = Eq.fromUniversalEquals + + val Format: Regex = "^-?\\d+$".r + + /** Create an instance of ProjectId from the given Long type. + * + * @param source + * An instance of type Long which will be returned as a ProjectId. + * @return + * The appropriate instance of ProjectId. + */ + def apply(source: Long): ProjectId = source + + /** Try to create an instance of ProjectId from the given Long. + * + * @param source + * A Long that should fulfil the requirements to be converted into a ProjectId. + * @return + * An option to the successfully converted ProjectId. + */ + def from(source: Long): Option[ProjectId] = Option(source) + + /** Try to create an instance of ProjectId from the given String. + * + * @param source + * A string that should fulfil the requirements to be converted into a ProjectId. + * @return + * An option to the successfully converted ProjectId. + */ + def fromString(source: String): Option[ProjectId] = + Option(source).filter(Format.matches).flatMap(string => Try(string.toLong).toOption).flatMap(from) + + extension (id: ProjectId) { + def toLong: Long = id + } +} + opaque type ProjectName = String object ProjectName { diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala 2025-01-30 22:53:28.113346433 +0000 @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.tickets + +import doobie.util.log.{ ExecFailure, LogHandler, ProcessingFailure, Success } +import org.slf4j.Logger + +object Slf4jLogHandler { + private val RedactArguments: Boolean = true // This SHALL only be set to `false` when debugging issues! + + private val sqlArgumentsToLogString: List[Any] => String = arguments => + if (RedactArguments) + arguments.map(_ => "redacted").mkString(", ") + else + arguments.mkString(", ") + + private val sqlQueryToLogString: String => String = _.linesIterator.dropWhile(_.trim.isEmpty).mkString("\n ") + + /** Create a [[doobie.util.log.LogHandler]] for logging doobie queries and errors. For convenience it is best to + * simply return the return value of this method to a given (implicit) instance. + * + * @param log + * A logger which provides an slf4j interface. + * @return + * A log handler as expected by doobie. + */ + def createLogHandler(log: Logger): LogHandler = + LogHandler { + case Success(sqlQuery, arguments, executionTime, processingTime) => + log.debug(s"""SQL command successful: + | + | ${sqlQueryToLogString(sqlQuery)} + | + | arguments: [${sqlArgumentsToLogString(arguments)}] + | + | execution time : ${executionTime.toMillis} ms + | processing time: ${processingTime.toMillis} ms + | total time : ${(executionTime + processingTime).toMillis} ms + |""".stripMargin) + case ProcessingFailure(sqlQuery, arguments, executionTime, processingTime, failure) => + log.error( + s"""SQL PROCESSING FAILURE: + | + | ${sqlQueryToLogString(sqlQuery)} + | + | arguments: [${sqlArgumentsToLogString(arguments)}] + | + | execution time : ${executionTime.toMillis} ms + | processing time: ${processingTime.toMillis} ms + | total time : ${(executionTime + processingTime).toMillis} ms + |""".stripMargin, + failure + ) + case ExecFailure(sqlQuery, arguments, executionTime, failure) => + log.error( + s"""SQL EXECUTION FAILURE: + | + | ${sqlQueryToLogString(sqlQuery)} + | + | arguments: [${sqlArgumentsToLogString(arguments)}] + | + | execution time : ${executionTime.toMillis} ms + |""".stripMargin, + failure + ) + } +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala 2025-01-30 22:53:28.113346433 +0000 @@ -28,8 +28,8 @@ /** Add the given assignee to the ticket of the given repository id. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticketNumber * The unique identifier of a ticket within the project scope is its number. * @param assignee @@ -37,12 +37,12 @@ * @return * The number of affected database rows. */ - def addAssignee(vcsRepositoryId: Long)(ticketNumber: TicketNumber)(assignee: Assignee): F[Int] + def addAssignee(projectId: ProjectId)(ticketNumber: TicketNumber)(assignee: Assignee): F[Int] /** Add the given label to the ticket of the given repository id. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticketNumber * The unique identifier of a ticket within the project scope is its number. * @param label @@ -50,12 +50,12 @@ * @return * The number of affected database rows. */ - def addLabel(vcsRepositoryId: Long)(ticketNumber: TicketNumber)(label: Label): F[Int] + def addLabel(projectId: ProjectId)(ticketNumber: TicketNumber)(label: Label): F[Int] /** Add the given milestone to the ticket of the given repository id. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticketNumber * The unique identifier of a ticket within the project scope is its number. * @param milestone @@ -63,87 +63,99 @@ * @return * The number of affected database rows. */ - def addMilestone(vcsRepositoryId: Long)(ticketNumber: TicketNumber)(milestone: Milestone): F[Int] + def addMilestone(projectId: ProjectId)(ticketNumber: TicketNumber)(milestone: Milestone): F[Int] /** Return all tickets associated with the given repository. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry for which all tickets shall be returned. + * @param projectId + * The unique internal ID of a ticket tracking project.for which all tickets shall be returned. * @return * A stream of tickets associated with the vcs repository which may be empty. */ - def allTickets(vcsRepositoryId: Long): Stream[F, Ticket] + def allTickets(projectId: ProjectId): Stream[F, Ticket] /** Create a database entry for the given ticket definition within the scope of the repository with the given id. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticket * The ticket definition that shall be written to the database. * @return * The number of affected database rows. */ - def createTicket(vcsRepositoryId: Long)(ticket: Ticket): F[Int] + def createTicket(projectId: ProjectId)(ticket: Ticket): F[Int] /** Delete the ticket of the repository with the given id from the database. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticket * The ticket definition that shall be deleted from the database. * @return * The number of affected database rows. */ - def deleteTicket(vcsRepositoryId: Long)(ticket: Ticket): F[Int] + def deleteTicket(projectId: ProjectId)(ticket: Ticket): F[Int] /** Find the ticket with the given number of the repository with the given id. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticketNumber * The unique identifier of a ticket within the project scope is its number. * @return * An option to the found ticket. */ - def findTicket(vcsRepositoryId: Long)(ticketNumber: TicketNumber): F[Option[Ticket]] + def findTicket(projectId: ProjectId)(ticketNumber: TicketNumber): F[Option[Ticket]] + + /** Find the ticket with the given number of the project with the given id and return the internal unique id of the + * ticket. + * + * @param projectId + * The unique internal ID of a ticket tracking project. + * @param ticketNumber + * The unique identifier of a ticket within the project scope is its number. + * @return + * An option to the found ticket. + */ + def findTicketId(projectId: ProjectId)(ticketNumber: TicketNumber): F[Option[TicketId]] /** Load all assignees that are assigned to the ticket with the given number and repository id. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticketNumber * The unique identifier of a ticket within the project scope is its number. * @return * A stream of assigness that may be empty. */ - def loadAssignees(vcsRepositoryId: Long)(ticketNumber: TicketNumber): Stream[F, Assignee] + def loadAssignees(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Assignee] /** Load all labels that are attached to the ticket with the given number and repository id. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticketNumber * The unique identifier of a ticket within the project scope is its number. * @return * A stream of labels that may be empty. */ - def loadLabels(vcsRepositoryId: Long)(ticketNumber: TicketNumber): Stream[F, Label] + def loadLabels(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Label] /** Load all milestones that are attached to the ticket with the given number and repository id. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticketNumber * The unique identifier of a ticket within the project scope is its number. * @return * A stream of milestones that may be empty. */ - def loadMilestones(vcsRepositoryId: Long)(ticketNumber: TicketNumber): Stream[F, Milestone] + def loadMilestones(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Milestone] /** Remove the given assignee from the ticket of the given repository id. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticketNumber * The unique identifier of a ticket within the project scope is its number. * @param assignee @@ -151,12 +163,12 @@ * @return * The number of affected database rows. */ - def removeAssignee(vcsRepositoryId: Long)(ticket: Ticket)(assignee: Assignee): F[Int] + def removeAssignee(projectId: ProjectId)(ticket: Ticket)(assignee: Assignee): F[Int] /** Remove the given label from the ticket of the given repository id. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticketNumber * The unique identifier of a ticket within the project scope is its number. * @param label @@ -164,12 +176,12 @@ * @return * The number of affected database rows. */ - def removeLabel(vcsRepositoryId: Long)(ticket: Ticket)(label: Label): F[Int] + def removeLabel(projectId: ProjectId)(ticket: Ticket)(label: Label): F[Int] /** Remove the given milestone from the ticket of the given repository id. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticketNumber * The unique identifier of a ticket within the project scope is its number. * @param milestone @@ -177,17 +189,17 @@ * @return * The number of affected database rows. */ - def removeMilestone(vcsRepositoryId: Long)(ticket: Ticket)(milestone: Milestone): F[Int] + def removeMilestone(projectId: ProjectId)(ticket: Ticket)(milestone: Milestone): F[Int] /** Update the database entry for the given ticket within the scope of the repository with the given id. * - * @param vcsRepositoryId - * The unique internal ID of a vcs repository metadata entry. + * @param projectId + * The unique internal ID of a ticket tracking project. * @param ticket * The ticket definition that shall be updated within the database. * @return * The number of affected database rows. */ - def updateTicket(vcsRepositoryId: Long)(ticket: Ticket): F[Int] + def updateTicket(projectId: ProjectId)(ticket: Ticket): F[Int] } diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala 2025-01-30 22:53:28.113346433 +0000 @@ -21,6 +21,8 @@ import cats._ +import scala.util.matching.Regex + /** An unlimited text field which must be not empty to describe the ticket in great detail if needed. */ opaque type TicketContent = String @@ -46,10 +48,51 @@ } +opaque type TicketId = Long +object TicketId { + given Eq[TicketId] = Eq.fromUniversalEquals + + val Format: Regex = "^-?\\d+$".r + + /** Create an instance of TicketId from the given Long type. + * + * @param source + * An instance of type Long which will be returned as a TicketId. + * @return + * The appropriate instance of TicketId. + */ + def apply(source: Long): TicketId = source + + /** Try to create an instance of TicketId from the given Long. + * + * @param source + * A Long that should fulfil the requirements to be converted into a TicketId. + * @return + * An option to the successfully converted TicketId. + */ + def from(source: Long): Option[TicketId] = Option(source) + + /** Try to create an instance of TicketId from the given String. + * + * @param source + * A string that should fulfil the requirements to be converted into a TicketId. + * @return + * An option to the successfully converted TicketId. + */ + def fromString(source: String): Option[TicketId] = Option(source).filter(Format.matches).map(_.toLong).flatMap(from) + + extension (id: TicketId) { + def toLong: Long = id + } +} + /** A ticket number maps to an integer beneath and has the requirement to be greater than zero. */ opaque type TicketNumber = Int object TicketNumber { + given Eq[TicketNumber] = Eq.fromUniversalEquals + given Order[TicketNumber] = Order.from((a, b) => a.compare(b)) + given Ordering[TicketNumber] = implicitly[Order[TicketNumber]].toOrdering /** Create an instance of TicketNumber from the given Int type. * @@ -68,6 +111,10 @@ * An option to the successfully converted TicketNumber. */ def from(source: Int): Option[TicketNumber] = Option(source).filter(_ > 0) + + extension (number: TicketNumber) { + def toInt: Int = number.toInt + } } /** Possible states of a ticket which shall model the life cycle from ticket creation until it is closed. To keep things diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala 2025-01-30 22:53:28.109346426 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala 2025-01-30 22:53:28.113346433 +0000 @@ -78,11 +78,13 @@ val genProjectOwnerId: Gen[ProjectOwnerId] = Gen.delay(ProjectOwnerId.randomProjectOwnerId) + val genSubmitterId: Gen[SubmitterId] = Gen.delay(SubmitterId.randomSubmitterId) + val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID) val genUserId: Gen[UserId] = Gen.delay(UserId.randomUserId) - val genValidUsername: Gen[Username] = for { + val genUsername: Gen[Username] = for { length <- Gen.choose(2, 30) prefix <- Gen.alphaChar chars <- Gen @@ -90,21 +92,60 @@ .map(_.take(length).mkString.toLowerCase(Locale.ROOT)) } yield Username(prefix.toString.toLowerCase(Locale.ROOT) + chars) - val genValidEmailAddress: Gen[EmailAddress] = + val genSubmitter: Gen[Submitter] = for { + id <- genSubmitterId + name <- genUsername.map(name => SubmitterName(name.toString)) + } yield Submitter(id, name) + + val genEmailAddress: Gen[EmailAddress] = for { length <- Gen.choose(4, 64) chars <- Gen.nonEmptyListOf(Gen.alphaNumChar) email = chars.take(length).mkString } yield EmailAddress(email + "@example.com") - val genValidTicketsUser: Gen[TicketsUser] = for { + val genTicketContent: Gen[Option[TicketContent]] = Gen.alphaStr.map(TicketContent.from) + + val genTicketStatus: Gen[TicketStatus] = Gen.oneOf(TicketStatus.values.toList) + + val genTicketResolution: Gen[TicketResolution] = Gen.oneOf(TicketResolution.values.toList) + + val genTicketNumber: Gen[TicketNumber] = Gen.choose(0, Int.MaxValue).map(TicketNumber.apply) + + val genTicketTitle: Gen[TicketTitle] = + Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(TicketTitle.MaxLength).mkString).map(TicketTitle.apply) + + val genTicketsUser: Gen[TicketsUser] = for { uid <- genUserId - name <- genValidUsername - email <- genValidEmailAddress + name <- genUsername + email <- genEmailAddress language <- Gen.option(genLanguageCode) } yield TicketsUser(uid, name, email, language) - val genValidProjectOwnerName: Gen[ProjectOwnerName] = for { + val genTicket: Gen[Ticket] = for { + number <- genTicketNumber + title <- genTicketTitle + content <- genTicketContent + status <- genTicketStatus + resolution <- Gen.option(genTicketResolution) + submitter <- Gen.option(genSubmitter) + createdAt <- genOffsetDateTime + updatedAt <- genOffsetDateTime + } yield Ticket( + number, + title, + content, + status, + resolution, + submitter, + createdAt, + updatedAt + ) + + val genTickets: Gen[List[Ticket]] = + Gen.nonEmptyListOf(genTicket).map(_.zipWithIndex.map(tuple => tuple._1.copy(number = TicketNumber(tuple._2)))) + + val genProjectOwnerName: Gen[ProjectOwnerName] = for { length <- Gen.choose(2, 30) prefix <- Gen.alphaChar chars <- Gen @@ -112,16 +153,16 @@ .map(_.take(length).mkString.toLowerCase(Locale.ROOT)) } yield ProjectOwnerName(prefix.toString.toLowerCase(Locale.ROOT) + chars) - val genValidProjectOwner: Gen[ProjectOwner] = for { + val genProjectOwner: Gen[ProjectOwner] = for { id <- genProjectOwnerId - name <- genValidProjectOwnerName - email <- genValidEmailAddress + name <- genProjectOwnerName + email <- genEmailAddress } yield ProjectOwner(uid = id, name = name, email = email) - given Arbitrary[ProjectOwner] = Arbitrary(genValidProjectOwner) + given Arbitrary[ProjectOwner] = Arbitrary(genProjectOwner) - val genValidProjectOwners: Gen[List[ProjectOwner]] = Gen - .nonEmptyListOf(genValidProjectOwner) + val genProjectOwners: Gen[List[ProjectOwner]] = Gen + .nonEmptyListOf(genProjectOwner) .suchThat(owners => owners.size === owners.map(_.name).distinct.size) // Ensure unique names. val genLabelName: Gen[LabelName] = @@ -162,7 +203,7 @@ val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct) - val genValidProjectName: Gen[ProjectName] = Gen + val genProjectName: Gen[ProjectName] = Gen .nonEmptyListOf( Gen.oneOf( List( @@ -212,14 +253,14 @@ val genProjectDescription: Gen[Option[ProjectDescription]] = Gen.alphaNumStr.map(_.take(ProjectDescription.MaximumLength)).map(ProjectDescription.from) - val genValidProject: Gen[Project] = + val genProject: Gen[Project] = for { - name <- genValidProjectName + name <- genProjectName description <- genProjectDescription - owner <- genValidProjectOwner + owner <- genProjectOwner isPrivate <- Gen.oneOf(List(false, true)) } yield Project(owner, name, description, isPrivate) - val genValidProjects: Gen[List[Project]] = Gen.nonEmptyListOf(genValidProject) + val genProjects: Gen[List[Project]] = Gen.nonEmptyListOf(genProject) }