~jan0sch/smederee

Showing details for patch 52c7ef91eb3fb699353122559c5c2d71b1a9f853.
2023-05-10 (Wed), 2:01 PM - Jens Grassel - 52c7ef91eb3fb699353122559c5c2d71b1a9f853

Add TicketRepository implementation for database functionality for tickets.

- refactor Long value for repo id into VcsRepositoryId
- refactor Long value for project into ProjectId
- add DoobieTicketRepository
- add tests for DoobieTicketRepository
- refactor some generators (naming)
- more helper functions for Testing
- add LogHandler implementation for doobie in the ticket service
Summary of changes
4 files added
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala
  • modules/tickets/src/main/resources/db/migration/tickets/V4__add_resolution.sql
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala
27 files modified with 646 lines added and 314 lines removed
  • modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala with 2 added and 2 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala with 6 added and 4 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala with 18 added and 15 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala with 3 added and 3 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala with 39 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 1 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala with 27 added and 27 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala with 107 added and 107 removed lines
  • modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala with 83 added and 3 removed lines
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala with 15 added and 14 removed lines
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala with 14 added and 14 removed lines
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala with 13 added and 13 removed lines
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala with 3 added and 3 removed lines
  • modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala with 61 added and 18 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala with 3 added and 1 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala with 13 added and 7 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala with 10 added and 4 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala with 7 added and 2 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala with 5 added and 1 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/Label.scala with 3 added and 2 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala with 6 added and 6 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala with 6 added and 6 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/Project.scala with 40 added and 0 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala with 1 added and 1 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala with 47 added and 0 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala with 54 added and 42 removed lines
  • modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala with 59 added and 18 removed lines
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)
 
 }